agent-manager v0.2.0 : gestionnaire d'agents IA locaux

- CLI Rust (am) : list/install/uninstall/update/start/stop/restart/run/status/search/info/config/doctor/completion/export/import/self-update + shell interactif (Tab, historique)
- 9 methodes d'installation (npm, pip, uv, cargo, go, bun, curl, binary, git)
- catalogue de 72 agents, dependances, processus, securite (dry-run, checksums)
- sondes de version paralleles + cache (am list ~25 ms a chaud)
- installateurs one-liner : install.sh, install.ps1
This commit is contained in:
2026-08-15 11:26:34 -04:00
commit 8ee3ddf3db
79 changed files with 15724 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
*.log
Generated
+1689
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "agent-manager"
version = "0.2.0"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
default-run = "am"
[[bin]]
name = "am"
path = "src/main.rs"
[lib]
name = "agent_manager"
path = "src/lib.rs"
[dependencies]
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive", "wrap_help"] }
clap_complete = "4"
colored = "2"
flate2 = "1"
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
rustyline = { version = "14", default-features = false, features = ["with-file-history"] }
sha2 = "0.10"
shell-words = "1"
tar = "0.4"
tempfile = "3"
ureq = { version = "2", default-features = false, features = ["tls"] }
wait-timeout = "0.2"
which = "7"
zip = "0.6"
[profile.release]
lto = "thin"
strip = true
+287
View File
@@ -0,0 +1,287 @@
# Exemples d'utilisation
Sorties réelles capturées sur Windows 11 (binaire debug). Les couleurs ANSI
sont désactivées automatiquement quand la sortie est redirigée ; les
exécutables présents sur cette machine sont détectés comme agents « externes »
(version détectée via --version, sondée en parallèle et mise en cache).
## Version et aide
agent-manager (am) — a CLI to manage local AI coding agents.
Install, uninstall, start, stop, restart and update AI agents from a YAML
catalog. Handles dependencies (Node, Python, Go, Rust, Bun), binaries from
GitHub Releases, install scripts, and git repositories. All state lives under
your user directories; nothing pollutes the system.
Usage: am [OPTIONS] [COMMAND]
Commands:
list List installed agents (default); use --all for the whole catalog
start Start an agent (foreground by default, or detached with --background)
stop Stop a background agent (SIGTERM, then SIGKILL after the timeout)
restart Restart an agent: stop, then start with the same options
status Show the state of one agent, or of every installed agent
install Install an agent and its dependencies
uninstall Uninstall an agent and clean its files
update Update an installed agent to the latest available version
search Search the catalog by keyword (name, description, category, tags)
info Show detailed information about one agent
config Manage the configuration file
doctor Check the environment: tools, config validity, paths, permissions
run Run the agent command directly with the given arguments (no process management)
completion Generate a shell completion script
self-update Update agent-manager itself from GitHub Releases
export Export the configuration and installation state (backup)
## Lister les agents
Par défaut, list n'affiche que les agents installés : ceux gérés par
agent-manager et ceux détectés sur le PATH (externes).
NAME VERSION STATUS SOURCE PATH
------------------------------------------------
Atomic Agent — external external C:\Users\bruno\AppData\Local\atomic-agent\atomi…
Claude Code 2.1.112 external external C:\Users\bruno\.local\bin\claude.exe
Hermes Agent 2026.8.3 external external C:\Users\bruno\AppData\Local\hermes\hermes-agen…
jcode 0.76.0 external external C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe
Kimi CLI 0.31.1 external external C:\Users\bruno\.kimi-code\bin\kimi.exe
LazyCodex 11.1.0 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
nanobot — external external C:\Users\bruno\AppData\Local\Microsoft\WindowsA…
NemoClaw — external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Pi 0.84.1 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Prime Agent 0.7.2 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Tiny Agents — external external C:\Users\bruno\scoop\apps\python313\current\Scr…
Avec --all, le catalogue complet apparaît avec l'état de chacun.
NAME VERSION STATUS SOURCE PATH
-------------------------------------------------------------
agentty — not-installed - —
Aider — not-installed - —
Amazon Q Dev CLI — not-installed - —
Amp — not-installable - —
Ante — not-installed - —
Atomic Agent — external external C:\Users\bruno\AppData\Local\atomic-agent\atomi…
cc-haha — not-installable - —
Claude Code 2.1.112 external external C:\Users\bruno\.local\bin\claude.exe
Claurst — not-installed - —
Claw Code — not-installed - —
## Rechercher
NAME CATEGORY INSTALLABLE DESCRIPTION
-------------------------------------------------
Claw Code coding-agent yes Réécriture clean-room Rust/Python de l'architec…
jcode coding-agent yes Agent TUI Rust optimisé RAM et latence de démar…
Zap coding-agent yes Agent TUI Rust skill-first, injecte seulement l…
Smelt coding-agent yes Agent TUI Rust multi-provider, 4 modes (Normal/…
Claurst coding-agent yes Claude Code réécrit en Rust idiomatique, avec d…
ZeroClaw assistant yes Runtime d'agent AI Rust autonome, architecture …
IronClaw assistant yes OpenClaw réécrit en Rust par NEAR AI. Sandbox W…
Moltis assistant yes Alternative Rust-native à OpenClaw, zéro code u…
Ante proprietary yes Binaire Rust 15 MB (research preview), architec…
NAME CATEGORY INSTALLABLE DESCRIPTION
----------------------------------------
no agent matches 'zzzz' (categories: assistant, coding-agent, desktop, local-first, proprietary, saas)
## Fiche détaillée
Claude Code
slug: claude-code
description: Agent terminal d'Anthropic, repo-aware. Édite, refactore, exécute des commandes git et itère sur les erreurs. Le standard de référence du coding agent.
category: coding-agent
website: https://github.com/anthropics/claude-code
tags: anthropic, assistant, source-available, mainstream
status: found on PATH (external — not managed)
run: claude
install methods:
1) type=npm package=@anthropic-ai/claude-code
dependencies:
- node >= 18.0.0 — https://nodejs.org
## État d'un agent / de tous les agents installés
Claude Code (claude-code)
status: external
version: 2.1.112
method: —
path: C:\Users\bruno\.local\bin\claude.exe
run: claude
NAME VERSION STATUS PID PATH
-------------------------------------------
Atomic Agent — external — C:\Users\bruno\AppData\Local\atomic-agent\atomi…
Claude Code 2.1.112 external — C:\Users\bruno\.local\bin\claude.exe
Hermes Agent 2026.8.3 external — C:\Users\bruno\AppData\Local\hermes\hermes-agen…
jcode 0.76.0 external — C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe
Kimi CLI 0.31.1 external — C:\Users\bruno\.kimi-code\bin\kimi.exe
LazyCodex 11.1.0 external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
## Simulation d'installation (--dry-run --verbose)
Aucune écriture : les commandes qui seraient exécutées sont affichées
(dépendance cargo vérifiée, puis cargo install dans le répertoire local).
[dry-run] would run: cargo --version
installing Smelt (type=cargo package=smelt)…
[dry-run] would run: cargo install --root C:\Users\bruno\AppData\Local\agent-manager\agents\smelt smelt
dry run complete — nothing was changed
Installer un agent non installable (SaaS) produit une erreur claire :
error: agent 'devin' is not installable locally: SaaS — pas d'installation locale; utilisez https://devin.ai (web ou API)
[exit code: 1]
## Exécution et groupes
[dry-run] would run C:\Users\bruno\scoop\apps\nodejs22\current\bin\pi.cmd
[dry-run] would start C:\Users\bruno\.local\bin\claude.exe (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\claude-code.log)
[dry-run] would start aider (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\aider.log)
[dry-run] would start codex (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\codex.log)
## Arrêt et mise à jour d'un agent non géré
agent 'claude-code' is not managed by agent-manager; nothing to stop
error: agent 'claude-code' is not installed by agent-manager — use 'am install claude-code'
[exit code: 1]
## Sortie JSON (pour les scripts)
[
{
"name": "agentty",
"display_name": "agentty",
"category": "coding-agent",
"description": "Agent terminal C++26, binaire statique 13.6 MB, cold start sub-milliseconde, zéro dépendance runtime, sandbox par défaut.",
"status": "not-installed",
"version": null,
"source": "-",
"method": null,
"path": null,
"running": false,
"pid": null,
"installable": true,
"note": null
},
{
"name": "aider",
## Configuration
no user config found — using the embedded default catalog
user config directory: C:\Users\bruno\AppData\Roaming\agent-manager
user config file: C:\Users\bruno\AppData\Roaming\agent-manager\config.yaml
local config file: agent-manager.yaml (current directory)
configuration is valid (72 agents)
## Doctor
CHECK STATUS DETAIL
-----------------------------
git ok C:\Users\bruno\scoop\apps\git\current\cmd\git.e…
sh ok C:\Users\bruno\scoop\shims\sh.exe (v5.3.15)
curl ok C:\Windows\system32\curl.exe (v8.21.0)
node ok C:\Users\bruno\scoop\apps\nodejs22\current\node…
npm ok C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
python3 ok C:\Users\bruno\scoop\shims\python3.exe (v3.13.1…
python ok C:\Users\bruno\AppData\Local\Microsoft\WindowsA…
cargo ok C:\Users\bruno\scoop\apps\rustup\current\.cargo…
go ok C:\Users\bruno\scoop\shims\go.exe (v1.26.5)
bun ok C:\Users\bruno\scoop\shims\bun.exe (v1.3.14)
uv ok C:\Users\bruno\scoop\shims\uv.exe (v0.12.2)
docker ok C:\Program Files\Docker\Docker\resources\bin\do…
config warn no user config file found; using the embedded d…
config-schema ok 72 agents defined
state ok 0 installed agent(s)
install-dir warn C:\Users\bruno\AppData\Local\agent-manager\agen…
bin-dir warn C:\Users\bruno\AppData\Local\agent-manager\agen…
log-dir ok C:\Users\bruno\AppData\Local\agent-manager\logs
bin-on-path warn C:\Users\bruno\AppData\Local\agent-manager\agen…
4 problem(s) found — 'am doctor --fix' repairs what it can
## Shell interactif
am lancé sans arguments ouvre un shell interactif avec complétion par la
touche Tab (commandes, noms d'agents, alias, group:xxx, options) et
historique persistant (fichier history.txt dans le répertoire d'état) :
$ am
agent-manager interactive shell — Tab pour compléter, flèches pour l'historique, 'help', 'exit'
am> inst<Tab> # complète en "install"
am> install cla<Tab> # propose claude-code, claurst, ...
am> start group:<Tab> # propose group:dev, group:local, group:claw, ...
am> exit
## Installation réelle de bout en bout (npm)
Session complète avec un agent de démonstration défini dans un fichier de
config temporaire (paquet npm cowsay) : installation, statut, exécution,
erreur propre au démarrage en arrière-plan, mise à jour, désinstallation
avec purge.
$ am install cowsay --yes
installing Cowsay (type=npm package=cowsay)…
installed Cowsay (v1.6.0) in .../agents/cowsay
start it with: am start cowsay
$ am status cowsay
Cowsay (cowsay)
status: installed
version: 1.6.0
method: npm
path: .../agents/cowsay/cowsay.cmd
run: cowsay
installed: 2026-08-15T14:39:26+00:00
$ am run cowsay bonjour
________________
< cowsay bonjour >
----------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
$ am start cowsay --background
error: agent 'cowsay' exited immediately after start (status exit code: 0); log: .../cowsay.log
________
< cowsay >
--------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
$ am update cowsay
Cowsay is up to date (v1.6.0)
$ am uninstall cowsay --purge --yes
removed 'cowsay' from .../cowsay.yaml (backup: .../cowsay.yaml.bak)
uninstalled Cowsay (files removed, config purged)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 agent-manager contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+433
View File
@@ -0,0 +1,433 @@
# agent-manager (am)
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.
---
## Sommaire
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)
---
## 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é)
**Windows** (PowerShell) :
powershell -ExecutionPolicy Bypass -Command "irm https://git.dracodev.net/Projets/agent-manager/raw/branch/main/install.ps1 | iex"
**Linux / macOS / WSL** :
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.
### 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.
---
## Démarrage rapide
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 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
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 run pi "explique ce fichier" # exécution directe, sans gestion de process
am doctor --fix # vérifie l'environnement et répare
---
## 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 |
**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).
---
## Options globales
Toutes les options globales fonctionnent avant **ou après** la sous-commande.
| 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 |
| -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 |
---
## Fichier de configuration
### Emplacements (par ordre de priorité)
1. --config <fichier> (ou la variable d'environnement AGENT_MANAGER_CONFIG)
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
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.
### 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 :
version: "1.0"
agents:
# paquet npm
- name: mon-agent-npm
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.
---
## Alias, groupes et mode interactif
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).
---
## Gestion des processus
- **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 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
---
## Structure du projet
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
---
## 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.
+53
View File
@@ -0,0 +1,53 @@
# Publier une release
Les installateurs one-liner (`install.sh` et `install.ps1`) téléchargent
les binaires attachés à la **dernière release** du dépôt Gitea
(https://git.dracodev.net/Projets/agent-manager). Sans binaire pour la
plateforme, ils retombent sur `cargo install --git` (plus lent mais toujours
fonctionnel).
## 1. Bumper la version
Dans Cargo.toml (ex. `0.2.0`), puis :
cargo build --release # binaire Windows (am.exe) sur Windows
# sur une machine Linux :
cargo build --release # binaire Linux (am)
## 2. Créer les archives des binaires
Windows (PowerShell) :
Compress-Archive target\release\am.exe am-windows-x86_64.zip
Linux :
tar -czf am-linux-x86_64.tar.gz -C target/release am
## 3. Créer la release sur Gitea
Via l'interface web : Releases > New Release, tag v0.2.0, attacher les
archives ci-dessus.
Ou via l'API (jeton Gitea requis) :
curl -X POST -H "Authorization: token <JETON>" \
-H "Content-Type: application/json" \
-d '{"tag_name":"v0.2.0","name":"v0.2.0","body":"notes de version"}' \
https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases
curl -X POST -H "Authorization: token <JETON>" \
-F "attachment=@am-windows-x86_64.zip" \
https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases/<id>/assets?name=am-windows-x86_64.zip
curl -X POST -H "Authorization: token <JETON>" \
-F "attachment=@am-linux-x86_64.tar.gz" \
https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases/<id>/assets?name=am-linux-x86_64.tar.gz
## 4. Tag git
git tag v0.2.0 && git push origin v0.2.0
Les conventions de nommage attendues par les installateurs :
- am-windows-x86_64.zip (contient am.exe)
- am-linux-x86_64.tar.gz (contient am)
+970
View File
@@ -0,0 +1,970 @@
#compdef am
autoload -U is-at-least
_am() {
typeset -A opt_args
typeset -a _arguments_options
local ret=1
if is-at-least 5.2; then
_arguments_options=(-s -S -C)
else
_arguments_options=(-s -C)
fi
local context curcontext="$curcontext" state line
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help (see more with '\''--help'\'')]' \
'--help[Print help (see more with '\''--help'\'')]' \
'-V[Print version]' \
'--version[Print version]' \
":: :_am_commands" \
"*::: :->am" \
&& ret=0
case $state in
(am)
words=($line[1] "${words[@]}")
(( CURRENT += 1 ))
curcontext="${curcontext%:*:*}:am-command-$line[1]:"
case $line[1] in
(list)
_arguments "${_arguments_options[@]}" : \
'--category=[Only show agents of this category]:CATEGORY:_default' \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'--installed-only[Only show agents that are installed (managed or external)]' \
'--all[Include hidden agents as well]' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
&& ret=0
;;
(start)
_arguments "${_arguments_options[@]}" : \
'*--args=[Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)]::ARGS:_default' \
'*--env=[Set an environment variable for the agent (repeatable)\: --env KEY=VALUE]:KEY=VALUE:_default' \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'(-f --foreground)-b[Run detached in the background; output goes to the agent log file]' \
'(-f --foreground)--background[Run detached in the background; output goes to the agent log file]' \
'-f[Run in the foreground, attached to this terminal (the default)]' \
'--foreground[Run in the foreground, attached to this terminal (the default)]' \
'--notify[Send a desktop notification once the agent has started]' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':agent -- Agent name, alias, or group\:<name>:_default' \
&& ret=0
;;
(stop)
_arguments "${_arguments_options[@]}" : \
'--timeout=[Grace period in seconds before killing (default\: from config, 5s)]:SECS:_default' \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'--force[Kill immediately instead of terminating gracefully]' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':agent -- Agent name, alias, or group\:<name>:_default' \
&& ret=0
;;
(restart)
_arguments "${_arguments_options[@]}" : \
'*--args=[Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)]::ARGS:_default' \
'*--env=[Set an environment variable for the agent (repeatable)\: --env KEY=VALUE]:KEY=VALUE:_default' \
'--timeout=[Grace period in seconds before killing (default\: from config, 5s)]:SECS:_default' \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'(-f --foreground)-b[Run detached in the background; output goes to the agent log file]' \
'(-f --foreground)--background[Run detached in the background; output goes to the agent log file]' \
'-f[Run in the foreground, attached to this terminal (the default)]' \
'--foreground[Run in the foreground, attached to this terminal (the default)]' \
'--notify[Send a desktop notification once the agent has started]' \
'--force[Kill immediately instead of terminating gracefully]' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':agent -- Agent name, alias, or group\:<name>:_default' \
&& ret=0
;;
(status)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
'::agent -- Agent name or alias (omit to show all installed agents):_default' \
&& ret=0
;;
(install)
_arguments "${_arguments_options[@]}" : \
'--method=[Select the install method (index, or type\: npm, pip, uv, cargo, go, bun, curl, binary, git)]:METHOD:_default' \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'--force[Reinstall even if already installed]' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':agent -- Agent name or alias:_default' \
&& ret=0
;;
(uninstall)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'--purge[Also remove logs and the agent entry from the user config file]' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':agent -- Agent name or alias:_default' \
&& ret=0
;;
(update)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'--all[Update every installed managed agent]' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
'::agent -- Agent name or alias (required unless --all):_default' \
&& ret=0
;;
(search)
_arguments "${_arguments_options[@]}" : \
'--category=[Restrict to this category]:CATEGORY:_default' \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':keyword -- Keyword to search for (case-insensitive substring):_default' \
&& ret=0
;;
(info)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':agent -- Agent name or alias:_default' \
&& ret=0
;;
(config)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
":: :_am__subcmd__config_commands" \
"*::: :->config" \
&& ret=0
case $state in
(config)
words=($line[1] "${words[@]}")
(( CURRENT += 1 ))
curcontext="${curcontext%:*:*}:am-config-command-$line[1]:"
case $line[1] in
(show)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
&& ret=0
;;
(path)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
&& ret=0
;;
(edit)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
&& ret=0
;;
(validate)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
&& ret=0
;;
(add)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':file -- YAML (or JSON) file defining agents to include:_files' \
&& ret=0
;;
(help)
_arguments "${_arguments_options[@]}" : \
":: :_am__subcmd__config__subcmd__help_commands" \
"*::: :->help" \
&& ret=0
case $state in
(help)
words=($line[1] "${words[@]}")
(( CURRENT += 1 ))
curcontext="${curcontext%:*:*}:am-config-help-command-$line[1]:"
case $line[1] in
(show)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(path)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(edit)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(validate)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(add)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(help)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
esac
;;
esac
;;
esac
;;
esac
;;
(doctor)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'--fix[Attempt to repair problems (create directories, fix the state file)]' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
&& ret=0
;;
(run)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':agent -- Agent name or alias:_default' \
'*::args -- Arguments passed through to the agent command:_default' \
&& ret=0
;;
(completion)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':shell -- Shell to generate completions for:(bash elvish fish powershell zsh)' \
&& ret=0
;;
(self-update)
_arguments "${_arguments_options[@]}" : \
'--to=[Download the new binary to this path instead of replacing the current one]:FILE:_files' \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'--check[Only check whether a newer version exists]' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
&& ret=0
;;
(export)
_arguments "${_arguments_options[@]}" : \
'--output=[Output file (default\: am-export.json)]:FILE:_files' \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
&& ret=0
;;
(import)
_arguments "${_arguments_options[@]}" : \
'-c+[Use an alternate configuration file]:FILE:_files' \
'--config=[Use an alternate configuration file]:FILE:_files' \
'-v[Verbose output\: show every command executed and its details]' \
'--verbose[Verbose output\: show every command executed and its details]' \
'(-v --verbose)-q[Quiet mode\: only errors are printed]' \
'(-v --verbose)--quiet[Quiet mode\: only errors are printed]' \
'-y[Answer yes to every confirmation prompt]' \
'--yes[Answer yes to every confirmation prompt]' \
'--dry-run[Simulate the action without changing anything]' \
'--json[Emit machine-readable JSON on stdout (for scripts)]' \
'--no-color[Disable ANSI colors]' \
'-h[Print help]' \
'--help[Print help]' \
':file -- File exported by the export command:_files' \
&& ret=0
;;
(help)
_arguments "${_arguments_options[@]}" : \
":: :_am__subcmd__help_commands" \
"*::: :->help" \
&& ret=0
case $state in
(help)
words=($line[1] "${words[@]}")
(( CURRENT += 1 ))
curcontext="${curcontext%:*:*}:am-help-command-$line[1]:"
case $line[1] in
(list)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(start)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(stop)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(restart)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(status)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(install)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(uninstall)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(update)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(search)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(info)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(config)
_arguments "${_arguments_options[@]}" : \
":: :_am__subcmd__help__subcmd__config_commands" \
"*::: :->config" \
&& ret=0
case $state in
(config)
words=($line[1] "${words[@]}")
(( CURRENT += 1 ))
curcontext="${curcontext%:*:*}:am-help-config-command-$line[1]:"
case $line[1] in
(show)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(path)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(edit)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(validate)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(add)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
esac
;;
esac
;;
(doctor)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(run)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(completion)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(self-update)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(export)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(import)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
(help)
_arguments "${_arguments_options[@]}" : \
&& ret=0
;;
esac
;;
esac
;;
esac
;;
esac
}
(( $+functions[_am_commands] )) ||
_am_commands() {
local commands; commands=(
'list:List agents\: installed ones, or every agent known to the catalog' \
'start:Start an agent (foreground by default, or detached with --background)' \
'stop:Stop a background agent (SIGTERM, then SIGKILL after the timeout)' \
'restart:Restart an agent\: stop, then start with the same options' \
'status:Show the state of one agent, or of every installed agent' \
'install:Install an agent and its dependencies' \
'uninstall:Uninstall an agent and clean its files' \
'update:Update an installed agent to the latest available version' \
'search:Search the catalog by keyword (name, description, category, tags)' \
'info:Show detailed information about one agent' \
'config:Manage the configuration file' \
'doctor:Check the environment\: tools, config validity, paths, permissions' \
'run:Run the agent command directly with the given arguments (no process management)' \
'completion:Generate a shell completion script' \
'self-update:Update agent-manager itself from GitHub Releases' \
'export:Export the configuration and installation state (backup)' \
'import:Import a previously exported configuration and state' \
'help:Print this message or the help of the given subcommand(s)' \
)
_describe -t commands 'am commands' commands "$@"
}
(( $+functions[_am__subcmd__completion_commands] )) ||
_am__subcmd__completion_commands() {
local commands; commands=()
_describe -t commands 'am completion commands' commands "$@"
}
(( $+functions[_am__subcmd__config_commands] )) ||
_am__subcmd__config_commands() {
local commands; commands=(
'show:Print the effective (merged) configuration as YAML' \
'path:Print the location of the configuration file(s)' \
'edit:Open the user configuration file in your editor' \
'validate:Validate the configuration file(s)' \
'add:Add another agent definition file to the user configuration' \
'help:Print this message or the help of the given subcommand(s)' \
)
_describe -t commands 'am config commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__add_commands] )) ||
_am__subcmd__config__subcmd__add_commands() {
local commands; commands=()
_describe -t commands 'am config add commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__edit_commands] )) ||
_am__subcmd__config__subcmd__edit_commands() {
local commands; commands=()
_describe -t commands 'am config edit commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__help_commands] )) ||
_am__subcmd__config__subcmd__help_commands() {
local commands; commands=(
'show:Print the effective (merged) configuration as YAML' \
'path:Print the location of the configuration file(s)' \
'edit:Open the user configuration file in your editor' \
'validate:Validate the configuration file(s)' \
'add:Add another agent definition file to the user configuration' \
'help:Print this message or the help of the given subcommand(s)' \
)
_describe -t commands 'am config help commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__help__subcmd__add_commands] )) ||
_am__subcmd__config__subcmd__help__subcmd__add_commands() {
local commands; commands=()
_describe -t commands 'am config help add commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__help__subcmd__edit_commands] )) ||
_am__subcmd__config__subcmd__help__subcmd__edit_commands() {
local commands; commands=()
_describe -t commands 'am config help edit commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__help__subcmd__help_commands] )) ||
_am__subcmd__config__subcmd__help__subcmd__help_commands() {
local commands; commands=()
_describe -t commands 'am config help help commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__help__subcmd__path_commands] )) ||
_am__subcmd__config__subcmd__help__subcmd__path_commands() {
local commands; commands=()
_describe -t commands 'am config help path commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__help__subcmd__show_commands] )) ||
_am__subcmd__config__subcmd__help__subcmd__show_commands() {
local commands; commands=()
_describe -t commands 'am config help show commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__help__subcmd__validate_commands] )) ||
_am__subcmd__config__subcmd__help__subcmd__validate_commands() {
local commands; commands=()
_describe -t commands 'am config help validate commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__path_commands] )) ||
_am__subcmd__config__subcmd__path_commands() {
local commands; commands=()
_describe -t commands 'am config path commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__show_commands] )) ||
_am__subcmd__config__subcmd__show_commands() {
local commands; commands=()
_describe -t commands 'am config show commands' commands "$@"
}
(( $+functions[_am__subcmd__config__subcmd__validate_commands] )) ||
_am__subcmd__config__subcmd__validate_commands() {
local commands; commands=()
_describe -t commands 'am config validate commands' commands "$@"
}
(( $+functions[_am__subcmd__doctor_commands] )) ||
_am__subcmd__doctor_commands() {
local commands; commands=()
_describe -t commands 'am doctor commands' commands "$@"
}
(( $+functions[_am__subcmd__export_commands] )) ||
_am__subcmd__export_commands() {
local commands; commands=()
_describe -t commands 'am export commands' commands "$@"
}
(( $+functions[_am__subcmd__help_commands] )) ||
_am__subcmd__help_commands() {
local commands; commands=(
'list:List agents\: installed ones, or every agent known to the catalog' \
'start:Start an agent (foreground by default, or detached with --background)' \
'stop:Stop a background agent (SIGTERM, then SIGKILL after the timeout)' \
'restart:Restart an agent\: stop, then start with the same options' \
'status:Show the state of one agent, or of every installed agent' \
'install:Install an agent and its dependencies' \
'uninstall:Uninstall an agent and clean its files' \
'update:Update an installed agent to the latest available version' \
'search:Search the catalog by keyword (name, description, category, tags)' \
'info:Show detailed information about one agent' \
'config:Manage the configuration file' \
'doctor:Check the environment\: tools, config validity, paths, permissions' \
'run:Run the agent command directly with the given arguments (no process management)' \
'completion:Generate a shell completion script' \
'self-update:Update agent-manager itself from GitHub Releases' \
'export:Export the configuration and installation state (backup)' \
'import:Import a previously exported configuration and state' \
'help:Print this message or the help of the given subcommand(s)' \
)
_describe -t commands 'am help commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__completion_commands] )) ||
_am__subcmd__help__subcmd__completion_commands() {
local commands; commands=()
_describe -t commands 'am help completion commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__config_commands] )) ||
_am__subcmd__help__subcmd__config_commands() {
local commands; commands=(
'show:Print the effective (merged) configuration as YAML' \
'path:Print the location of the configuration file(s)' \
'edit:Open the user configuration file in your editor' \
'validate:Validate the configuration file(s)' \
'add:Add another agent definition file to the user configuration' \
)
_describe -t commands 'am help config commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__config__subcmd__add_commands] )) ||
_am__subcmd__help__subcmd__config__subcmd__add_commands() {
local commands; commands=()
_describe -t commands 'am help config add commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__config__subcmd__edit_commands] )) ||
_am__subcmd__help__subcmd__config__subcmd__edit_commands() {
local commands; commands=()
_describe -t commands 'am help config edit commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__config__subcmd__path_commands] )) ||
_am__subcmd__help__subcmd__config__subcmd__path_commands() {
local commands; commands=()
_describe -t commands 'am help config path commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__config__subcmd__show_commands] )) ||
_am__subcmd__help__subcmd__config__subcmd__show_commands() {
local commands; commands=()
_describe -t commands 'am help config show commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__config__subcmd__validate_commands] )) ||
_am__subcmd__help__subcmd__config__subcmd__validate_commands() {
local commands; commands=()
_describe -t commands 'am help config validate commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__doctor_commands] )) ||
_am__subcmd__help__subcmd__doctor_commands() {
local commands; commands=()
_describe -t commands 'am help doctor commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__export_commands] )) ||
_am__subcmd__help__subcmd__export_commands() {
local commands; commands=()
_describe -t commands 'am help export commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__help_commands] )) ||
_am__subcmd__help__subcmd__help_commands() {
local commands; commands=()
_describe -t commands 'am help help commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__import_commands] )) ||
_am__subcmd__help__subcmd__import_commands() {
local commands; commands=()
_describe -t commands 'am help import commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__info_commands] )) ||
_am__subcmd__help__subcmd__info_commands() {
local commands; commands=()
_describe -t commands 'am help info commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__install_commands] )) ||
_am__subcmd__help__subcmd__install_commands() {
local commands; commands=()
_describe -t commands 'am help install commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__list_commands] )) ||
_am__subcmd__help__subcmd__list_commands() {
local commands; commands=()
_describe -t commands 'am help list commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__restart_commands] )) ||
_am__subcmd__help__subcmd__restart_commands() {
local commands; commands=()
_describe -t commands 'am help restart commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__run_commands] )) ||
_am__subcmd__help__subcmd__run_commands() {
local commands; commands=()
_describe -t commands 'am help run commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__search_commands] )) ||
_am__subcmd__help__subcmd__search_commands() {
local commands; commands=()
_describe -t commands 'am help search commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__self-update_commands] )) ||
_am__subcmd__help__subcmd__self-update_commands() {
local commands; commands=()
_describe -t commands 'am help self-update commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__start_commands] )) ||
_am__subcmd__help__subcmd__start_commands() {
local commands; commands=()
_describe -t commands 'am help start commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__status_commands] )) ||
_am__subcmd__help__subcmd__status_commands() {
local commands; commands=()
_describe -t commands 'am help status commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__stop_commands] )) ||
_am__subcmd__help__subcmd__stop_commands() {
local commands; commands=()
_describe -t commands 'am help stop commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__uninstall_commands] )) ||
_am__subcmd__help__subcmd__uninstall_commands() {
local commands; commands=()
_describe -t commands 'am help uninstall commands' commands "$@"
}
(( $+functions[_am__subcmd__help__subcmd__update_commands] )) ||
_am__subcmd__help__subcmd__update_commands() {
local commands; commands=()
_describe -t commands 'am help update commands' commands "$@"
}
(( $+functions[_am__subcmd__import_commands] )) ||
_am__subcmd__import_commands() {
local commands; commands=()
_describe -t commands 'am import commands' commands "$@"
}
(( $+functions[_am__subcmd__info_commands] )) ||
_am__subcmd__info_commands() {
local commands; commands=()
_describe -t commands 'am info commands' commands "$@"
}
(( $+functions[_am__subcmd__install_commands] )) ||
_am__subcmd__install_commands() {
local commands; commands=()
_describe -t commands 'am install commands' commands "$@"
}
(( $+functions[_am__subcmd__list_commands] )) ||
_am__subcmd__list_commands() {
local commands; commands=()
_describe -t commands 'am list commands' commands "$@"
}
(( $+functions[_am__subcmd__restart_commands] )) ||
_am__subcmd__restart_commands() {
local commands; commands=()
_describe -t commands 'am restart commands' commands "$@"
}
(( $+functions[_am__subcmd__run_commands] )) ||
_am__subcmd__run_commands() {
local commands; commands=()
_describe -t commands 'am run commands' commands "$@"
}
(( $+functions[_am__subcmd__search_commands] )) ||
_am__subcmd__search_commands() {
local commands; commands=()
_describe -t commands 'am search commands' commands "$@"
}
(( $+functions[_am__subcmd__self-update_commands] )) ||
_am__subcmd__self-update_commands() {
local commands; commands=()
_describe -t commands 'am self-update commands' commands "$@"
}
(( $+functions[_am__subcmd__start_commands] )) ||
_am__subcmd__start_commands() {
local commands; commands=()
_describe -t commands 'am start commands' commands "$@"
}
(( $+functions[_am__subcmd__status_commands] )) ||
_am__subcmd__status_commands() {
local commands; commands=()
_describe -t commands 'am status commands' commands "$@"
}
(( $+functions[_am__subcmd__stop_commands] )) ||
_am__subcmd__stop_commands() {
local commands; commands=()
_describe -t commands 'am stop commands' commands "$@"
}
(( $+functions[_am__subcmd__uninstall_commands] )) ||
_am__subcmd__uninstall_commands() {
local commands; commands=()
_describe -t commands 'am uninstall commands' commands "$@"
}
(( $+functions[_am__subcmd__update_commands] )) ||
_am__subcmd__update_commands() {
local commands; commands=()
_describe -t commands 'am update commands' commands "$@"
}
if [ "$funcstack[1]" = "_am" ]; then
_am "$@"
else
compdef _am am
fi
+573
View File
@@ -0,0 +1,573 @@
using namespace System.Management.Automation
using namespace System.Management.Automation.Language
Register-ArgumentCompleter -Native -CommandName 'am' -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
$commandElements = $commandAst.CommandElements
$command = @(
'am'
for ($i = 1; $i -lt $commandElements.Count; $i++) {
$element = $commandElements[$i]
if ($element -isnot [StringConstantExpressionAst] -or
$element.StringConstantType -ne [StringConstantType]::BareWord -or
$element.Value.StartsWith('-') -or
$element.Value -eq $wordToComplete) {
break
}
$element.Value
}) -join ';'
$completions = @(switch ($command) {
'am' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help (see more with ''--help'')')
[CompletionResult]::new('-V', '-V ', [CompletionResultType]::ParameterName, 'Print version')
[CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version')
[CompletionResult]::new('list', 'list', [CompletionResultType]::ParameterValue, 'List agents: installed ones, or every agent known to the catalog')
[CompletionResult]::new('start', 'start', [CompletionResultType]::ParameterValue, 'Start an agent (foreground by default, or detached with --background)')
[CompletionResult]::new('stop', 'stop', [CompletionResultType]::ParameterValue, 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)')
[CompletionResult]::new('restart', 'restart', [CompletionResultType]::ParameterValue, 'Restart an agent: stop, then start with the same options')
[CompletionResult]::new('status', 'status', [CompletionResultType]::ParameterValue, 'Show the state of one agent, or of every installed agent')
[CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install an agent and its dependencies')
[CompletionResult]::new('uninstall', 'uninstall', [CompletionResultType]::ParameterValue, 'Uninstall an agent and clean its files')
[CompletionResult]::new('update', 'update', [CompletionResultType]::ParameterValue, 'Update an installed agent to the latest available version')
[CompletionResult]::new('search', 'search', [CompletionResultType]::ParameterValue, 'Search the catalog by keyword (name, description, category, tags)')
[CompletionResult]::new('info', 'info', [CompletionResultType]::ParameterValue, 'Show detailed information about one agent')
[CompletionResult]::new('config', 'config', [CompletionResultType]::ParameterValue, 'Manage the configuration file')
[CompletionResult]::new('doctor', 'doctor', [CompletionResultType]::ParameterValue, 'Check the environment: tools, config validity, paths, permissions')
[CompletionResult]::new('run', 'run', [CompletionResultType]::ParameterValue, 'Run the agent command directly with the given arguments (no process management)')
[CompletionResult]::new('completion', 'completion', [CompletionResultType]::ParameterValue, 'Generate a shell completion script')
[CompletionResult]::new('self-update', 'self-update', [CompletionResultType]::ParameterValue, 'Update agent-manager itself from GitHub Releases')
[CompletionResult]::new('export', 'export', [CompletionResultType]::ParameterValue, 'Export the configuration and installation state (backup)')
[CompletionResult]::new('import', 'import', [CompletionResultType]::ParameterValue, 'Import a previously exported configuration and state')
[CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
break
}
'am;list' {
[CompletionResult]::new('--category', '--category', [CompletionResultType]::ParameterName, 'Only show agents of this category')
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--installed-only', '--installed-only', [CompletionResultType]::ParameterName, 'Only show agents that are installed (managed or external)')
[CompletionResult]::new('--all', '--all', [CompletionResultType]::ParameterName, 'Include hidden agents as well')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;start' {
[CompletionResult]::new('--args', '--args', [CompletionResultType]::ParameterName, 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)')
[CompletionResult]::new('--env', '--env', [CompletionResultType]::ParameterName, 'Set an environment variable for the agent (repeatable): --env KEY=VALUE')
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-b', '-b', [CompletionResultType]::ParameterName, 'Run detached in the background; output goes to the agent log file')
[CompletionResult]::new('--background', '--background', [CompletionResultType]::ParameterName, 'Run detached in the background; output goes to the agent log file')
[CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'Run in the foreground, attached to this terminal (the default)')
[CompletionResult]::new('--foreground', '--foreground', [CompletionResultType]::ParameterName, 'Run in the foreground, attached to this terminal (the default)')
[CompletionResult]::new('--notify', '--notify', [CompletionResultType]::ParameterName, 'Send a desktop notification once the agent has started')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;stop' {
[CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'Grace period in seconds before killing (default: from config, 5s)')
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'Kill immediately instead of terminating gracefully')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;restart' {
[CompletionResult]::new('--args', '--args', [CompletionResultType]::ParameterName, 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)')
[CompletionResult]::new('--env', '--env', [CompletionResultType]::ParameterName, 'Set an environment variable for the agent (repeatable): --env KEY=VALUE')
[CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'Grace period in seconds before killing (default: from config, 5s)')
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-b', '-b', [CompletionResultType]::ParameterName, 'Run detached in the background; output goes to the agent log file')
[CompletionResult]::new('--background', '--background', [CompletionResultType]::ParameterName, 'Run detached in the background; output goes to the agent log file')
[CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'Run in the foreground, attached to this terminal (the default)')
[CompletionResult]::new('--foreground', '--foreground', [CompletionResultType]::ParameterName, 'Run in the foreground, attached to this terminal (the default)')
[CompletionResult]::new('--notify', '--notify', [CompletionResultType]::ParameterName, 'Send a desktop notification once the agent has started')
[CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'Kill immediately instead of terminating gracefully')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;status' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;install' {
[CompletionResult]::new('--method', '--method', [CompletionResultType]::ParameterName, 'Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)')
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--force', '--force', [CompletionResultType]::ParameterName, 'Reinstall even if already installed')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;uninstall' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--purge', '--purge', [CompletionResultType]::ParameterName, 'Also remove logs and the agent entry from the user config file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;update' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--all', '--all', [CompletionResultType]::ParameterName, 'Update every installed managed agent')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;search' {
[CompletionResult]::new('--category', '--category', [CompletionResultType]::ParameterName, 'Restrict to this category')
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;info' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;config' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Print the effective (merged) configuration as YAML')
[CompletionResult]::new('path', 'path', [CompletionResultType]::ParameterValue, 'Print the location of the configuration file(s)')
[CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Open the user configuration file in your editor')
[CompletionResult]::new('validate', 'validate', [CompletionResultType]::ParameterValue, 'Validate the configuration file(s)')
[CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add another agent definition file to the user configuration')
[CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
break
}
'am;config;show' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;config;path' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;config;edit' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;config;validate' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;config;add' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;config;help' {
[CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Print the effective (merged) configuration as YAML')
[CompletionResult]::new('path', 'path', [CompletionResultType]::ParameterValue, 'Print the location of the configuration file(s)')
[CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Open the user configuration file in your editor')
[CompletionResult]::new('validate', 'validate', [CompletionResultType]::ParameterValue, 'Validate the configuration file(s)')
[CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add another agent definition file to the user configuration')
[CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
break
}
'am;config;help;show' {
break
}
'am;config;help;path' {
break
}
'am;config;help;edit' {
break
}
'am;config;help;validate' {
break
}
'am;config;help;add' {
break
}
'am;config;help;help' {
break
}
'am;doctor' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--fix', '--fix', [CompletionResultType]::ParameterName, 'Attempt to repair problems (create directories, fix the state file)')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;run' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;completion' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;self-update' {
[CompletionResult]::new('--to', '--to', [CompletionResultType]::ParameterName, 'Download the new binary to this path instead of replacing the current one')
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--check', '--check', [CompletionResultType]::ParameterName, 'Only check whether a newer version exists')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;export' {
[CompletionResult]::new('--output', '--output', [CompletionResultType]::ParameterName, 'Output file (default: am-export.json)')
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;import' {
[CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('--config', '--config', [CompletionResultType]::ParameterName, 'Use an alternate configuration file')
[CompletionResult]::new('-v', '-v', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('--verbose', '--verbose', [CompletionResultType]::ParameterName, 'Verbose output: show every command executed and its details')
[CompletionResult]::new('-q', '-q', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('--quiet', '--quiet', [CompletionResultType]::ParameterName, 'Quiet mode: only errors are printed')
[CompletionResult]::new('-y', '-y', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--yes', '--yes', [CompletionResultType]::ParameterName, 'Answer yes to every confirmation prompt')
[CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Simulate the action without changing anything')
[CompletionResult]::new('--json', '--json', [CompletionResultType]::ParameterName, 'Emit machine-readable JSON on stdout (for scripts)')
[CompletionResult]::new('--no-color', '--no-color', [CompletionResultType]::ParameterName, 'Disable ANSI colors')
[CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
[CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
break
}
'am;help' {
[CompletionResult]::new('list', 'list', [CompletionResultType]::ParameterValue, 'List agents: installed ones, or every agent known to the catalog')
[CompletionResult]::new('start', 'start', [CompletionResultType]::ParameterValue, 'Start an agent (foreground by default, or detached with --background)')
[CompletionResult]::new('stop', 'stop', [CompletionResultType]::ParameterValue, 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)')
[CompletionResult]::new('restart', 'restart', [CompletionResultType]::ParameterValue, 'Restart an agent: stop, then start with the same options')
[CompletionResult]::new('status', 'status', [CompletionResultType]::ParameterValue, 'Show the state of one agent, or of every installed agent')
[CompletionResult]::new('install', 'install', [CompletionResultType]::ParameterValue, 'Install an agent and its dependencies')
[CompletionResult]::new('uninstall', 'uninstall', [CompletionResultType]::ParameterValue, 'Uninstall an agent and clean its files')
[CompletionResult]::new('update', 'update', [CompletionResultType]::ParameterValue, 'Update an installed agent to the latest available version')
[CompletionResult]::new('search', 'search', [CompletionResultType]::ParameterValue, 'Search the catalog by keyword (name, description, category, tags)')
[CompletionResult]::new('info', 'info', [CompletionResultType]::ParameterValue, 'Show detailed information about one agent')
[CompletionResult]::new('config', 'config', [CompletionResultType]::ParameterValue, 'Manage the configuration file')
[CompletionResult]::new('doctor', 'doctor', [CompletionResultType]::ParameterValue, 'Check the environment: tools, config validity, paths, permissions')
[CompletionResult]::new('run', 'run', [CompletionResultType]::ParameterValue, 'Run the agent command directly with the given arguments (no process management)')
[CompletionResult]::new('completion', 'completion', [CompletionResultType]::ParameterValue, 'Generate a shell completion script')
[CompletionResult]::new('self-update', 'self-update', [CompletionResultType]::ParameterValue, 'Update agent-manager itself from GitHub Releases')
[CompletionResult]::new('export', 'export', [CompletionResultType]::ParameterValue, 'Export the configuration and installation state (backup)')
[CompletionResult]::new('import', 'import', [CompletionResultType]::ParameterValue, 'Import a previously exported configuration and state')
[CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
break
}
'am;help;list' {
break
}
'am;help;start' {
break
}
'am;help;stop' {
break
}
'am;help;restart' {
break
}
'am;help;status' {
break
}
'am;help;install' {
break
}
'am;help;uninstall' {
break
}
'am;help;update' {
break
}
'am;help;search' {
break
}
'am;help;info' {
break
}
'am;help;config' {
[CompletionResult]::new('show', 'show', [CompletionResultType]::ParameterValue, 'Print the effective (merged) configuration as YAML')
[CompletionResult]::new('path', 'path', [CompletionResultType]::ParameterValue, 'Print the location of the configuration file(s)')
[CompletionResult]::new('edit', 'edit', [CompletionResultType]::ParameterValue, 'Open the user configuration file in your editor')
[CompletionResult]::new('validate', 'validate', [CompletionResultType]::ParameterValue, 'Validate the configuration file(s)')
[CompletionResult]::new('add', 'add', [CompletionResultType]::ParameterValue, 'Add another agent definition file to the user configuration')
break
}
'am;help;config;show' {
break
}
'am;help;config;path' {
break
}
'am;help;config;edit' {
break
}
'am;help;config;validate' {
break
}
'am;help;config;add' {
break
}
'am;help;doctor' {
break
}
'am;help;run' {
break
}
'am;help;completion' {
break
}
'am;help;self-update' {
break
}
'am;help;export' {
break
}
'am;help;import' {
break
}
'am;help;help' {
break
}
})
$completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
Sort-Object -Property ListItemText
}
+1175
View File
File diff suppressed because it is too large Load Diff
+514
View File
@@ -0,0 +1,514 @@
use builtin;
use str;
set edit:completion:arg-completer[am] = {|@words|
fn spaces {|n|
builtin:repeat $n ' ' | str:join ''
}
fn cand {|text desc|
edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc
}
var command = 'am'
for word $words[1..-1] {
if (str:has-prefix $word '-') {
break
}
set command = $command';'$word
}
var completions = [
&'am'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help (see more with ''--help'')'
cand --help 'Print help (see more with ''--help'')'
cand -V 'Print version'
cand --version 'Print version'
cand list 'List agents: installed ones, or every agent known to the catalog'
cand start 'Start an agent (foreground by default, or detached with --background)'
cand stop 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)'
cand restart 'Restart an agent: stop, then start with the same options'
cand status 'Show the state of one agent, or of every installed agent'
cand install 'Install an agent and its dependencies'
cand uninstall 'Uninstall an agent and clean its files'
cand update 'Update an installed agent to the latest available version'
cand search 'Search the catalog by keyword (name, description, category, tags)'
cand info 'Show detailed information about one agent'
cand config 'Manage the configuration file'
cand doctor 'Check the environment: tools, config validity, paths, permissions'
cand run 'Run the agent command directly with the given arguments (no process management)'
cand completion 'Generate a shell completion script'
cand self-update 'Update agent-manager itself from GitHub Releases'
cand export 'Export the configuration and installation state (backup)'
cand import 'Import a previously exported configuration and state'
cand help 'Print this message or the help of the given subcommand(s)'
}
&'am;list'= {
cand --category 'Only show agents of this category'
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand --installed-only 'Only show agents that are installed (managed or external)'
cand --all 'Include hidden agents as well'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;start'= {
cand --args 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)'
cand --env 'Set an environment variable for the agent (repeatable): --env KEY=VALUE'
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -b 'Run detached in the background; output goes to the agent log file'
cand --background 'Run detached in the background; output goes to the agent log file'
cand -f 'Run in the foreground, attached to this terminal (the default)'
cand --foreground 'Run in the foreground, attached to this terminal (the default)'
cand --notify 'Send a desktop notification once the agent has started'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;stop'= {
cand --timeout 'Grace period in seconds before killing (default: from config, 5s)'
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand --force 'Kill immediately instead of terminating gracefully'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;restart'= {
cand --args 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)'
cand --env 'Set an environment variable for the agent (repeatable): --env KEY=VALUE'
cand --timeout 'Grace period in seconds before killing (default: from config, 5s)'
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -b 'Run detached in the background; output goes to the agent log file'
cand --background 'Run detached in the background; output goes to the agent log file'
cand -f 'Run in the foreground, attached to this terminal (the default)'
cand --foreground 'Run in the foreground, attached to this terminal (the default)'
cand --notify 'Send a desktop notification once the agent has started'
cand --force 'Kill immediately instead of terminating gracefully'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;status'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;install'= {
cand --method 'Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)'
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand --force 'Reinstall even if already installed'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;uninstall'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand --purge 'Also remove logs and the agent entry from the user config file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;update'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand --all 'Update every installed managed agent'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;search'= {
cand --category 'Restrict to this category'
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;info'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;config'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
cand show 'Print the effective (merged) configuration as YAML'
cand path 'Print the location of the configuration file(s)'
cand edit 'Open the user configuration file in your editor'
cand validate 'Validate the configuration file(s)'
cand add 'Add another agent definition file to the user configuration'
cand help 'Print this message or the help of the given subcommand(s)'
}
&'am;config;show'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;config;path'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;config;edit'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;config;validate'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;config;add'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;config;help'= {
cand show 'Print the effective (merged) configuration as YAML'
cand path 'Print the location of the configuration file(s)'
cand edit 'Open the user configuration file in your editor'
cand validate 'Validate the configuration file(s)'
cand add 'Add another agent definition file to the user configuration'
cand help 'Print this message or the help of the given subcommand(s)'
}
&'am;config;help;show'= {
}
&'am;config;help;path'= {
}
&'am;config;help;edit'= {
}
&'am;config;help;validate'= {
}
&'am;config;help;add'= {
}
&'am;config;help;help'= {
}
&'am;doctor'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand --fix 'Attempt to repair problems (create directories, fix the state file)'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;run'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;completion'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;self-update'= {
cand --to 'Download the new binary to this path instead of replacing the current one'
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand --check 'Only check whether a newer version exists'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;export'= {
cand --output 'Output file (default: am-export.json)'
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;import'= {
cand -c 'Use an alternate configuration file'
cand --config 'Use an alternate configuration file'
cand -v 'Verbose output: show every command executed and its details'
cand --verbose 'Verbose output: show every command executed and its details'
cand -q 'Quiet mode: only errors are printed'
cand --quiet 'Quiet mode: only errors are printed'
cand -y 'Answer yes to every confirmation prompt'
cand --yes 'Answer yes to every confirmation prompt'
cand --dry-run 'Simulate the action without changing anything'
cand --json 'Emit machine-readable JSON on stdout (for scripts)'
cand --no-color 'Disable ANSI colors'
cand -h 'Print help'
cand --help 'Print help'
}
&'am;help'= {
cand list 'List agents: installed ones, or every agent known to the catalog'
cand start 'Start an agent (foreground by default, or detached with --background)'
cand stop 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)'
cand restart 'Restart an agent: stop, then start with the same options'
cand status 'Show the state of one agent, or of every installed agent'
cand install 'Install an agent and its dependencies'
cand uninstall 'Uninstall an agent and clean its files'
cand update 'Update an installed agent to the latest available version'
cand search 'Search the catalog by keyword (name, description, category, tags)'
cand info 'Show detailed information about one agent'
cand config 'Manage the configuration file'
cand doctor 'Check the environment: tools, config validity, paths, permissions'
cand run 'Run the agent command directly with the given arguments (no process management)'
cand completion 'Generate a shell completion script'
cand self-update 'Update agent-manager itself from GitHub Releases'
cand export 'Export the configuration and installation state (backup)'
cand import 'Import a previously exported configuration and state'
cand help 'Print this message or the help of the given subcommand(s)'
}
&'am;help;list'= {
}
&'am;help;start'= {
}
&'am;help;stop'= {
}
&'am;help;restart'= {
}
&'am;help;status'= {
}
&'am;help;install'= {
}
&'am;help;uninstall'= {
}
&'am;help;update'= {
}
&'am;help;search'= {
}
&'am;help;info'= {
}
&'am;help;config'= {
cand show 'Print the effective (merged) configuration as YAML'
cand path 'Print the location of the configuration file(s)'
cand edit 'Open the user configuration file in your editor'
cand validate 'Validate the configuration file(s)'
cand add 'Add another agent definition file to the user configuration'
}
&'am;help;config;show'= {
}
&'am;help;config;path'= {
}
&'am;help;config;edit'= {
}
&'am;help;config;validate'= {
}
&'am;help;config;add'= {
}
&'am;help;doctor'= {
}
&'am;help;run'= {
}
&'am;help;completion'= {
}
&'am;help;self-update'= {
}
&'am;help;export'= {
}
&'am;help;import'= {
}
&'am;help;help'= {
}
]
$completions[$command]
}
+290
View File
@@ -0,0 +1,290 @@
# Print an optspec for argparse to handle cmd's options that are independent of any subcommand.
function __fish_am_global_optspecs
string join \n c/config= v/verbose q/quiet y/yes dry-run json no-color h/help V/version
end
function __fish_am_needs_command
# Figure out if the current invocation already has a command.
set -l cmd (commandline -opc)
set -e cmd[1]
argparse -s (__fish_am_global_optspecs) -- $cmd 2>/dev/null
or return
if set -q argv[1]
# Also print the command, so this can be used to figure out what it is.
echo $argv[1]
return 1
end
return 0
end
function __fish_am_using_subcommand
set -l cmd (__fish_am_needs_command)
test -z "$cmd"
and return 1
contains -- $cmd[1] $argv
end
complete -c am -n "__fish_am_needs_command" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_needs_command" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_needs_command" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_needs_command" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_needs_command" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_needs_command" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_needs_command" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_needs_command" -s h -l help -d 'Print help (see more with \'--help\')'
complete -c am -n "__fish_am_needs_command" -s V -l version -d 'Print version'
complete -c am -n "__fish_am_needs_command" -f -a "list" -d 'List agents: installed ones, or every agent known to the catalog'
complete -c am -n "__fish_am_needs_command" -f -a "start" -d 'Start an agent (foreground by default, or detached with --background)'
complete -c am -n "__fish_am_needs_command" -f -a "stop" -d 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)'
complete -c am -n "__fish_am_needs_command" -f -a "restart" -d 'Restart an agent: stop, then start with the same options'
complete -c am -n "__fish_am_needs_command" -f -a "status" -d 'Show the state of one agent, or of every installed agent'
complete -c am -n "__fish_am_needs_command" -f -a "install" -d 'Install an agent and its dependencies'
complete -c am -n "__fish_am_needs_command" -f -a "uninstall" -d 'Uninstall an agent and clean its files'
complete -c am -n "__fish_am_needs_command" -f -a "update" -d 'Update an installed agent to the latest available version'
complete -c am -n "__fish_am_needs_command" -f -a "search" -d 'Search the catalog by keyword (name, description, category, tags)'
complete -c am -n "__fish_am_needs_command" -f -a "info" -d 'Show detailed information about one agent'
complete -c am -n "__fish_am_needs_command" -f -a "config" -d 'Manage the configuration file'
complete -c am -n "__fish_am_needs_command" -f -a "doctor" -d 'Check the environment: tools, config validity, paths, permissions'
complete -c am -n "__fish_am_needs_command" -f -a "run" -d 'Run the agent command directly with the given arguments (no process management)'
complete -c am -n "__fish_am_needs_command" -f -a "completion" -d 'Generate a shell completion script'
complete -c am -n "__fish_am_needs_command" -f -a "self-update" -d 'Update agent-manager itself from GitHub Releases'
complete -c am -n "__fish_am_needs_command" -f -a "export" -d 'Export the configuration and installation state (backup)'
complete -c am -n "__fish_am_needs_command" -f -a "import" -d 'Import a previously exported configuration and state'
complete -c am -n "__fish_am_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
complete -c am -n "__fish_am_using_subcommand list" -l category -d 'Only show agents of this category' -r
complete -c am -n "__fish_am_using_subcommand list" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand list" -l installed-only -d 'Only show agents that are installed (managed or external)'
complete -c am -n "__fish_am_using_subcommand list" -l all -d 'Include hidden agents as well'
complete -c am -n "__fish_am_using_subcommand list" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand list" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand list" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand list" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand list" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand list" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand list" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand start" -l args -d 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)' -r
complete -c am -n "__fish_am_using_subcommand start" -l env -d 'Set an environment variable for the agent (repeatable): --env KEY=VALUE' -r
complete -c am -n "__fish_am_using_subcommand start" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand start" -s b -l background -d 'Run detached in the background; output goes to the agent log file'
complete -c am -n "__fish_am_using_subcommand start" -s f -l foreground -d 'Run in the foreground, attached to this terminal (the default)'
complete -c am -n "__fish_am_using_subcommand start" -l notify -d 'Send a desktop notification once the agent has started'
complete -c am -n "__fish_am_using_subcommand start" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand start" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand start" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand start" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand start" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand start" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand start" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand stop" -l timeout -d 'Grace period in seconds before killing (default: from config, 5s)' -r
complete -c am -n "__fish_am_using_subcommand stop" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand stop" -l force -d 'Kill immediately instead of terminating gracefully'
complete -c am -n "__fish_am_using_subcommand stop" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand stop" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand stop" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand stop" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand stop" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand stop" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand stop" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand restart" -l args -d 'Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)' -r
complete -c am -n "__fish_am_using_subcommand restart" -l env -d 'Set an environment variable for the agent (repeatable): --env KEY=VALUE' -r
complete -c am -n "__fish_am_using_subcommand restart" -l timeout -d 'Grace period in seconds before killing (default: from config, 5s)' -r
complete -c am -n "__fish_am_using_subcommand restart" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand restart" -s b -l background -d 'Run detached in the background; output goes to the agent log file'
complete -c am -n "__fish_am_using_subcommand restart" -s f -l foreground -d 'Run in the foreground, attached to this terminal (the default)'
complete -c am -n "__fish_am_using_subcommand restart" -l notify -d 'Send a desktop notification once the agent has started'
complete -c am -n "__fish_am_using_subcommand restart" -l force -d 'Kill immediately instead of terminating gracefully'
complete -c am -n "__fish_am_using_subcommand restart" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand restart" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand restart" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand restart" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand restart" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand restart" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand restart" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand status" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand status" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand status" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand status" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand status" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand status" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand status" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand status" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand install" -l method -d 'Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)' -r
complete -c am -n "__fish_am_using_subcommand install" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand install" -l force -d 'Reinstall even if already installed'
complete -c am -n "__fish_am_using_subcommand install" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand install" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand install" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand install" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand install" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand install" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand install" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand uninstall" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand uninstall" -l purge -d 'Also remove logs and the agent entry from the user config file'
complete -c am -n "__fish_am_using_subcommand uninstall" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand uninstall" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand uninstall" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand uninstall" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand uninstall" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand uninstall" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand uninstall" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand update" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand update" -l all -d 'Update every installed managed agent'
complete -c am -n "__fish_am_using_subcommand update" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand update" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand update" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand update" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand update" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand update" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand update" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand search" -l category -d 'Restrict to this category' -r
complete -c am -n "__fish_am_using_subcommand search" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand search" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand search" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand search" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand search" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand search" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand search" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand search" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand info" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand info" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand info" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand info" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand info" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand info" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand info" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand info" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "show" -d 'Print the effective (merged) configuration as YAML'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "path" -d 'Print the location of the configuration file(s)'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "edit" -d 'Open the user configuration file in your editor'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "validate" -d 'Validate the configuration file(s)'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "add" -d 'Add another agent definition file to the user configuration'
complete -c am -n "__fish_am_using_subcommand config; and not __fish_seen_subcommand_from show path edit validate add help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from show" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from path" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from edit" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from validate" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from add" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "show" -d 'Print the effective (merged) configuration as YAML'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "path" -d 'Print the location of the configuration file(s)'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "edit" -d 'Open the user configuration file in your editor'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "validate" -d 'Validate the configuration file(s)'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "add" -d 'Add another agent definition file to the user configuration'
complete -c am -n "__fish_am_using_subcommand config; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
complete -c am -n "__fish_am_using_subcommand doctor" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand doctor" -l fix -d 'Attempt to repair problems (create directories, fix the state file)'
complete -c am -n "__fish_am_using_subcommand doctor" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand doctor" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand doctor" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand doctor" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand doctor" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand doctor" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand doctor" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand run" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand run" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand run" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand run" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand run" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand run" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand run" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand run" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand completion" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand completion" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand completion" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand completion" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand completion" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand completion" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand completion" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand completion" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand self-update" -l to -d 'Download the new binary to this path instead of replacing the current one' -r -F
complete -c am -n "__fish_am_using_subcommand self-update" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand self-update" -l check -d 'Only check whether a newer version exists'
complete -c am -n "__fish_am_using_subcommand self-update" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand self-update" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand self-update" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand self-update" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand self-update" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand self-update" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand self-update" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand export" -l output -d 'Output file (default: am-export.json)' -r -F
complete -c am -n "__fish_am_using_subcommand export" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand export" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand export" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand export" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand export" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand export" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand export" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand export" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand import" -s c -l config -d 'Use an alternate configuration file' -r -F
complete -c am -n "__fish_am_using_subcommand import" -s v -l verbose -d 'Verbose output: show every command executed and its details'
complete -c am -n "__fish_am_using_subcommand import" -s q -l quiet -d 'Quiet mode: only errors are printed'
complete -c am -n "__fish_am_using_subcommand import" -s y -l yes -d 'Answer yes to every confirmation prompt'
complete -c am -n "__fish_am_using_subcommand import" -l dry-run -d 'Simulate the action without changing anything'
complete -c am -n "__fish_am_using_subcommand import" -l json -d 'Emit machine-readable JSON on stdout (for scripts)'
complete -c am -n "__fish_am_using_subcommand import" -l no-color -d 'Disable ANSI colors'
complete -c am -n "__fish_am_using_subcommand import" -s h -l help -d 'Print help'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "list" -d 'List agents: installed ones, or every agent known to the catalog'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "start" -d 'Start an agent (foreground by default, or detached with --background)'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "stop" -d 'Stop a background agent (SIGTERM, then SIGKILL after the timeout)'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "restart" -d 'Restart an agent: stop, then start with the same options'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "status" -d 'Show the state of one agent, or of every installed agent'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "install" -d 'Install an agent and its dependencies'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "uninstall" -d 'Uninstall an agent and clean its files'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "update" -d 'Update an installed agent to the latest available version'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "search" -d 'Search the catalog by keyword (name, description, category, tags)'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "info" -d 'Show detailed information about one agent'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "config" -d 'Manage the configuration file'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "doctor" -d 'Check the environment: tools, config validity, paths, permissions'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "run" -d 'Run the agent command directly with the given arguments (no process management)'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "completion" -d 'Generate a shell completion script'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "self-update" -d 'Update agent-manager itself from GitHub Releases'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "export" -d 'Export the configuration and installation state (backup)'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "import" -d 'Import a previously exported configuration and state'
complete -c am -n "__fish_am_using_subcommand help; and not __fish_seen_subcommand_from list start stop restart status install uninstall update search info config doctor run completion self-update export import help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "show" -d 'Print the effective (merged) configuration as YAML'
complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "path" -d 'Print the location of the configuration file(s)'
complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "edit" -d 'Open the user configuration file in your editor'
complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "validate" -d 'Validate the configuration file(s)'
complete -c am -n "__fish_am_using_subcommand help; and __fish_seen_subcommand_from config" -f -a "add" -d 'Add another agent definition file to the user configuration'
+980
View File
@@ -0,0 +1,980 @@
# ============================================================================
# agent-manager — default configuration (embedded in the binary)
# ============================================================================
# This is the base catalog. User configuration files
# (./agent-manager.yaml, ~/.config/agent-manager/config.yaml, --config <file>)
# override or extend it. Run 'am config show' to see the effective config.
#
# Install types: npm | pip | uv | cargo | go | bun | curl | binary | git
# ============================================================================
version: "1.0"
# --- Global settings (null = platform default) -------------------------------
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
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
self_update_repo: null # e.g. "you/agent-manager" for am self-update
# --- Command aliases ----------------------------------------------------------
aliases:
cc: claude-code
gemini: gemini-cli
gh-copilot: github-copilot
q: amazon-q
droid: factory-droid
gjc: gajae-code
tiny: tiny-agents
continue: continue-cli
# --- Groups (start/stop/restart several agents together) ----------------------
groups:
dev: [claude-code, aider, codex]
local: [atomic-agent, nanocoder, gptme, goose]
claw: [openclaw, zeroclaw, picoclaw]
mainstream: [claude-code, opencode, gemini-cli, codex, qwen-code]
# ============================================================================
# Agents
# ============================================================================
agents:
# ---------------------------------------------------------------- installés
- name: claude-code
display_name: "Claude Code"
description: "Agent terminal d'Anthropic, repo-aware. Édite, refactore, exécute des commandes git et itère sur les erreurs. Le standard de référence du coding agent."
category: coding-agent
website: https://github.com/anthropics/claude-code
install:
type: npm
package: "@anthropic-ai/claude-code"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: claude
tags: [anthropic, assistant, source-available, mainstream]
- 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)."
category: assistant
website: https://github.com/NousResearch/hermes-agent
install:
type: curl
script: "https://hermes-agent.nousresearch.com/install.sh"
dependencies:
- { name: curl, install_hint: "https://curl.se" }
- { name: sh, install_hint: "un shell POSIX" }
run: hermes
platforms: [linux, macos]
tags: [nous-research, memory, skills, multi-channel]
- name: atomic-agent
display_name: "Atomic Agent"
description: "Agent local-first optimisé pour les modèles locaux (llama.cpp/Ollama). Contrôle navigateur (CDP) + OS, 14 skills intégrés (docker, github, notion, obsidian, pdf, ffmpeg...)."
category: local-first
website: https://github.com/AtomicBot-ai/atomic-agent
install:
type: binary
repo: AtomicBot-ai/atomic-agent
binary: atomic-agent
run: atomic-agent
tags: [local, ollama, browser, skills]
- name: pi
display_name: "Pi"
description: "Harness terminal minimal et adaptable (pi-mono). API LLM unifiée, TUI, skills et MCP. C'est la base sur laquelle Prime Agent est construit."
category: coding-agent
website: https://github.com/badlogic/pi-mono
install:
type: npm
package: "@earendil-works/pi-coding-agent"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: pi
tags: [harness, mcp, tui]
- name: tiny-agents
display_name: "Tiny Agents"
description: "Squad d'agents composables basés sur l'Inference Client HuggingFace + MCP. Chaque agent est défini par un agent.json."
category: coding-agent
website: https://huggingface.co/datasets/tiny-agents/tiny-agents
install:
type: pip
package: tiny-agents
dependencies:
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
run: tiny-agents
note: "usage : tiny-agents run <chemin-vers-agent.json>"
tags: [huggingface, mcp, experimental]
- name: nemoclaw
display_name: "NemoClaw"
description: "CLI NVIDIA pour provisionner et gérer des sandboxes sécurisées (OpenShell runtime) exécutant des agents. Politiques réseau, filesystem et process."
category: coding-agent
website: https://github.com/NVIDIA/NemoClaw
install:
type: npm
package: nemoclaw
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: nemoclaw
tags: [nvidia, sandbox, docker]
# --------------------------------------------------------------- Prime Agent
- name: prime-agent
display_name: "Prime Agent"
description: "Agent open-source (MIT) de Prime Intellect. RLM (Recursive Language Model) — contexte traité comme variables, outils comme appels de fonction dans un REPL IPython persistant. Construit sur pi."
category: coding-agent
website: https://github.com/PrimeIntellect-ai/prime-agent
install:
type: curl
script: "https://app.primeintellect.ai/prime-agent/install.sh"
dependencies:
- { name: curl, install_hint: "https://curl.se" }
- { name: sh, install_hint: "un shell POSIX" }
run: prime-agent
platforms: [linux, macos]
tags: [rlm, daemon, self-improving]
# --------------------------------------------------- Poids lourds open source
- name: opencode
display_name: "OpenCode"
description: "Agent terminal 75+ providers, intégration LSP, design privacy-first."
category: coding-agent
website: https://github.com/anomalyco/opencode
install:
type: npm
package: opencode-ai
methods:
- type: curl
script: "https://opencode.ai/install"
note: "installateur officiel (bash)"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: opencode
tags: [multi-provider, lsp, privacy]
- name: claw-code
display_name: "Claw Code"
description: "Réécriture clean-room Rust/Python de l'architecture Claude Code. Le repo le plus rapide de l'histoire de GitHub à atteindre 100K stars."
category: coding-agent
website: https://github.com/ultraworkers/claw-code
install:
type: binary
repo: ultraworkers/claw-code
binary: claw-code
run: claw-code
note: "projet en mode musée — maintenance 100 % automatisée"
tags: [rust, clean-room, fork]
- name: gemini-cli
display_name: "Gemini CLI"
description: "Agent terminal officiel Google (Apache-2.0), outils repo + recherche."
category: coding-agent
website: https://github.com/google-gemini/gemini-cli
install:
type: npm
package: "@google/gemini-cli"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: gemini
tags: [google, mainstream]
- name: codex
display_name: "Codex CLI"
description: "Agent local officiel OpenAI, lecture/édition/exécution de code, TUI interactif (Apache-2.0)."
category: coding-agent
website: https://github.com/openai/codex
install:
type: npm
package: "@openai/codex"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: codex
tags: [openai, mainstream]
- name: openhands
display_name: "OpenHands"
description: "Environnement de développement agentique open-source (ex-OpenDevin), avec entrées CLI et web."
category: coding-agent
website: https://github.com/All-Hands-AI/OpenHands
install:
type: pip
package: openhands
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: openhands
note: "une image Docker officielle existe aussi (allhandsai/openhands)"
tags: [agentic-dev, web-ui]
- name: cline
display_name: "Cline CLI"
description: "Agent autonome multi-modèle pour planning, édition de fichiers, exécution de commandes et browser use."
category: coding-agent
website: https://github.com/cline/cline
install:
type: npm
package: "@cline/cline"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: cline
tags: [multi-model, planning, browser]
- name: goose
display_name: "Goose"
description: "Agent local et extensible, conçu pour tourner on-device, intégration MCP."
category: local-first
website: https://github.com/aaif-goose/goose
install:
type: npm
package: "@aaif-goose/goose"
methods:
- type: binary
repo: aaif-goose/goose
binary: goose
note: "binaire depuis les releases GitHub"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: goose
tags: [local, mcp, on-device]
- name: aider
display_name: "Aider"
description: "Pair-programming par diffs/patches, excellent support git et multi-fichiers. Le vétéran du genre."
category: coding-agent
website: https://github.com/Aider-AI/aider
install:
type: uv
package: aider-install
methods:
- type: pip
package: aider-install
note: "via pip (plus lent)"
post_install: ["aider-install"]
dependencies:
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
run: aider
note: "aider-install installe aider dans ~/.local (style pipx)"
tags: [diffs, git, pair-programming]
- name: continue-cli
display_name: "Continue CLI"
description: "Extension terminal multi-modèle, focus local/privacy."
category: coding-agent
website: https://github.com/continuedev/continue
install:
type: npm
package: "@continuedev/continue"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: continue
tags: [multi-model, privacy]
- name: deep-agents-code
display_name: "Deep Agents Code"
description: "Agent coding terminal officiel LangChain (Deep Agents SDK). TUI, opérations fichiers, shell, subagents, mode headless, human-in-the-loop."
category: coding-agent
website: https://github.com/langchain-ai/deepagents
install:
type: pip
package: deepagents-code
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: deepagents-code
tags: [langchain, subagents]
- name: crush
display_name: "Crush"
description: "TUI agentic Go signé Charmbracelet, multi-provider, LSP-aware, riche UI terminal."
category: coding-agent
website: https://github.com/charmbracelet/crush
install:
type: go
package: github.com/charmbracelet/crush@latest
dependencies:
- { name: go, min_version: "1.22.0", install_hint: "https://go.dev/dl" }
run: crush
tags: [go, tui, lsp]
- name: qwen-code
display_name: "Qwen Code"
description: "Agent CLI officiel Alibaba pour les modèles Qwen coder (Apache-2.0)."
category: coding-agent
website: https://github.com/QwenLM/qwen-code
install:
type: npm
package: "@qwen-code/qwen-code"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: qwen-code
tags: [alibaba, qwen]
- name: grok-build
display_name: "Grok Build"
description: "Harness et TUI de coding officiel xAI, fullscreen, interactif à la souris (Apache-2.0)."
category: coding-agent
website: https://github.com/xai-org/grok-build
install:
type: npm
package: "@xai-org/grok-build"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: grok-build
platforms: [linux, macos]
tags: [xai, tui]
- name: kimi-cli
display_name: "Kimi CLI"
description: "Agent CLI officiel Moonshot AI, skills, MCP, intégration ACP IDE (Apache-2.0)."
category: coding-agent
website: https://github.com/MoonshotAI/kimi-cli
install:
type: npm
package: "@moonshotai/kimi-cli"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: kimi
tags: [moonshot, skills, mcp]
- name: plandex
display_name: "Plandex"
description: "Agent CLI plan-first pour construire des features multi-fichiers avec étapes structurées et 2M tokens de contexte."
category: coding-agent
website: https://github.com/plandex-ai/plandex
install:
type: curl
script: "https://plandex.ai/install.sh"
dependencies:
- { name: curl, install_hint: "https://curl.se" }
- { name: sh, install_hint: "un shell POSIX" }
run: plandex
platforms: [linux, macos]
note: "développement au ralenti (dernier commit oct. 2025)"
tags: [plan-first, large-context]
- name: swe-agent
display_name: "SWE-agent"
description: "Agent pour résoudre de vrais issues de repo et tâches PR (workflows SWE-bench)."
category: coding-agent
website: https://github.com/SWE-agent/SWE-agent
install:
type: pip
package: swe-agent
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: sweagent
tags: [swe-bench, issues]
- name: open-interpreter
display_name: "Open Interpreter"
description: "Outil terminal qui exécute du code et des actions — faire des choses sur ma machine."
category: coding-agent
website: https://github.com/OpenInterpreter/open-interpreter
install:
type: pip
package: open-interpreter
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: interpreter
tags: [code-execution]
- name: mimo-code
display_name: "MiMo Code"
description: "Agent terminal officiel Xiaomi. TUI + mode non-interactif, MCP, skills, hooks, git worktrees, sessions reprenables."
category: coding-agent
website: https://github.com/XiaomiMiMo/MiMo-Code
install:
type: npm
package: "@xiaomi-mimo/cli"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: mimo
tags: [xiaomi, worktrees]
- name: smol-developer
display_name: "Smol Developer"
description: "Agent développeur embarquable qui génère des codebases entières depuis un prompt."
category: coding-agent
website: https://github.com/smol-ai/developer
install:
type: pip
package: smol-developer
dependencies:
- { name: python, min_version: "3.8.0", install_hint: "https://python.org" }
run: smol-developer
note: "projet inactif (dernier commit sept. 2023)"
tags: [codegen, pioneer]
- name: groq-code-cli
display_name: "Groq Code CLI"
description: "CLI léger propulsé par l'inférence ultra-rapide de Groq, outils/commandes extensibles."
category: coding-agent
website: https://github.com/build-with-groq/groq-code-cli
install:
type: npm
package: groq-code-cli
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: groq-code
tags: [groq, fast]
# ------------------------------------------------------- Trésors open source
- name: jcode
display_name: "jcode"
description: "Agent TUI Rust optimisé RAM et latence de démarrage (~28 MB/session). Mémoire agent, swarm mode, browser automation, MCP, 40+ providers avec OAuth intégré."
category: coding-agent
website: https://github.com/1jehuang/jcode
install:
type: binary
repo: 1jehuang/jcode
binary: jcode
run: jcode
tags: [rust, swarm, tui]
- name: agentty
display_name: "agentty"
description: "Agent terminal C++26, binaire statique 13.6 MB, cold start sub-milliseconde, zéro dépendance runtime, sandbox par défaut."
category: coding-agent
website: https://github.com/1ay1/agentty
install:
type: binary
repo: 1ay1/agentty
binary: agentty
run: agentty
tags: [c++, static, sandbox]
- name: nullclaw
display_name: "NullClaw"
description: "Plus petit agent compatible OpenClaw, en Zig. 678 KB, ~1MB RAM, <2ms startup, 23+ providers, 18 channels."
category: assistant
website: https://github.com/nullclaw/nullclaw
install:
type: binary
repo: nullclaw/nullclaw
binary: nullclaw
run: nullclaw
tags: [zig, tiny, openclaw]
- name: nanocoder
display_name: "Nanocoder"
description: "Agent CLI local-first communautaire. BYO model (Ollama, OpenRouter, API OpenAI-compatible), tool calling natif + fallback XML, MCP, tools personnalisés."
category: local-first
website: https://github.com/Nano-Collective/nanocoder
install:
type: uv
package: nanocoder
methods:
- type: pip
package: nanocoder
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: nanocoder
tags: [local, ollama, byo-model]
- name: gptme
display_name: "gptme"
description: "Agent dans ton terminal avec agents persistants auto-modifiants, mémoire git-backed. Exécute du code, édite des fichiers, browse le web."
category: coding-agent
website: https://github.com/gptme/gptme
install:
type: uv
package: gptme
methods:
- type: pip
package: gptme
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: gptme
tags: [persistent, memory]
- name: devon
display_name: "Devon"
description: "Pair programmer open-source avec TUI, planification, exécution et debugging autonomes dans les workflows git."
category: coding-agent
website: https://github.com/entropy-research/Devon
install:
type: pip
package: devon-agent
dependencies:
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
run: devon
note: "projet inactif (dernier commit juil. 2024)"
tags: [tui, autonomous]
- name: ra-aid
display_name: "RA.Aid"
description: "Agent de coding autonome sur LangGraph avec pipeline research -> plan -> implement, intégration aider optionnelle."
category: coding-agent
website: https://github.com/ai-christianson/RA.Aid
install:
type: pip
package: ra-aid
dependencies:
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
run: ra-aid
note: "développement au ralenti (juin 2025)"
tags: [langgraph, pipeline]
- name: letta-code
display_name: "Letta Code"
description: "Agent CLI memory-first (ex-MemGPT). Mémoire persistante entre sessions, model-agnostique, skill learning, context repositories."
category: coding-agent
website: https://github.com/letta-ai/letta-code
install:
type: pip
package: letta-code
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: letta-code
tags: [memory, memgpt]
- name: dvalincode
display_name: "DvalinCode"
description: "Agent local-first provider-neutre orienté gouvernance : policy engine, egress réseau forcé, audit trail hash-chaîné, approval diff inline, Web GUI embarqué."
category: local-first
website: https://github.com/arthurpanhku/dvalincode
install:
type: binary
repo: arthurpanhku/dvalincode
binary: dvalincode
run: dvalincode
tags: [governance, audit]
- name: san
display_name: "San"
description: "Runtime Go provider-agnostique pour agents spécialisés. Exécute les skills/plugins/MCP Claude Code sans modification, 9+ providers, mémoire auto-évolutive."
category: coding-agent
website: https://github.com/genai-io/san
install:
type: binary
repo: genai-io/san
binary: san
run: san
tags: [go, skills, runtime]
- name: zap
display_name: "Zap"
description: "Agent TUI Rust skill-first, injecte seulement le contexte nécessaire. Code-indexé SQLite, MCP, modèles locaux via LM Studio."
category: coding-agent
website: https://github.com/zap-coding-agent/zap-coding-agent
install:
type: binary
repo: zap-coding-agent/zap-coding-agent
binary: zap
run: zap
tags: [rust, tui, sqlite]
- name: smelt
display_name: "Smelt"
description: "Agent TUI Rust multi-provider, 4 modes (Normal/Plan/Apply/Yolo), permissions granulaires, subagents parallèles, keybinds vim, mode headless."
category: coding-agent
website: https://github.com/leonardcser/smelt
install:
type: cargo
package: smelt
dependencies:
- { name: cargo, min_version: "1.75.0", install_hint: "https://rustup.rs" }
run: smelt
tags: [rust, tui, vim]
- name: grinta
display_name: "Grinta"
description: "Agent terminal local-first pour exécution autonome long-horizon : état durable, récupération, gestion de contexte, orchestration d'outils, LSP/DAP."
category: local-first
website: https://github.com/josephsenior/Grinta-Coding-Agent
install:
type: pip
package: grinta
dependencies:
- { name: python, min_version: "3.10.0", install_hint: "https://python.org" }
run: grinta
tags: [long-horizon, lsp]
- name: waveloom
display_name: "Waveloom"
description: "Agent Go avec TUI Bubble Tea, DeepSeek V4 prompt caching, 4 niveaux de compaction de contexte, 3 modes subagent (Fork/Cold/Explore)."
category: coding-agent
website: https://github.com/Menfre01/waveloom
install:
type: binary
repo: Menfre01/waveloom
binary: waveloom
run: waveloom
tags: [go, tui, deepseek]
- name: every-code
display_name: "Every Code"
description: "Fork de Codex CLI avec validation, automation, intégration browser, multi-agents, theming et orchestration multi-provider (OpenAI/Claude/Gemini)."
category: coding-agent
website: https://github.com/just-every/code
install:
type: git
repo: "https://github.com/just-every/code"
build: ["npm", "install"]
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: "every code"
tags: [fork, codex, multi-provider]
- name: free-code
display_name: "Free Code"
description: "Fork de Claude Code sans télémétrie, guardrails retirés, toutes les features expérimentales activées."
category: coding-agent
website: https://github.com/paoloanzn/free-code
install:
type: git
repo: "https://github.com/paoloanzn/free-code"
build: ["npm", "install"]
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: free-code
tags: [fork, no-telemetry]
- name: claurst
display_name: "Claurst"
description: "Claude Code réécrit en Rust idiomatique, avec découvertes du leak source (KAIROS, buddy system)."
category: coding-agent
website: https://github.com/Kuberwastaken/claurst
install:
type: git
repo: "https://github.com/Kuberwastaken/claurst"
build: ["cargo", "build", "--release"]
binary_path: target/release/claurst
dependencies:
- { name: cargo, min_version: "1.75.0", install_hint: "https://rustup.rs" }
run: claurst
tags: [rust, rewrite]
- name: mistral-vibe
display_name: "Mistral Vibe"
description: "Assistant CLI Mistral pour interaction conversationnelle et éditions de repo."
category: coding-agent
website: https://github.com/mistralai/mistral-vibe
install:
type: npm
package: "@mistralai/mistral-vibe"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: mistral-vibe
tags: [mistral]
- name: amazon-q
display_name: "Amazon Q Dev CLI"
description: "Chat terminal agentic AWS pour build, debugging et DevOps en langage naturel."
category: coding-agent
website: https://github.com/aws/amazon-q-developer-cli
install:
type: npm
package: "@aws/amazon-q-developer-cli"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: q
tags: [aws, devops]
- name: lazycodex
display_name: "LazyCodex"
description: "Harness d'agent pour codebases complexes — package OmO comme distribution Codex. Mémoire projet, planning, exécution et complétion vérifiée à l'intérieur de Codex. L'équivalent de LazyVim pour Codex."
category: coding-agent
website: https://github.com/code-yeongyu/lazycodex
install:
type: npm
package: lazycodex-ai
post_install: ["lazycodex-ai", "install"]
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: "npx lazycodex-ai"
note: "s'intègre dans Codex (nécessite @openai/codex)"
tags: [harness, codex, plan]
- name: gajae-code
display_name: "Gajae-Code"
description: "Harness de coding externe (gjc) — se connecte au plan que tu paies déjà (Claude, Codex, Cursor, Copilot...). Workflow plan-gated, réponses aux questions depuis le téléphone."
category: coding-agent
website: https://github.com/Yeachan-Heo/gajae-code
install:
type: npm
package: gajae-code
methods:
- type: bun
package: gajae-code
note: "via bun (plus rapide)"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: gjc
tags: [harness, subscription, telegram]
# ------------------------------------------------------------ Local-first
- name: openyak
display_name: "openyak"
description: "Agent desktop local-first, zéro compte, zéro télémétrie. Modèles locaux via Ollama/Rapid-MLX ou BYO."
category: desktop
website: https://github.com/openyak/openyak
installable: false
note: "application desktop — téléchargez-la depuis https://github.com/openyak/openyak/releases"
tags: [desktop, ollama]
- name: kun
display_name: "Kun"
description: "Workspace local-first desktop GUI + TUI pour coding, écriture, design, recherche et automation."
category: desktop
website: https://github.com/KunAgent/Kun
installable: false
note: "application desktop — https://github.com/KunAgent/Kun/releases"
tags: [desktop, workspace]
- name: ipollowork
display_name: "iPolloWork"
description: "Workspace AI local-first source-available, alternative à Codex et Claude Code pour code, fichiers, docs, sites web."
category: local-first
website: https://github.com/Devin-AXIS/iPolloWork
install:
type: git
repo: "https://github.com/Devin-AXIS/iPolloWork"
run: ipollowork
note: "étapes de build spécifiques au repo — consultez son README"
tags: [workspace, source-available]
- name: cc-haha
display_name: "cc-haha"
description: "Workspace desktop local-first cross-platform pour Claude Code/agents : multi-agent, git worktrees, diffs, skills marketplace."
category: desktop
website: https://github.com/NanmiCoder/cc-haha
installable: false
note: "application desktop — https://github.com/NanmiCoder/cc-haha/releases"
tags: [desktop, multi-agent]
- name: claw-empire
display_name: "claw-empire"
description: "Simulateur de bureau d'agents AI local-first — orchestre CLI, OAuth et API depuis un CEO Desk."
category: local-first
website: https://github.com/GreenSheep01201/claw-empire
install:
type: git
repo: "https://github.com/GreenSheep01201/claw-empire"
run: claw-empire
note: "étapes de build spécifiques au repo — consultez son README"
tags: [simulator, orchestration]
# ----------------------------------------------------- Écosystème OpenClaw
- name: openclaw
display_name: "OpenClaw"
description: "L'assistant personnel AI local d'origine. CLI avec onboarding, skills, outils, multi-channel (WhatsApp/Slack/Discord)."
category: assistant
website: https://github.com/openclaw/openclaw
install:
type: npm
package: "@openclaw/openclaw"
dependencies:
- { name: node, min_version: "22.0.0", install_hint: "https://nodejs.org" }
run: openclaw
tags: [assistant, multi-channel, skills]
- name: nanobot
display_name: "nanobot"
description: "Réécriture ultra-légère (~4000 lignes Python) d'OpenClaw. Tool use, mémoire persistante, tâches planifiées, Telegram/Discord/WhatsApp."
category: assistant
website: https://github.com/HKUDS/nanobot
install:
type: git
repo: "https://github.com/HKUDS/nanobot"
dependencies:
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
run: "python -m nanobot"
tags: [python, lightweight]
- name: zeroclaw
display_name: "ZeroClaw"
description: "Runtime d'agent AI Rust autonome, architecture pluggable trait-driven, tourne sur matériel minimal (<5MB RAM), exécution sandboxée, recherche hybride."
category: assistant
website: https://github.com/zeroclaw-labs/zeroclaw
install:
type: binary
repo: zeroclaw-labs/zeroclaw
binary: zeroclaw
run: zeroclaw
tags: [rust, minimal, sandbox]
- name: nanoclaw
display_name: "NanoClaw"
description: "Alternative légère security-first à OpenClaw, agents en containers Apple/Docker, sandbox, basé sur Anthropic Agents SDK."
category: assistant
website: https://github.com/gavrielc/nanoclaw
install:
type: git
repo: "https://github.com/gavrielc/nanoclaw"
dependencies:
- { name: python, min_version: "3.9.0", install_hint: "https://python.org" }
run: nanoclaw
tags: [security, sandbox]
- name: picoclaw
display_name: "PicoClaw"
description: "Assistant personnel AI ultra-léger en Go, <10MB RAM, tourne sur du matériel à 10 dollars."
category: assistant
website: https://github.com/sipeed/picoclaw
install:
type: git
repo: "https://github.com/sipeed/picoclaw"
dependencies:
- { name: go, min_version: "1.21.0", install_hint: "https://go.dev/dl" }
run: picoclaw
tags: [go, tiny]
- name: ironclaw
display_name: "IronClaw"
description: "OpenClaw réécrit en Rust par NEAR AI. Sandbox WASM, permissions capability-based, défense anti prompt-injection."
category: assistant
website: https://github.com/nearai/ironclaw
install:
type: git
repo: "https://github.com/nearai/ironclaw"
dependencies:
- { name: cargo, min_version: "1.75.0", install_hint: "https://rustup.rs" }
run: ironclaw
tags: [rust, wasm, security]
- name: moltis
display_name: "Moltis"
description: "Alternative Rust-native à OpenClaw, zéro code unsafe, 2300+ tests, voice I/O intégré, MCP, multi-channel."
category: assistant
website: https://github.com/moltis-org/moltis
install:
type: git
repo: "https://github.com/moltis-org/moltis"
dependencies:
- { name: cargo, min_version: "1.75.0", install_hint: "https://rustup.rs" }
run: moltis
tags: [rust, voice, mcp]
# ---------------------------------------------------------- Propriétaires
- name: cursor-cli
display_name: "Cursor CLI"
description: "Agent CLI officiel Cursor, shell mode, headless/CI, agents parallèles, multi-modèles."
category: proprietary
website: https://cursor.com/cli
install:
type: npm
package: cursor-agent
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: cursor
note: "paquet npm communautaire — l'officiel est livré avec l'app Cursor"
tags: [cursor, proprietary]
- name: warp
display_name: "Warp"
description: "Terminal moderne avec mode agent AI intégré — comprend les tâches, exécute les commandes, édite les fichiers."
category: desktop
website: https://github.com/warpdotdev/Warp
installable: false
note: "application desktop — téléchargez-la depuis https://warp.dev"
tags: [terminal, desktop]
- name: github-copilot
display_name: "GitHub Copilot CLI"
description: "CLI agentic GitHub pour workflows repo/PR/issue, suggestions de commandes, automation headless."
category: proprietary
website: https://github.com/github/copilot-cli
install:
type: npm
package: "@github/copilot"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: copilot
tags: [github, copilot]
- name: devin
display_name: "Devin"
description: "Ingénieur logiciel AI autonome de Cognition, accès shell/browser complet, code auto-réparant, collaboration PR."
category: saas
website: https://devin.ai
installable: false
note: "SaaS — pas d'installation locale; utilisez https://devin.ai (web ou API)"
tags: [saas, autonomous]
- name: factory-droid
display_name: "Factory Droid"
description: "Agent CLI multi-modèle de Factory, #1 sur Terminal-Bench, droids spécialisés, mode headless CI."
category: proprietary
website: https://github.com/Factory-AI/factory
install:
type: npm
package: "@factory-ai/factory"
dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: droid
tags: [factory, benchmark, ci]
- name: amp
display_name: "Amp"
description: "Agent AI de Sourcegraph pour implémenter des tâches sur de vraies codebases."
category: saas
website: https://sourcegraph.com/amp
installable: false
note: "SaaS — via https://sourcegraph.com/amp"
tags: [saas, sourcegraph]
- name: junie-cli
display_name: "Junie CLI"
description: "Agent CLI LLM-agnostique JetBrains (EAP), GPT-5/Claude/Gemini/Grok, plan mode, headless CI/CD."
category: saas
website: https://junie.jetbrains.com
installable: false
note: "SaaS — via https://junie.jetbrains.com (installation gérée par JetBrains)"
tags: [saas, jetbrains]
- name: cortex-code-cli
display_name: "Cortex Code CLI"
description: "Agent CLI data-native de Snowflake pour pipelines, analytics et apps AI avec gouvernance enterprise."
category: saas
website: https://www.snowflake.com/en/product/cortex-code/
installable: false
note: "SaaS — via Snowflake (https://www.snowflake.com)"
tags: [saas, snowflake, data]
- name: tabnine-cli
display_name: "Tabnine CLI"
description: "Assistant coding AI agentic de Tabnine, distribué en container Docker, licence enterprise."
category: saas
website: https://docs.tabnine.com/main/getting-started/tabnine-cli
installable: false
note: "distribué en container Docker (licence enterprise) — voir https://docs.tabnine.com"
tags: [saas, docker]
- name: mentat-cli
display_name: "Mentat CLI"
description: "Agent CLI cloud-native pour gérer des agents Mentat distants depuis le terminal, détection auto repo/branch."
category: saas
website: https://mentat.ai/docs/cli
installable: false
note: "SaaS — via https://mentat.ai/docs/cli"
tags: [saas, remote-agents]
- name: pool
display_name: "Pool"
description: "Agent terminal de Poolside propulsé par les modèles Laguna, TUI + headless (pool exec), ACP client/server."
category: proprietary
website: https://github.com/poolsideai/pool
install:
type: binary
repo: poolsideai/pool
binary: pool
run: pool
note: "EULA propriétaire"
tags: [poolside, laguna]
- name: ante
display_name: "Ante"
description: "Binaire Rust 15 MB (research preview), architecture client-daemon, TUI + headless, inférence GGUF offline embarquée (llama.cpp), 12+ providers."
category: proprietary
website: https://github.com/AntigmaLabs/ante-preview
install:
type: binary
repo: AntigmaLabs/ante-preview
binary: ante
run: ante
tags: [offline, gguf, daemon]
+4
View File
@@ -0,0 +1,4 @@
no user config found — using the embedded default catalog
user config directory: C:\Users\bruno\AppData\Roaming\agent-manager
user config file: C:\Users\bruno\AppData\Roaming\agent-manager\config.yaml
local config file: agent-manager.yaml (current directory)
+1
View File
@@ -0,0 +1 @@
configuration is valid (72 agents)
+22
View File
@@ -0,0 +1,22 @@
CHECK STATUS DETAIL
-----------------------------
git ok C:\Users\bruno\scoop\apps\git\current\cmd\git.e…
sh ok C:\Users\bruno\scoop\shims\sh.exe (v5.3.15)
curl ok C:\Windows\system32\curl.exe (v8.21.0)
node ok C:\Users\bruno\scoop\apps\nodejs22\current\node…
npm ok C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
python3 ok C:\Users\bruno\scoop\shims\python3.exe (v3.13.1…
python ok C:\Users\bruno\AppData\Local\Microsoft\WindowsA…
cargo ok C:\Users\bruno\scoop\apps\rustup\current\.cargo…
go ok C:\Users\bruno\scoop\shims\go.exe (v1.26.5)
bun ok C:\Users\bruno\scoop\shims\bun.exe (v1.3.14)
uv ok C:\Users\bruno\scoop\shims\uv.exe (v0.12.2)
docker ok C:\Program Files\Docker\Docker\resources\bin\do…
config warn no user config file found; using the embedded d…
config-schema ok 72 agents defined
state ok 0 installed agent(s)
install-dir warn C:\Users\bruno\AppData\Local\agent-manager\agen…
bin-dir warn C:\Users\bruno\AppData\Local\agent-manager\agen…
log-dir ok C:\Users\bruno\AppData\Local\agent-manager\logs
bin-on-path warn C:\Users\bruno\AppData\Local\agent-manager\agen…
4 problem(s) found — 'am doctor --fix' repairs what it can
+56
View File
@@ -0,0 +1,56 @@
agent-manager (am) — a CLI to manage local AI coding agents.
Install, uninstall, start, stop, restart and update AI agents from a YAML
catalog. Handles dependencies (Node, Python, Go, Rust, Bun), binaries from
GitHub Releases, install scripts, and git repositories. All state lives under
your user directories; nothing pollutes the system.
Usage: am [OPTIONS] [COMMAND]
Commands:
list List installed agents (default); use --all for the whole catalog
start Start an agent (foreground by default, or detached with --background)
stop Stop a background agent (SIGTERM, then SIGKILL after the timeout)
restart Restart an agent: stop, then start with the same options
status Show the state of one agent, or of every installed agent
install Install an agent and its dependencies
uninstall Uninstall an agent and clean its files
update Update an installed agent to the latest available version
search Search the catalog by keyword (name, description, category, tags)
info Show detailed information about one agent
config Manage the configuration file
doctor Check the environment: tools, config validity, paths, permissions
run Run the agent command directly with the given arguments (no process management)
completion Generate a shell completion script
self-update Update agent-manager itself from GitHub Releases
export Export the configuration and installation state (backup)
import Import a previously exported configuration and state
help Print this message or the help of the given subcommand(s)
Options:
-c, --config <FILE>
Use an alternate configuration file
-v, --verbose
Verbose output: show every command executed and its details
-q, --quiet
Quiet mode: only errors are printed
-y, --yes
Answer yes to every confirmation prompt
--dry-run
Simulate the action without changing anything
--json
Emit machine-readable JSON on stdout (for scripts)
--no-color
Disable ANSI colors
-h, --help
Print help (see a summary with '-h')
-V, --version
Print version
+12
View File
@@ -0,0 +1,12 @@
Claude Code
slug: claude-code
description: Agent terminal d'Anthropic, repo-aware. Édite, refactore, exécute des commandes git et itère sur les erreurs. Le standard de référence du coding agent.
category: coding-agent
website: https://github.com/anthropics/claude-code
tags: anthropic, assistant, source-available, mainstream
status: found on PATH (external — not managed)
run: claude
install methods:
1) type=npm package=@anthropic-ai/claude-code
dependencies:
- node >= 18.0.0 — https://nodejs.org
+12
View File
@@ -0,0 +1,12 @@
Claude Code
slug: claude-code
description: Agent terminal d'Anthropic, repo-aware. Édite, refactore, exécute des commandes git et itère sur les erreurs. Le standard de référence du coding agent.
category: coding-agent
website: https://github.com/anthropics/claude-code
tags: anthropic, assistant, source-available, mainstream
status: found on PATH (external — not managed)
run: claude
install methods:
1) type=npm package=@anthropic-ai/claude-code
dependencies:
- node >= 18.0.0 — https://nodejs.org
+3
View File
@@ -0,0 +1,3 @@
error: agent 'devin' is not installable locally: SaaS — pas d'installation locale; utilisez https://devin.ai (web ou API)
[exit code: 1]
+4
View File
@@ -0,0 +1,4 @@
[dry-run] would run: cargo --version
installing Smelt (type=cargo package=smelt)…
[dry-run] would run: cargo install --root C:\Users\bruno\AppData\Local\agent-manager\agents\smelt smelt
dry run complete — nothing was changed
+74
View File
@@ -0,0 +1,74 @@
NAME VERSION STATUS SOURCE PATH
-------------------------------------------------------------
agentty — not-installed - —
Aider — not-installed - —
Amazon Q Dev CLI — not-installed - —
Amp — not-installable - —
Ante — not-installed - —
Atomic Agent — external external C:\Users\bruno\AppData\Local\atomic-agent\atomi…
cc-haha — not-installable - —
Claude Code 2.1.112 external external C:\Users\bruno\.local\bin\claude.exe
Claurst — not-installed - —
Claw Code — not-installed - —
claw-empire — not-installed - —
Cline CLI — not-installed - —
Codex CLI — not-installed - —
Continue CLI — not-installed - —
Cortex Code CLI — not-installable - —
Crush — not-installed - —
Cursor CLI — not-installed - —
Deep Agents Code — not-installed - —
Devin — not-installable - —
Devon — not-installed - —
DvalinCode — not-installed - —
Every Code — not-installed - —
Factory Droid — not-installed - —
Free Code — not-installed - —
Gajae-Code — not-installed - —
Gemini CLI — not-installed - —
GitHub Copilot CLI — not-installed - —
Goose — not-installed - —
gptme — not-installed - —
Grinta — not-installed - —
Grok Build — not-installed - —
Groq Code CLI — not-installed - —
Hermes Agent 2026.8.3 external external C:\Users\bruno\AppData\Local\hermes\hermes-agen…
iPolloWork — not-installed - —
IronClaw — not-installed - —
jcode 0.76.0 external external C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe
Junie CLI — not-installable - —
Kimi CLI 0.31.1 external external C:\Users\bruno\.kimi-code\bin\kimi.exe
Kun — not-installable - —
LazyCodex 11.1.0 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Letta Code — not-installed - —
Mentat CLI — not-installable - —
MiMo Code — not-installed - —
Mistral Vibe — not-installed - —
Moltis — not-installed - —
nanobot — external external C:\Users\bruno\AppData\Local\Microsoft\WindowsA…
NanoClaw — not-installed - —
Nanocoder — not-installed - —
NemoClaw — external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
NullClaw — not-installed - —
Open Interpreter — not-installed - —
OpenClaw — not-installed - —
OpenCode — not-installed - —
OpenHands — not-installed - —
openyak — not-installable - —
Pi 0.84.1 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
PicoClaw — not-installed - —
Plandex — not-installed - —
Pool — not-installed - —
Prime Agent 0.7.2 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Qwen Code — not-installed - —
RA.Aid — not-installed - —
San — not-installed - —
Smelt — not-installed - —
Smol Developer — not-installed - —
SWE-agent — not-installed - —
Tabnine CLI — not-installable - —
Tiny Agents — external external C:\Users\bruno\scoop\apps\python313\current\Scr…
Warp — not-installable - —
Waveloom — not-installed - —
Zap — not-installed - —
ZeroClaw — not-installed - —
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
NAME VERSION STATUS SOURCE PATH
------------------------------------------------
Atomic Agent — external external C:\Users\bruno\AppData\Local\atomic-agent\atomi…
Claude Code 2.1.112 external external C:\Users\bruno\.local\bin\claude.exe
Hermes Agent 2026.8.3 external external C:\Users\bruno\AppData\Local\hermes\hermes-agen…
jcode 0.76.0 external external C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe
Kimi CLI 0.31.1 external external C:\Users\bruno\.kimi-code\bin\kimi.exe
LazyCodex 11.1.0 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
nanobot — external external C:\Users\bruno\AppData\Local\Microsoft\WindowsA…
NemoClaw — external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Pi 0.84.1 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Prime Agent 0.7.2 external external C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Tiny Agents — external external C:\Users\bruno\scoop\apps\python313\current\Scr…
+36
View File
@@ -0,0 +1,36 @@
agent-manager (am) — manage local AI coding agents
Usage: am [OPTIONS] [COMMAND]
Commands:
list List agents: installed ones, or every agent known to the catalog
start Start an agent (foreground by default, or detached with --background)
stop Stop a background agent (SIGTERM, then SIGKILL after the timeout)
restart Restart an agent: stop, then start with the same options
status Show the state of one agent, or of every installed agent
install Install an agent and its dependencies
uninstall Uninstall an agent and clean its files
update Update an installed agent to the latest available version
search Search the catalog by keyword (name, description, category, tags)
info Show detailed information about one agent
config Manage the configuration file
doctor Check the environment: tools, config validity, paths, permissions
run Run the agent command directly with the given arguments (no process management)
completion Generate a shell completion script
self-update Update agent-manager itself from GitHub Releases
export Export the configuration and installation state (backup)
import Import a previously exported configuration and state
help Print this message or the help of the given subcommand(s)
Options:
-c, --config <FILE> Use an alternate configuration file
-v, --verbose Verbose output: show every command executed and its details
-q, --quiet Quiet mode: only errors are printed
-y, --yes Answer yes to every confirmation prompt
--dry-run Simulate the action without changing anything
--json Emit machine-readable JSON on stdout (for scripts)
--no-color Disable ANSI colors
-h, --help Print help (see more with '--help')
-V, --version Print version
exitcode=2
+1
View File
@@ -0,0 +1 @@
[dry-run] would run C:\Users\bruno\scoop\apps\nodejs22\current\bin\pi.cmd
+3
View File
@@ -0,0 +1,3 @@
NAME CATEGORY INSTALLABLE DESCRIPTION
----------------------------------------
no agent matches 'zzzz' (categories: assistant, coding-agent, desktop, local-first, proprietary, saas)
+11
View File
@@ -0,0 +1,11 @@
NAME CATEGORY INSTALLABLE DESCRIPTION
-------------------------------------------------
Claw Code coding-agent yes Réécriture clean-room Rust/Python de l'architec…
jcode coding-agent yes Agent TUI Rust optimisé RAM et latence de démar…
Zap coding-agent yes Agent TUI Rust skill-first, injecte seulement l…
Smelt coding-agent yes Agent TUI Rust multi-provider, 4 modes (Normal/…
Claurst coding-agent yes Claude Code réécrit en Rust idiomatique, avec d…
ZeroClaw assistant yes Runtime d'agent AI Rust autonome, architecture …
IronClaw assistant yes OpenClaw réécrit en Rust par NEAR AI. Sandbox W…
Moltis assistant yes Alternative Rust-native à OpenClaw, zéro code u…
Ante proprietary yes Binaire Rust 15 MB (research preview), architec…
+3
View File
@@ -0,0 +1,3 @@
[dry-run] would start C:\Users\bruno\.local\bin\claude.exe (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\claude-code.log)
[dry-run] would start aider (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\aider.log)
[dry-run] would start codex (detached, log: C:\Users\bruno\AppData\Local\agent-manager\logs\codex.log)
+13
View File
@@ -0,0 +1,13 @@
NAME VERSION STATUS PID PATH
-------------------------------------------
Atomic Agent — external — C:\Users\bruno\AppData\Local\atomic-agent\atomi…
Claude Code 2.1.112 external — C:\Users\bruno\.local\bin\claude.exe
Hermes Agent 2026.8.3 external — C:\Users\bruno\AppData\Local\hermes\hermes-agen…
jcode 0.76.0 external — C:\Users\bruno\AppData\Local\jcode\bin\jcode.exe
Kimi CLI 0.31.1 external — C:\Users\bruno\.kimi-code\bin\kimi.exe
LazyCodex 11.1.0 external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
nanobot — external — C:\Users\bruno\AppData\Local\Microsoft\WindowsA…
NemoClaw — external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Pi 0.84.1 external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Prime Agent 0.7.2 external — C:\Users\bruno\scoop\apps\nodejs22\current\bin\…
Tiny Agents — external — C:\Users\bruno\scoop\apps\python313\current\Scr…
+6
View File
@@ -0,0 +1,6 @@
Claude Code (claude-code)
status: external
version: 2.1.112
method: —
path: C:\Users\bruno\.local\bin\claude.exe
run: claude
+1
View File
@@ -0,0 +1 @@
agent 'claude-code' is not managed by agent-manager; nothing to stop
+3
View File
@@ -0,0 +1,3 @@
error: agent 'claude-code' is not installed by agent-manager — use 'am install claude-code'
[exit code: 1]
+1
View File
@@ -0,0 +1 @@
am 0.1.0
+81
View File
@@ -0,0 +1,81 @@
# agent-manager (am) — installateur Windows (PowerShell 5.1+)
#
# One-liner :
# powershell -ExecutionPolicy Bypass -Command "irm https://git.dracodev.net/Projets/agent-manager/raw/branch/main/install.ps1 | iex"
#
# Télécharge le binaire de la dernière release Gitea ; à défaut, compile via
# cargo install --git. Relancez le script pour mettre à jour.
param(
[string]$BaseUrl = "https://git.dracodev.net/Projets/agent-manager",
[string]$ApiUrl = "https://git.dracodev.net/api/v1/repos/Projets/agent-manager",
[string]$InstallDir = ""
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
# PowerShell 5.1 : forcer TLS 1.2 pour les serveurs modernes
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
function Fail([string]$msg) { Write-Host "erreur: $msg" -ForegroundColor Red; exit 1 }
function Install-FromSource {
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Fail "pas de binaire precompile et cargo est absent - installez Rust (https://rustup.rs) puis relancez"
}
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"
Write-Host ""
Write-Host "OK - am est installe dans $bin"
Write-Host "Essayez : am list | am doctor (mise a jour : relancez ce script)"
exit 0
}
$arch = if ($env:PROCESSOR_ARCHITECTURE -match "ARM") { "arm64" } else { "x86_64" }
$binDir = if ($InstallDir) { $InstallDir } else { Join-Path $env:LOCALAPPDATA "agent-managerin" }
if ($arch -eq "x86_64") {
$ok = $false
try {
$release = Invoke-RestMethod -Uri "$ApiUrl/releases/latest" -UseBasicParsing -TimeoutSec 20
$tag = $release.tag_name
$asset = "am-windows-x86_64.zip"
$url = "$BaseUrl/releases/download/$tag/$asset"
$tmp = Join-Path $env:TEMP "am-$tag.zip"
Write-Host "Telechargement de $url ..."
Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing -TimeoutSec 300
$tmpDir = Join-Path $env:TEMP "am-$tag"
Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
Expand-Archive -Path $tmp -DestinationPath $tmpDir -Force
$exe = Join-Path $tmpDir "am.exe"
if (Test-Path $exe) {
New-Item -ItemType Directory -Force -Path $binDir | Out-Null
Copy-Item $exe (Join-Path $binDir "am.exe") -Force
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
$ok = $true
} else { throw "am.exe absent de l'archive" }
} catch {
Write-Host "Binaire precompile indisponible ($($_.Exception.Message)) - installation depuis les sources ..."
}
if (-not $ok) { Install-FromSource }
} else {
Write-Host "Architecture $arch : pas de binaire precompile - installation depuis les sources ..."
Install-FromSource
}
# Ajout au PATH utilisateur (permanent) uniquement pour le dossier par defaut
if (-not $InstallDir) {
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$binDir*") {
[Environment]::SetEnvironmentVariable("Path", "$binDir;$userPath", "User")
Write-Host "Ajout de $binDir au PATH utilisateur (rouvrez votre terminal pour en profiter)"
}
}
$env:Path = "$binDir;$env:Path"
& (Join-Path $binDir "am.exe") --version
Write-Host ""
Write-Host "OK - 'am' est installe. Essayez :"
Write-Host " am list"
Write-Host " am doctor"
Write-Host "Mise a jour : relancez ce script."
+83
View File
@@ -0,0 +1,83 @@
#!/bin/sh
# agent-manager (am) — installateur Linux / macOS / WSL
#
# One-liner :
# curl -fsSL https://git.dracodev.net/Projets/agent-manager/raw/branch/main/install.sh | sh
#
# Télécharge le binaire de la dernière release Gitea ; à défaut, compile via
# cargo install --git. Relancez le script pour mettre à jour.
set -eu
BASE_URL="${AM_BASE_URL:-https://git.dracodev.net/Projets/agent-manager}"
API_URL="${AM_API_URL:-https://git.dracodev.net/api/v1/repos/Projets/agent-manager}"
INSTALL_DIR="${AM_INSTALL_DIR:-$HOME/.local/bin}"
GIT_URL="$BASE_URL.git"
fail() { echo "erreur: $1" >&2; exit 1; }
arch=$(uname -m)
case "$arch" in
x86_64|amd64) arch="x86_64" ;;
aarch64|arm64) arch="aarch64" ;;
*) fail "architecture non supportée: $arch" ;;
esac
install_from_source() {
if ! command -v cargo >/dev/null 2>&1; then
fail "pas de binaire précompilé pour $arch et cargo est absent — installez Rust (https://rustup.rs) puis relancez"
fi
echo "Installation depuis les sources (cargo install --git $GIT_URL)…"
cargo install --git "$GIT_URL" --locked
echo ""
echo "OK — am est installé dans ~/.cargo/bin/am"
echo "Essayez : am list | am doctor (mise à jour : relancez ce script)"
exit 0
}
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
tag=""
if command -v curl >/dev/null 2>&1; then
release=$(curl -fsSL --connect-timeout 15 "$API_URL/releases/latest" 2>/dev/null || true)
tag=$(printf '%s' "$release" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 | cut -d'"' -f4)
fi
installed=""
if [ -n "$tag" ] && command -v curl >/dev/null 2>&1; then
asset="am-linux-$arch.tar.gz"
url="$BASE_URL/releases/download/$tag/$asset"
echo "Téléchargement de $url …"
if curl -fsSL --connect-timeout 20 -o "$tmpdir/am.tar.gz" "$url"; then
tar -xzf "$tmpdir/am.tar.gz" -C "$tmpdir" 2>/dev/null || true
if [ -f "$tmpdir/am" ]; then
mkdir -p "$INSTALL_DIR"
cp "$tmpdir/am" "$INSTALL_DIR/am"
chmod +x "$INSTALL_DIR/am"
installed=1
fi
fi
fi
if [ -z "$installed" ]; then
echo "Binaire précompilé indisponible — installation depuis les sources…"
install_from_source
fi
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*)
echo "Astuce : $INSTALL_DIR n'est pas dans votre PATH"
if [ -f "$HOME/.profile" ] && ! grep -qF "$INSTALL_DIR" "$HOME/.profile"; then
echo "export PATH="$INSTALL_DIR:\$PATH"" >> "$HOME/.profile"
echo "Ajouté à ~/.profile (reconnectez-vous pour en profiter)"
fi
;;
esac
"$INSTALL_DIR/am" --version || true
echo ""
echo "OK — 'am' est installé. Essayez :"
echo " am list"
echo " am doctor"
echo "Mise à jour : relancez ce script."
+162
View File
@@ -0,0 +1,162 @@
//! Application context: configuration, catalog, state, paths, logger and
//! shared flags.
use crate::catalog::Catalog;
use crate::cli::Cli;
use crate::config::{self, Config, Settings};
use crate::output::Logger;
use crate::state::StateStore;
use anyhow::{Context, Result};
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
#[derive(Debug)]
pub struct App {
pub cli: Cli,
pub config: Config,
pub config_origin: Option<PathBuf>,
pub paths: Paths,
pub catalog: Catalog,
pub state: StateStore,
pub log: Logger,
}
#[derive(Debug, Clone)]
pub struct Paths {
pub install_dir: PathBuf,
pub bin_dir: PathBuf,
pub log_dir: PathBuf,
pub state_file: PathBuf,
pub probe_cache_file: PathBuf,
pub config_dir: Option<PathBuf>,
}
impl Paths {
pub fn from_settings(settings: &Settings) -> Self {
let install_dir = settings
.install_dir
.as_deref()
.map(config::expand_path)
.unwrap_or_else(|| data_dir().join("agents"));
let log_dir = settings
.log_dir
.as_deref()
.map(config::expand_path)
.unwrap_or_else(|| state_dir().join("logs"));
let bin_dir = install_dir.join("bin");
let state_file = state_dir().join("state.json");
let probe_cache_file = state_dir().join("probe-cache.json");
let config_dir = config::user_config_dir();
Self {
install_dir,
bin_dir,
log_dir,
state_file,
probe_cache_file,
config_dir,
}
}
/// Create every directory the manager needs.
pub fn ensure(&self) -> Result<()> {
std::fs::create_dir_all(&self.install_dir)
.with_context(|| format!("cannot create {}", self.install_dir.display()))?;
std::fs::create_dir_all(&self.bin_dir)?;
std::fs::create_dir_all(&self.log_dir)?;
Ok(())
}
}
impl App {
pub fn from_cli(cli: Cli) -> Result<Self> {
let loaded = config::load(&cli)?;
let paths = Paths::from_settings(&loaded.config.settings);
let log = Logger::new(paths.log_dir.clone(), cli.verbose, cli.quiet, cli.no_color);
let state = StateStore::new(paths.state_file.clone());
let catalog = Catalog::new(
loaded.config.agents.clone(),
loaded.config.aliases.clone(),
loaded.config.groups.clone(),
);
Ok(Self {
cli,
config: loaded.config,
config_origin: loaded.origin,
paths,
catalog,
state,
log,
})
}
pub fn dry_run(&self) -> bool {
self.cli.dry_run
}
pub fn json(&self) -> bool {
self.cli.json
}
pub fn yes(&self) -> bool {
self.cli.yes
}
pub fn color(&self) -> bool {
!self.cli.no_color && std::io::stdout().is_terminal()
}
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
pub fn confirm(&self, question: &str) -> Result<bool> {
if self.cli.yes {
return Ok(true);
}
eprint!("{question} [y/N] ");
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line).ok();
let answer = line.trim().to_lowercase();
Ok(matches!(answer.as_str(), "y" | "yes" | "o" | "oui" | "true"))
}
}
/// Root directory for agent data (overridable with AGENT_MANAGER_DATA,
/// mostly used by tests).
pub fn data_dir() -> PathBuf {
if let Some(d) = std::env::var_os("AGENT_MANAGER_DATA") {
return PathBuf::from(d);
}
if cfg!(windows) {
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.or_else(|| config::home_dir().map(|h| h.join(".local").join("share")));
return base
.map(|b| b.join("agent-manager"))
.unwrap_or_else(|| PathBuf::from(".agent-manager"));
}
if cfg!(target_os = "macos") {
return config::home_dir()
.map(|h| h.join("Library").join("Application Support").join("agent-manager"))
.unwrap_or_else(|| PathBuf::from(".agent-manager"));
}
let base = std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| config::home_dir().map(|h| h.join(".local").join("share")));
base.map(|b| b.join("agent-manager"))
.unwrap_or_else(|| PathBuf::from(".agent-manager"))
}
/// Root directory for runtime state (logs, state file).
pub fn state_dir() -> PathBuf {
if let Some(d) = std::env::var_os("AGENT_MANAGER_STATE") {
return PathBuf::from(d);
}
if cfg!(target_os = "linux") {
let base = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| config::home_dir().map(|h| h.join(".local").join("state")));
if let Some(b) = base {
return b.join("agent-manager");
}
}
data_dir()
}
+159
View File
@@ -0,0 +1,159 @@
//! The merged agent catalog: lookup, aliases, groups and search.
use crate::config::AgentDef;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Clone, Default)]
pub struct Catalog {
agents: Vec<AgentDef>,
by_name: HashMap<String, usize>,
aliases: BTreeMap<String, String>,
groups: BTreeMap<String, Vec<String>>,
}
impl Catalog {
pub fn new(
agents: Vec<AgentDef>,
aliases: BTreeMap<String, String>,
groups: BTreeMap<String, Vec<String>>,
) -> Self {
let mut by_name = HashMap::new();
for (i, a) in agents.iter().enumerate() {
by_name.insert(a.name.clone(), i);
}
Self {
agents,
by_name,
aliases,
groups,
}
}
pub fn agents(&self) -> &[AgentDef] {
&self.agents
}
pub fn len(&self) -> usize {
self.agents.len()
}
pub fn is_empty(&self) -> bool {
self.agents.is_empty()
}
pub fn get(&self, name: &str) -> Option<&AgentDef> {
self.by_name.get(name).map(|&i| &self.agents[i])
}
/// Resolve an agent name or an alias to the agent definition.
pub fn resolve(&self, name_or_alias: &str) -> Option<&AgentDef> {
if let Some(target) = self.aliases.get(name_or_alias) {
return self.get(target);
}
self.get(name_or_alias)
}
pub fn alias_target(&self, alias: &str) -> Option<&str> {
self.aliases.get(alias).map(|s| s.as_str())
}
/// Members of a group, in definition order (missing names are skipped).
pub fn group_members(&self, group: &str) -> Vec<&AgentDef> {
let mut out = Vec::new();
if let Some(names) = self.groups.get(group) {
for n in names {
if let Some(a) = self.get(n) {
out.push(a);
}
}
}
out
}
pub fn has_group(&self, group: &str) -> bool {
self.groups.contains_key(group)
}
/// Is this token a group selector ("group:<name>")?
pub fn parse_group_selector(s: &str) -> Option<&str> {
s.strip_prefix("group:")
}
/// Case-insensitive search over name, display name, description,
/// category, tags and note.
pub fn search(&self, keyword: &str, category: Option<&str>) -> Vec<&AgentDef> {
let kw = keyword.to_lowercase();
self.agents
.iter()
.filter(|a| {
if a.hidden {
return false;
}
if let Some(cat) = category {
if a.category.as_deref() != Some(cat) {
return false;
}
}
let hay = format!(
"{} {} {} {} {} {}",
a.name,
a.title(),
a.description.as_deref().unwrap_or(""),
a.category.as_deref().unwrap_or(""),
a.tags.join(" "),
a.note.as_deref().unwrap_or("")
)
.to_lowercase();
hay.contains(&kw)
})
.collect()
}
/// Sorted list of all categories present in the catalog.
pub fn categories(&self) -> Vec<String> {
let mut cats: Vec<String> = self
.agents
.iter()
.filter_map(|a| a.category.clone())
.collect();
cats.sort();
cats.dedup();
cats
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn catalog() -> Catalog {
let cfg: Config = serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap();
Catalog::new(cfg.agents, cfg.aliases, cfg.groups)
}
#[test]
fn resolves_aliases() {
let cat = catalog();
let via_alias = cat.resolve("cc").map(|a| a.name.clone());
assert_eq!(via_alias.as_deref(), Some("claude-code"));
}
#[test]
fn resolves_groups() {
let cat = catalog();
assert!(cat.has_group("dev"));
let members = cat.group_members("dev");
assert!(!members.is_empty());
assert!(members.iter().any(|a| a.name == "claude-code"));
}
#[test]
fn searches() {
let cat = catalog();
let hits = cat.search("anthropic", None);
assert!(hits.iter().any(|a| a.name == "claude-code"));
let cats = cat.categories();
assert!(cats.contains(&"coding-agent".to_string()));
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Command line interface definition (clap v4, derive API).
use clap::{ArgAction, Args, Parser, Subcommand};
use clap_complete::Shell;
use std::path::PathBuf;
/// Manage local AI coding agents: install, start, stop, update.
#[derive(Parser, Debug, Clone)]
#[command(
name = "am",
bin_name = "am",
version,
about = "agent-manager (am) — manage local AI coding agents",
long_about = "agent-manager (am) — a CLI to manage local AI coding agents.
Install, uninstall, start, stop, restart and update AI agents from a YAML
catalog. Handles dependencies (Node, Python, Go, Rust, Bun), binaries from
GitHub Releases, install scripts, and git repositories. All state lives under
your user directories; nothing pollutes the system."
)]
pub struct Cli {
/// Use an alternate configuration file
#[arg(short = 'c', long, global = true, value_name = "FILE")]
pub config: Option<PathBuf>,
/// Verbose output: show every command executed and its details
#[arg(short = 'v', long, global = true, action = ArgAction::SetTrue)]
pub verbose: bool,
/// Quiet mode: only errors are printed
#[arg(short = 'q', long, global = true, conflicts_with = "verbose", action = ArgAction::SetTrue)]
pub quiet: bool,
/// Answer yes to every confirmation prompt
#[arg(short = 'y', long, global = true, action = ArgAction::SetTrue)]
pub yes: bool,
/// Simulate the action without changing anything
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub dry_run: bool,
/// Emit machine-readable JSON on stdout (for scripts)
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub json: bool,
/// Disable ANSI colors
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub no_color: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
/// List installed agents (default); use --all for the whole catalog
List {
/// Show every agent known to the catalog, not only the installed ones
#[arg(long)]
all: bool,
/// Only show agents of this category
#[arg(long, value_name = "CATEGORY")]
category: Option<String>,
},
/// Start an agent (foreground by default, or detached with --background)
Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
Stop {
/// Agent name, alias, or group:<name>
agent: String,
/// Kill immediately instead of terminating gracefully
#[arg(long)]
force: bool,
/// Grace period in seconds before killing (default: from config, 5s)
#[arg(long, value_name = "SECS")]
timeout: Option<u64>,
},
/// Restart an agent: stop, then start with the same options
Restart {
#[command(flatten)]
start: StartArgs,
/// Kill immediately instead of terminating gracefully
#[arg(long)]
force: bool,
/// Grace period in seconds before killing (default: from config, 5s)
#[arg(long, value_name = "SECS")]
timeout: Option<u64>,
},
/// Show the state of one agent, or of every installed agent
Status {
/// Agent name or alias (omit to show all installed agents)
agent: Option<String>,
},
/// Install an agent and its dependencies
Install {
/// Agent name or alias
agent: String,
/// Select the install method (index, or type: npm, pip, uv, cargo, go, bun, curl, binary, git)
#[arg(long, value_name = "METHOD")]
method: Option<String>,
/// Reinstall even if already installed
#[arg(long)]
force: bool,
},
/// Uninstall an agent and clean its files
Uninstall {
/// Agent name or alias
agent: String,
/// Also remove logs and the agent entry from the user config file
#[arg(long)]
purge: bool,
},
/// Update an installed agent to the latest available version
Update {
/// Agent name or alias (required unless --all)
agent: Option<String>,
/// Update every installed managed agent
#[arg(long)]
all: bool,
},
/// Search the catalog by keyword (name, description, category, tags)
Search {
/// Keyword to search for (case-insensitive substring)
keyword: String,
/// Restrict to this category
#[arg(long, value_name = "CATEGORY")]
category: Option<String>,
},
/// Show detailed information about one agent
Info {
/// Agent name or alias
agent: String,
},
/// Manage the configuration file
#[command(subcommand)]
Config(ConfigCmd),
/// Check the environment: tools, config validity, paths, permissions
Doctor {
/// Attempt to repair problems (create directories, fix the state file)
#[arg(long)]
fix: bool,
},
/// Run the agent command directly with the given arguments (no process management)
Run {
/// Agent name or alias
agent: String,
/// Arguments passed through to the agent command
#[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "ARGS...")]
args: Vec<std::ffi::OsString>,
},
/// Generate a shell completion script
Completion {
/// Shell to generate completions for
#[arg(value_enum, value_name = "SHELL")]
shell: Shell,
},
/// Update agent-manager itself from GitHub Releases
SelfUpdate {
/// Only check whether a newer version exists
#[arg(long)]
check: bool,
/// Download the new binary to this path instead of replacing the current one
#[arg(long, value_name = "FILE")]
to: Option<PathBuf>,
},
/// Export the configuration and installation state (backup)
Export {
/// Output file (default: am-export.json)
#[arg(long, value_name = "FILE", default_value = "am-export.json")]
output: PathBuf,
},
/// Import a previously exported configuration and state
Import {
/// File exported by the export command
file: PathBuf,
},
}
/// Arguments shared by start and restart.
#[derive(Args, Debug, Clone, Default)]
pub struct StartArgs {
/// Agent name, alias, or group:<name>
pub agent: String,
/// Run detached in the background; output goes to the agent log file
#[arg(short = 'b', long, conflicts_with = "foreground", action = ArgAction::SetTrue)]
pub background: bool,
/// Run in the foreground, attached to this terminal (the default)
#[arg(short = 'f', long, action = ArgAction::SetTrue)]
pub foreground: bool,
/// Extra arguments passed to the agent (repeatable; quoted strings are split on spaces)
#[arg(long, value_name = "ARGS", num_args = 0.., action = ArgAction::Append)]
pub args: Vec<String>,
/// Set an environment variable for the agent (repeatable): --env KEY=VALUE
#[arg(long, value_name = "KEY=VALUE", action = ArgAction::Append)]
pub env: Vec<String>,
/// Send a desktop notification once the agent has started
#[arg(long, action = ArgAction::SetTrue)]
pub notify: bool,
}
/// Configuration management subcommands.
#[derive(Subcommand, Debug, Clone)]
pub enum ConfigCmd {
/// Print the effective (merged) configuration as YAML
Show,
/// Print the location of the configuration file(s)
Path,
/// Open the user configuration file in your editor
Edit,
/// Validate the configuration file(s)
Validate,
/// Add another agent definition file to the user configuration
Add {
/// YAML (or JSON) file defining agents to include
file: PathBuf,
},
}
+13
View File
@@ -0,0 +1,13 @@
//! completion: generate shell completion scripts (bash, zsh, fish,
//! powershell, elvish) via clap_complete.
use crate::cli::Cli;
use anyhow::Result;
use clap::CommandFactory;
use clap_complete::{generate, Shell};
pub fn run(shell: Shell) -> Result<i32> {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "am", &mut std::io::stdout());
Ok(0)
}
+182
View File
@@ -0,0 +1,182 @@
//! config subcommands: show, path, edit, validate, add.
use super::*;
use crate::cli::ConfigCmd;
use crate::config;
use crate::output::print_json;
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
const USER_CONFIG_TEMPLATE: &str = "# User configuration for agent-manager.
# This file overrides/completes the embedded default catalog.
# Run 'am config show' to see the effective configuration.
version: \"1.0\"
agents: []
";
pub fn run(app: &App, cmd: &ConfigCmd) -> Result<i32> {
match cmd {
ConfigCmd::Show => {
if app.json() {
print_json(&app.config);
} else {
println!("{}", serde_yaml::to_string(&app.config)?);
}
Ok(0)
}
ConfigCmd::Path => {
if app.json() {
print_json(&serde_json::json!({
"origin": app.config_origin.as_ref().map(|p| p.display().to_string()),
"user_dir": app.paths.config_dir.as_ref().map(|p| p.display().to_string()),
"user_file": user_config_path_if_any(app).map(|p| p.display().to_string()),
"local_file": PathBuf::from(config::LOCAL_CONFIG_NAME).display().to_string(),
}));
} else {
match &app.config_origin {
Some(p) => println!("active config: {}", p.display()),
None => println!("no user config found — using the embedded default catalog"),
}
if let Some(d) = &app.paths.config_dir {
println!("user config directory: {}", d.display());
println!(
"user config file: {}",
d.join(config::CONFIG_FILE_NAME).display()
);
}
println!(
"local config file: {} (current directory)",
PathBuf::from(config::LOCAL_CONFIG_NAME).display()
);
}
Ok(0)
}
ConfigCmd::Validate => {
let problems = config::validate(&app.config);
if app.json() {
print_json(&serde_json::json!({
"valid": problems.is_empty(),
"problems": problems,
}));
return Ok(if problems.is_empty() { 0 } else { 1 });
}
if problems.is_empty() {
app.log
.success(&format!("configuration is valid ({} agents)", app.catalog.len()));
Ok(0)
} else {
app.log.error(&format!(
"configuration problems:
- {}",
problems.join("
- ")
));
Ok(1)
}
}
ConfigCmd::Edit => {
let path = user_config_path(app)?;
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| {
if cfg!(windows) {
"notepad".to_string()
} else {
"vi".to_string()
}
});
let status = Command::new(&editor)
.arg(&path)
.status()
.with_context(|| format!("cannot launch editor '{editor}'"))?;
Ok(status.code().unwrap_or(1))
}
ConfigCmd::Add { file } => add_file(app, file),
}
}
fn user_config_path_if_any(app: &App) -> Option<PathBuf> {
app.paths
.config_dir
.as_ref()
.map(|d| d.join(config::CONFIG_FILE_NAME))
}
/// Ensure the user config file exists (creating a template when needed).
fn user_config_path(app: &App) -> Result<PathBuf> {
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)
.with_context(|| format!("cannot create {}", dir.display()))?;
let path = dir.join(config::CONFIG_FILE_NAME);
if !path.exists() {
std::fs::write(&path, USER_CONFIG_TEMPLATE)
.with_context(|| format!("cannot create {}", path.display()))?;
}
Ok(path)
}
/// config add: copy an agent definition file into the config dir and
/// register it via an include entry in the user config file.
fn add_file(app: &App, file: &Path) -> Result<i32> {
config::parse_file(file)?;
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
let base = file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("agents.yaml")
.to_string();
let dest = dir.join(&base);
std::fs::copy(file, &dest)
.with_context(|| format!("cannot copy {} to {}", file.display(), dest.display()))?;
let cfg_path = user_config_path(app)?;
let text = std::fs::read_to_string(&cfg_path)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.unwrap_or_else(|_| serde_yaml::Value::Null);
match &mut value {
serde_yaml::Value::Mapping(map) => {
let key = serde_yaml::Value::String("include".to_string());
match map.get_mut(&key) {
Some(serde_yaml::Value::Sequence(seq)) => {
let entry = serde_yaml::Value::String(base.clone());
if !seq.contains(&entry) {
seq.push(entry);
}
}
_ => {
map.insert(
key,
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
base.clone(),
)]),
);
}
}
std::fs::write(&cfg_path, serde_yaml::to_string(&value)?)
.with_context(|| format!("cannot write {}", cfg_path.display()))?;
}
_ => {
std::fs::write(
&cfg_path,
format!("version: \"1.0\"\ninclude:\n - {base}\n"),
)?;
}
}
app.log.success(&format!(
"added {} and registered it in {}",
dest.display(),
cfg_path.display()
));
Ok(0)
}
+43
View File
@@ -0,0 +1,43 @@
//! doctor command: render the environment report, optionally with --fix.
use super::*;
use crate::doctor::{self, Status};
use crate::output::{print_json, status_style, Table};
use crate::runner::SystemRunner;
use anyhow::Result;
pub fn run(app: &App, fix: bool) -> Result<i32> {
if fix {
let repaired = doctor::fix(app)?;
for r in &repaired {
app.log.success(r);
}
app.log.info("run 'am doctor' again to verify");
return Ok(0);
}
// The doctor report probes real tools, even under --dry-run.
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(app, &runner);
if app.json() {
print_json(&report.checks);
return Ok(if report.ok() { 0 } else { 1 });
}
let color = app.color();
let mut table = Table::new(vec!["CHECK", "STATUS", "DETAIL"]);
for c in &report.checks {
let status = match c.status {
Status::Ok => status_style("ok", color),
Status::Warn => status_style("warn", color),
Status::Error => status_style("error", color),
};
table.row(vec![c.name.clone(), status, c.detail.clone()]);
}
print!("{}", table.render());
if report.problems() > 0 {
app.log
.warn(&format!("{} problem(s) found — 'am doctor --fix' repairs what it can", report.problems()));
} else {
app.log.success("environment looks good");
}
Ok(if report.ok() { 0 } else { 1 })
}
+135
View File
@@ -0,0 +1,135 @@
//! export/import: back up the configuration and installation state.
use super::*;
use crate::config::{Config, Settings};
use crate::state::StateFile;
use crate::output::print_json;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Serialize, Deserialize)]
struct ExportFile {
tool: String,
version: String,
generated_at: String,
config: Config,
state: StateFile,
}
pub fn export_cmd(app: &App, output: &Path) -> Result<i32> {
let export = ExportFile {
tool: "agent-manager".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
generated_at: crate::installers::now_rfc3339(),
config: app.config.clone(),
state: app.state.load()?,
};
if app.dry_run() {
app.log
.dry(format!("would write {}", output.display()));
return Ok(0);
}
let text = serde_json::to_string_pretty(&export)?;
std::fs::write(output, text)
.with_context(|| format!("cannot write {}", output.display()))?;
app.log
.success(&format!("exported config + state to {}", output.display()));
Ok(0)
}
pub fn import_cmd(app: &App, file: &Path) -> Result<i32> {
let text = std::fs::read_to_string(file)
.with_context(|| format!("cannot read {}", file.display()))?;
let export: ExportFile = serde_json::from_str(&text)
.with_context(|| format!("{} is not a valid agent-manager export", file.display()))?;
if app.dry_run() {
app.log
.dry(format!("would import from {}", file.display()));
return Ok(0);
}
// State: merge entries (existing ones win).
let mut sf = app.state.load()?;
let mut imported_state = 0;
for (name, entry) in export.state.installed {
if !sf.installed.contains_key(&name) {
sf.installed.insert(name, entry);
imported_state += 1;
}
}
app.state.save(&sf)?;
// Config: write the exported agents to a file and include it.
let dir = app
.paths
.config_dir
.clone()
.ok_or_else(|| anyhow!("cannot determine the user config directory (no HOME set)"))?;
std::fs::create_dir_all(&dir)?;
let ts = chrono::Local::now().format("%Y%m%d-%H%M%S");
let fname = format!("imported-{ts}.yaml");
let dest = dir.join(&fname);
let cfg = Config {
version: "1.0".to_string(),
settings: Settings::default(),
aliases: Default::default(),
groups: Default::default(),
include: vec![],
agents: export.config.agents.clone(),
};
std::fs::write(&dest, serde_yaml::to_string(&cfg)?)
.with_context(|| format!("cannot write {}", dest.display()))?;
let cfg_path = dir.join(crate::config::CONFIG_FILE_NAME);
if !cfg_path.exists() {
std::fs::write(
&cfg_path,
format!("version: \"1.0\"\ninclude:\n - {fname}\n"),
)?;
} else {
let text = std::fs::read_to_string(&cfg_path)?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.unwrap_or_else(|_| serde_yaml::Value::Null);
if let serde_yaml::Value::Mapping(map) = &mut value {
let key = serde_yaml::Value::String("include".to_string());
match map.get_mut(&key) {
Some(serde_yaml::Value::Sequence(seq)) => {
let entry = serde_yaml::Value::String(fname.clone());
if !seq.contains(&entry) {
seq.push(entry);
}
}
_ => {
map.insert(
key,
serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
fname.clone(),
)]),
);
}
}
std::fs::write(&cfg_path, serde_yaml::to_string(&value)?)?;
}
}
if app.json() {
print_json(&serde_json::json!({
"agents_imported": export.config.agents.len(),
"state_entries_imported": imported_state,
"config_file": dest.display().to_string(),
}));
} else {
app.log.success(&format!(
"imported {} agent definition(s) and {} state entrie(s) from {}",
export.config.agents.len(),
imported_state,
file.display()
));
app.log.info(&format!(
"definitions written to {}",
dest.display()
));
}
Ok(0)
}
+197
View File
@@ -0,0 +1,197 @@
//! info: everything known about one agent.
use super::*;
use crate::output::print_json;
use serde::Serialize;
#[derive(Serialize)]
struct InfoJson {
name: String,
display_name: String,
description: Option<String>,
category: Option<String>,
website: Option<String>,
tags: Vec<String>,
status: String,
version: Option<String>,
managed: bool,
running: bool,
pid: Option<u32>,
installable: bool,
note: Option<String>,
platforms: Vec<String>,
run: Option<String>,
args: Vec<String>,
env: std::collections::BTreeMap<String, String>,
install_methods: Vec<String>,
post_install: Vec<String>,
dependencies: Vec<DepJson>,
path: Option<String>,
}
#[derive(Serialize)]
struct DepJson {
name: String,
min_version: Option<String>,
install_hint: Option<String>,
}
pub fn run(app: &App, name: &str) -> Result<i32> {
let agent = require_agent(app, name)?;
let entry = app.state.get(&agent.name).ok().flatten();
let running = entry
.as_ref()
.and_then(|e| e.pid)
.map(crate::process::is_running)
.unwrap_or(false);
let methods: Vec<String> = agent
.install
.as_ref()
.map(|spec| spec.all_methods().iter().map(|m| describe_method(m)).collect())
.unwrap_or_default();
let deps: Vec<DepJson> = agent
.dependencies
.iter()
.map(|d| DepJson {
name: d.name.clone(),
min_version: d.min_version.clone(),
install_hint: d.install_hint.clone(),
})
.collect();
if app.json() {
print_json(&InfoJson {
name: agent.name.clone(),
display_name: agent.title().to_string(),
description: agent.description.clone(),
category: agent.category.clone(),
website: agent.website.clone(),
tags: agent.tags.clone(),
status: if running {
"running".to_string()
} else if entry.is_some() {
"installed".to_string()
} else if !agent.installable {
"not-installable".to_string()
} else {
"not-installed".to_string()
},
version: entry.as_ref().and_then(|e| e.version.clone()),
managed: entry.is_some(),
running,
pid: if running { entry.as_ref().and_then(|e| e.pid) } else { None },
installable: agent.installable,
note: agent.note.clone(),
platforms: agent.platforms.clone(),
run: agent.run.clone(),
args: agent.args.clone(),
env: agent.env.clone(),
install_methods: methods,
post_install: agent
.install
.as_ref()
.map(|s| s.post_install.clone())
.unwrap_or_default(),
dependencies: deps,
path: entry
.as_ref()
.map(|e| e.install_dir.clone())
.or_else(|| external_path(agent).map(|p| p.display().to_string())),
});
return Ok(0);
}
println!("{}", agent.title());
if agent.name != agent.title() {
println!(" slug: {}", agent.name);
}
if let Some(d) = &agent.description {
println!(" description: {d}");
}
if let Some(c) = &agent.category {
println!(" category: {c}");
}
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(", "));
}
// Status block
let status = if running {
format!("running (pid {})", entry.as_ref().and_then(|e| e.pid).unwrap_or(0))
} else if entry.is_some() {
format!("installed (version {})", entry.as_ref().and_then(|e| e.version.clone()).unwrap_or_else(|| "unknown".to_string()))
} else if !agent.installable {
"not installable locally".to_string()
} else if external_path(agent).is_some() {
"found on PATH (external — not managed)".to_string()
} else {
"not installed".to_string()
};
println!(" status: {status}");
if let Some(run) = &agent.run {
let args = if agent.args.is_empty() {
String::new()
} else {
format!(" {}", agent.args.join(" "))
};
println!(" run: {run}{args}");
}
if !agent.env.is_empty() {
println!(
" env: {}",
agent
.env
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ")
);
}
if let Some(spec) = &agent.install {
println!(" install methods:");
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);
}
if !spec.post_install.is_empty() {
println!(" post-install: {}", spec.post_install.join(" && "));
}
}
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);
}
}
if let Some(n) = &agent.note {
println!(" note: {n}");
}
Ok(0)
}
fn external_path(agent: &AgentDef) -> Option<std::path::PathBuf> {
let first = agent.first_token()?;
which::which(first).ok()
}
+152
View File
@@ -0,0 +1,152 @@
//! install: resolve the agent, check dependencies, confirm, run the right
//! installer, and record the installation in the state database.
use super::*;
use crate::installers;
use crate::runner::SystemRunner;
use anyhow::{anyhow, bail, Result};
use std::collections::BTreeMap;
pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -> Result<i32> {
let agent = require_agent(app, agent_name)?;
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
if !agent.supports_current_platform() {
bail!(
"agent '{}' is not available on {} (supported: {})",
agent.name,
crate::config::current_platform(),
agent.platforms.join(", ")
);
}
if !agent.installable {
bail!(
"agent '{}' is not installable locally{}",
agent.name,
agent
.note
.as_deref()
.map(|n| format!(": {n}"))
.unwrap_or_default()
);
}
let spec = agent
.install
.as_ref()
.ok_or_else(|| anyhow!("agent '{}' has no install definition", agent.name))?;
let methods = spec.all_methods();
let method = select_method(&methods, method_sel)?;
// Already installed?
let existing = app.state.get(&agent.name).ok().flatten();
if existing.is_some() && !force && !app.dry_run() {
let version = existing
.as_ref()
.and_then(|e| e.version.clone())
.unwrap_or_else(|| "version unknown".to_string());
bail!(
"agent '{}' is already installed ({version}); use --force to reinstall, or 'am update {}'",
agent.name,
agent.name
);
}
if let Some(e) = &existing {
if let Some(pid) = e.pid {
if crate::process::is_running(pid) {
bail!(
"agent '{}' is currently running (pid {pid}); stop it first with 'am stop {}'",
agent.name,
agent.name
);
}
}
}
// Dependencies (checked against real tools unless dry-run).
crate::deps::ensure_deps(app, agent, &runner)?;
// Confirmation summary for scripts and destructive actions.
if !app.dry_run()
&& !app.yes()
&& app.config.settings.confirm_before_run()
&& method.kind == crate::config::InstallType::Curl
{
app.log.info(&format!(
"About to download and run the install script for {}",
agent.title()
));
app.log
.info(&format!(" script: {}", method.script.as_deref().unwrap_or("?")));
if !app.confirm("Run this install script?")? {
app.log.info("aborted");
return Ok(0);
}
} else if !app.dry_run() && !app.yes() && existing.is_some() {
app.log.info(&format!(
"Reinstalling {} via {}",
agent.title(),
describe_method(method)
));
if !app.confirm("Proceed with reinstall?")? {
app.log.info("aborted");
return Ok(0);
}
}
app.log.info(&format!(
"installing {} ({})…",
agent.title(),
describe_method(method)
));
let mut outcome = installers::run_install(app, agent, method, &runner)?;
if app.dry_run() {
app.log.info("dry run complete — nothing was changed");
return Ok(0);
}
// Post-install commands (bin dir first on PATH).
if !spec.post_install.is_empty() {
let bin_str = app.paths.bin_dir.display().to_string();
let mut env = BTreeMap::new();
if let Ok(path) = std::env::var("PATH") {
env.insert(
"PATH".to_string(),
format!("{bin_str}{}{path}", std::path::MAIN_SEPARATOR),
);
}
for cmd in &spec.post_install {
let tokens = shell_words::split(cmd)
.map_err(|e| anyhow!("cannot parse post-install command '{cmd}': {e}"))?;
if tokens.is_empty() {
continue;
}
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
app.log.info(&format!("post-install: {cmd}"));
installers::sh(&runner, &prog, &args, &env, None)?;
}
}
// Fallback version detection.
if outcome.version.is_none() {
if let Some(bin) = outcome.bins.first() {
outcome.version = installers::detect_version(&runner, bin);
}
}
let entry = installers::make_entry(agent, method, &outcome, app);
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&entry)?;
app.log.success(&format!(
"installed {} (v{version}) in {}",
agent.title(),
entry.install_dir
));
if existing.is_none() {
app.log.info(&format!(
"start it with: am start {}",
agent.name
));
}
Ok(0)
}
+151
View File
@@ -0,0 +1,151 @@
//! list: installed agents by default (managed, running and external), or
//! the whole catalog with --all.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::probe::ExternalInfo;
use crate::state::InstalledEntry;
use serde::Serialize;
#[derive(Serialize)]
struct AgentRow {
name: String,
display_name: String,
category: Option<String>,
description: Option<String>,
status: String,
version: Option<String>,
source: String,
method: Option<String>,
path: Option<String>,
running: bool,
pid: Option<u32>,
installable: bool,
note: Option<String>,
}
/// run: installed agents only, unless all is set (whole catalog).
pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
let entries = app.state.all().unwrap_or_default();
let probe = crate::probe::detect_externals(app);
let mut rows: Vec<AgentRow> = Vec::new();
for agent in app.catalog.agents() {
if agent.hidden {
continue;
}
if let Some(cat) = category {
if agent.category.as_deref() != Some(cat) {
continue;
}
}
rows.push(row_for(agent, entries.get(&agent.name), &probe));
}
if !all {
rows.retain(|r| r.status == "running" || r.status == "installed" || r.status == "external");
}
rows.sort_by(|a, b| a.name.cmp(&b.name));
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "SOURCE", "PATH"]);
for r in &rows {
table.row(vec![
r.display_name.clone(),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, color),
r.source.clone(),
r.path.clone().unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
if rows.is_empty() {
app.log.info(if all {
"no agents found"
} else {
"no installed agents found — run 'am list --all' for the full catalog"
});
}
Ok(0)
}
fn row_for(agent: &AgentDef, entry: Option<&InstalledEntry>, probe: &ExternalInfo) -> AgentRow {
let (status, source, path, version, running, pid, method) = if let Some(e) = entry {
let running = e.pid.map(crate::process::is_running).unwrap_or(false);
let status = if running { "running" } else { "installed" };
let path = if e
.bins
.first()
.map(|b| std::path::Path::new(b).exists())
.unwrap_or(false)
{
Some(e.bins[0].clone())
} else {
Some(e.install_dir.clone())
};
(
status.to_string(),
"managed".to_string(),
path,
e.version.clone(),
running,
e.pid,
Some(e.method.clone()),
)
} else if !agent.installable {
(
"not-installable".to_string(),
"-".to_string(),
None,
None,
false,
None,
None,
)
} else {
match probe.path_of(&agent.name) {
Some(p) => {
let version = probe
.version_of(&agent.name)
.cloned()
.flatten()
.or_else(|| agent.version.clone());
(
"external".to_string(),
"external".to_string(),
Some(p.display().to_string()),
version,
false,
None,
None,
)
}
None => (
"not-installed".to_string(),
"-".to_string(),
None,
None,
false,
None,
None,
),
}
};
AgentRow {
name: agent.name.clone(),
display_name: agent.title().to_string(),
category: agent.category.clone(),
description: agent.description.clone(),
status,
version,
source,
method,
path,
running,
pid,
installable: agent.installable,
note: agent.note.clone(),
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Command implementations and dispatch.
pub mod completion_cmd;
pub mod config_cmd;
pub mod doctor_cmd;
pub mod export_import;
pub mod info_cmd;
pub mod install_cmd;
pub mod list_cmd;
pub mod run_cmd;
pub mod search_cmd;
pub mod self_update;
pub mod status_cmd;
pub mod uninstall_cmd;
pub mod update_cmd;
use crate::app::App;
use crate::cli::Command;
use crate::config::{AgentDef, MethodSpec};
use crate::state::InstalledEntry;
use anyhow::{anyhow, bail, Result};
use std::collections::BTreeMap;
use std::path::Path;
pub fn execute(app: &App) -> Result<i32> {
match &app.cli.command {
None => crate::repl::run(app),
Some(cmd) => execute_command(app, cmd),
}
}
/// Execute one parsed command (also used by the interactive shell).
pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
match cmd {
Command::List { all, category } => list_cmd::run(app, *all, category.as_deref()),
Command::Start(a) => run_cmd::start(app, a),
Command::Stop {
agent,
force,
timeout,
} => run_cmd::stop(app, agent, *force, *timeout),
Command::Restart {
start,
force,
timeout,
} => run_cmd::restart(app, start, *force, *timeout),
Command::Status { agent } => status_cmd::run(app, agent.as_deref()),
Command::Install {
agent,
method,
force,
} => install_cmd::run(app, agent, method.as_deref(), *force),
Command::Uninstall { agent, purge } => uninstall_cmd::run(app, agent, *purge),
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
Command::Info { agent } => info_cmd::run(app, agent),
Command::Config(sub) => config_cmd::run(app, sub),
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
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::Export { output } => export_import::export_cmd(app, output),
Command::Import { file } => export_import::import_cmd(app, file),
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
pub struct ResolvedExec {
pub program: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
}
/// Resolve the program, arguments and environment for running an agent.
/// Preference order: recorded binary > run command first token (resolved on
/// PATH when possible).
pub fn resolve_exec(
app: &App,
agent: &AgentDef,
extra_args: &[String],
extra_env: &BTreeMap<String, String>,
) -> Result<ResolvedExec> {
let entry: Option<InstalledEntry> = app.state.get(&agent.name).ok().flatten();
let mut tokens = agent.run_tokens();
if tokens.is_empty() {
bail!("agent '{}' has no run command", agent.name);
}
let program = match &entry {
Some(e) if !e.bins.is_empty() => {
let p = std::path::PathBuf::from(&e.bins[0]);
if p.exists() || app.dry_run() {
p.display().to_string()
} else {
tokens.remove(0)
}
}
_ => {
let first = tokens.remove(0);
if first.contains(std::path::MAIN_SEPARATOR) || Path::new(&first).exists() {
first
} else if let Ok(p) = which::which(&first) {
p.display().to_string()
} else {
first
}
}
};
let mut args: Vec<String> = tokens;
args.extend(agent.args.iter().cloned());
args.extend(extra_args.iter().cloned());
let mut env = agent.env.clone();
env.extend(extra_env.clone());
Ok(ResolvedExec { program, args, env })
}
/// Parse --env KEY=VALUE entries.
pub fn parse_env_list(items: &[String]) -> Result<BTreeMap<String, String>> {
let mut map = BTreeMap::new();
for item in items {
let Some((k, v)) = item.split_once('=') else {
bail!("invalid --env '{item}': expected KEY=VALUE");
};
if k.trim().is_empty() {
bail!("invalid --env '{item}': empty key");
}
map.insert(k.to_string(), v.to_string());
}
Ok(map)
}
/// Parse --args entries; quoted strings are split on whitespace.
pub fn parse_extra_args(items: &[String]) -> Vec<String> {
let mut out = Vec::new();
for item in items {
if item.contains(' ') || item.contains('\t') {
if let Ok(parts) = shell_words::split(item) {
out.extend(parts);
continue;
}
}
out.push(item.clone());
}
out
}
/// Resolve an agent by name or alias, with a helpful error message.
pub fn require_agent<'a>(app: &'a App, name_or_alias: &str) -> Result<&'a AgentDef> {
app.catalog.resolve(name_or_alias).ok_or_else(|| {
anyhow!(
"unknown agent '{name_or_alias}' — run 'am search <keyword>' or 'am list' to see available agents"
)
})
}
/// One-line description of an install method.
pub fn describe_method(m: &MethodSpec) -> String {
let mut parts = vec![format!("type={}", m.kind)];
if let Some(p) = &m.package {
parts.push(format!("package={p}"));
}
if let Some(u) = &m.url {
parts.push(format!("url={u}"));
}
if let Some(s) = &m.script {
parts.push(format!("script={s}"));
}
if let Some(r) = &m.repo {
parts.push(format!("repo={r}"));
}
if let Some(b) = &m.binary_path {
parts.push(format!("binary_path={b}"));
}
parts.join(" ")
}
/// Select an install method by index (1-based) or type name.
pub fn select_method<'a>(
methods: &[&'a MethodSpec],
sel: Option<&str>,
) -> Result<&'a MethodSpec> {
if methods.is_empty() {
bail!("no install methods defined");
}
match sel {
None => Ok(methods[0]),
Some(s) => {
if let Ok(idx) = s.parse::<usize>() {
if (1..=methods.len()).contains(&idx) {
return Ok(methods[idx - 1]);
}
bail!("method index {idx} out of range (1..={})", methods.len());
}
let lower = s.trim_start_matches("type:").to_lowercase();
methods
.iter()
.find(|m| m.kind.as_str() == lower)
.copied()
.ok_or_else(|| {
anyhow!(
"unknown method '{s}'; available: {}",
methods
.iter()
.map(|m| m.kind.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})
}
}
}
+187
View File
@@ -0,0 +1,187 @@
//! start, stop, restart and run: process management commands.
use super::*;
use crate::cli::StartArgs;
use crate::process;
use anyhow::{bail, Context, Result};
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::PathBuf;
use std::process::Command;
pub fn start(app: &App, opts: &StartArgs) -> Result<i32> {
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) {
let members = app.catalog.group_members(group);
if members.is_empty() {
bail!("unknown group '{group}'");
}
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true)?;
}
return Ok(0);
}
let agent = require_agent(app, &opts.agent)?;
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background)
}
fn start_one(
app: &App,
agent: &AgentDef,
extra_args: &[String],
extra_env: &BTreeMap<String, String>,
notify: bool,
background: bool,
) -> Result<i32> {
let exec = resolve_exec(app, agent, extra_args, extra_env)?;
let bin = PathBuf::from(&exec.program);
let is_managed = app.state.get(&agent.name).ok().flatten().is_some();
if background {
let pid = process::spawn_background(app, &agent.name, &bin, &exec.args, &exec.env)?;
if app.dry_run() {
return Ok(0);
}
if is_managed {
app.state
.update_pid(&agent.name, Some(pid), Some(crate::installers::now_rfc3339()))?;
} else {
app.log.warn(&format!(
"agent '{}' is external (not installed by agent-manager); PID {pid} is not recorded",
agent.name
));
}
let log = process::agent_log_path(app, &agent.name);
app.log.success(&format!(
"{} started (pid {pid}); log: {}",
agent.title(),
log.display()
));
if notify {
process::desktop_notify("agent-manager", &format!("{} started", agent.title()));
}
Ok(0)
} else {
if app.dry_run() {
app.log.dry(format!(
"would run {} {} (foreground)",
exec.program,
exec.args.join(" ")
));
return Ok(0);
}
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1))
}
}
pub fn stop(app: &App, target: &str, force: bool, timeout: Option<u64>) -> Result<i32> {
let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout());
if let Some(group) = crate::catalog::Catalog::parse_group_selector(target) {
let members = app.catalog.group_members(group);
if members.is_empty() {
bail!("unknown group '{group}'");
}
for m in members.iter().rev() {
stop_one(app, m, force, timeout)?;
}
return Ok(0);
}
let agent = require_agent(app, target)?;
stop_one(app, agent, force, timeout)
}
fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i32> {
let Some(entry) = app.state.get(&agent.name).ok().flatten() else {
app.log.info(&format!(
"agent '{}' is not managed by agent-manager; nothing to stop",
agent.name
));
return Ok(0);
};
let Some(pid) = entry.pid else {
app.log.info(&format!("agent '{}' is not running", agent.name));
return Ok(0);
};
if app.dry_run() {
app.log.dry(format!(
"would stop pid {pid} ({}{})",
agent.name,
if force { "force" } else { "graceful" }
));
return Ok(0);
}
if !process::is_running(pid) {
app.log.warn(&format!(
"stale PID {pid} for '{}' (process no longer running); cleaning up",
agent.name
));
app.state.update_pid(&agent.name, None, None)?;
return Ok(0);
}
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
let stopped = process::stop_pid(pid, force, timeout);
app.state.update_pid(&agent.name, None, None)?;
if stopped {
app.log.success(&format!("{} stopped", agent.title()));
Ok(0)
} else {
app.log.error(&format!("failed to stop pid {pid}"));
Ok(1)
}
}
pub fn restart(app: &App, opts: &StartArgs, force: bool, timeout: Option<u64>) -> Result<i32> {
let timeout = timeout.unwrap_or_else(|| app.config.settings.stop_timeout());
if let Some(group) = crate::catalog::Catalog::parse_group_selector(&opts.agent) {
let members = app.catalog.group_members(group);
if members.is_empty() {
bail!("unknown group '{group}'");
}
for m in members.iter().rev() {
stop_one(app, m, force, timeout)?;
}
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
for m in members {
start_one(app, m, &extra_args, &extra_env, opts.notify, true)?;
}
return Ok(0);
}
let agent = require_agent(app, &opts.agent)?;
stop_one(app, agent, force, timeout)?;
let extra_env = parse_env_list(&opts.env)?;
let extra_args = parse_extra_args(&opts.args);
start_one(app, agent, &extra_args, &extra_env, opts.notify, opts.background)
}
/// run: execute the agent command directly, no process management.
pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
let agent = require_agent(app, target)?;
let extra_args: Vec<String> = extra.iter().map(|o| o.to_string_lossy().to_string()).collect();
let exec = resolve_exec(app, agent, &extra_args, &BTreeMap::new())?;
if app.dry_run() {
app.log.dry(format!(
"would run {} {}",
exec.program,
exec.args.join(" ")
));
return Ok(0);
}
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
let status = Command::new(&prog)
.args(&full_args)
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1))
}
+62
View File
@@ -0,0 +1,62 @@
//! search: keyword search over the catalog.
use super::*;
use crate::output::{print_json, Table};
use serde::Serialize;
#[derive(Serialize)]
struct SearchRow {
name: String,
display_name: String,
category: Option<String>,
installable: bool,
description: Option<String>,
website: Option<String>,
tags: Vec<String>,
}
pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
let hits = app.catalog.search(keyword, category);
if app.json() {
let rows: Vec<SearchRow> = hits
.iter()
.map(|a| SearchRow {
name: a.name.clone(),
display_name: a.title().to_string(),
category: a.category.clone(),
installable: a.installable,
description: a.description.clone(),
website: a.website.clone(),
tags: a.tags.clone(),
})
.collect();
print_json(&rows);
return Ok(0);
}
let mut table = Table::new(vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"]);
for a in hits {
let desc = truncate(a.description.as_deref().unwrap_or(""), 56);
table.row(vec![
a.title().to_string(),
a.category.clone().unwrap_or_else(|| "—".to_string()),
if a.installable { "yes" } else { "no" }.to_string(),
desc,
]);
}
print!("{}", table.render());
if app.catalog.search(keyword, category).is_empty() {
app.log
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
}
Ok(0)
}
pub fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut cut: String = s.chars().take(max - 1).collect();
cut.push('…');
cut
}
}
+78
View File
@@ -0,0 +1,78 @@
//! self-update: download a newer agent-manager binary from GitHub Releases.
use super::*;
use crate::download;
use anyhow::{anyhow, bail, Context, Result};
use std::cmp::Ordering;
use std::path::Path;
pub fn run(app: &App, check: bool, to: Option<&Path>) -> Result<i32> {
let Some(repo) = app.config.settings.self_update_repo.clone() else {
bail!(
"self-update is not configured: set 'settings.self_update_repo: \"owner/repo\"' in your config"
);
};
let current = env!("CARGO_PKG_VERSION");
let release = download::latest_release(&repo)?;
let tag = release.tag_name.trim_start_matches('v').to_string();
if check {
match crate::version::compare(&tag, current) {
Some(Ordering::Greater) => {
println!("update available: {current} -> {tag}");
Ok(0)
}
_ => {
println!("agent-manager {current} is up to date");
Ok(0)
}
}
} else {
if crate::version::compare(&tag, current) != Some(Ordering::Greater) {
app.log.success(&format!(
"agent-manager {current} is already the latest version"
));
return Ok(0);
}
if app.dry_run() {
app.log
.dry(format!("would download agent-manager {tag}"));
return Ok(0);
}
let picked = download::pick_asset(&release, &[], &[], None)
.ok_or_else(|| anyhow!("no release asset of '{repo}' matches this platform"))?;
app.log.info(&format!(
"downloading agent-manager {tag} ({})",
picked.name
));
let current_exe = std::env::current_exe()?;
let dest = to.map(|p| p.to_path_buf()).unwrap_or_else(|| current_exe.clone());
let tmpdir = tempfile::tempdir()?;
let tmp = tmpdir.path().join("am.new");
download::download(&picked.url, &tmp, None, true)?;
if dest == current_exe && cfg!(windows) {
// The running executable is locked on Windows.
let new_path = current_exe.with_extension("exe.new");
std::fs::copy(&tmp, &new_path)
.with_context(|| format!("cannot write {}", new_path.display()))?;
app.log.success(&format!(
"downloaded {tag} to {} — replace the running binary manually",
new_path.display()
));
} else {
std::fs::copy(&tmp, &dest)
.with_context(|| format!("cannot write {}", dest.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(
&dest,
std::fs::Permissions::from_mode(0o755),
);
}
app.log
.success(&format!("updated agent-manager to {tag} ({})", dest.display()));
}
Ok(0)
}
}
+179
View File
@@ -0,0 +1,179 @@
//! status: detailed state of one agent, or of every installed agent.
use super::*;
use crate::output::{print_json, status_style, Table};
use crate::probe::ExternalInfo;
use crate::state::InstalledEntry;
use serde::Serialize;
#[derive(Serialize)]
struct StatusDetail {
name: String,
display_name: String,
status: String,
version: Option<String>,
method: Option<String>,
path: Option<String>,
run: Option<String>,
pid: Option<u32>,
started_at: Option<String>,
installed_at: Option<String>,
log: Option<String>,
managed: bool,
}
pub fn run(app: &App, agent_name: Option<&str>) -> Result<i32> {
let probe = crate::probe::detect_externals(app);
match agent_name {
Some(name) => status_one(app, name, &probe),
None => status_all(app, &probe),
}
}
fn status_one(app: &App, name: &str, probe: &ExternalInfo) -> Result<i32> {
let agent = require_agent(app, name)?;
let entry: Option<InstalledEntry> = app.state.get(&agent.name).ok().flatten();
let detail = detail_for(app, agent, entry.as_ref(), probe);
if app.json() {
print_json(&detail);
return Ok(0);
}
let color = app.color();
println!("{} ({})", agent.title(), agent.name);
let status_line = status_style(&detail.status, color);
println!(" status: {status_line}");
println!(
" version: {}",
detail.version.as_deref().unwrap_or("unknown")
);
println!(" method: {}", detail.method.as_deref().unwrap_or("—"));
println!(" path: {}", detail.path.as_deref().unwrap_or("—"));
println!(" run: {}", detail.run.as_deref().unwrap_or("—"));
if let Some(pid) = detail.pid {
println!(" pid: {pid}");
}
if let Some(t) = &detail.started_at {
println!(" started: {t}");
}
if let Some(t) = &detail.installed_at {
println!(" installed: {t}");
}
if let Some(l) = &detail.log {
println!(" log: {l}");
}
Ok(0)
}
fn status_all(app: &App, probe: &ExternalInfo) -> Result<i32> {
let entries = app.state.all().unwrap_or_default();
let mut rows: Vec<AgentRow> = Vec::new();
for agent in app.catalog.agents() {
if agent.hidden {
continue;
}
let entry = entries.get(&agent.name);
let d = detail_for(app, agent, entry, probe);
if d.status == "running" || d.status == "installed" || d.status == "external" {
rows.push(AgentRow {
name: agent.name.clone(),
display_name: agent.title().to_string(),
status: d.status,
version: d.version,
pid: d.pid,
path: d.path,
});
}
}
rows.sort_by(|a, b| a.name.cmp(&b.name));
if app.json() {
print_json(&rows);
return Ok(0);
}
let color = app.color();
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "PID", "PATH"]);
for r in &rows {
table.row(vec![
r.display_name.clone(),
r.version.clone().unwrap_or_else(|| "—".to_string()),
status_style(&r.status, color),
r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()),
r.path.clone().unwrap_or_else(|| "—".to_string()),
]);
}
print!("{}", table.render());
if rows.is_empty() {
app.log.info("no installed or running agents");
}
Ok(0)
}
#[derive(Serialize)]
struct AgentRow {
name: String,
display_name: String,
status: String,
version: Option<String>,
pid: Option<u32>,
path: Option<String>,
}
fn detail_for(
app: &App,
agent: &AgentDef,
entry: Option<&InstalledEntry>,
probe: &ExternalInfo,
) -> StatusDetail {
match entry {
Some(e) => {
let running = e.pid.map(crate::process::is_running).unwrap_or(false);
let status = if running { "running" } else { "installed" };
let path = e
.bins
.first()
.filter(|b| std::path::Path::new(b).exists())
.cloned()
.or_else(|| Some(e.install_dir.clone()));
StatusDetail {
name: agent.name.clone(),
display_name: agent.title().to_string(),
status: status.to_string(),
version: e.version.clone(),
method: Some(e.method.clone()),
path,
run: Some(e.run.clone()),
pid: if running { e.pid } else { None },
started_at: if running { e.started_at.clone() } else { None },
installed_at: Some(e.installed_at.clone()),
log: Some(crate::process::agent_log_path(app, &agent.name).display().to_string()),
managed: true,
}
}
None => {
let external = probe.path_of(&agent.name).cloned();
let version = external
.as_ref()
.and_then(|_p| probe.version_of(&agent.name).cloned().flatten())
.or_else(|| agent.version.clone());
StatusDetail {
name: agent.name.clone(),
display_name: agent.title().to_string(),
status: if external.is_some() {
"external".to_string()
} else if agent.installable {
"not-installed".to_string()
} else {
"not-installable".to_string()
},
version,
method: None,
path: external.map(|p| p.display().to_string()),
run: agent.run.clone(),
pid: None,
started_at: None,
installed_at: None,
log: None,
managed: false,
}
}
}
}
+113
View File
@@ -0,0 +1,113 @@
//! uninstall: stop the agent if needed, remove its files, bins and state
//! entry; --purge also removes logs and the user-config definition.
use super::*;
use anyhow::{Context, Result};
use std::path::PathBuf;
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);
};
// Stop it first when running.
if let Some(pid) = entry.pid {
if crate::process::is_running(pid) {
app.log.info(&format!("stopping running agent (pid {pid})"));
if app.dry_run() {
app.log.dry(format!("would stop pid {pid}"));
} else {
crate::process::stop_pid(pid, false, app.config.settings.stop_timeout());
app.state.update_pid(&agent.name, None, None)?;
}
}
}
let dir = PathBuf::from(&entry.install_dir);
if app.dry_run() {
app.log.dry(format!("would remove {}", dir.display()));
} else {
if dir.exists() {
std::fs::remove_dir_all(&dir)
.with_context(|| format!("cannot remove {}", dir.display()))?;
}
for b in &entry.bins {
let p = PathBuf::from(b);
if p.is_file() {
std::fs::remove_file(&p)
.with_context(|| format!("cannot remove {}", p.display()))?;
}
}
app.state.remove(&agent.name)?;
}
if purge {
let log = crate::process::agent_log_path(app, &agent.name);
if app.dry_run() {
app.log.dry(format!("would remove log {}", log.display()));
} else if log.exists() {
std::fs::remove_file(&log).ok();
}
remove_from_user_config(app, &agent.name)?;
}
app.log.success(&format!(
"uninstalled {} (files removed{})",
agent.title(),
if purge { ", config purged" } else { "" }
));
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 {
app.log.verbose("no user config file — nothing to purge");
return Ok(());
};
let is_json = path.extension().map(|e| e == "json").unwrap_or(false);
if is_json {
app.log
.warn("config purge is not supported for JSON config files");
return Ok(());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let mut value: serde_yaml::Value = serde_yaml::from_str(&text)
.with_context(|| format!("cannot parse {}", path.display()))?;
let mut removed = false;
if let serde_yaml::Value::Mapping(map) = &mut value {
if let Some(serde_yaml::Value::Sequence(seq)) = map.get_mut("agents") {
let before = seq.len();
seq.retain(|v| {
v.get("name")
.and_then(|n| n.as_str())
.map(|n| n != name)
.unwrap_or(true)
});
removed = seq.len() != before;
}
}
if !removed {
app.log.verbose(&format!("'{name}' not defined in {}", path.display()));
return Ok(());
}
let backup = path.with_extension("yaml.bak");
std::fs::copy(path, &backup)
.with_context(|| format!("cannot create backup {}", backup.display()))?;
std::fs::write(path, serde_yaml::to_string(&value)?)
.with_context(|| format!("cannot write {}", path.display()))?;
app.log.info(&format!(
"removed '{name}' from {} (backup: {})",
path.display(),
backup.display()
));
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
//! update: compare the installed version with the latest available one and
//! reinstall when an update exists.
use super::*;
use crate::installers;
use crate::runner::{Runner, SystemRunner};
use anyhow::{bail, Result};
use std::cmp::Ordering;
use std::path::PathBuf;
pub fn run(app: &App, agent_name: Option<&str>, all: bool) -> Result<i32> {
let runner = SystemRunner::new(app.dry_run(), app.cli.verbose, &app.log);
if let Some(name) = agent_name {
return update_one(app, &runner, name);
}
if all {
let entries = app.state.all()?;
let names: Vec<String> = entries.keys().cloned().collect();
let mut failed = 0;
for name in names {
if let Err(e) = update_one(app, &runner, &name) {
failed += 1;
app.log.error(&format!("{name}: {e:#}"));
}
}
return Ok(if failed > 0 { 1 } else { 0 });
}
bail!("update requires an agent name, or --all");
}
fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
let agent = require_agent(app, name)?;
let Some(entry) = app.state.get(&agent.name).ok().flatten() else {
bail!(
"agent '{}' is not installed by agent-manager — use 'am install {}'",
agent.name,
agent.name
);
};
let spec = agent
.install
.as_ref()
.ok_or_else(|| anyhow::anyhow!("agent '{}' has no install definition", agent.name))?;
let methods = spec.all_methods();
// Prefer the method that was originally used.
let method = methods
.iter()
.find(|m| m.kind.as_str() == entry.method)
.copied()
.unwrap_or(methods[0]);
let latest = if app.dry_run() {
None
} else {
installers::latest_version(app, agent, method, runner)?
};
let current = entry.version.clone().or_else(|| {
entry
.bins
.first()
.and_then(|b| installers::detect_version(runner, &PathBuf::from(b)))
});
match (&latest, &current) {
(Some(l), Some(c)) => match crate::version::compare(l, c) {
Some(Ordering::Greater) => {
app.log.info(&format!("update available: {c} -> {l}"));
}
Some(Ordering::Equal) | Some(Ordering::Less) => {
app.log
.success(&format!("{} is up to date (v{c})", agent.title()));
return Ok(0);
}
None => {
app.log.warn(&format!("cannot compare versions ({c} vs {l}); reinstalling"));
}
},
(Some(l), None) => app.log.info(&format!(
"latest available: {l} (installed version unknown); reinstalling"
)),
(None, Some(c)) => app.log.warn(&format!(
"cannot determine the latest version (installed: {c}); reinstalling"
)),
(None, None) => app.log.warn("cannot determine versions; reinstalling"),
}
if app.dry_run() {
app.log
.dry(format!("would reinstall {} via {}", agent.name, method.kind));
return Ok(0);
}
crate::deps::ensure_deps(app, agent, runner)?;
let outcome = installers::run_install(app, agent, method, runner)?;
let mut new_entry = installers::make_entry(agent, method, &outcome, app);
new_entry.installed_at = entry.installed_at.clone();
new_entry.updated_at = Some(installers::now_rfc3339());
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&new_entry)?;
app.log.success(&format!(
"updated {} to v{version}",
agent.title()
));
Ok(0)
}
+739
View File
@@ -0,0 +1,739 @@
//! Configuration: YAML schema, loading with includes, merging with the
//! embedded default catalog, validation, and path expansion.
use crate::cli::Cli;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
/// Default catalog embedded in the binary at compile time.
pub const DEFAULT_CONFIG: &str = include_str!("../config.yaml");
/// Name of the user configuration file inside the config directory.
pub const CONFIG_FILE_NAME: &str = "config.yaml";
/// Name of the local (current directory) configuration file.
pub const LOCAL_CONFIG_NAME: &str = "agent-manager.yaml";
// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "default_version")]
pub version: String,
#[serde(default)]
pub settings: Settings,
#[serde(default)]
pub aliases: BTreeMap<String, String>,
#[serde(default)]
pub groups: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub agents: Vec<AgentDef>,
}
fn default_version() -> String {
"1.0".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Settings {
/// Installation root for all agents.
#[serde(default)]
pub install_dir: Option<String>,
/// Directory for logs.
#[serde(default)]
pub log_dir: Option<String>,
/// Shell used to execute install scripts (defaults to sh on Unix).
#[serde(default)]
pub default_shell: Option<String>,
/// Try to install missing dependencies automatically.
#[serde(default)]
pub auto_install_deps: Option<bool>,
/// Ask for confirmation before running install scripts.
#[serde(default)]
pub confirm_before_run: Option<bool>,
/// Grace period (seconds) between SIGTERM and SIGKILL; 0 = default (5).
#[serde(default)]
pub stop_timeout_secs: u64,
/// GitHub repository (owner/repo) used by the self-update command.
#[serde(default)]
pub self_update_repo: Option<String>,
}
impl Settings {
pub fn auto_install_deps(&self) -> bool {
self.auto_install_deps.unwrap_or(true)
}
pub fn confirm_before_run(&self) -> bool {
self.confirm_before_run.unwrap_or(true)
}
pub fn stop_timeout(&self) -> u64 {
if self.stop_timeout_secs == 0 {
5
} else {
self.stop_timeout_secs
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentDef {
/// Unique identifier (slug).
pub name: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub website: Option<String>,
#[serde(default)]
pub install: Option<InstallSpec>,
#[serde(default)]
pub dependencies: Vec<Dependency>,
/// Command used to start the agent (e.g. "claude", "python -m nanobot").
#[serde(default)]
pub run: Option<String>,
/// Default arguments appended to the run command.
#[serde(default)]
pub args: Vec<String>,
/// Default environment variables for the agent.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Known version (otherwise detected via --version).
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
/// When false, the agent is listed but cannot be installed locally.
#[serde(default = "default_true")]
pub installable: bool,
/// Explanation shown when installable is false.
#[serde(default)]
pub note: Option<String>,
/// Hidden from list/search output (still addressable by name).
#[serde(default)]
pub hidden: bool,
/// Restrict the agent to platforms: linux, macos, windows (empty = all).
#[serde(default)]
pub platforms: Vec<String>,
}
fn default_true() -> bool {
true
}
impl AgentDef {
pub fn title(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.name)
}
/// First word of the run command (the executable name).
pub fn first_token(&self) -> Option<String> {
self.run
.as_deref()
.and_then(|r| shell_words::split(r).ok())
.and_then(|mut v| {
if v.is_empty() {
None
} else {
Some(v.remove(0))
}
})
}
/// The run command split into tokens (empty when unset).
pub fn run_tokens(&self) -> Vec<String> {
self.run
.as_deref()
.and_then(|r| shell_words::split(r).ok())
.unwrap_or_default()
}
pub fn supports_current_platform(&self) -> bool {
if self.platforms.is_empty() {
return true;
}
let platform = current_platform();
self.platforms.iter().any(|p| p == platform)
}
}
pub fn current_platform() -> &'static str {
if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InstallType {
Npm,
Pip,
Uv,
Cargo,
Go,
Bun,
Curl,
Binary,
Git,
}
impl InstallType {
pub fn as_str(&self) -> &'static str {
match self {
InstallType::Npm => "npm",
InstallType::Pip => "pip",
InstallType::Uv => "uv",
InstallType::Cargo => "cargo",
InstallType::Go => "go",
InstallType::Bun => "bun",
InstallType::Curl => "curl",
InstallType::Binary => "binary",
InstallType::Git => "git",
}
}
}
impl std::fmt::Display for InstallType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// A single install method: npm, pip, uv, cargo, go, bun, curl, binary or git.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MethodSpec {
#[serde(rename = "type")]
pub kind: InstallType,
/// Package name (npm, pip, cargo, go, bun).
#[serde(default)]
pub package: Option<String>,
/// Direct download URL (binary method).
#[serde(default)]
pub url: Option<String>,
/// Install script URL (curl method).
#[serde(default)]
pub script: Option<String>,
/// GitHub repository "owner/repo" (binary method) or full clone URL (git).
#[serde(default)]
pub repo: Option<String>,
/// Substrings that must all appear in the release asset name.
#[serde(default)]
pub asset_match: Vec<String>,
/// Substrings that must not appear in the release asset name.
#[serde(default)]
pub asset_exclude: Vec<String>,
/// Expected checksum of the downloaded file, "sha256:<hex>".
#[serde(default)]
pub checksum: Option<String>,
/// Expected binary name (used to locate the executable).
#[serde(default)]
pub binary: Option<String>,
/// Build command(s) for the git method (string or list).
#[serde(default)]
pub build: Option<OneOrMany>,
/// Path of the built binary inside the repository (git method).
#[serde(default)]
pub binary_path: Option<String>,
/// Extra arguments for the installer command.
#[serde(default)]
pub args: Vec<String>,
/// Extra environment for the installer command.
#[serde(default)]
pub env: BTreeMap<String, String>,
/// Free-form note shown in info and method listings.
#[serde(default)]
pub note: Option<String>,
}
/// A string or a list of strings (for build commands and similar fields).
#[derive(Debug, Clone, Serialize)]
pub enum OneOrMany {
One(String),
Many(Vec<String>),
}
impl OneOrMany {
pub fn items(&self) -> Vec<String> {
match self {
OneOrMany::One(s) => vec![s.clone()],
OneOrMany::Many(v) => v.clone(),
}
}
}
impl<'de> Deserialize<'de> for OneOrMany {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = OneOrMany;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a string or a list of strings")
}
fn visit_str<E>(self, v: &str) -> Result<OneOrMany, E> {
Ok(OneOrMany::One(v.to_string()))
}
fn visit_string<E>(self, v: String) -> Result<OneOrMany, E> {
Ok(OneOrMany::One(v))
}
fn visit_seq<A>(self, mut seq: A) -> Result<OneOrMany, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut out = Vec::new();
while let Some(v) = seq.next_element::<String>()? {
out.push(v);
}
Ok(OneOrMany::Many(out))
}
}
deserializer.deserialize_any(Visitor)
}
}
/// Install specification: one primary method, optionally several alternatives.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstallSpec {
#[serde(flatten)]
pub primary: MethodSpec,
/// Alternative methods; the first one is the default.
#[serde(default)]
pub methods: Vec<MethodSpec>,
/// Commands executed after a successful install (PATH includes the bin dir).
#[serde(default)]
pub post_install: Vec<String>,
}
impl InstallSpec {
/// All available methods: the alternatives when present, else the primary.
pub fn all_methods(&self) -> Vec<&MethodSpec> {
if self.methods.is_empty() {
vec![&self.primary]
} else {
self.methods.iter().collect()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Dependency {
pub name: String,
/// Command used to probe the tool (defaults from the name).
#[serde(default)]
pub check: Option<String>,
/// Minimum required version (lenient comparison).
#[serde(default)]
pub min_version: Option<String>,
/// Human-readable hint on how to install the tool.
#[serde(default)]
pub install_hint: Option<String>,
/// Command that installs the tool (used with auto_install_deps).
#[serde(default)]
pub install_cmd: Vec<String>,
}
// ---------------------------------------------------------------------------
// Loading and merging
// ---------------------------------------------------------------------------
pub struct LoadedConfig {
pub config: Config,
/// The main user configuration file that was found (if any).
pub origin: Option<PathBuf>,
}
/// Load the effective configuration: embedded defaults, then the user file
/// (found by priority order), then its includes.
pub fn load(cli: &Cli) -> Result<LoadedConfig> {
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG)
.context("embedded default catalog is invalid — this is a bug in agent-manager")?;
let mut origin: Option<PathBuf> = None;
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(p) = std::env::var_os("AGENT_MANAGER_CONFIG") {
candidates.push(PathBuf::from(p));
}
if let Some(p) = &cli.config {
candidates.push(p.clone());
}
candidates.push(PathBuf::from(LOCAL_CONFIG_NAME));
if let Some(dir) = user_config_dir() {
candidates.push(dir.join(CONFIG_FILE_NAME));
}
let chosen = candidates.into_iter().find(|p| p.is_file());
if let Some(path) = chosen {
let overlay = load_file_with_includes(&path, &mut HashSet::new())?;
merge(&mut base, overlay);
origin = Some(path);
}
let problems = validate(&base);
if !problems.is_empty() {
return Err(anyhow!(
"configuration problems:
- {}",
problems.join("
- ")
));
}
Ok(LoadedConfig { config: base, origin })
}
/// Parse a YAML or JSON config file.
pub fn parse_file(path: &Path) -> Result<Config> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read config file {}", path.display()))?;
let is_json = path.extension().map(|e| e == "json").unwrap_or(false);
if is_json {
serde_json::from_str(&text)
.map_err(|e| anyhow!("invalid JSON in {}: {e}", path.display()))
} else {
serde_yaml::from_str(&text)
.map_err(|e| anyhow!("invalid YAML in {}: {e}", path.display()))
}
}
fn load_file_with_includes(path: &Path, visited: &mut HashSet<PathBuf>) -> Result<Config> {
let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if !visited.insert(canon.clone()) {
return Err(anyhow!("config include cycle detected at {}", path.display()));
}
let mut cfg = parse_file(path)?;
let includes = cfg.include.clone();
cfg.include.clear();
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut merged = Config::default();
merged.version = cfg.version.clone();
for inc in &includes {
let inc_path = expand_path(inc);
let abs = if inc_path.is_absolute() {
inc_path
} else {
base_dir.join(inc_path)
};
let sub = load_file_with_includes(&abs, visited)?;
merge(&mut merged, sub);
}
merge(&mut merged, cfg);
Ok(merged)
}
/// Merge an overlay config into a base config: user definitions replace
/// default agents with the same name; maps are merged key by key.
pub fn merge(base: &mut Config, overlay: Config) {
let s = &mut base.settings;
let o = overlay.settings;
if o.install_dir.is_some() {
s.install_dir = o.install_dir;
}
if o.log_dir.is_some() {
s.log_dir = o.log_dir;
}
if o.default_shell.is_some() {
s.default_shell = o.default_shell;
}
if o.auto_install_deps.is_some() {
s.auto_install_deps = o.auto_install_deps;
}
if o.confirm_before_run.is_some() {
s.confirm_before_run = o.confirm_before_run;
}
if o.stop_timeout_secs != 0 {
s.stop_timeout_secs = o.stop_timeout_secs;
}
if o.self_update_repo.is_some() {
s.self_update_repo = o.self_update_repo;
}
for (k, v) in overlay.aliases {
base.aliases.insert(k, v);
}
for (k, v) in overlay.groups {
base.groups.insert(k, v);
}
for agent in overlay.agents {
if let Some(existing) = base.agents.iter_mut().find(|a| a.name == agent.name) {
*existing = agent;
} else {
base.agents.push(agent);
}
}
}
/// Validate a merged config. Returns a list of human-readable problems.
pub fn validate(config: &Config) -> Vec<String> {
let mut problems = Vec::new();
if config.version != "1" && config.version != "1.0" {
problems.push(format!(
"unsupported config version '{}' (expected '1.0')",
config.version
));
}
let mut seen = HashSet::new();
for agent in &config.agents {
if !seen.insert(agent.name.clone()) {
problems.push(format!("duplicate agent name '{}'", agent.name));
}
if agent.name.is_empty()
|| !agent
.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
problems.push(format!(
"agent name '{}' must be a lowercase slug ([a-z0-9_-])",
agent.name
));
}
if agent.installable {
if agent.run.as_deref().unwrap_or("").trim().is_empty() {
problems.push(format!("agent '{}': 'run' is required when installable", agent.name));
}
if agent.install.is_none() {
problems.push(format!(
"agent '{}': 'install' block is required when installable",
agent.name
));
}
}
for dep in &agent.dependencies {
if dep.name.trim().is_empty() {
problems.push(format!("agent '{}': dependency without a name", agent.name));
}
}
}
problems
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
/// Expand "~" and environment variables ("$HOME", "${VAR}") in a path.
pub fn expand_path(input: &str) -> PathBuf {
let mut s = input.to_string();
if s == "~" {
s = home_dir()
.map(|h| h.to_string_lossy().to_string())
.unwrap_or_else(|| "~".to_string());
} else if let Some(rest) = s.strip_prefix("~/").or_else(|| s.strip_prefix("~\\")) {
if let Some(h) = home_dir() {
s = format!("{}{}{}", h.to_string_lossy(), std::path::MAIN_SEPARATOR, rest);
}
}
PathBuf::from(expand_vars(&s))
}
/// Expand "$VAR" and "${VAR}" occurrences.
pub fn expand_vars(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let mut out = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '$' && i + 1 < chars.len() {
if chars[i + 1] == '{' {
if let Some(end) = chars[i + 2..].iter().position(|&c| c == '}') {
let name: String = chars[i + 2..i + 2 + end].iter().collect();
if let Ok(v) = std::env::var(&name) {
out.push_str(&v);
i += end + 3;
continue;
}
}
} else {
let mut j = i + 1;
while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') {
j += 1;
}
if j > i + 1 {
let name: String = chars[i + 1..j].iter().collect();
if let Ok(v) = std::env::var(&name) {
out.push_str(&v);
i = j;
continue;
}
}
}
}
out.push(chars[i]);
i += 1;
}
out
}
/// The user's home directory.
pub fn home_dir() -> Option<PathBuf> {
if let Some(h) = std::env::var_os("HOME") {
return Some(PathBuf::from(h));
}
if let Some(h) = std::env::var_os("USERPROFILE") {
return Some(PathBuf::from(h));
}
None
}
/// Directory holding the user configuration file.
pub fn user_config_dir() -> Option<PathBuf> {
if let Some(d) = std::env::var_os("AGENT_MANAGER_CONFIG_DIR") {
return Some(PathBuf::from(d));
}
let base = if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from)
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| home_dir().map(|h| h.join(".config")))
};
base.map(|b| b.join("agent-manager"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_embedded_default() {
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!(validate(&cfg).is_empty(), "embedded config invalid: {:?}", validate(&cfg));
}
#[test]
fn expands_paths() {
std::env::set_var("AM_TEST_EXPAND_VAR", "expanded-value");
let home = home_dir().unwrap_or_else(|| PathBuf::from("/home/test"));
let p = expand_path("~/a/$AM_TEST_EXPAND_VAR/b");
let s = p.to_string_lossy().to_string();
assert!(!s.contains('~'));
assert!(!s.contains("$AM_TEST_EXPAND_VAR"));
assert!(s.contains("expanded-value"));
assert!(s.contains("a"));
assert!(s.contains(&home.to_string_lossy().to_string()));
}
#[test]
fn merges_user_over_default() {
let mut base: Config = serde_yaml::from_str(DEFAULT_CONFIG).unwrap();
let overlay: Config = serde_yaml::from_str(
r#"
version: "1.0"
agents:
- name: claude-code
display_name: "Mon Claude"
install:
type: npm
package: "@anthropic-ai/claude-code"
run: claude
- name: my-agent
display_name: "My Agent"
install:
type: binary
url: "https://example.com/bin"
run: my-agent
"#,
)
.unwrap();
merge(&mut base, overlay);
let cc = base.agents.iter().find(|a| a.name == "claude-code").unwrap();
assert_eq!(cc.display_name.as_deref(), Some("Mon Claude"));
assert!(base.agents.iter().any(|a| a.name == "my-agent"));
assert!(validate(&base).is_empty());
}
#[test]
fn validate_rejects_bad_agents() {
let cfg: Config = serde_yaml::from_str(
r#"
version: "1.0"
agents:
- name: broken
installable: true
run: ""
install:
type: npm
package: x
- name: broken
install:
type: npm
package: y
run: broken
"#,
)
.unwrap();
let problems = validate(&cfg);
assert!(problems.iter().any(|p| p.contains("duplicate")));
assert!(problems.iter().any(|p| p.contains("'run' is required")));
}
#[test]
fn one_or_many_parses_both() {
let a: OneOrMany = serde_yaml::from_str("\"make build\"").unwrap();
assert_eq!(a.items(), vec!["make build".to_string()]);
let b: OneOrMany = serde_yaml::from_str("- cargo
- build").unwrap();
assert_eq!(b.items(), vec!["cargo".to_string(), "build".to_string()]);
}
#[test]
fn includes_resolve_relative() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub.yaml");
std::fs::write(
&sub,
r#"
version: "1.0"
agents:
- name: from-include
display_name: "Included"
install: { type: npm, package: pkg }
run: from-include
"#,
)
.unwrap();
let main = dir.path().join("main.yaml");
std::fs::write(
&main,
r#"
version: "1.0"
include: [sub.yaml]
agents:
- name: from-main
display_name: "Main"
install: { type: npm, package: pkg2 }
run: from-main
"#,
)
.unwrap();
let cfg = load_file_with_includes(&main, &mut HashSet::new()).unwrap();
assert!(cfg.agents.iter().any(|a| a.name == "from-include"));
assert!(cfg.agents.iter().any(|a| a.name == "from-main"));
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Dependency checking: probe required tools (node, python, go, ...),
//! compare minimum versions, and produce actionable error messages.
use crate::app::App;
use crate::config::{AgentDef, Dependency};
use crate::runner::Runner;
use anyhow::{bail, Result};
use std::collections::BTreeMap;
use std::cmp::Ordering;
#[derive(Debug, Clone)]
pub struct DepCheck {
pub name: String,
pub found: bool,
pub version: Option<String>,
pub min: Option<String>,
pub ok: bool,
pub detail: String,
}
/// Default probe command for a dependency name: (program, args).
fn default_probe(name: &str) -> (String, Vec<String>) {
match name {
"go" => ("go".to_string(), vec!["version".to_string()]),
_ => (name.to_string(), vec!["--version".to_string()]),
}
}
/// Probe one dependency. Python is special: python3 is preferred.
pub fn check_dep(runner: &dyn Runner, dep: &Dependency) -> DepCheck {
let probes: Vec<(String, Vec<String>)> = if dep.name == "python" {
vec![
("python3".to_string(), vec!["--version".to_string()]),
("python".to_string(), vec!["--version".to_string()]),
]
} else if let Some(check) = &dep.check {
match shell_words::split(check) {
Ok(tokens) if !tokens.is_empty() => {
let prog = tokens[0].clone();
vec![(prog, tokens[1..].to_vec())]
}
_ => vec![default_probe(&dep.name)],
}
} else {
vec![default_probe(&dep.name)]
};
let empty = BTreeMap::new();
for (prog, args) in probes {
if runner.which(&prog).is_none() {
continue;
}
match runner.run(&prog, &args, &empty, None, true) {
Ok(out) if out.code == 0 => {
let combined = format!("{}
{}", out.stdout, out.stderr);
let version = crate::version::find_version(&combined);
let ok = match (&dep.min_version, &version) {
(Some(min), Some(v)) => crate::version::compare(v, min)
.map(|o| o != Ordering::Less)
.unwrap_or(true),
_ => true,
};
let detail = if ok {
version.clone().unwrap_or_else(|| "found".to_string())
} else {
format!(
"version {} is too old (need >= {})",
version.as_deref().unwrap_or("unknown"),
dep.min_version.as_deref().unwrap_or("?")
)
};
return DepCheck {
name: dep.name.clone(),
found: true,
version,
min: dep.min_version.clone(),
ok,
detail,
};
}
Ok(out) => {
return DepCheck {
name: dep.name.clone(),
found: true,
version: None,
min: dep.min_version.clone(),
ok: false,
detail: format!("probe failed (exit code {})", out.code),
};
}
Err(_) => {}
}
}
DepCheck {
name: dep.name.clone(),
found: false,
version: None,
min: dep.min_version.clone(),
ok: false,
detail: "not found on PATH".to_string(),
}
}
/// Check every dependency of an agent.
pub fn check_agent(agent: &AgentDef, runner: &dyn Runner) -> Vec<DepCheck> {
agent
.dependencies
.iter()
.map(|d| check_dep(runner, d))
.collect()
}
/// 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.
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(());
}
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));
}
}
}
checks = check_agent(agent, runner);
}
if checks.iter().all(|c| c.ok) {
return Ok(());
}
let mut lines = Vec::new();
for c in checks.iter().filter(|c| !c.ok) {
let mut line = format!(" - {}: {}", c.name, c.detail);
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) {
if let Some(hint) = &dep.install_hint {
line.push_str(&format!(" — install via {hint}"));
}
}
lines.push(line);
}
bail!(
"agent '{}' has unmet dependencies:
{}",
agent.name,
lines.join("
")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Dependency;
use crate::runner::MockRunner;
#[test]
fn missing_tool_reported() {
let runner = MockRunner::new();
let dep = Dependency {
name: "node".to_string(),
check: None,
min_version: Some("18.0.0".to_string()),
install_hint: Some("https://nodejs.org".to_string()),
install_cmd: vec![],
};
let c = check_dep(&runner, &dep);
assert!(!c.ok);
assert!(!c.found);
assert_eq!(c.detail, "not found on PATH");
}
#[test]
fn python3_preferred() {
let runner = MockRunner::new().with_found("python3").with_found("python");
let dep = Dependency {
name: "python".to_string(),
check: None,
min_version: Some("3.9".to_string()),
install_hint: None,
install_cmd: vec![],
};
let c = check_dep(&runner, &dep);
// MockRunner returns empty output => version unknown => ok when no min.
assert!(c.found);
}
}
+229
View File
@@ -0,0 +1,229 @@
//! doctor: environment checks — tools, config validity, paths,
//! permissions — with an optional --fix mode.
use crate::app::App;
use crate::runner::Runner;
use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct Check {
pub name: String,
pub status: Status,
pub detail: String,
}
pub struct DoctorReport {
pub checks: Vec<Check>,
}
impl DoctorReport {
pub fn ok(&self) -> bool {
!self.checks.iter().any(|c| c.status == Status::Error)
}
pub fn problems(&self) -> usize {
self.checks.iter().filter(|c| c.status != Status::Ok).count()
}
}
/// Args for probing a tool's version.
fn probe_args(tool: &str) -> Vec<String> {
match tool {
"go" => vec!["version".to_string()],
_ => vec!["--version".to_string()],
}
}
pub fn run(app: &App, runner: &dyn Runner) -> DoctorReport {
let mut checks = Vec::new();
// --- tools ----------------------------------------------------------
// (name, required)
let tools: &[(&str, bool)] = &[
("git", true),
("sh", cfg!(unix)),
("curl", true),
("node", false),
("npm", false),
("python3", false),
("python", false),
("cargo", false),
("go", false),
("bun", false),
("uv", false),
("docker", false),
];
for (tool, required) in tools {
match runner.which(tool) {
Some(path) => {
let mut detail = path.display().to_string();
let empty = BTreeMap::new();
if let Ok(out) = runner.run(tool, &probe_args(tool), &empty, None, true) {
if out.code == 0 {
let combined = format!("{}
{}", out.stdout, out.stderr);
if let Some(v) = crate::version::find_version(&combined) {
detail = format!("{} (v{v})", path.display());
}
}
}
checks.push(Check {
name: tool.to_string(),
status: Status::Ok,
detail,
});
}
None => {
let (status, detail) = if *required {
(
Status::Error,
"required but not found on PATH".to_string(),
)
} else {
(Status::Ok, "not installed".to_string())
};
checks.push(Check {
name: tool.to_string(),
status,
detail,
});
}
}
}
// --- configuration ---------------------------------------------------
match &app.config_origin {
Some(path) => checks.push(Check {
name: "config".to_string(),
status: Status::Ok,
detail: path.display().to_string(),
}),
None => checks.push(Check {
name: "config".to_string(),
status: Status::Warn,
detail: "no user config file found; using the embedded default catalog".to_string(),
}),
}
let problems = crate::config::validate(&app.config);
if problems.is_empty() {
checks.push(Check {
name: "config-schema".to_string(),
status: Status::Ok,
detail: format!("{} agents defined", app.catalog.len()),
});
} else {
checks.push(Check {
name: "config-schema".to_string(),
status: Status::Error,
detail: problems.join("; "),
});
}
// --- state file ------------------------------------------------------
match app.state.load() {
Ok(sf) => checks.push(Check {
name: "state".to_string(),
status: Status::Ok,
detail: format!("{} installed agent(s)", sf.installed.len()),
}),
Err(e) => checks.push(Check {
name: "state".to_string(),
status: Status::Error,
detail: format!("{e:#}"),
}),
}
// --- directories -----------------------------------------------------
for (name, dir) in [
("install-dir", app.paths.install_dir.clone()),
("bin-dir", app.paths.bin_dir.clone()),
("log-dir", app.paths.log_dir.clone()),
] {
let status = if dir.is_dir() {
Status::Ok
} else if app.paths.install_dir.parent().map(|p| p.is_dir()).unwrap_or(false) {
Status::Warn
} else {
Status::Warn
};
let detail = if dir.is_dir() {
dir.display().to_string()
} else {
format!("{} (missing; 'am doctor --fix' creates it)", dir.display())
};
checks.push(Check {
name: name.to_string(),
status,
detail,
});
}
// --- bin dir on PATH --------------------------------------------------
let on_path = is_on_path(&app.paths.bin_dir);
checks.push(Check {
name: "bin-on-path".to_string(),
status: if on_path { Status::Ok } else { Status::Warn },
detail: if on_path {
format!("{} is on PATH", app.paths.bin_dir.display())
} else {
format!(
"{} is not on PATH — add it so agents are found from your shell",
app.paths.bin_dir.display()
)
},
});
DoctorReport { checks }
}
fn is_on_path(dir: &std::path::Path) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|p| {
if cfg!(windows) {
p.to_string_lossy().to_lowercase() == dir.to_string_lossy().to_lowercase()
} else {
p == dir
}
})
}
/// 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();
app.paths.ensure().context("creating directories")?;
repaired.push(format!("created {}", app.paths.install_dir.display()));
repaired.push(format!("created {}", app.paths.log_dir.display()));
// Corrupt state file: back it up and start fresh.
if app.state.path().exists() && app.state.load().is_err() {
let backup = app
.state
.path()
.with_extension("json.corrupt.bak");
std::fs::rename(app.state.path(), &backup).with_context(|| {
format!(
"cannot back up corrupt state file {} to {}",
app.state.path().display(),
backup.display()
)
})?;
app.state.save(&crate::state::StateFile::default())?;
repaired.push(format!(
"corrupt state file moved to {} and recreated empty",
backup.display()
));
}
Ok(repaired)
}
+470
View File
@@ -0,0 +1,470 @@
//! Downloading (with checksum verification), GitHub Releases resolution,
//! archive extraction and executable discovery.
use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::io::{IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
pub const USER_AGENT: &str = concat!("agent-manager/", env!("CARGO_PKG_VERSION"));
// ---------------------------------------------------------------------------
// URL validation + download
// ---------------------------------------------------------------------------
/// Only http, https and file schemes are allowed.
pub fn validate_url(url: &str) -> Result<()> {
let lower = url.to_lowercase();
let ok = lower.starts_with("https://")
|| lower.starts_with("http://")
|| lower.starts_with("file://");
if !ok {
bail!(
"refusing URL with unsupported scheme (only http, https and file are allowed): {url}"
);
}
Ok(())
}
/// Download a URL to dest, verifying the optional sha256 checksum.
pub fn download(url: &str, dest: &Path, expected_sha256: Option<&str>, progress: bool) -> Result<()> {
validate_url(url)?;
if let Some(local) = url.strip_prefix("file://") {
let src = PathBuf::from(local);
std::fs::copy(&src, dest)
.with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?;
verify_sha256(dest, expected_sha256)?;
return Ok(());
}
let resp = ureq::get(url)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| {
match e {
ureq::Error::Status(code, r) => anyhow!(
"download failed for {url}: HTTP {code} {}",
r.status_text()
),
other => anyhow!("download failed for {url}: {other}"),
}
})?;
let total: Option<u64> = resp.header("content-length").and_then(|v| v.parse().ok());
let mut reader = resp.into_reader();
let mut file = std::fs::File::create(dest)
.with_context(|| format!("cannot create {}", dest.display()))?;
let mut buf = [0u8; 65536];
let mut done: u64 = 0;
let mut last_shown: u64 = 0;
let show = progress && std::io::stderr().is_terminal();
loop {
let n = reader
.read(&mut buf)
.with_context(|| format!("read error while downloading {url}"))?;
if n == 0 {
break;
}
file.write_all(&buf[..n])?;
done += n as u64;
if show && (done - last_shown >= 8 * 1024 * 1024 || done == total.unwrap_or(u64::MAX)) {
last_shown = done;
match total {
Some(t) => eprint!(
"\r downloading {:.1} / {:.1} MB",
done as f64 / 1e6,
t as f64 / 1e6
),
None => eprint!("\r downloaded {:.1} MB", done as f64 / 1e6),
}
std::io::stderr().flush().ok();
}
}
if show {
eprintln!();
}
verify_sha256(dest, expected_sha256)?;
Ok(())
}
fn verify_sha256(path: &Path, expected: Option<&str>) -> Result<()> {
let Some(exp) = expected else {
return Ok(());
};
let hex = exp
.strip_prefix("sha256:")
.or_else(|| exp.strip_prefix("SHA256:"))
.unwrap_or(exp)
.trim();
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("invalid sha256 checksum in config: {exp}");
}
let mut f = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
std::io::copy(&mut f, &mut hasher)?;
let actual = to_hex(&hasher.finalize());
if !actual.eq_ignore_ascii_case(hex) {
bail!(
"checksum mismatch for {}: expected {hex}, got {actual}",
path.display()
);
}
Ok(())
}
pub fn to_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
// ---------------------------------------------------------------------------
// GitHub Releases
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct GhAsset {
pub name: String,
pub browser_download_url: String,
#[serde(default)]
pub size: u64,
}
#[derive(Debug, Deserialize)]
pub struct GhRelease {
pub tag_name: String,
#[serde(default)]
pub assets: Vec<GhAsset>,
}
pub fn latest_release(repo: &str) -> Result<GhRelease> {
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
let resp = ureq::get(&url)
.set("User-Agent", USER_AGENT)
.call()
.map_err(|e| match e {
ureq::Error::Status(404, _) => {
anyhow!("no GitHub releases found for repository '{repo}'")
}
other => anyhow!("GitHub API request failed for '{repo}': {other}"),
})?;
let text = resp
.into_string()
.context("reading GitHub API response")?;
serde_json::from_str::<GhRelease>(&text).context("parsing GitHub API response")
}
pub struct PickedAsset {
pub name: String,
pub url: String,
pub tag: String,
}
/// Choose the release asset that best matches the current platform and arch.
pub fn pick_asset(
release: &GhRelease,
want: &[String],
exclude: &[String],
binary_hint: Option<&str>,
) -> Option<PickedAsset> {
let platform = if cfg!(windows) {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
};
let arch = if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x86_64"
};
let mut best: Option<(u32, usize, &GhAsset)> = None;
for asset in &release.assets {
let lower = asset.name.to_lowercase();
// Skip checksums, signatures and desktop packages.
if lower.ends_with(".sha256")
|| lower.ends_with(".sha512")
|| lower.ends_with(".sig")
|| lower.ends_with(".asc")
|| lower.ends_with(".pem")
|| lower.contains("checksums")
|| lower.ends_with(".dmg")
|| lower.ends_with(".deb")
|| lower.ends_with(".rpm")
|| lower.ends_with(".msi")
|| lower.ends_with(".apk")
{
continue;
}
if !want.iter().all(|w| lower.contains(&w.to_lowercase())) {
continue;
}
if exclude.iter().any(|w| lower.contains(&w.to_lowercase())) {
continue;
}
let score = match platform_score(&lower, platform, arch) {
Some(s) => s,
None => continue,
};
let replace = match best {
None => true,
Some((s, _, _)) => score > s,
};
if replace {
best = Some((score, asset.name.len(), asset));
}
}
best.map(|(_, _, a)| {
let _ = binary_hint;
PickedAsset {
name: a.name.clone(),
url: a.browser_download_url.clone(),
tag: release.tag_name.clone(),
}
})
}
fn platform_score(lower: &str, platform: &str, arch: &str) -> Option<u32> {
let plat_ok = match platform {
"windows" => {
lower.contains("windows")
|| lower.ends_with(".exe")
|| lower.contains("win64")
|| lower.contains("win32")
|| lower.contains("msvc")
|| lower.contains("mingw")
|| lower.contains("pc-windows")
}
"macos" => {
lower.contains("darwin")
|| lower.contains("macos")
|| lower.contains("apple")
|| lower.contains("osx")
}
_ => {
lower.contains("linux")
|| lower.contains("unknown-linux")
|| lower.contains("manylinux")
}
};
if !plat_ok {
return None;
}
let arch_ok = match arch {
"aarch64" => lower.contains("aarch64") || lower.contains("arm64") || lower.contains("armv8"),
_ => {
lower.contains("x86_64")
|| lower.contains("amd64")
|| lower.contains("x64")
|| lower.contains("i686")
}
};
if !arch_ok && !lower.contains("universal") {
return None;
}
let mut s = 1u32;
if lower.contains("universal") {
s += 4;
}
if arch == "aarch64" && lower.contains("arm64") {
s += 3;
}
if arch == "x86_64" && (lower.contains("x86_64") || lower.contains("amd64")) {
s += 3;
}
if lower.contains("musl") {
s += 1;
}
if lower.contains("static") || lower.contains("portable") {
s += 1;
}
if lower.contains("armv7")
|| lower.contains("armhf")
|| lower.contains("armv6")
|| lower.contains("i686")
|| lower.contains("i386")
{
s = s.saturating_sub(2);
}
Some(s)
}
// ---------------------------------------------------------------------------
// Archives and executables
// ---------------------------------------------------------------------------
/// Extract zip / tar.gz / tgz / tar / gz archives. Returns false when the
/// file is not a recognized archive (caller treats it as a plain binary).
pub fn extract_archive(path: &Path, dest: &Path) -> Result<bool> {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
std::fs::create_dir_all(dest)?;
if name.ends_with(".zip") {
extract_zip(path, dest)?;
Ok(true)
} else if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
let f = std::fs::File::open(path)?;
let dec = flate2::read::GzDecoder::new(f);
let mut ar = tar::Archive::new(dec);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
} else if name.ends_with(".tar") {
let f = std::fs::File::open(path)?;
let mut ar = tar::Archive::new(f);
ar.unpack(dest)
.with_context(|| format!("extracting {}", path.display()))?;
Ok(true)
} else if name.ends_with(".gz") {
let inner = name.trim_end_matches(".gz").to_string();
let out = dest.join(inner);
let f = std::fs::File::open(path)?;
let mut dec = flate2::read::GzDecoder::new(f);
let mut o = std::fs::File::create(&out)?;
std::io::copy(&mut dec, &mut o)?;
Ok(true)
} else {
Ok(false)
}
}
fn extract_zip(path: &Path, dest: &Path) -> Result<()> {
let f = std::fs::File::open(path)?;
let mut zip = zip::ZipArchive::new(f)
.with_context(|| format!("opening zip archive {}", path.display()))?;
for i in 0..zip.len() {
let mut entry = zip.by_index(i)?;
let Some(enclosed) = entry.enclosed_name() else {
bail!("zip archive {} contains an unsafe path", path.display());
};
let out_path = dest.join(enclosed);
if entry.is_dir() {
std::fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = std::fs::File::create(&out_path)?;
std::io::copy(&mut entry, &mut out)?;
}
}
Ok(())
}
/// Find the executable in a directory tree: exact name match first, then a
/// single candidate file, then a single executable file (Unix).
pub fn find_executable(dir: &Path, expected: Option<&str>) -> Result<PathBuf> {
let mut files = Vec::new();
collect_files(dir, &mut files);
if files.is_empty() {
bail!("no files found in {}", dir.display());
}
if let Some(exp) = expected {
for f in &files {
let name = f
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let stripped = name.strip_suffix(".exe").unwrap_or(&name);
if stripped.eq_ignore_ascii_case(exp) {
return Ok(f.clone());
}
}
}
if files.len() == 1 {
return Ok(files.remove(0));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let exec: Vec<&PathBuf> = files
.iter()
.filter(|f| {
std::fs::metadata(f)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
})
.collect();
if exec.len() == 1 {
return Ok(exec[0].clone());
}
}
let names: Vec<String> = files
.iter()
.map(|f| format!(" {}", f.display()))
.collect();
bail!(
"cannot determine which file is the executable in {}; candidates:
{}",
dir.display(),
names.join("
")
)
}
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
if let Ok(rd) = std::fs::read_dir(dir) {
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
if p.is_dir() {
collect_files(&p, out);
} else {
out.push(p);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_validation() {
assert!(validate_url("https://example.com/x").is_ok());
assert!(validate_url("http://example.com/x").is_ok());
assert!(validate_url("file:///tmp/x").is_ok());
assert!(validate_url("ftp://example.com/x").is_err());
assert!(validate_url("javascript:alert(1)").is_err());
}
#[test]
fn sha256_verification() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("f.bin");
std::fs::write(&p, b"hello world").unwrap();
let good = to_hex(&Sha256::digest(b"hello world"));
verify_sha256(&p, Some(&good)).unwrap();
verify_sha256(&p, Some("0000000000000000000000000000000000000000000000000000000000000000"))
.unwrap_err();
}
#[test]
fn extracts_and_finds() {
let dir = tempfile::tempdir().unwrap();
let payload = dir.path().join("app.tar.gz");
{
let f = std::fs::File::create(&payload).unwrap();
let enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
let mut ar = tar::Builder::new(enc);
let mut header = tar::Header::new_gnu();
header.set_size(6);
header.set_mode(0o755);
header.set_cksum();
ar.append_data(&mut header, "bin/app", &b"binary"[..]).unwrap();
ar.finish().unwrap();
}
let out = dir.path().join("out");
assert!(extract_archive(&payload, &out).unwrap());
let found = find_executable(&out, Some("app")).unwrap();
assert!(found.ends_with("bin/app"));
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Binary installer: download a precompiled binary (direct URL or GitHub
//! Releases asset), verify its checksum, extract it if needed, and install
//! it into the global bin directory.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let root = install_root(app, &agent.name);
let dist = root.join("dist");
let bindir = bin_dir(app);
ensure_dir(app, &root)?;
ensure_dir(app, &dist)?;
ensure_dir(app, &bindir)?;
// Resolve the download URL (direct or GitHub release asset).
let (url, tag): (String, Option<String>) = if let Some(u) = &method.url {
(u.clone(), None)
} else if let Some(repo) = &method.repo {
let release = crate::download::latest_release(repo)?;
let picked = crate::download::pick_asset(
&release,
&method.asset_match,
&method.asset_exclude,
method.binary.as_deref(),
)
.ok_or_else(|| {
anyhow!(
"no release asset of '{repo}' matches this platform ({}/{})",
crate::config::current_platform(),
std::env::consts::ARCH
)
})?;
app.log
.info(&format!("selected asset: {} ({})", picked.name, picked.tag));
(
picked.url,
Some(picked.tag.trim_start_matches('v').to_string()),
)
} else {
bail!("binary install requires 'url' or 'repo' (GitHub owner/repo)");
};
let names = expected_bin_names(agent, method);
let bin_name = names
.first()
.cloned()
.unwrap_or_else(|| agent.name.clone());
let dest_bin = bindir.join(exe_name(&bin_name));
if app.dry_run() {
app.log
.dry(format!("would download {url} and install {}", dest_bin.display()));
return Ok(InstallOutcome {
bins: vec![dest_bin],
version: tag,
});
}
let tmpdir = tempfile::tempdir()?;
let tmpfile = tmpdir.path().join("download");
crate::download::download(&url, &tmpfile, method.checksum.as_deref(), true)?;
if crate::download::extract_archive(&tmpfile, &dist)? {
let found = crate::download::find_executable(&dist, Some(&bin_name))?;
std::fs::copy(&found, &dest_bin).with_context(|| {
format!("cannot copy {} to {}", found.display(), dest_bin.display())
})?;
app.log
.info(&format!("extracted {} -> {}", found.display(), dest_bin.display()));
} else {
std::fs::copy(&tmpfile, &dest_bin).with_context(|| {
format!("cannot copy {} to {}", tmpfile.display(), dest_bin.display())
})?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dest_bin, std::fs::Permissions::from_mode(0o755));
}
let version = detect_version(runner, &dest_bin).or(tag);
Ok(InstallOutcome {
bins: vec![dest_bin],
version,
})
}
/// Latest release tag (without the leading "v").
pub fn latest(
_app: &App,
_agent: &AgentDef,
method: &MethodSpec,
_runner: &dyn Runner,
) -> Result<Option<String>> {
let Some(repo) = method.repo.as_deref() else {
return Ok(None);
};
let release = crate::download::latest_release(repo)?;
Ok(Some(release.tag_name.trim_start_matches('v').to_string()))
}
+76
View File
@@ -0,0 +1,76 @@
//! cargo installer: cargo install --root <root> <crate>.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("cargo install requires 'package' (crate name)"))?;
require_tool(runner, "cargo", "https://rustup.rs")?;
let root = install_root(app, &agent.name);
ensure_dir(app, &root)?;
let mut args: Vec<String> = vec![
"install".into(),
"--root".into(),
root.display().to_string(),
pkg.to_string(),
];
args.extend(method.args.iter().cloned());
sh(runner, "cargo", &args, &method.env, None)?;
// cargo puts the binaries in <root>/bin
let expected = expected_bin_names(agent, method);
let bins = find_bins(&root.join("bin"), &expected);
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
/// Latest version from cargo search <crate> --limit 1.
pub fn latest(
_app: &App,
_agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<Option<String>> {
let Some(pkg) = method.package.as_deref() else {
return Ok(None);
};
if runner.which("cargo").is_none() {
return Ok(None);
}
let empty = BTreeMap::new();
let out = runner.run(
"cargo",
&[
"search".to_string(),
pkg.to_string(),
"--limit".to_string(),
"1".to_string(),
],
&empty,
None,
true,
)?;
if out.code != 0 {
return Ok(None);
}
// Output looks like: smelt = "0.5.0" # description
let text = out.stdout.trim().to_string();
let Some(eq) = text.find('=') else {
return Ok(None);
};
let rest = text[eq + 1..].trim();
let Some(start) = rest.find('"') else {
return Ok(None);
};
let inner = &rest[start + 1..];
let Some(end) = inner.find('"') else {
return Ok(None);
};
Ok(Some(inner[..end].to_string()))
}
+159
View File
@@ -0,0 +1,159 @@
//! Git installer: clone the repository, run the build command(s), then
//! either copy the built binary or create a shim that runs the agent from
//! the repository directory.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let url = method
.repo
.as_deref()
.ok_or_else(|| anyhow!("git install requires 'repo' (clone URL)"))?;
if !(url.starts_with("https://")
|| url.starts_with("http://")
|| url.starts_with("git@")
|| url.starts_with("file://"))
{
bail!("unsupported git URL: {url}");
}
require_tool(runner, "git", "https://git-scm.com")?;
let root = install_root(app, &agent.name);
let repo_dir = root.join("repo");
let bindir = bin_dir(app);
ensure_dir(app, &root)?;
ensure_dir(app, &bindir)?;
if app.dry_run() {
app.log
.dry(format!("would clone {url} into {} and build", repo_dir.display()));
return Ok(InstallOutcome::default());
}
if repo_dir.exists() {
std::fs::remove_dir_all(&repo_dir)
.with_context(|| format!("cannot remove {}", repo_dir.display()))?;
}
let mut env = method.env.clone();
env.insert("GIT_TERMINAL_PROMPT".to_string(), "0".to_string());
sh(
runner,
"git",
&[
"clone".to_string(),
"--depth".to_string(),
"1".to_string(),
url.to_string(),
repo_dir.display().to_string(),
],
&env,
None,
)?;
// Build commands (string or list).
if let Some(build) = &method.build {
for cmd in build.items() {
let tokens = shell_words::split(&cmd)
.map_err(|e| anyhow!("cannot parse build command '{cmd}': {e}"))?;
if tokens.is_empty() {
continue;
}
let prog = tokens[0].clone();
let args: Vec<String> = tokens[1..].to_vec();
let mut benv = BTreeMap::new();
let nm = repo_dir.join("node_modules").join(".bin");
if nm.is_dir() {
if let Ok(path) = std::env::var("PATH") {
benv.insert(
"PATH".to_string(),
format!("{}{}{}", nm.display(), std::path::MAIN_SEPARATOR, path),
);
}
}
sh(runner, &prog, &args, &benv, Some(&repo_dir))?;
}
}
// Install the binary (copy) or a shim that runs from the repo.
let names = expected_bin_names(agent, method);
let bin_name = names
.first()
.cloned()
.unwrap_or_else(|| agent.name.clone());
let mut bins = Vec::new();
if let Some(bp) = &method.binary_path {
let src = repo_dir.join(bp);
if !src.exists() {
bail!(
"built binary not found at {} (build succeeded but 'binary_path' may be wrong)",
src.display()
);
}
let dest = bindir.join(exe_name(&bin_name));
std::fs::copy(&src, &dest)
.with_context(|| format!("cannot copy {} to {}", src.display(), dest.display()))?;
bins.push(dest);
} else {
let shim = bindir.join(exe_name(&bin_name));
write_shim(agent, &shim, &repo_dir)?;
bins.push(shim);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for b in &bins {
let _ = std::fs::set_permissions(b, std::fs::Permissions::from_mode(0o755));
}
}
// Version: git describe --tags --always inside the repo.
let empty = BTreeMap::new();
let version = runner
.run(
"git",
&[
"-C".to_string(),
repo_dir.display().to_string(),
"describe".to_string(),
"--tags".to_string(),
"--always".to_string(),
],
&empty,
None,
true,
)
.ok()
.filter(|o| o.code == 0)
.map(|o| o.stdout.trim().to_string())
.filter(|s| !s.is_empty());
Ok(InstallOutcome { bins, version })
}
/// Create a small script that runs the agent from inside the repository
/// directory (with node_modules/.bin on PATH for Node-based projects).
fn write_shim(agent: &AgentDef, shim: &Path, repo_dir: &Path) -> Result<()> {
let run = agent.run.clone().unwrap_or_default();
if run.is_empty() {
bail!("git install without 'binary_path' needs a 'run' command for the shim");
}
let script = if cfg!(windows) {
let nm = repo_dir.join("node_modules").join(".bin");
format!(
"@echo off\r\ncd /d \"{repo}\"\r\nset \"PATH={nm};%PATH%\"\r\n{run} %*\r\n",
repo = repo_dir.display(),
nm = nm.display()
)
} else {
let nm = repo_dir.join("node_modules").join(".bin");
format!(
"#!/bin/sh\ncd \"{repo}\"\n[ -d \"{nm}\" ] && PATH=\"{nm}:$PATH\"\nexec {run} \"$@\"\n",
repo = repo_dir.display(),
nm = nm.display()
)
};
std::fs::write(shim, script).with_context(|| format!("cannot write shim {}", shim.display()))?;
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
//! Go installer: go install <module>@latest into our own GOBIN.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("go install requires 'package' (module path)"))?;
require_tool(runner, "go", "https://go.dev/dl")?;
let bindir = bin_dir(app);
ensure_dir(app, &bindir)?;
// go install needs "@version"; default to @latest.
let target = if pkg.contains('@') {
pkg.to_string()
} else {
format!("{pkg}@latest")
};
let mut env = method.env.clone();
env.insert("GOBIN".to_string(), bindir.display().to_string());
let args = vec!["install".to_string(), target];
sh(runner, "go", &args, &env, None)?;
let expected = expected_bin_names(agent, method);
let bins = find_bins(&bindir, &expected);
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
+296
View File
@@ -0,0 +1,296 @@
//! Installer implementations: one module per install method.
//!
//! All installers share the same helpers (command execution through a
//! Runner, bin-dir snapshots, version detection) so they behave identically
//! under --dry-run and in tests.
pub mod binary;
pub mod cargo;
pub mod git;
pub mod golang;
pub mod npm;
pub mod pipuv;
pub mod script;
use crate::app::App;
use crate::config::{AgentDef, InstallType, MethodSpec};
use crate::runner::{RunOutput, Runner};
use crate::state::InstalledEntry;
use anyhow::{anyhow, bail, Context, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Platform executable suffix ("" on Unix, ".exe" on Windows).
pub const EXE_SUFFIX: &str = if cfg!(windows) { ".exe" } else { "" };
#[derive(Debug, Clone, Default)]
pub struct InstallOutcome {
/// Executables (or shims) that belong to this installation.
pub bins: Vec<PathBuf>,
/// Detected version (when possible).
pub version: Option<String>,
}
/// Execute the right installer for the method type.
pub fn run_install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
match method.kind {
InstallType::Npm => npm::install(app, agent, method, runner),
InstallType::Bun => npm::bun_install(app, agent, method, runner),
InstallType::Pip => pipuv::install(app, agent, method, runner, false),
InstallType::Uv => pipuv::install(app, agent, method, runner, true),
InstallType::Cargo => cargo::install(app, agent, method, runner),
InstallType::Go => golang::install(app, agent, method, runner),
InstallType::Curl => script::install(app, agent, method, runner),
InstallType::Binary => binary::install(app, agent, method, runner),
InstallType::Git => git::install(app, agent, method, runner),
}
}
/// Best-effort "latest available version" for the method type.
pub fn latest_version(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<Option<String>> {
match method.kind {
InstallType::Npm | InstallType::Bun => npm::latest(app, agent, method, runner),
InstallType::Pip | InstallType::Uv => pipuv::latest(app, agent, method, runner),
InstallType::Cargo => cargo::latest(app, agent, method, runner),
InstallType::Binary => binary::latest(app, agent, method, runner),
_ => Ok(None),
}
}
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/// Root directory for one agent's installation.
pub fn install_root(app: &App, agent_name: &str) -> PathBuf {
app.paths.install_dir.join(agent_name)
}
/// Global bin directory (shims/executables, added to PATH).
pub fn bin_dir(app: &App) -> PathBuf {
app.paths.bin_dir.clone()
}
/// Create a directory unless running in dry-run mode.
pub fn ensure_dir(app: &App, path: &Path) -> Result<()> {
if !app.dry_run() {
std::fs::create_dir_all(path)
.with_context(|| format!("cannot create {}", path.display()))?;
}
Ok(())
}
/// Run a captured command, failing on a non-zero exit code.
pub fn sh(
runner: &dyn Runner,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
) -> Result<RunOutput> {
let out = runner.run(cmd, args, env, cwd, true)?;
if out.code != 0 {
let mut detail = out.stderr.trim().to_string();
if detail.is_empty() {
detail = out.stdout.trim().to_string();
}
bail!(
"command failed (exit code {}): {} {}
{}",
out.code,
cmd,
args.join(" "),
detail
);
}
Ok(out)
}
/// Run a command with inherited stdio, failing on a non-zero exit code.
pub fn sh_inherit(
runner: &dyn Runner,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
) -> Result<()> {
let out = runner.run(cmd, args, env, cwd, false)?;
if out.code != 0 {
bail!(
"command failed (exit code {}): {} {}",
out.code,
cmd,
args.join(" ")
);
}
Ok(())
}
/// Require a tool on the PATH, with an actionable error message.
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})"))
}
/// Detect a binary's version via --version, then -V.
pub fn detect_version(runner: &dyn Runner, bin: &Path) -> Option<String> {
let empty = BTreeMap::new();
if let Ok(out) = runner.run(&bin.display().to_string(), &["--version".to_string()], &empty, None, true)
{
if out.code == 0 {
let combined = format!("{}
{}", out.stdout, out.stderr);
if let Some(v) = crate::version::find_version(&combined) {
return Some(v);
}
}
}
if let Ok(out) = runner.run(&bin.display().to_string(), &["-V".to_string()], &empty, None, true) {
if out.code == 0 {
let combined = format!("{}
{}", out.stdout, out.stderr);
return crate::version::find_version(&combined);
}
}
None
}
/// Executable name with the platform suffix.
pub fn exe_name(name: &str) -> String {
format!("{name}{EXE_SUFFIX}")
}
/// Names the installed binaries should match: the binary hint, the run
/// command's first token, and the agent name.
pub fn expected_bin_names(agent: &AgentDef, method: &MethodSpec) -> Vec<String> {
let mut names = Vec::new();
if let Some(b) = &method.binary {
names.push(b.clone());
}
if let Some(t) = agent.first_token() {
if !names.iter().any(|n| n.eq_ignore_ascii_case(&t)) {
names.push(t);
}
}
if !names.iter().any(|n| n == &agent.name) {
names.push(agent.name.clone());
}
names
}
/// Snapshot a directory: file name -> full path.
pub fn snapshot_dir(dir: &Path) -> BTreeMap<String, PathBuf> {
let mut map = BTreeMap::new();
if let Ok(rd) = std::fs::read_dir(dir) {
for entry in rd.flatten() {
let p = entry.path();
if p.is_file() {
if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
map.insert(name.to_string(), p);
}
}
}
}
map
}
/// Entries present in "after" but not in "before".
pub fn diff_dir(
before: &BTreeMap<String, PathBuf>,
after: &BTreeMap<String, PathBuf>,
) -> Vec<PathBuf> {
after
.iter()
.filter(|(k, _)| !before.contains_key(*k))
.map(|(_, v)| v.clone())
.collect()
}
/// Files in dir whose base name (sans extension) matches an expected name.
/// On Windows, .cmd/.bat shims are preferred over bare scripts.
pub fn find_bins(dir: &Path, expected: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else {
return out;
};
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for p in entries {
if !p.is_file() {
continue;
}
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let stripped = name
.strip_suffix(".exe")
.or_else(|| name.strip_suffix(".cmd"))
.or_else(|| name.strip_suffix(".bat"))
.or_else(|| name.strip_suffix(".ps1"))
.unwrap_or(&name)
.to_string();
if expected.iter().any(|e| e.eq_ignore_ascii_case(&stripped)) {
out.push(p);
}
}
// Prefer a launchable shim for this platform.
out.sort_by_key(|p| {
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_lowercase();
if cfg!(windows) {
if name.ends_with(".cmd") || name.ends_with(".bat") {
0u8
} else if name.ends_with(".exe") {
1
} else {
2
}
} else if name.ends_with(".cmd") || name.ends_with(".exe") || name.ends_with(".ps1") {
2
} else {
0
}
});
out
}
pub fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339()
}
/// Build the state entry recorded after a successful install.
pub fn make_entry(
agent: &AgentDef,
method: &MethodSpec,
outcome: &InstallOutcome,
app: &App,
) -> InstalledEntry {
InstalledEntry {
name: agent.name.clone(),
version: outcome.version.clone().or_else(|| agent.version.clone()),
method: method.kind.as_str().to_string(),
install_dir: install_root(app, &agent.name).display().to_string(),
bins: outcome.bins.iter().map(|b| b.display().to_string()).collect(),
run: agent.run.clone().unwrap_or_default(),
installed_at: now_rfc3339(),
updated_at: None,
pid: None,
started_at: None,
}
}
+115
View File
@@ -0,0 +1,115 @@
//! npm and bun installers: install a package globally inside our own
//! prefix so nothing pollutes the system.
use super::*;
/// npm: npm install -g --prefix <root> <package>.
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("npm install requires 'package'"))?;
require_tool(runner, "npm", "https://nodejs.org")?;
let root = install_root(app, &agent.name);
let bindir = bin_dir(app);
let before = snapshot_dir(&bindir);
ensure_dir(app, &root)?;
ensure_dir(app, &bindir)?;
let mut args: Vec<String> = vec![
"install".into(),
"-g".into(),
"--prefix".into(),
root.display().to_string(),
pkg.to_string(),
];
args.extend(method.args.iter().cloned());
sh(runner, "npm", &args, &method.env, None)?;
// npm places the shims directly in <prefix> on Windows and in
// <prefix>/bin on Unix — check both, then the global bin dir.
let expected = expected_bin_names(agent, method);
let mut bins = find_bins(&root, &expected);
if bins.is_empty() {
bins = find_bins(&root.join("bin"), &expected);
}
if bins.is_empty() {
let after = snapshot_dir(&bindir);
bins = diff_dir(&before, &after);
}
if bins.is_empty() {
bins = find_bins(&bindir, &expected);
}
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
/// bun: bun install -g <package> with BUN_INSTALL pointed at our root.
pub fn bun_install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("bun install requires 'package'"))?;
require_tool(runner, "bun", "https://bun.sh")?;
let root = install_root(app, &agent.name);
let bindir = bin_dir(app);
let before = snapshot_dir(&bindir);
ensure_dir(app, &root)?;
ensure_dir(app, &bindir)?;
let mut env = method.env.clone();
env.insert("BUN_INSTALL".to_string(), root.display().to_string());
let mut args: Vec<String> = vec!["install".into(), "-g".into(), pkg.to_string()];
args.extend(method.args.iter().cloned());
sh(runner, "bun", &args, &env, None)?;
let expected = expected_bin_names(agent, method);
let mut bins = find_bins(&root.join("bin"), &expected);
if bins.is_empty() {
bins = find_bins(&bindir, &expected);
}
if bins.is_empty() {
let after = snapshot_dir(&bindir);
bins = diff_dir(&before, &after);
}
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
/// Latest published version: npm view <package> version.
pub fn latest(
_app: &App,
_agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<Option<String>> {
let Some(pkg) = method.package.as_deref() else {
return Ok(None);
};
if runner.which("npm").is_none() {
return Ok(None);
}
let empty = BTreeMap::new();
let out = runner.run(
"npm",
&["view".to_string(), pkg.to_string(), "version".to_string()],
&empty,
None,
true,
)?;
if out.code != 0 {
return Ok(None);
}
let text = out.stdout.trim();
if text.is_empty() {
return Ok(None);
}
// npm view prints one version per line; take the last (latest).
Ok(text.lines().last().map(|l| l.trim().to_string()))
}
+130
View File
@@ -0,0 +1,130 @@
//! pip and uv installers: a private virtualenv per agent keeps everything
//! self-contained (no --user, no system pollution).
use super::*;
fn find_python(runner: &dyn Runner) -> Result<PathBuf> {
runner
.which("python3")
.or_else(|| runner.which("python"))
.ok_or_else(|| {
anyhow!(
"neither 'python3' nor 'python' found on PATH — install Python first (https://python.org)"
)
})
}
fn venv_bin_dir(venv: &Path) -> PathBuf {
if cfg!(windows) {
venv.join("Scripts")
} else {
venv.join("bin")
}
}
fn venv_python(venv: &Path) -> PathBuf {
venv_bin_dir(venv).join(if cfg!(windows) {
"python.exe"
} else {
"python"
})
}
/// Shared implementation for pip (use_uv=false) and uv (use_uv=true).
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
use_uv: bool,
) -> Result<InstallOutcome> {
let pkg = method
.package
.as_deref()
.ok_or_else(|| anyhow!("pip/uv install requires 'package'"))?;
let python = find_python(runner)?;
let root = install_root(app, &agent.name);
let venv = root.join("venv");
ensure_dir(app, &root)?;
if !app.dry_run() && venv.exists() {
std::fs::remove_dir_all(&venv)
.with_context(|| format!("cannot remove {}", venv.display()))?;
}
let empty = BTreeMap::new();
sh(
runner,
&python.display().to_string(),
&[
"-m".to_string(),
"venv".to_string(),
venv.display().to_string(),
],
&empty,
None,
)?;
let vp = venv_python(&venv);
let vbin = venv_bin_dir(&venv);
if use_uv {
let uv = require_tool(
runner,
"uv",
"https://docs.astral.sh/uv — e.g. 'pip install uv'",
)?;
let mut args: Vec<String> = vec![
"pip".into(),
"install".into(),
"--python".into(),
vp.display().to_string(),
pkg.to_string(),
];
args.extend(method.args.iter().cloned());
sh(runner, &uv.display().to_string(), &args, &method.env, None)?;
} else {
let mut args: Vec<String> = vec![
"-m".into(),
"pip".into(),
"install".into(),
"--upgrade".into(),
pkg.to_string(),
];
args.extend(method.args.iter().cloned());
sh(runner, &vp.display().to_string(), &args, &method.env, None)?;
}
let expected = expected_bin_names(agent, method);
let mut bins = find_bins(&vbin, &expected);
if bins.is_empty() {
bins = find_bins(&bin_dir(app), &expected);
}
let version = bins.first().and_then(|b| detect_version(runner, b));
Ok(InstallOutcome { bins, version })
}
/// Latest published version from the PyPI JSON API.
pub fn latest(
_app: &App,
_agent: &AgentDef,
method: &MethodSpec,
_runner: &dyn Runner,
) -> Result<Option<String>> {
let Some(pkg) = method.package.as_deref() else {
return Ok(None);
};
let url = format!("https://pypi.org/pypi/{pkg}/json");
let resp = match ureq::get(&url)
.set("User-Agent", crate::download::USER_AGENT)
.call()
{
Ok(r) => r,
Err(_) => return Ok(None),
};
let Ok(text) = resp.into_string() else {
return Ok(None);
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
return Ok(None);
};
Ok(v.get("info")
.and_then(|i| i.get("version"))
.and_then(|x| x.as_str())
.map(|s| s.to_string()))
}
+68
View File
@@ -0,0 +1,68 @@
//! curl|sh installer: download the script, show it (verbose), confirm, then
//! execute it in its own directory. Never pipes curl directly into a shell.
use super::*;
pub fn install(
app: &App,
agent: &AgentDef,
method: &MethodSpec,
runner: &dyn Runner,
) -> Result<InstallOutcome> {
let url = method
.script
.as_deref()
.ok_or_else(|| anyhow!("curl install requires 'script' (URL)"))?;
crate::download::validate_url(url)?;
if cfg!(windows) {
bail!(
"the curl install method needs a Unix shell; on Windows install '{}' manually or use WSL",
agent.title()
);
}
require_tool(runner, "sh", "a POSIX shell (sh)")?;
let root = install_root(app, &agent.name);
ensure_dir(app, &root)?;
if app.dry_run() {
app.log
.dry(format!("would download and run install script {url}"));
return Ok(InstallOutcome::default());
}
let tmp = tempfile::Builder::new().suffix(".sh").tempfile()?;
crate::download::download(url, tmp.path(), None, true)?;
if app.log.verbose {
if let Ok(text) = std::fs::read_to_string(tmp.path()) {
eprintln!("--- install script ({url}) ---");
eprintln!("{text}");
eprintln!("--- end of script ---");
}
}
let shell = app
.config
.settings
.default_shell
.clone()
.unwrap_or_else(|| "sh".to_string());
sh_inherit(
runner,
&shell,
&[tmp.path().display().to_string()],
&method.env,
Some(&root),
)?;
// Scripts usually install into the user profile; locate the binaries.
let expected = expected_bin_names(agent, method);
let mut bins = Vec::new();
for name in &expected {
if let Some(p) = runner.which(name) {
if !bins.contains(&p) {
bins.push(p);
}
}
}
let version = bins
.first()
.and_then(|b| detect_version(runner, b))
.or_else(|| agent.version.clone());
Ok(InstallOutcome { bins, version })
}
+62
View File
@@ -0,0 +1,62 @@
//! agent-manager — a CLI to manage local AI coding agents.
//!
//! The crate is organised around a few core concepts:
//!
//! * cli — clap definitions for the am command line interface.
//! * config — YAML schema, loading, validation, includes, path expansion.
//! * catalog — the merged agent catalog (embedded defaults + user config).
//! * state — the local installation database (JSON file).
//! * process — background/foreground process management (PID bookkeeping).
//! * installers — one installer per method (npm, pip, cargo, go, bun, curl,
//! binary, git).
//! * commands — one module per CLI command.
pub mod app;
pub mod catalog;
pub mod cli;
pub mod commands;
pub mod config;
pub mod deps;
pub mod doctor;
pub mod download;
pub mod installers;
pub mod output;
pub mod probe;
pub mod process;
pub mod repl;
pub mod runner;
pub mod state;
pub mod version;
use clap::Parser;
/// CLI entry point used by the am binary. Returns the process exit code.
pub fn main_entry() -> i32 {
let cli = cli::Cli::parse();
match app::App::from_cli(cli) {
Ok(app) => match commands::execute(&app) {
Ok(code) => code,
Err(err) => {
if app.json() {
let payload = serde_json::json!({
"error": format!("{:#}", err),
});
let _ = serde_json::to_writer_pretty(std::io::stdout(), &payload);
let _ = writeln!(std::io::stdout());
} else {
eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err));
}
1
}
},
Err(err) => {
// Config could not even be loaded: fall back to plain stderr output.
eprintln!("{} {}", "error:".red().bold(), format!("{:#}", err));
1
}
}
}
#[allow(unused_imports)]
use colored::Colorize;
use std::io::Write;
+8
View File
@@ -0,0 +1,8 @@
//! Binary entry point for the am (agent-manager) CLI.
//!
//! All the logic lives in the agent_manager library crate so that
//! integration tests can exercise it directly.
fn main() {
std::process::exit(agent_manager::main_entry());
}
+273
View File
@@ -0,0 +1,273 @@
//! Console output helpers: colored logging, plain text tables, JSON output.
use colored::Colorize;
use serde::Serialize;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
/// Name of the global log file inside the log directory.
pub const LOG_FILE: &str = "agent-manager.log";
// ---------------------------------------------------------------------------
// Logger
// ---------------------------------------------------------------------------
/// Structured logger: writes every message to the timestamped log file and
/// echoes the important ones to stderr (stdout stays reserved for data).
#[derive(Debug)]
pub struct Logger {
log_dir: PathBuf,
pub verbose: bool,
pub quiet: bool,
pub no_color: bool,
}
impl Logger {
pub fn new(log_dir: PathBuf, verbose: bool, quiet: bool, no_color: bool) -> Self {
Self {
log_dir,
verbose,
quiet,
no_color,
}
}
pub fn log_dir(&self) -> &PathBuf {
&self.log_dir
}
pub fn color_enabled(&self) -> bool {
!self.no_color && std::io::stderr().is_terminal()
}
fn write_log(&self, line: &str) {
if std::fs::create_dir_all(&self.log_dir).is_err() {
return;
}
let path = self.log_dir.join(LOG_FILE);
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
let _ = writeln!(f, "[{ts}] {line}");
}
}
/// Detailed message, shown only in verbose mode (always written to the log).
pub fn verbose<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if self.verbose && !self.quiet {
if self.color_enabled() {
eprintln!("{}", msg.dimmed());
} else {
eprintln!("{msg}");
}
}
}
/// Normal informational message.
pub fn info<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if !self.quiet {
eprintln!("{msg}");
}
}
/// Success message (green).
pub fn success<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if !self.quiet {
if self.color_enabled() {
eprintln!("{}", msg.green());
} else {
eprintln!("{msg}");
}
}
}
/// Warning message (yellow).
pub fn warn<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if !self.quiet {
if self.color_enabled() {
eprintln!("{}", msg.yellow());
} else {
eprintln!("{msg}");
}
}
}
/// Error message (red); always shown, even in quiet mode.
pub fn error<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if self.color_enabled() {
eprintln!("{}", msg.red());
} else {
eprintln!("{msg}");
}
}
/// Dry-run note (yellow prefix).
pub fn dry<S: AsRef<str>>(&self, msg: S) {
let msg = msg.as_ref();
self.write_log(msg);
if !self.quiet {
if self.color_enabled() {
eprintln!("{} {msg}", "[dry-run]".yellow().bold());
} else {
eprintln!("[dry-run] {msg}");
}
}
}
/// Record an executed command (log file; echoed when verbose).
pub fn cmd(&self, display: &str) {
self.write_log(&format!("exec: {display}"));
if self.verbose && !self.quiet {
eprintln!(" {}", display.dimmed());
}
}
/// Record a command result.
pub fn result(&self, code: i32, stderr: &str) {
if code != 0 {
self.write_log(&format!("exit code {code}; stderr: {}", stderr.trim()));
}
}
}
// ---------------------------------------------------------------------------
// Tables
// ---------------------------------------------------------------------------
/// A minimal plain-text table. Cells must be pre-colored (widths are computed
/// on ANSI-stripped text).
pub struct Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
}
const MAX_CELL: usize = 48;
impl Table {
pub fn new(headers: Vec<&str>) -> Self {
Self {
headers: headers.into_iter().map(|h| h.to_string()).collect(),
rows: Vec::new(),
}
}
pub fn row(&mut self, cells: Vec<String>) -> &mut Self {
self.rows.push(cells);
self
}
pub fn render(&self) -> String {
if self.headers.is_empty() {
return String::new();
}
let cols = self.headers.len();
let mut widths: Vec<usize> = self
.headers
.iter()
.map(|h| visible_len(h).min(MAX_CELL))
.collect();
for row in &self.rows {
for (i, cell) in row.iter().enumerate().take(cols) {
widths[i] = widths[i].max(visible_len(cell).min(MAX_CELL));
}
}
let mut out = String::new();
let mut header_line = String::new();
for (i, h) in self.headers.iter().enumerate() {
if i > 0 {
header_line.push_str(" ");
}
header_line.push_str(&pad(h, widths[i]));
}
let header_line = header_line.trim_end().to_string();
out.push_str(&format!("{}
", header_line.bold()));
out.push_str(&"-".repeat(visible_len(&header_line)));
out.push('\n');
for row in &self.rows {
let mut l = String::new();
for i in 0..cols {
if i > 0 {
l.push_str(" ");
}
let cell = row.get(i).cloned().unwrap_or_default();
l.push_str(&pad(&cell, widths[i]));
}
out.push_str(l.trim_end());
out.push('\n');
}
out
}
}
/// Color a status cell for display in tables.
pub fn status_style(status: &str, color: bool) -> String {
let s = status.to_string();
if !color {
return s;
}
match status {
"running" => s.green().bold().to_string(),
"installed" => s.cyan().to_string(),
"external" => s.yellow().to_string(),
"not-installable" => s.red().to_string(),
"not-installed" => s.dimmed().to_string(),
"ok" => s.green().to_string(),
"warn" => s.yellow().to_string(),
"error" => s.red().bold().to_string(),
_ => s,
}
}
/// Print a value as pretty JSON on stdout (for scripts).
pub fn print_json<T: Serialize>(value: &T) {
if serde_json::to_writer_pretty(std::io::stdout(), value).is_ok() {
println!();
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn visible_len(s: &str) -> usize {
strip_ansi(s).chars().count()
}
fn pad(s: &str, width: usize) -> String {
let n = visible_len(s);
if n > width {
let plain = strip_ansi(s);
let mut cut: String = plain.chars().take(width.saturating_sub(1)).collect();
cut.push('…');
cut
} else {
format!("{s}{}", " ".repeat(width - n))
}
}
+325
View File
@@ -0,0 +1,325 @@
//! External agent detection: PATH lookup for unmanaged agents, version
//! probing in parallel (one thread per binary), and an on-disk cache keyed
//! by the PATH string and by binary identity (path + mtime + size), so
//! repeated runs never re-scan the PATH or re-probe versions.
use crate::app::App;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use wait_timeout::ChildExt;
#[derive(Debug, Default)]
pub struct ExternalInfo {
/// agent name -> binary path (catalog agents found on PATH, unmanaged).
pub paths: BTreeMap<String, PathBuf>,
/// agent name -> detected version (None = unknown).
pub versions: BTreeMap<String, Option<String>>,
}
impl ExternalInfo {
pub fn path_of(&self, name: &str) -> Option<&PathBuf> {
self.paths.get(name)
}
pub fn version_of(&self, name: &str) -> Option<&Option<String>> {
self.versions.get(name)
}
}
/// Detect the version of an external binary (--version), bounded by a
/// timeout so a misbehaving agent can never hang the whole listing.
pub fn detect_external_version(bin_path: &Path) -> Option<String> {
use std::io::Read;
let (prog, prefix) = crate::runner::resolve_program(&bin_path.display().to_string());
let mut cmd = std::process::Command::new(&prog);
cmd.args(&prefix)
.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let Ok(mut child) = cmd.spawn() else {
return None;
};
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
let status: Option<std::process::ExitStatus> = match child.wait_timeout(PROBE_TIMEOUT) {
Ok(Some(s)) => Some(s),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
None
}
Err(_) => None,
};
let Some(status) = status else {
return None;
};
if !status.success() {
return None;
}
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(mut o) = child.stdout.take() {
let _ = o.read_to_string(&mut stdout);
}
if let Some(mut e) = child.stderr.take() {
let _ = e.read_to_string(&mut stderr);
}
let combined = format!("{stdout}
{stderr}");
crate::version::find_version(&combined)
}
// ---------------------------------------------------------------------------
// On-disk cache
// ---------------------------------------------------------------------------
#[derive(Debug, Default, Serialize, Deserialize)]
struct ProbeCache {
/// FNV-1a hash of the PATH string the token index was built from.
#[serde(default)]
path_hash: u64,
/// Catalog run-command first token -> resolved path (null = not found).
#[serde(default)]
tokens: BTreeMap<String, Option<String>>,
/// Binary identity key -> detected version.
#[serde(default)]
versions: BTreeMap<String, Option<String>>,
}
fn path_hash() -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
if let Some(p) = std::env::var_os("PATH") {
for b in p.to_string_lossy().bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
h
}
/// Build an index of every executable on the PATH in a single pass.
/// Much cheaper than one which() call per agent when the catalog is large.
fn build_path_index() -> BTreeMap<String, PathBuf> {
let mut map: BTreeMap<String, PathBuf> = BTreeMap::new();
let Some(path) = std::env::var_os("PATH") else {
return map;
};
for dir in std::env::split_paths(&path) {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
let p = entry.path();
if !p.is_file() {
continue;
}
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
continue;
};
let lower = name.to_lowercase();
#[cfg(windows)]
{
let stem = if lower.ends_with(".exe") {
lower[..lower.len() - 4].to_string()
} else if lower.ends_with(".cmd") {
lower[..lower.len() - 4].to_string()
} else if lower.ends_with(".bat") {
lower[..lower.len() - 4].to_string()
} else if lower.ends_with(".com") {
lower[..lower.len() - 4].to_string()
} else {
continue;
};
map.entry(stem).or_insert(p);
}
#[cfg(not(windows))]
{
use std::os::unix::fs::PermissionsExt;
let executable = std::fs::metadata(&p)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false);
if executable {
map.entry(lower).or_insert(p);
}
}
}
}
map
}
/// Detect every unmanaged catalog agent present on the PATH.
///
/// The PATH is scanned once and its result cached (invalidated by the PATH
/// string hash); version probes run in parallel and are cached by binary
/// identity. A steady-state run does no PATH scanning and no subprocess.
pub fn detect_externals(app: &App) -> ExternalInfo {
let managed: BTreeMap<String, crate::state::InstalledEntry> =
app.state.all().unwrap_or_default();
let cache_path = app.paths.probe_cache_file.clone();
let mut cache: ProbeCache = std::fs::read_to_string(&cache_path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default();
let mut dirty = false;
// Resolve the run-command tokens of the catalog (once per PATH change).
let current_hash = path_hash();
if cache.path_hash != current_hash {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
let found = index
.get(&token.to_lowercase())
.map(|p| p.display().to_string());
cache.tokens.insert(token, found);
}
}
cache.path_hash = current_hash;
dirty = true;
}
let mut info = ExternalInfo::default();
// Stale cached paths (binary removed since the cache was built) force a
// rebuild of the token index.
let stale = cache
.tokens
.values()
.flatten()
.any(|p| !Path::new(p).exists());
if stale {
let index = build_path_index();
cache.tokens.clear();
for agent in app.catalog.agents() {
let Some(token) = agent.first_token() else {
continue;
};
if !cache.tokens.contains_key(&token) {
let found = index
.get(&token.to_lowercase())
.map(|p| p.display().to_string());
cache.tokens.insert(token, found);
}
}
dirty = true;
}
for agent in app.catalog.agents() {
if agent.hidden || !agent.installable || managed.contains_key(&agent.name) {
continue;
}
let Some(token) = agent.first_token() else {
continue;
};
if let Some(Some(path)) = cache.tokens.get(&token) {
info.paths.insert(agent.name.clone(), PathBuf::from(path));
}
}
// Versions: on-disk cache first, then parallel probes for the misses.
let mut missing: Vec<(String, PathBuf)> = Vec::new();
for (name, bin) in &info.paths {
let key = bin_key(bin);
match cache.versions.get(&key) {
Some(v) => {
info.versions.insert(name.clone(), v.clone());
}
None => missing.push((name.clone(), bin.clone())),
}
}
if !missing.is_empty() {
let results = std::sync::Mutex::new(Vec::new());
std::thread::scope(|s| {
for (name, bin) in &missing {
let results = &results;
let name = name.clone();
let bin = bin.clone();
s.spawn(move || {
let version = detect_external_version(&bin);
results.lock().unwrap().push((name, bin, version));
});
}
});
for (name, bin, version) in results.into_inner().unwrap_or_default() {
let key = bin_key(&bin);
cache.versions.insert(key.clone(), version.clone());
info.versions.insert(name, version);
dirty = true;
}
}
// Keep the caches bounded.
if cache.versions.len() > 500 {
cache.versions.clear();
dirty = true;
}
if dirty {
if let Ok(text) = serde_json::to_string_pretty(&cache) {
if let Some(parent) = cache_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&cache_path, text);
}
}
info
}
/// Identity key for a binary: path + modification time + size, so a stale
/// cache entry is never reused after the binary changed.
fn bin_key(bin: &Path) -> String {
let meta = std::fs::metadata(bin).ok();
let mtime = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
let size = meta.map(|m| m.len()).unwrap_or(0);
format!("{}|{mtime}|{size}", bin.display())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bin_key_is_stable_and_distinct() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.exe");
std::fs::write(&a, b"abc").unwrap();
let k1 = bin_key(&a);
let k2 = bin_key(&a);
assert_eq!(k1, k2);
std::fs::write(&a, b"abcd").unwrap();
let k3 = bin_key(&a);
assert_ne!(k1, k3);
}
#[test]
fn probe_cache_roundtrip() {
let mut cache = ProbeCache::default();
cache
.versions
.insert("C:/x|1|2".to_string(), Some("1.2.3".to_string()));
cache.tokens.insert("node".to_string(), None);
let text = serde_json::to_string(&cache).unwrap();
let back: ProbeCache = serde_json::from_str(&text).unwrap();
assert_eq!(
back.versions.get("C:/x|1|2").unwrap().as_deref(),
Some("1.2.3")
);
assert!(back.tokens.contains_key("node"));
}
#[test]
fn old_cache_format_is_ignored() {
// The previous cache format had only an "entries" field; loading it
// must not fail.
let text = r#"{ "entries": { "a": "1.0" } }"#;
let back: ProbeCache = serde_json::from_str(text).unwrap();
assert!(back.versions.is_empty());
assert!(back.tokens.is_empty());
}
}
+221
View File
@@ -0,0 +1,221 @@
//! Process management: detached background start, PID bookkeeping, stop
//! with a graceful signal followed by a forceful kill after a timeout.
use crate::app::App;
use anyhow::{bail, Context, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// Log file for a background agent.
pub fn agent_log_path(app: &App, agent_name: &str) -> PathBuf {
app.log.log_dir().join(format!("{agent_name}.log"))
}
/// Is a process with this PID alive?
pub fn is_running(pid: u32) -> bool {
#[cfg(windows)]
{
let out = Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.any(|w| w == pid.to_string()),
Err(_) => false,
}
}
#[cfg(not(windows))]
{
Command::new("kill")
.args(["-0", &pid.to_string()])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
/// Send SIGTERM/SIGKILL (or taskkill / taskkill /F on Windows).
fn signal(pid: u32, force: bool) -> bool {
#[cfg(windows)]
{
let mut cmd = Command::new("taskkill");
cmd.arg("/PID").arg(pid.to_string());
if force {
cmd.arg("/T").arg("/F");
}
cmd.status().map(|s| s.success()).unwrap_or(false)
}
#[cfg(not(windows))]
{
let sig = if force { "-KILL" } else { "-TERM" };
Command::new("kill")
.arg(sig)
.arg(pid.to_string())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
/// Stop a process: TERM first, then KILL after the timeout. Returns whether
/// the process was running when the call started.
pub fn stop_pid(pid: u32, force: bool, timeout_secs: u64) -> bool {
if !is_running(pid) {
return false;
}
if force {
signal(pid, true);
} else {
signal(pid, false);
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
while Instant::now() < deadline {
if !is_running(pid) {
return true;
}
std::thread::sleep(Duration::from_millis(200));
}
if is_running(pid) {
signal(pid, true);
}
}
for _ in 0..50 {
if !is_running(pid) {
return true;
}
std::thread::sleep(Duration::from_millis(100));
}
!is_running(pid)
}
/// Start a program detached: stdout/stderr are redirected to the agent log
/// file. Returns the PID. Fails when the process exits immediately.
pub fn spawn_background(
app: &App,
agent_name: &str,
program: &Path,
args: &[String],
env: &BTreeMap<String, String>,
) -> Result<u32> {
let log = agent_log_path(app, agent_name);
if app.dry_run() {
app.log.dry(format!(
"would start {} (detached, log: {})",
program.display(),
log.display()
));
return Ok(0);
}
if let Some(parent) = log.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(&log)
.with_context(|| format!("cannot create log file {}", log.display()))?;
let (prog, prefix) = crate::runner::resolve_program(&program.display().to_string());
let mut full_args = prefix;
full_args.extend(args.iter().cloned());
let mut cmd = Command::new(&prog);
cmd.args(&full_args)
.envs(env)
.stdout(Stdio::from(file.try_clone()?))
.stderr(Stdio::from(file));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
app.log
.cmd(&format!("{} {}", program.display(), args.join(" ")));
let mut child = cmd
.spawn()
.with_context(|| format!("failed to start {}", program.display()))?;
let pid = child.id();
// Give the process a moment: some agents crash at startup.
std::thread::sleep(Duration::from_millis(400));
match child.try_wait() {
Ok(Some(status)) => {
let tail = last_lines(&log, 8);
bail!(
"agent '{agent_name}' exited immediately after start (status {status}); log: {}
{}",
log.display(),
tail
);
}
Ok(None) => {}
Err(_) => {}
}
Ok(pid)
}
/// Tail of a text file (last n lines).
pub fn last_lines(path: &Path, n: usize) -> String {
let Ok(text) = std::fs::read_to_string(path) else {
return String::new();
};
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("
")
}
/// Best-effort desktop notification once an agent has started.
pub fn desktop_notify(title: &str, body: &str) {
#[cfg(target_os = "linux")]
{
let _ = Command::new("notify-send")
.args(["-a", "agent-manager", title, body])
.spawn();
}
#[cfg(target_os = "macos")]
{
let _ = Command::new("osascript")
.args([
"-e",
&format!("display notification \"{body}\" with title \"{title}\""),
])
.spawn();
}
#[cfg(windows)]
{
// No built-in non-blocking toast without extra tooling; log instead.
let _ = (title, body);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sleep_cmd() -> (&'static str, Vec<String>) {
if cfg!(windows) {
("ping", vec!["-n".to_string(), "30".to_string(), "127.0.0.1".to_string()])
} else {
("sleep", vec!["30".to_string()])
}
}
#[test]
fn pid_lifecycle() {
let (prog, args) = sleep_cmd();
let mut child = Command::new(prog).args(&args).spawn().unwrap();
let pid = child.id();
assert!(is_running(pid));
assert!(stop_pid(pid, true, 5));
assert!(!is_running(pid));
let _ = child.wait();
}
#[test]
fn stop_non_running_is_false() {
assert!(!stop_pid(u32::MAX - 1, true, 1));
}
}
+436
View File
@@ -0,0 +1,436 @@
//! Interactive shell launched when am is run without a subcommand.
//!
//! Uses rustyline for line editing: Tab completion (commands, agents, aliases,
//! groups, flags), command history persisted in the state directory, and
//! graceful fallback to plain line input when no terminal is available.
use crate::app::App;
use crate::cli::{Cli, Command, StartArgs};
use crate::commands;
use anyhow::{anyhow, Result};
use clap::CommandFactory;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Context, Helper};
use std::collections::BTreeMap;
use std::io::{IsTerminal, Write};
// ---------------------------------------------------------------------------
// Tab completion
// ---------------------------------------------------------------------------
/// Completion candidates for the interactive shell.
pub struct AmCompleter {
commands: Vec<&'static str>,
config_sub: Vec<&'static str>,
shells: Vec<&'static str>,
options: Vec<&'static str>,
agents: Vec<String>,
aliases: Vec<String>,
groups: Vec<String>,
}
impl AmCompleter {
pub fn new(app: &App) -> Self {
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
}
pub fn from_catalog(
catalog: &crate::catalog::Catalog,
aliases: &BTreeMap<String, String>,
groups: &BTreeMap<String, Vec<String>>,
) -> Self {
let mut agents: Vec<String> = catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.map(|a| a.name.clone())
.collect();
agents.sort();
let mut alias_list: Vec<String> = aliases.keys().cloned().collect();
alias_list.sort();
let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect();
group_list.sort();
Self {
commands: vec![
"list", "status", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
"help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
options: vec![
"--background", "--force", "--purge", "--method", "--all",
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
],
agents,
aliases: alias_list,
groups: group_list,
}
}
/// Pure completion logic: given the line up to the cursor, returns the
/// replacement start position and the matching candidate strings.
pub fn candidates_for(&self, line: &str, pos: usize) -> (usize, Vec<String>) {
let before = &line[..pos.min(line.len())];
let start = before
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
let word = &before[start..];
let previous: Vec<&str> = before[..start].split_whitespace().collect();
let mut pool: Vec<String> = Vec::new();
if previous.is_empty() {
// Completing the command name itself.
pool = self.commands.iter().map(|s| s.to_string()).collect();
} else {
let first = previous[0];
match first {
"config" => {
pool = self.config_sub.iter().map(|s| s.to_string()).collect();
}
"completion" => {
pool = self.shells.iter().map(|s| s.to_string()).collect();
}
"install" | "uninstall" | "update" | "start" | "stop" | "restart" | "run"
| "info" | "status" => {
if word.starts_with("group:") {
pool = self.groups.clone();
} else if previous.len() == 1 {
pool.extend(self.agents.iter().cloned());
pool.extend(self.aliases.iter().cloned());
pool.extend(self.groups.iter().cloned());
} else if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
} else {
// Extra arguments: only offer flags.
pool = self.options.iter().map(|s| s.to_string()).collect();
}
}
_ => {
if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
}
}
}
}
let mut cands: Vec<String> = pool
.iter()
.filter(|c| c.starts_with(word))
.cloned()
.collect();
if cands.is_empty() && !word.is_empty() {
let lower = word.to_lowercase();
cands = pool
.into_iter()
.filter(|c| c.to_lowercase().starts_with(&lower))
.collect();
} else if word.is_empty() {
cands = pool;
}
cands.sort();
cands.dedup();
(start, cands)
}
}
impl Completer for AmCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let (start, cands) = self.candidates_for(line, pos);
let pairs = cands
.into_iter()
.map(|c| Pair {
display: c.clone(),
replacement: c,
})
.collect();
Ok((start, pairs))
}
}
impl Hinter for AmCompleter {
type Hint = String;
fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
None
}
}
impl Highlighter for AmCompleter {}
impl Validator for AmCompleter {}
impl Helper for AmCompleter {}
// ---------------------------------------------------------------------------
// Interactive loop
// ---------------------------------------------------------------------------
pub fn run(app: &App) -> Result<i32> {
if !std::io::stdin().is_terminal() {
// Piped stdin: print the help instead of hanging.
let mut cmd = Cli::command();
cmd.print_help()?;
println!();
return Ok(2);
}
match run_with_editor(app) {
Ok(code) => Ok(code),
Err(e) => {
app.log.verbose(&format!(
"line editor unavailable ({e:#}); falling back to plain input"
));
run_plain(app)
}
}
}
fn run_with_editor(app: &App) -> Result<i32> {
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
rustyline::Editor::new()?;
rl.set_helper(Some(AmCompleter::new(app)));
let history_path = app.paths.state_file.with_file_name("history.txt");
if rl.load_history(&history_path).is_err() {
// First run: no history yet.
}
eprintln!(
"agent-manager interactive shell — Tab pour compléter, flèches pour l'historique, 'help', 'exit'"
);
loop {
match rl.readline("am> ") {
Ok(line) => {
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
continue;
}
let _ = rl.add_history_entry(trimmed.as_str());
match trimmed.as_str() {
"exit" | "quit" | "q" => break,
"help" | "?" => print_help(),
"version" => println!("am {}", env!("CARGO_PKG_VERSION")),
_ => {
if let Err(e) = handle_line(app, &trimmed) {
app.log.error(&format!("{e:#}"));
}
}
}
}
Err(ReadlineError::Interrupted) => continue,
Err(ReadlineError::Eof) => break,
Err(e) => {
app.log.warn(&format!("input error: {e}"));
break;
}
}
}
let _ = rl.save_history(&history_path);
Ok(0)
}
/// Plain fallback when no terminal/line editor is available.
fn run_plain(app: &App) -> Result<i32> {
eprintln!("agent-manager interactive shell — type 'help' for commands, 'exit' to quit");
loop {
eprint!("am> ");
std::io::stderr().flush().ok();
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_err() {
break;
}
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
match line.as_str() {
"exit" | "quit" | "q" => break,
"help" | "?" => print_help(),
"version" => println!("am {}", env!("CARGO_PKG_VERSION")),
_ => {
if let Err(e) = handle_line(app, &line) {
app.log.error(&format!("{e:#}"));
}
}
}
}
Ok(0)
}
fn print_help() {
println!("commands:");
println!(" list [--all]");
println!(" status [agent]");
println!(" search <keyword>");
println!(" info <agent>");
println!(" install <agent> [--method <n|type>] [--force]");
println!(" uninstall <agent> [--purge]");
println!(" update <agent> | update --all");
println!(" start <agent> [--background]");
println!(" stop <agent> [--force]");
println!(" restart <agent> [--background] [--force]");
println!(" run <agent> [args...]");
println!(" doctor [--fix]");
println!(" config show | path | validate | edit");
println!(" help | version | exit");
}
fn handle_line(app: &App, line: &str) -> Result<()> {
let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?;
if tokens.is_empty() {
return Ok(());
}
let cmd = tokens[0].as_str();
let rest = &tokens[1..];
let need = |what: &str| -> Result<String> {
rest.first()
.cloned()
.ok_or_else(|| anyhow!("missing {what}"))
};
let flag = |f: &str| rest.iter().any(|t| t == f);
let method_flag = || {
rest.iter()
.position(|t| t == "--method")
.and_then(|i| rest.get(i + 1))
.cloned()
};
let command = match cmd {
"list" => Command::List {
all: flag("--all"),
category: None,
},
"status" => Command::Status {
agent: rest.first().cloned(),
},
"search" => Command::Search {
keyword: rest.join(" "),
category: None,
},
"info" => Command::Info {
agent: need("agent name")?,
},
"start" => Command::Start(StartArgs {
agent: need("agent name")?,
background: flag("--background") || flag("-b"),
..Default::default()
}),
"stop" => Command::Stop {
agent: need("agent name")?,
force: flag("--force"),
timeout: None,
},
"restart" => Command::Restart {
start: StartArgs {
agent: need("agent name")?,
background: flag("--background") || flag("-b"),
..Default::default()
},
force: flag("--force"),
timeout: None,
},
"install" => Command::Install {
agent: need("agent name")?,
method: method_flag(),
force: flag("--force"),
},
"uninstall" => Command::Uninstall {
agent: need("agent name")?,
purge: flag("--purge"),
},
"update" => {
if flag("--all") {
Command::Update {
agent: None,
all: true,
}
} else {
Command::Update {
agent: rest.first().cloned(),
all: false,
}
}
}
"doctor" => Command::Doctor { fix: flag("--fix") },
"run" => {
let agent = need("agent name")?;
Command::Run {
agent,
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
}
}
"config" => Command::Config(crate::cli::ConfigCmd::Show),
other => {
app.log
.error(&format!("unknown command '{other}' — type 'help'"));
return Ok(());
}
};
commands::execute_command(app, &command)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn completer() -> AmCompleter {
let cfg: crate::config::Config =
serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap();
let catalog = crate::catalog::Catalog::new(
cfg.agents,
cfg.aliases.clone(),
cfg.groups.clone(),
);
AmCompleter::from_catalog(&catalog, &cfg.aliases, &cfg.groups)
}
#[test]
fn completes_commands() {
let c = completer();
let (start, cands) = c.candidates_for("sta", 3);
assert_eq!(start, 0);
assert!(cands.contains(&"start".to_string()));
assert!(cands.contains(&"status".to_string()));
}
#[test]
fn completes_agent_names_and_aliases() {
let c = completer();
let (start, cands) = c.candidates_for("install claude", 14);
assert_eq!(start, 8);
assert!(cands.contains(&"claude-code".to_string()));
let (_, cands) = c.candidates_for("info cc", 7);
assert!(cands.contains(&"cc".to_string()));
}
#[test]
fn completes_group_selectors() {
let c = completer();
let (_, cands) = c.candidates_for("start group:d", 13);
assert!(!cands.is_empty());
assert!(cands.iter().all(|x| x.starts_with("group:")));
assert!(cands.contains(&"group:dev".to_string()));
}
#[test]
fn completes_flags_after_arguments() {
let c = completer();
let (_, cands) = c.candidates_for("install smelt --dr", 18);
assert!(cands.contains(&"--dry-run".to_string()));
}
#[test]
fn completes_config_subcommands() {
let c = completer();
let (_, cands) = c.candidates_for("config sh", 9);
assert!(cands.contains(&"show".to_string()));
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Command execution abstraction: real process execution for production,
//! an in-memory recorder for tests and dry runs.
use crate::output::Logger;
use anyhow::{Context, Result};
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default)]
pub struct RunOutput {
pub code: i32,
pub stdout: String,
pub stderr: String,
}
/// Resolve a command name to an executable path, handling Windows
/// .cmd/.bat scripts (which std::process::Command cannot launch directly).
/// Returns the program to spawn and a prefix of arguments to insert before
/// the caller's arguments (e.g. cmd.exe /C script.cmd).
pub fn resolve_program(cmd: &str) -> (String, Vec<String>) {
#[cfg(windows)]
{
let path = if cmd.contains('\\') || cmd.contains('/') {
Some(PathBuf::from(cmd))
} else {
which::which(cmd).ok()
};
if let Some(p) = path {
let ext = p
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if ext == "cmd" || ext == "bat" {
let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string());
return (comspec, vec!["/C".to_string(), p.display().to_string()]);
}
return (p.display().to_string(), Vec::new());
}
}
(cmd.to_string(), Vec::new())
}
/// Execute external commands with logging and dry-run support.
pub trait Runner {
fn run(
&self,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
capture: bool,
) -> Result<RunOutput>;
/// Locate a binary on the PATH (like the which command).
fn which(&self, bin: &str) -> Option<PathBuf>;
}
/// Production runner backed by std::process::Command.
pub struct SystemRunner<'a> {
pub dry_run: bool,
pub verbose: bool,
pub log: &'a Logger,
}
impl<'a> SystemRunner<'a> {
pub fn new(dry_run: bool, verbose: bool, log: &'a Logger) -> Self {
Self {
dry_run,
verbose,
log,
}
}
}
impl<'a> Runner for SystemRunner<'a> {
fn run(
&self,
cmd: &str,
args: &[String],
env: &BTreeMap<String, String>,
cwd: Option<&Path>,
capture: bool,
) -> Result<RunOutput> {
let display = if args.is_empty() {
cmd.to_string()
} else {
format!("{cmd} {}", args.join(" "))
};
if self.dry_run {
self.log.dry(format!("would run: {display}"));
return Ok(RunOutput::default());
}
self.log.cmd(&display);
let (prog, prefix) = resolve_program(cmd);
let mut full_args = prefix;
full_args.extend(args.iter().cloned());
let mut c = std::process::Command::new(&prog);
c.args(&full_args);
c.envs(env);
if let Some(dir) = cwd {
c.current_dir(dir);
}
if capture {
let out = c
.output()
.with_context(|| format!("failed to execute: {display}"))?;
let code = out.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
self.log.result(code, &stderr);
Ok(RunOutput {
code,
stdout,
stderr,
})
} else {
let status = c
.status()
.with_context(|| format!("failed to execute: {display}"))?;
Ok(RunOutput {
code: status.code().unwrap_or(-1),
stdout: String::new(),
stderr: String::new(),
})
}
}
fn which(&self, bin: &str) -> Option<PathBuf> {
which::which(bin).ok()
}
}
/// In-memory runner for tests: records every invocation, returns success.
#[derive(Default)]
pub struct MockRunner {
pub calls: std::sync::Mutex<Vec<String>>,
pub found: HashSet<String>,
pub outputs: std::sync::Mutex<Vec<RunOutput>>,
}
impl MockRunner {
pub fn new() -> Self {
Self::default()
}
pub fn with_found(mut self, bin: &str) -> Self {
self.found.insert(bin.to_string());
self
}
pub fn with_output(mut self, out: RunOutput) -> Self {
self.outputs.get_mut().unwrap().push(out);
self
}
pub fn snapshot(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
impl Runner for MockRunner {
fn run(
&self,
cmd: &str,
args: &[String],
_env: &BTreeMap<String, String>,
_cwd: Option<&Path>,
_capture: bool,
) -> Result<RunOutput> {
self.calls
.lock()
.unwrap()
.push(format!("{cmd} {}", args.join(" ")));
let out = self.outputs.lock().unwrap().pop();
Ok(out.unwrap_or_default())
}
fn which(&self, bin: &str) -> Option<PathBuf> {
if self.found.contains(bin) {
Some(PathBuf::from(bin))
} else {
None
}
}
}
+178
View File
@@ -0,0 +1,178 @@
//! The local installation database: a JSON file recording every agent
//! installed by agent-manager (name, version, paths, PID, dates).
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const STATE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateFile {
#[serde(default = "state_version")]
pub version: u32,
#[serde(default)]
pub installed: BTreeMap<String, InstalledEntry>,
}
impl Default for StateFile {
fn default() -> Self {
Self {
version: STATE_VERSION,
installed: BTreeMap::new(),
}
}
}
fn state_version() -> u32 {
STATE_VERSION
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledEntry {
pub name: String,
#[serde(default)]
pub version: Option<String>,
/// Installer method that was used (npm, pip, binary, git, ...).
pub method: String,
/// Root directory of this agent's installation.
pub install_dir: String,
/// Executable files/shims that belong to this installation.
#[serde(default)]
pub bins: Vec<String>,
/// Run command (as configured at install time).
pub run: String,
/// RFC 3339 timestamp of the installation.
pub installed_at: String,
/// RFC 3339 timestamp of the last update (if any).
#[serde(default)]
pub updated_at: Option<String>,
/// PID of the background process (when running).
#[serde(default)]
pub pid: Option<u32>,
/// RFC 3339 timestamp of the last background start.
#[serde(default)]
pub started_at: Option<String>,
}
#[derive(Debug, Clone)]
pub struct StateStore {
path: PathBuf,
}
impl StateStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<StateFile> {
if !self.path.exists() {
return Ok(StateFile::default());
}
let text = std::fs::read_to_string(&self.path)
.with_context(|| format!("cannot read state file {}", self.path.display()))?;
let sf: StateFile = serde_json::from_str(&text).map_err(|e| {
anyhow!(
"state file {} is corrupt ({e}); run 'am doctor --fix' to repair it",
self.path.display()
)
})?;
Ok(sf)
}
pub fn save(&self, sf: &StateFile) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = self.path.with_extension("json.tmp");
let text = serde_json::to_string_pretty(sf)?;
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &self.path)
.with_context(|| format!("cannot write state file {}", self.path.display()))?;
Ok(())
}
pub fn get(&self, name: &str) -> Result<Option<InstalledEntry>> {
Ok(self.load()?.installed.get(name).cloned())
}
pub fn all(&self) -> Result<BTreeMap<String, InstalledEntry>> {
Ok(self.load()?.installed)
}
pub fn set(&self, entry: &InstalledEntry) -> Result<()> {
let mut sf = self.load()?;
sf.installed.insert(entry.name.clone(), entry.clone());
self.save(&sf)
}
pub fn remove(&self, name: &str) -> Result<bool> {
let mut sf = self.load()?;
let removed = sf.installed.remove(name).is_some();
if removed {
self.save(&sf)?;
}
Ok(removed)
}
/// Update the PID / start timestamp of a background agent.
pub fn update_pid(&self, name: &str, pid: Option<u32>, started_at: Option<String>) -> Result<()> {
let mut sf = self.load()?;
if let Some(e) = sf.installed.get_mut(name) {
e.pid = pid;
e.started_at = started_at;
self.save(&sf)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store(dir: &Path) -> StateStore {
StateStore::new(dir.join("state.json"))
}
#[test]
fn roundtrip() {
let dir = tempfile::tempdir().unwrap();
let st = store(dir.path());
assert!(st.all().unwrap().is_empty());
let entry = InstalledEntry {
name: "claude-code".to_string(),
version: Some("1.2.3".to_string()),
method: "npm".to_string(),
install_dir: "/tmp/cc".to_string(),
bins: vec!["/tmp/cc/bin/claude".to_string()],
run: "claude".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: Some(42),
started_at: None,
};
st.set(&entry).unwrap();
let got = st.get("claude-code").unwrap().unwrap();
assert_eq!(got.version.as_deref(), Some("1.2.3"));
assert_eq!(got.pid, Some(42));
st.update_pid("claude-code", None, None).unwrap();
assert_eq!(st.get("claude-code").unwrap().unwrap().pid, None);
assert!(st.remove("claude-code").unwrap());
assert!(st.all().unwrap().is_empty());
}
#[test]
fn corrupt_file_is_reported() {
let dir = tempfile::tempdir().unwrap();
let st = store(dir.path());
std::fs::write(st.path(), "{ not json").unwrap();
let err = st.load().unwrap_err().to_string();
assert!(err.contains("corrupt"), "unexpected error: {err}");
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Version string utilities: extraction from arbitrary command output and
//! lenient version comparison.
use std::cmp::Ordering;
/// Extract the first plausible version string from arbitrary text.
///
/// Handles forms such as "v1.2.3", "1.2", "go1.21.0", "2026.1", as well as
/// suffixes like "-beta" or "+meta". Prefers candidates with the most
/// dot-separated components.
pub fn find_version(text: &str) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut best: Option<(usize, usize, usize)> = None; // (dots, len, start)
let mut i = 0;
while i < n {
if chars[i].is_ascii_digit() {
let mut j = i;
while j < n && chars[j].is_ascii_digit() {
j += 1;
}
let mut dots = 0usize;
loop {
if j + 1 < n && chars[j] == '.' && chars[j + 1].is_ascii_digit() {
dots += 1;
j += 1;
while j < n && chars[j].is_ascii_digit() {
j += 1;
}
} else {
break;
}
}
let mut k = j;
if k < n && (chars[k] == '-' || chars[k] == '+') {
k += 1;
while k < n
&& (chars[k].is_ascii_alphanumeric() || chars[k] == '.' || chars[k] == '-')
{
k += 1;
}
}
let len = k - i;
let score = dots.saturating_mul(1000) + len.min(999);
if score > best.map(|(d, l, _)| d * 1000 + l.min(999)).unwrap_or(0) {
best = Some((dots, len, i));
}
i = k.max(j);
} else {
i += 1;
}
}
let (_, _, start) = best?;
// Reconstruct the chosen candidate.
let mut j = start;
while j < n && chars[j].is_ascii_digit() {
j += 1;
}
while j + 1 < n && chars[j] == '.' && chars[j + 1].is_ascii_digit() {
j += 1;
while j < n && chars[j].is_ascii_digit() {
j += 1;
}
}
let mut k = j;
if k < n && (chars[k] == '-' || chars[k] == '+') {
k += 1;
while k < n && (chars[k].is_ascii_alphanumeric() || chars[k] == '.' || chars[k] == '-')
{
k += 1;
}
}
while k > start && (chars[k - 1] == '.' || chars[k - 1] == '-') {
k -= 1;
}
if k <= start {
return None;
}
Some(chars[start..k].iter().collect())
}
/// Strip a leading "v"/"V" and surrounding whitespace.
pub fn normalize(s: &str) -> String {
let mut t = s.trim().to_string();
if t.len() > 1
&& (t.starts_with('v') || t.starts_with('V'))
&& t[1..].chars().next().map_or(false, |c| c.is_ascii_digit())
{
t = t[1..].to_string();
}
t
}
/// Compare two version strings leniently: semver when possible, otherwise a
/// numeric-tuple comparison. Returns None when the two cannot be compared at
/// all (never happens for two non-empty strings).
pub fn compare(a: &str, b: &str) -> Option<Ordering> {
let a = normalize(a);
let b = normalize(b);
if a == b {
return Some(Ordering::Equal);
}
if let (Ok(av), Ok(bv)) = (semver::Version::parse(&a), semver::Version::parse(&b)) {
return Some(av.cmp(&bv));
}
let pa = numeric_parts(&a);
let pb = numeric_parts(&b);
let mut idx = 0;
loop {
match (pa.get(idx), pb.get(idx)) {
(Some(x), Some(y)) => {
if x != y {
return Some(x.cmp(y));
}
idx += 1;
}
(Some(_), None) => return Some(Ordering::Greater),
(None, Some(_)) => return Some(Ordering::Less),
(None, None) => return None,
}
}
}
fn numeric_parts(s: &str) -> Vec<u64> {
s.split(|c: char| !c.is_ascii_digit())
.filter(|p| !p.is_empty())
.filter_map(|p| p.parse::<u64>().ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_common_forms() {
assert_eq!(find_version("Claude Code v1.2.3 (abc)").as_deref(), Some("1.2.3"));
assert_eq!(find_version("go version go1.21.0 windows/amd64").as_deref(), Some("1.21.0"));
assert_eq!(find_version("uv 0.7.12").as_deref(), Some("0.7.12"));
assert_eq!(find_version("node 18.20.4").as_deref(), Some("18.20.4"));
assert_eq!(find_version("1.2.3-beta.1").as_deref(), Some("1.2.3-beta.1"));
assert_eq!(find_version("no version here"), None);
}
#[test]
fn prefers_dotted_candidate() {
assert_eq!(find_version("cargo 1.85.0 (8a56 2026-01-01)").as_deref(), Some("1.85.0"));
}
#[test]
fn compares_semver_and_numeric() {
assert_eq!(compare("1.10.0", "1.9.0"), Some(Ordering::Greater));
assert_eq!(compare("v2.0.0", "2.0.0"), Some(Ordering::Equal));
assert_eq!(compare("2.0.0-rc1", "2.0.0"), Some(Ordering::Less));
assert_eq!(compare("2026", "2025"), Some(Ordering::Greater));
assert_eq!(compare("0.7", "0.7.12"), Some(Ordering::Less));
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Shared helpers for integration tests: build an isolated App whose data,
//! state, logs and config all live inside one temporary directory.
#![allow(dead_code)]
use agent_manager::app::{App, Paths};
use agent_manager::cli::Cli;
use agent_manager::output::Logger;
use agent_manager::state::StateStore;
use clap::Parser;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn fresh_dir(tag: &str) -> PathBuf {
let id = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("am-test-{tag}-{}-{id}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Build an App from CLI args, isolated in a fresh temporary directory.
/// A minimal config file is passed via --config so no user config leaks in.
pub fn test_app(tag: &str, args: &[&str]) -> App {
let dir = fresh_dir(tag);
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\nagents: []\n",
)
.unwrap();
let mut full = vec!["am".to_string(), "--config".to_string(), cfg.display().to_string()];
full.extend(args.iter().map(|s| s.to_string()));
let cli = Cli::parse_from(full);
let mut app = App::from_cli(cli).expect("app should build");
isolate(&mut app, &dir);
app
}
/// Point every runtime path (data, state, logs) inside dir.
pub fn isolate(app: &mut App, dir: &PathBuf) {
app.paths = Paths {
install_dir: dir.join("agents"),
bin_dir: dir.join("agents").join("bin"),
log_dir: dir.join("logs"),
state_file: dir.join("state.json"),
probe_cache_file: dir.join("probe-cache.json"),
config_dir: Some(dir.join("config")),
};
app.state = StateStore::new(app.paths.state_file.clone());
app.log = Logger::new(
app.paths.log_dir.clone(),
app.cli.verbose,
app.cli.quiet,
app.cli.no_color,
);
}
/// A minimal valid config file with one agent, written into dir.
pub fn write_config(dir: &PathBuf, body: &str) -> PathBuf {
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
format!(
"version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{body}\n"
),
)
.unwrap();
cfg
}
+91
View File
@@ -0,0 +1,91 @@
//! Integration tests for configuration loading, overriding and validation.
mod common;
use agent_manager::app::App;
use agent_manager::cli::Cli;
use clap::Parser;
#[test]
fn user_config_overrides_embedded_catalog() {
let dir = std::env::temp_dir().join(format!("am-cfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
r#"
version: "1.0"
agents:
- name: claude-code
display_name: "Mon Claude Custom"
install: { type: npm, package: "@anthropic-ai/claude-code" }
run: claude
"#,
)
.unwrap();
let cli = Cli::parse_from([
"am".to_string(),
"--config".to_string(),
cfg.display().to_string(),
"list".to_string(),
]);
let app = App::from_cli(cli).unwrap();
let cc = app.catalog.get("claude-code").unwrap();
assert_eq!(cc.display_name.as_deref(), Some("Mon Claude Custom"));
// The rest of the embedded catalog is still there.
assert!(app.catalog.len() >= 60);
assert_eq!(app.config_origin.as_deref(), Some(cfg.as_path()));
}
#[test]
fn invalid_user_config_fails_to_load() {
let dir = std::env::temp_dir().join(format!("am-badcfg-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
r#"
version: "1.0"
agents:
- name: broken
installable: true
run: ""
install: { type: npm, package: x }
"#,
)
.unwrap();
let cli = Cli::parse_from([
"am".to_string(),
"--config".to_string(),
cfg.display().to_string(),
"list".to_string(),
]);
let err = App::from_cli(cli).unwrap_err().to_string();
assert!(err.contains("'run' is required"), "unexpected error: {err}");
}
#[test]
fn unknown_field_is_rejected() {
let dir = std::env::temp_dir().join(format!("am-badfield-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg = dir.join("config.yaml");
std::fs::write(
&cfg,
r#"
version: "1.0"
agents:
- name: my-agent
install: { type: npm, package: pkg, typo_field: true }
run: my-agent
"#,
)
.unwrap();
let cli = Cli::parse_from([
"am".to_string(),
"--config".to_string(),
cfg.display().to_string(),
"list".to_string(),
]);
let err = App::from_cli(cli).unwrap_err().to_string();
assert!(err.contains("typo_field"), "unexpected error: {err}");
}
+37
View File
@@ -0,0 +1,37 @@
//! doctor report: structure and config/state checks.
mod common;
use agent_manager::doctor;
use agent_manager::runner::SystemRunner;
#[test]
fn report_contains_core_checks() {
let app = common::test_app("doctor", &["doctor"]);
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(&app, &runner);
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
for expected in ["git", "config", "config-schema", "state", "install-dir", "bin-on-path"] {
assert!(
names.contains(&expected),
"missing check '{expected}' in {names:?}"
);
}
// With our isolated test config the schema must be valid.
let schema = report
.checks
.iter()
.find(|c| c.name == "config-schema")
.unwrap();
assert_eq!(schema.status, doctor::Status::Ok);
}
#[test]
fn fix_creates_directories() {
let app = common::test_app("doctorfix", &["doctor"]);
assert!(!app.paths.install_dir.exists());
let repaired = doctor::fix(&app).unwrap();
assert!(repaired.len() >= 2);
assert!(app.paths.install_dir.is_dir());
assert!(app.paths.log_dir.is_dir());
}
+88
View File
@@ -0,0 +1,88 @@
//! Dry-run behaviour: no directories created, no state written, but the
//! commands that would run are announced.
mod common;
use agent_manager::commands::install_cmd;
use agent_manager::commands::uninstall_cmd;
use agent_manager::state::InstalledEntry;
#[test]
fn install_dry_run_creates_nothing() {
let app = common::test_app(
"dryinstall",
&["install", "smelt", "--dry-run", "--yes"],
);
assert!(app.dry_run());
let code = install_cmd::run(&app, "smelt", None, false).unwrap();
assert_eq!(code, 0);
// Nothing was written.
assert!(!app.paths.install_dir.join("smelt").exists());
assert!(app.state.all().unwrap().is_empty());
}
#[test]
fn install_without_dry_run_but_mock_is_not_supported() {
// Guard: install command requires a real runner; ensure the API surface
// used by the previous test is stable.
let app = common::test_app("dryguard", &["install", "smelt", "--dry-run", "--yes"]);
assert!(app.dry_run());
}
#[test]
fn uninstall_dry_run_keeps_everything() {
let app = common::test_app(
"dryuninstall",
&["uninstall", "smelt", "--dry-run", "--yes"],
);
// Simulate an installed agent.
let dir = app.paths.install_dir.join("smelt");
std::fs::create_dir_all(&dir).unwrap();
let marker = dir.join("bin");
std::fs::write(&marker, "x").unwrap();
let entry = InstalledEntry {
name: "smelt".to_string(),
version: Some("0.5.0".to_string()),
method: "cargo".to_string(),
install_dir: dir.display().to_string(),
bins: vec![],
run: "smelt".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
let code = uninstall_cmd::run(&app, "smelt", true).unwrap();
assert_eq!(code, 0);
// Directory and state entry survive a dry run.
assert!(dir.exists());
assert!(app.state.get("smelt").unwrap().is_some());
}
#[test]
fn real_uninstall_removes_files_and_state() {
let app = common::test_app("uninstall", &["uninstall", "smelt", "--yes"]);
let dir = app.paths.install_dir.join("smelt");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("bin"), "x").unwrap();
let entry = InstalledEntry {
name: "smelt".to_string(),
version: Some("0.5.0".to_string()),
method: "cargo".to_string(),
install_dir: dir.display().to_string(),
bins: vec![],
run: "smelt".to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: None,
pid: None,
started_at: None,
};
app.state.set(&entry).unwrap();
let code = uninstall_cmd::run(&app, "smelt", false).unwrap();
assert_eq!(code, 0);
assert!(!dir.exists());
assert!(app.state.get("smelt").unwrap().is_none());
}
+66
View File
@@ -0,0 +1,66 @@
//! Process lifecycle: background spawn, liveness, stop.
mod common;
use agent_manager::process;
use std::collections::BTreeMap;
fn sleep_cmd() -> (&'static str, Vec<String>) {
if cfg!(windows) {
(
"ping",
vec![
"-n".to_string(),
"40".to_string(),
"127.0.0.1".to_string(),
],
)
} else {
("sleep", vec!["40".to_string()])
}
}
#[test]
fn spawn_detect_stop_cycle() {
let app = common::test_app("proc", &["status"]);
let (prog, args) = sleep_cmd();
let env = BTreeMap::new();
let pid = process::spawn_background(
&app,
"test-agent",
std::path::Path::new(prog),
&args,
&env,
)
.unwrap();
assert!(pid > 0);
assert!(process::is_running(pid));
assert!(process::stop_pid(pid, true, 5));
assert!(!process::is_running(pid));
// The log file was created.
assert!(process::agent_log_path(&app, "test-agent").exists());
}
#[test]
fn spawning_a_failing_program_reports_an_error() {
let app = common::test_app("procfail", &["status"]);
let env = BTreeMap::new();
// A command that exits immediately with an error.
let (prog, args) = if cfg!(windows) {
("cmd", vec!["/C".to_string(), "exit 7".to_string()])
} else {
("sh", vec!["-c".to_string(), "exit 7".to_string()])
};
let err = process::spawn_background(
&app,
"failing",
std::path::Path::new(prog),
&args,
&env,
)
.unwrap_err();
assert!(
err.to_string().contains("exited immediately"),
"unexpected error: {err:#}"
);
}