v0.4.2 : menu de completion Nushell (reedline) + correction des chemins Windows - remplace rustyline par reedline (menu Tab interactif : 1er choix surbrille, Tab navigue, Entree valide, Esc ferme, filtrage en direct, descriptions, couleurs du theme), tokenizer Windows preservant les backslashes pour cd/ls (shell_words mangeait les \), historique history.txt conserve, repli sans terminal, tests + man pages regeneres
release / linux (push) Failing after 24s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s

This commit is contained in:
2026-08-17 13:08:50 -04:00
parent e6e85e2a9e
commit 2c079b0ccd
11 changed files with 1198 additions and 371 deletions
+688
View File
@@ -0,0 +1,688 @@
# 🏗️ Document d'architecture — agent-manager (`am`)
> Ce document décrit la conception, le fonctionnement et les fonctionnalités de **agent-manager** (binaire `am`), un CLI Rust multiplateforme pour gérer des agents IA de coding locaux.
>
> 📦 Version : `0.4.2` · 🦀 Rust 2021 · ✅ Windows · ✅ Linux · ✅ macOS
---
## 🎯 Vue d'ensemble
`am` est un gestionnaire d'agents IA de coding qui permet de :
- 📋 Découvrir et lister plus de **72 agents** via un catalogue YAML embarqué
- 📦 Les installer avec **9 méthodes** différentes (npm, pip, uv, cargo, go, bun, curl, binaire, git)
- 🚀 Les démarrer, arrêter, redémarrer en avant-plan ou en arrière-plan
- 🔍 Observer leur état, leurs logs, leurs statistiques d'utilisation
- 🛠️ Gérer dépendances, alias, groupes, profils, secrets, favoris et annotations
Le tout sans toucher au système : chaque agent est installé dans un répertoire utilisateur isolé.
---
## 🧩 Architecture globale
```mermaid
flowchart TB
subgraph UI["🖥️ Interfaces utilisateur"]
CLI["Ligne de commande<br/>clap v4"]
REPL["Shell interactif<br/>rustyline"]
TUI["Dashboard TUI<br/>ratatui + crossterm"]
end
subgraph CORE["⚙️ Noyau applicatif"]
APP["App<br/>contexte partagé"]
CLI_DEF["cli.rs<br/>définition des commandes"]
CMD["commands/<br/>dispatch"]
end
subgraph DATA["💾 Données persistantes"]
CONFIG["Configuration YAML<br/>embarquée + utilisateur"]
CATALOG["Catalog<br/>index agents/alias/groupes"]
STATE["StateStore<br/>state.json"]
EVENTS["Journal JSONL<br/>events-YYYYMM.jsonl"]
CACHE["Probe cache<br/>probe-cache.json"]
end
subgraph EXEC["🔧 Exécution"]
RUNNER["Runner trait<br/>système / mock"]
INSTALLERS["installers/<br/>9 méthodes"]
DEPS["deps.rs<br/>vérification dépendances"]
PROCESS["process.rs<br/>PID / signaux"]
end
subgraph OBS["👁️ Observation"]
PROBE["probe.rs<br/>détection agents externes"]
DASH["dashboard.rs<br/>agrégation"]
STATS["stats.rs<br/>statistiques"]
SESSIONS["sessions.rs<br/>registre sessions"]
end
CLI --> APP
REPL --> APP
TUI --> APP
APP --> CLI_DEF
APP --> CONFIG
APP --> CATALOG
APP --> STATE
APP --> EVENTS
APP --> CACHE
CMD --> RUNNER
CMD --> INSTALLERS
CMD --> DEPS
CMD --> PROCESS
CMD --> PROBE
CMD --> DASH
CMD --> STATS
CMD --> SESSIONS
```
---
## 🚀 Flux d'exécution d'une commande
```mermaid
sequenceDiagram
autonumber
participant User
participant main as main.rs
participant lib as lib.rs
participant help as help.rs
participant cli as cli.rs
participant app as app.rs
participant cfg as config.rs
participant state as state.rs
participant cmd as commands/mod.rs
participant impl as commands/xxx_cmd.rs
User->>main: am install jcode
main->>lib: main_entry()
lib->>lib: spawn thread 8 Mo
lib->>help: intercept(-h/--help) ?
help-->>lib: None
lib->>cli: Cli::parse()
cli-->>lib: Cli { command: Install {...} }
lib->>app: App::from_cli(cli)
app->>cfg: load() config embarquée + user
cfg-->>app: Config
app->>state: StateStore::new()
state-->>app: state.json chargé
app-->>lib: App
lib->>cmd: commands::execute(&app)
cmd->>impl: install_cmd::run(...)
impl->>impl: vérifier dépendances, installer
impl->>state: persister
impl->>app: emit(event install)
impl-->>cmd: Ok(0)
cmd-->>lib: 0
lib-->>main: code sortie
```
---
## 📁 Structure du code source
```
src/
├── main.rs 🚪 Point d'entrée
├── lib.rs 🧭 main_entry(), doc du crate
├── cli.rs 📋 Définition clap de toutes les commandes
├── app.rs 🧰 Contexte App (config, state, paths, logger, theme)
├── config.rs ⚙️ Schéma YAML, chargement, fusion, validation
├── catalog.rs 🔍 Index agents + recherche fuzzy + suggestions
├── state.rs 💾 Base JSON des installations et annotations
├── events.rs 📝 Journal d'événements JSONL
├── runner.rs 🏃 Trait d'exécution système / mock
├── process.rs ⚙️ Lancement, arrêt, signaux des processus
├── deps.rs ✅ Vérification et auto-installation des dépendances
├── toolchain.rs 🖥️ Détection OS / gestionnaire de paquets
├── download.rs ⬇️ Téléchargement, checksums, extraction
├── probe.rs 🔎 Détection des agents externes sur le PATH
├── dashboard.rs 📊 Données agrégées du dashboard
├── ps.rs 🧮 Table des processus
├── context.rs 📂 Contexte du projet courant
├── repl.rs 💬 Shell interactif
├── shell.rs 🐚 Gestion des shells supportés
├── help.rs ❓ Aide Nushell-style
├── output.rs 🖨️ Logger et rendu
├── tables.rs 📋 Rendu tabulaire
├── theme.rs 🎨 Thèmes de couleur
├── history.rs ⏪ Historique des commandes
├── sessions.rs 📅 Registre des sessions
├── projects.rs 🗂️️ Agrégation par projet
├── hooks.rs 🪝 Hooks de cycle de vie
├── secrets.rs 🔒 Gestion des secrets (keyring OS)
├── version.rs 🏷️ Informations de version
├── commands/ 📦 35 modules, un par commande
└── installers/ 📦 9 installateurs spécialisés
```
---
## ⚙️ Configuration : catalogue YAML extensible
### Hiérarchie de chargement
```mermaid
flowchart LR
EMB["📦 config.yaml<br/>embarqué dans le binaire"] --> MERGE["🔀 Fusion"]
USER["👤 ~/.config/agent-manager/config.yaml"] --> MERGE
LOCAL["📂 ./agent-manager.yaml"] --> MERGE
FLAG["⚡ --config <file>"] --> MERGE
MERGE --> EFFECTIVE["✅ Configuration effective"]
```
Priorité (la plus prioritaire en dernier) :
1. `config.yaml` embarqué (catalogue par défaut, ~72 agents)
2. `~/.config/agent-manager/config.yaml`
3. `./agent-manager.yaml` (configuration locale par projet)
4. `--config <fichier>`
### Schéma de configuration
```yaml
version: "1.0"
settings:
install_dir: null # ~/.local/share/agent-manager/agents
log_dir: null # ~/.local/state/agent-manager/logs
default_shell: null # shell utilisateur par défaut
auto_install_deps: true # proposer d'installer les dépendances manquantes
confirm_before_run: true # confirmer avant les scripts d'installation
stop_timeout_secs: 5 # délai SIGTERM → SIGKILL
self_update_repo: Projets/agent-manager
self_update_base_url: https://git.dracodev.net/api/v1
theme: null
hooks:
on_install: []
on_start: []
on_stop: []
on_update: []
aliases:
cc: claude-code
gemini: gemini-cli
groups:
dev: [claude-code, aider, codex]
include:
- ./extra-agents.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"
dependencies:
- { name: node, min_version: "18.0.0" }
run: claude
tags: [anthropic, assistant]
profiles:
dev:
agent: claude-code
env: { API_ENV: dev }
args: [--verbose]
projects:
mon-projet:
root: ~/projets/mon-projet
default_agent: claude-code
env: { KEY: value }
```
### Définition d'un agent (`AgentDef`)
| Champ | Description |
|-------|-------------|
| `name` | Identifiant unique (slug) |
| `display_name` | Nom lisible |
| `description` | Description longue |
| `category` | Catégorie (coding-agent, assistant, local-first...) |
| `website` | URL du projet |
| `install` | Spécification d'installation |
| `dependencies` | Outils requis (node, python, go...) avec version min |
| `run` | Commande de lancement |
| `args` | Arguments par défaut |
| `env` | Variables d'environnement par défaut |
| `tags` | Tags pour recherche/filtrage |
| `installable` | `false` pour les agents SaaS/Desktop |
| `platforms` | Restriction `linux`/`macos`/`windows` |
### Méthodes d'installation supportées
| Type | Fichier | Principe |
|------|---------|----------|
| `npm` | `installers/npm.rs` | `npm install -g --prefix <root>` |
| `bun` | `installers/npm.rs` | `bun install -g` |
| `pip` / `uv` | `installers/pipuv.rs` | virtualenv privé dans `install_dir` |
| `cargo` | `installers/cargo.rs` | `cargo install --root` |
| `go` | `installers/golang.rs` | `go install` avec `GOBIN` local |
| `curl` | `installers/script.rs` | Télécharge et exécute un script |
| `binary` | `installers/binary.rs` | Release GitHub/Gitea → extraction archive |
| `git` | `installers/git.rs` | Clone + build + `binary_path` |
---
## 💾 Gestion des états
### `state.json` — base d'installation locale
Format JSON version 3, atomique (écriture `.tmp` + `rename`).
```json
{
"version": 3,
"installed": {
"jcode": {
"name": "jcode",
"version": "0.76.0",
"method": "binary",
"directory": ".../agents/jcode",
"binaries": ["jcode"],
"pid": null,
"installed_at": "...",
"updated_at": "..."
}
},
"annotations": {
"jcode": {
"favorite": true,
"note": "mon agent préféré",
"tags": ["perso"]
}
}
}
```
### Journal d'événements JSONL
- Un fichier par mois : `events-YYYYMM.jsonl`
- Append-only, horodaté
- Types d'événements : `start`, `stop`, `run`, `install`, `update`, `uninstall`, `doctor`, `config`, `repl`, `shell`, `annotate`
- Les secrets ne sont jamais journalisés
- Source de vérité pour les statistiques, sessions, projets et timeline
### Cache de sondes
`probe-cache.json` évite de re-scanner le PATH à chaque commande pour détecter les agents externes et leurs versions.
---
## 🛡️ Gestion des dépendances
```mermaid
flowchart LR
INSTALL["am install jcode"] --> CHECK["deps::check_dep"]
CHECK --> MISSING{Manquant ?}
MISSING -->|Oui| DETECT["toolchain::detect OS"]
DETECT --> COMMANDS["toolchain::install_commands"]
COMMANDS --> PROMPT["Proposer la commande"]
PROMPT --> AUTO["Exécuter si --yes"]
AUTO --> INSTALL2["installers::run_install"]
MISSING -->|Non| INSTALL2
```
- `deps.rs` vérifie chaque dépendance via `<outil> --version`
- `toolchain.rs` détecte l'OS et le gestionnaire de paquets :
- Windows : `scoop` → `winget`
- Linux : `apt` / `dnf` / `pacman` / `apk`
- macOS : `brew`
- Si `auto_install_deps: true`, `am` propose et peut exécuter la commande d'installation
- Les dépendances de l'agent (modules npm/pip) restent isolées dans le répertoire `install_dir`
---
## ⚡ Gestion des processus
```mermaid
flowchart TB
START["am start <agent>"] --> MODE{Mode ?}
MODE -->|Foreground| FG["Attache au terminal<br/>Ctrl-C pour quitter"]
MODE -->|Background| BG["spawn_background()"]
BG --> LOG["Redirection stdout/stderr<br/>vers log agent"]
LOG --> PID["Enregistrement PID<br/>dans state.json"]
PID --> EVENT["Émission événement start"]
STOP["am stop <agent>"] --> SIGTERM["SIGTERM"]
SIGTERM --> WAIT{"Processus terminé ?"}
WAIT -->|Non| SIGKILL["SIGKILL après timeout"]
WAIT -->|Oui| CLEAN["PID effacé"]
```
- Démarrage avant-plan (`-f`) ou arrière-plan (`-b`, `--background`)
- Variables d'environnement injectées via `--env KEY=VALUE`
- Secrets via `--env KEY=@secret` (résolu depuis le trousseau OS)
- Arrêt gracieux avec timeout configurable (défaut 5 s)
- Notifications desktop optionnelles (`--notify`)
---
## 📋 Commandes détaillées
### 🔍 Découvrir
| Commande | Description |
|----------|-------------|
| `am list` | Agents installés + détectés sur le PATH |
| `am list --all` | Tout le catalogue avec état |
| `am search <mot>` | Recherche fuzzy avec suggestions |
| `am info <agent>` | Fiche détaillée |
| `am status [agent]` | État, version, PID, logs |
### 📦 Cycle de vie
| Commande | Description |
|----------|-------------|
| `am install <agent>` | Installe un agent et ses dépendances |
| `am install <agent> --method pip` | Choisit la méthode |
| `am uninstall <agent> [--purge]` | Désinstalle et nettoie |
| `am update <agent>` / `--all` | Met à jour |
| `am start [agent]` | Lance en avant-plan |
| `am start <agent> -b` | Lance en arrière-plan |
| `am stop <agent>` | Arrêt gracieux |
| `am restart <agent>` | Redémarrage |
| `am run <agent> [args...]` | Exécution directe sans gestion de PID |
### 👁️ Observer
| Commande | Description |
|----------|-------------|
| `am logs <agent>` | Tail du log agent |
| `am log [agent]` | Journal des événements `am` |
| `am timeline` | Vue chronologique unifiée |
| `am sessions [agent]` | Registre des sessions |
| `am stats [agent] --period 7d` | Statistiques d'utilisation |
| `am top --period 30d` | Top 10 agents |
| `am report --last-week` | Rapport markdown |
| `am projects [nom]` | Agents par projet |
| `am dashboard` | Dashboard TUI temps réel |
| `am watch <agent> --restart` | Supervision et relance automatique |
### 🎯 Personnaliser
| Commande | Description |
|----------|-------------|
| `am favorite <agent>` | Marquer comme favori |
| `am note <agent> <texte>` | Ajouter une note |
| `am tag <agent> <tag>` | Taguer |
| `am tags [agent]` | Lister les tags |
| `am profile list/show <name>` | Profils d'environnement |
| `am alias add <nom> <cible>` | Créer un alias |
| `am secret set <nom> --agent <a> --value <v>` | Secret dans le trousseau |
### 🛠️ Système
| Commande | Description |
|----------|-------------|
| `am doctor [--fix]` | Diagnostic environnement |
| `am config show/path/edit/validate/set` | Gestion config |
| `am completion <shell>` | Script de complétion |
| `am man [commande]` | Page de manuel |
| `am open <agent>` | Ouvrir le répertoire d'installation |
| `am export/import` | Sauvegarde/restauration |
| `am self-update [--check]` | Mise à jour de `am` |
| `am self-uninstall` | Désinstallation complète |
| `am version` | Version et build |
| `am` (sans commande) | REPL interactif |
---
## 🌐 Options globales
Disponibles avant ou après la sous-commande.
| Option | Effet |
|--------|-------|
| `-c, --config <FILE>` | Fichier de configuration alternatif |
| `-v, --verbose` | Affiche chaque commande exécutée |
| `-q, --quiet` | Seules les erreurs sont affichées |
| `-y, --yes` | Oui à toutes les confirmations |
| `--dry-run` | Simulation sans modification |
| `--json` | Sortie JSON structurée |
| `--no-color` | Désactive les couleurs |
| `--theme <THEME>` | Thème de couleur |
---
## 🎨 Fonctionnalités avancées
### Alias
```yaml
aliases:
cc: claude-code
gemini: gemini-cli
```
`am start cc` démarre `claude-code`.
### Groupes
```yaml
groups:
dev: [claude-code, aider, codex]
```
`am start group:dev` démarre tous les agents du groupe en arrière-plan.
### Profils d'environnement
```yaml
profiles:
dev:
agent: claude-code
env: { API_ENV: dev }
args: [--verbose]
```
`am start --profile dev` lance `claude-code` avec les variables et arguments du profil.
### Projets
```yaml
projects:
mon-projet:
root: ~/projets/mon-projet
default_agent: claude-code
```
Dans le répertoire du projet, `am start` sans argument lance l'agent par défaut du projet.
### Secrets
Les secrets sont stockés dans le trousseau du système d'exploitation (via `keyring`), jamais en clair dans la config.
```bash
am secret set OPENAI_API_KEY --agent claude-code --value sk-...
am start claude-code --env OPENAI_API_KEY=@secret
```
### Hooks
Commandes exécutées automatiquement aux étapes clés du cycle de vie :
- `on_install`
- `on_start`
- `on_stop`
- `on_update`
Définissables globalement dans `settings.hooks` ou par projet.
### Shell interactif (REPL)
Lancé par `am` sans sous-commande :
- Complétion Tab personnalisée (commandes, agents, alias, groupes, flags, shells, thèmes)
- Historique des commandes
- Passerelle système : commandes inconnues exécutées dans le shell actif
- Commandes internes : `ls`, `dir`, `cd`, `ps`, `where`, `get`, `shell`, `theme`, `exit`, `/help`
- Bannière ASCII art
---
## 📊 Dashboard TUI
```mermaid
flowchart LR
DASH["am dashboard"] --> OVER["Overview"]
DASH --> ACT["Activité"]
DASH --> STATS["Statistiques"]
DASH --> SESS["Sessions"]
DASH --> PROJ["Projets"]
```
- Navigation : `Tab` / `←` / `→` pour les onglets
- Défilement : `j` / `k` ou flèches
- Quitter : `q`
- Données agrégées depuis `state.json`, le journal d'événements, les sessions et les projets
---
## 🧪 Tests
### Organisation
- **Tests unitaires** : dans chaque module `src/*.rs` sous `#[cfg(test)]`
- **Tests d'intégration** : dans `tests/`
### Fichiers de tests notables
| Fichier | Couverture |
|---------|------------|
| `annotations_test.rs` | Favoris, notes, tags |
| `config_test.rs` | Chargement/validation/fusion |
| `dashboard_test.rs` | Dashboard |
| `doctor_test.rs` | `am doctor` |
| `dry_run_test.rs` | Mode `--dry-run` |
| `events_test.rs` | Journal d'événements |
| `history_test.rs` | Historique |
| `process_test.rs` | Processus |
| `profiles_test.rs` | Profils |
| `projects_test.rs` | Projets |
| `sessions_test.rs` | Sessions |
| `stats_test.rs` | Statistiques |
Lancer les tests :
```bash
cargo test
```
---
## 📦 Packaging et distribution
```
dist/ Binaires précompilés
├── am-linux-x86_64
├── am-linux-aarch64
├── am-windows-x86_64.zip
├── am_0.4.2_amd64.deb
└── ...
packaging/ Scripts de packaging
├── deb/
├── rpm/
├── homebrew/
├── scoop/
└── winget/
completions/ Scripts de complétion
├── _am zsh
├── _am.ps1 PowerShell
├── am.bash bash
├── am.elv elvish
└── am.fish fish
man/ Pages de manuel générées
```
- `build.rs` enregistre le commit et la branche git pour `am version`
- `scripts/build-release.ps1` construit les releases multiplateformes
- `scripts/render-manifests.ps1` génère les manifests de packaging
---
## 🗂️️ Arborescence des données utilisateur
### Windows
```
%LOCALAPPDATA%\agent-manager\
├── agents\ installations
├── logs\ logs agents
└── state\ state.json, events-*.jsonl, probe-cache.json
```
### Linux
```
~/.local/share/agent-manager/ données
~/.local/state/agent-manager/ logs, state, events, cache
~/.config/agent-manager/ config.yaml
```
### macOS
```
~/Library/Application Support/agent-manager/ données
~/.local/state/agent-manager/ logs, state, events, cache
~/.config/agent-manager/ config.yaml
```
Variables d'environnement de débogage/test :
- `AGENT_MANAGER_DATA` : remplace le répertoire de données
- `AGENT_MANAGER_STATE` : remplace le répertoire d'état
---
## 🔐 Sécurité
- Mode `--dry-run` universel pour simuler sans modifier
- Checksums `sha256` pour les binaires téléchargés
- Confirmation avant exécution des scripts d'installation
- Secrets dans le trousseau OS, jamais dans les logs ni le journal
- Installation isolée : aucune modification système
---
## 🔁 Résumé du flux de données
```mermaid
flowchart LR
CONFIG["📄 YAML config"] --> CATALOG["📚 Catalog"]
CATALOG --> INSTALL["📦 Installateurs"]
INSTALL --> STATE["💾 state.json"]
STATE --> PROCESS["⚙️ Processus"]
PROCESS --> EVENTS["📝 Journal JSONL"]
EVENTS --> STATS["📊 Stats / Dashboard"]
EVENTS --> SESSIONS["📅 Sessions"]
EVENTS --> PROJECTS["🗂️️ Projets"]
```
---
## 📚 Références
- `src/lib.rs:4` : documentation d'architecture du crate
- `src/cli.rs:24` : options globales
- `src/cli.rs:61` : énumération des commandes
- `src/app.rs:16` : struct `App`
- `src/config.rs:25` : struct `Config`
- `src/config.rs:148` : struct `AgentDef`
- `src/config.rs:241` : enum `InstallType`
- `src/state.rs:12` : struct `StateFile`
- `src/events.rs:57` : struct `Event`
- `README.md` : documentation utilisateur complète
---
*Document généré pour agent-manager v0.4.2* 🚀
Generated
+102 -73
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]]
name = "agent-manager"
version = "0.4.1"
version = "0.4.2"
dependencies = [
"anyhow",
"chrono",
@@ -32,8 +32,9 @@ dependencies = [
"crossterm",
"flate2",
"keyring",
"nu-ansi-term",
"ratatui",
"rustyline",
"reedline",
"semver",
"serde",
"serde_json",
@@ -191,6 +192,9 @@ name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
dependencies = [
"serde_core",
]
[[package]]
name = "block-buffer"
@@ -272,12 +276,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
@@ -368,15 +366,6 @@ dependencies = [
"roff",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -471,6 +460,7 @@ dependencies = [
"mio",
"parking_lot",
"rustix",
"serde",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -632,12 +622,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "euclid"
version = "0.22.14"
@@ -1036,6 +1020,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -1174,7 +1167,7 @@ version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [
"nix 0.29.0",
"nix",
"winapi",
]
@@ -1227,18 +1220,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -1247,7 +1228,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"cfg_aliases 0.2.2",
"cfg_aliases",
"libc",
"memoffset",
]
@@ -1262,6 +1243,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -1601,16 +1591,16 @@ dependencies = [
"compact_str",
"critical-section",
"hashbrown 0.17.1",
"itertools",
"itertools 0.14.0",
"kasuari",
"lru",
"palette",
"serde",
"strum",
"strum 0.28.0",
"thiserror 2.0.20",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.2",
"unicode-width",
]
[[package]]
@@ -1666,14 +1656,14 @@ dependencies = [
"hashbrown 0.17.1",
"indoc",
"instability",
"itertools",
"itertools 0.14.0",
"line-clipping",
"ratatui-core",
"serde",
"strum",
"strum 0.28.0",
"time",
"unicode-segmentation",
"unicode-width 0.2.2",
"unicode-width",
]
[[package]]
@@ -1685,6 +1675,26 @@ dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "reedline"
version = "0.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "826c1fc22a2b1f14c3f6a80fc3d56adfbfb913d07f170bce4246700de7adfd50"
dependencies = [
"chrono",
"crossterm",
"fd-lock",
"itertools 0.13.0",
"nu-ansi-term",
"serde",
"strip-ansi-escapes",
"strum 0.27.2",
"thiserror 2.0.20",
"unicase",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "regex"
version = "1.13.1"
@@ -1797,26 +1807,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rustyline"
version = "14.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63"
dependencies = [
"bitflags 2.13.1",
"cfg-if",
"clipboard-win",
"fd-lock",
"libc",
"log",
"memchr",
"nix 0.28.0",
"unicode-segmentation",
"unicode-width 0.1.14",
"utf8parse",
"windows-sys 0.52.0",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -1992,19 +1982,49 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strip-ansi-escapes"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025"
dependencies = [
"vte",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros 0.27.2",
]
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
"strum_macros 0.28.0",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -2155,7 +2175,7 @@ dependencies = [
"libc",
"log",
"memmem",
"nix 0.29.0",
"nix",
"num-derive",
"num-traits",
"ordered-float",
@@ -2262,6 +2282,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -2280,17 +2306,11 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
dependencies = [
"itertools",
"itertools 0.14.0",
"unicode-segmentation",
"unicode-width 0.2.2",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -2366,6 +2386,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vte"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
dependencies = [
"memchr",
]
[[package]]
name = "vtparse"
version = "0.6.2"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-manager"
version = "0.4.1"
version = "0.4.2"
edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT"
@@ -25,7 +25,8 @@ semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
rustyline = { version = "14", default-features = false, features = ["with-file-history"] }
reedline = "0.49"
nu-ansi-term = "0.50"
sha2 = "0.10"
shell-words = "1"
tar = "0.4"
+9 -4
View File
@@ -227,16 +227,21 @@ PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
❯ ls | where size > 1mb # filtre la dernière table (>, <, >=, <=, ==, !=, =~)
❯ ps | where name =~ am # =~ cherche dans le texte
❯ ls | get name # sélectionne une colonne
❯ cd <Tab> # complétion des dossiers uniquement, un niveau par Tab
❯ cd <Tab> # complétion des dossiers — menu interactif façon Nushell
❯ shell # affiche le shell courant + les shells disponibles
❯ shell bash # change le shell de la session
❯ !ls · !list # force l'exécution système (même nom qu'une commande am)
❯ cd ~/projets # change le répertoire de la session (persistant)
❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
Le menu Tab affiche une **description** à côté de chaque candidat
(commandes et agents), comme Nushell, et **cycle** entre les propositions à
chaque Tab.
La complétion Tab est un **menu interactif façon Nushell** : une
correspondance unique est insérée directement ; plusieurs correspondances
ouvrent un menu sous la ligne avec le **premier choix en surbrillance**, et
chaque Tab déplace la surbrillance vers le choix suivant (Shift+Tab vers
l'arrière). **Entrée** valide le choix, **Échap** ferme le menu, les flèches
↑/↓ naviguent et continuer à taper filtre la liste en direct. Le menu
affiche une **description** à côté de chaque candidat (commandes et agents)
et suit la palette du thème actif.
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
`config.yaml` (voir `am config path`).
+5 -5
View File
@@ -56,7 +56,7 @@ doit signaler aucune dépendance manquante.
Une fois toutes les archives dans `dist/` :
scripts/render-manifests.ps1 -Version 0.4.1
scripts/render-manifests.ps1 -Version 0.4.2
Ce script rend, avec la version et les sha256 réels :
@@ -67,16 +67,16 @@ Ce script rend, avec la version et les sha256 réels :
- `dist/homebrew/am.rb` → à servir via un tap, ou à soumettre
- `dist/rpm/am.spec` → rpmbuild -bb (avec am-linux-x86_64.tar.gz
dans ~/rpmbuild/SOURCES)
- Paquet deb : `packaging/deb/make-deb.sh 0.4.1` → dist/am_0.4.1_amd64.deb
- Paquet deb : `packaging/deb/make-deb.sh 0.4.2` → dist/am_0.4.2_amd64.deb
## 4. Créer la release sur Gitea
Via l'interface web : Releases > New Release, tag v0.4.1, attacher les
Via l'interface web : Releases > New Release, tag v0.4.2, attacher les
archives. Ou via l'API (jeton Gitea requis) :
curl -X POST -H "Authorization: token <JETON>" \
-H "Content-Type: application/json" \
-d '{"tag_name":"v0.4.1","name":"v0.4.1","body":"notes de version"}' \
-d '{"tag_name":"v0.4.2","name":"v0.4.2","body":"notes de version"}' \
https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases
curl -X POST -H "Authorization: token <JETON>" \
@@ -89,7 +89,7 @@ archives. Ou via l'API (jeton Gitea requis) :
## 5. Tag git (déclenche aussi le pipeline CI)
git tag v0.4.1 && git push origin v0.4.1
git tag v0.4.2 && git push origin v0.4.2
## Conventions de nommage (attendues par les installateurs et self-update)
+2 -2
View File
@@ -2,11 +2,11 @@
.el .ds Aq '
.TH am-self-update 1 "self-update "
.SH NAME
self\-update \- Update agent\-manager itself from GitHub Releases
self\-update \- Update agent\-manager itself from the latest release
.SH SYNOPSIS
\fBself\-update\fR [\fB\-\-check\fR] [\fB\-\-to\fR] [\fB\-h\fR|\fB\-\-help\fR]
.SH DESCRIPTION
Update agent\-manager itself from GitHub Releases
Update agent\-manager itself from the latest release
.SH OPTIONS
.TP
\fB\-\-check\fR
+3 -3
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq
.el .ds Aq '
.TH am 1 "am 0.4.1"
.TH am 1 "am 0.4.2"
.SH NAME
am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS
@@ -163,7 +163,7 @@ am\-completion(1)
Generate a shell completion script
.TP
am\-self\-update(1)
Update agent\-manager itself from GitHub Releases
Update agent\-manager itself from the latest release
.TP
am\-self\-uninstall(1)
Remove agent\-manager and everything it created from this machine
@@ -174,4 +174,4 @@ Export the configuration and installation state (backup)
am\-import(1)
Import a previously exported configuration and state
.SH VERSION
v0.4.1
v0.4.2
+1 -1
View File
@@ -1,6 +1,6 @@
# Renders the packaging templates (scoop, winget, homebrew, rpm) with the
# version and the sha256 of every release archive.
# Usage: scripts/render-manifests.ps1 -Version 0.4.1 [-DistDir dist]
# Usage: scripts/render-manifests.ps1 -Version 0.4.2 [-DistDir dist]
param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$DistDir = "dist"
+3
View File
@@ -223,6 +223,9 @@ mod tests {
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
// The state store keeps the path it was created with: repoint it at
// the sandbox too, or the test reads the developer's real state.
app.state = crate::state::StateStore::new(dir.join("state.json"));
app
}
-89
View File
@@ -235,61 +235,6 @@ pub fn kv_table(
out
}
/// Build the lines of the boxed Tab-completion menu from (candidate,
/// description) rows. Single column when no descriptions exist.
pub fn completion_box(rows: &[(String, String)]) -> Vec<String> {
let has_desc = rows.iter().any(|(_, d)| !d.is_empty());
let name_w = rows
.iter()
.map(|(n, _)| n.chars().count())
.max()
.unwrap_or(0)
.max("name".chars().count());
let desc_w = if has_desc {
rows.iter()
.map(|(_, d)| truncate(d, 40).chars().count())
.max()
.unwrap_or(0)
.max("description".chars().count())
} else {
0
};
let border = |left: char, mid: char, right: char| -> String {
let mut line = String::new();
line.push(left);
line.push_str(&"─".repeat(name_w + 2));
if has_desc {
line.push(mid);
line.push_str(&"─".repeat(desc_w + 2));
}
line.push(right);
line
};
let mut out = Vec::new();
out.push(border('╭', '┬', '╮'));
if has_desc {
out.push(format!(
"│ {} │ {} │",
pad("name", name_w),
pad("description", desc_w)
));
out.push(border('├', '┼', '┤'));
}
for (name, desc) in rows {
if has_desc {
out.push(format!(
"│ {} │ {} │",
pad(name, name_w),
pad(&truncate(desc, 40), desc_w)
));
} else {
out.push(format!("│ {} │", pad(name, name_w)));
}
}
out.push(border('╰', '┴', '╯'));
out
}
// ---------------------------------------------------------------------------
// Tables
// ---------------------------------------------------------------------------
@@ -457,40 +402,6 @@ fn pad(s: &str, width: usize) -> String {
mod tests {
use super::*;
#[test]
fn completion_box_with_descriptions_is_aligned() {
let rows = vec![
("list".to_string(), "list installed agents".to_string()),
("status".to_string(), "show agent state".to_string()),
];
let lines = completion_box(&rows);
assert_eq!(lines.len(), 6); // top, header, sep, 2 rows, bottom
assert!(lines[0].starts_with('╭') && lines[0].contains('┬'));
assert!(lines[0].ends_with('╮'));
assert!(lines[1].contains("name") && lines[1].contains("description"));
assert!(lines[2].starts_with('├'));
assert!(lines[5].starts_with('╰'));
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
assert!(
widths.iter().all(|w| *w == widths[0]),
"unaligned: {lines:?}"
);
assert!(lines[3].contains("list") && lines[3].contains("list installed agents"));
}
#[test]
fn completion_box_without_descriptions_is_single_column() {
let rows = vec![
("src".to_string(), "".to_string()),
("am.exe".to_string(), "".to_string()),
];
let lines = completion_box(&rows);
assert_eq!(lines.len(), 4); // top, 2 rows, bottom
assert!(!lines.iter().any(|l| l.contains("description")));
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]));
}
#[test]
fn kv_table_is_aligned_with_and_without_colors() {
let rows = vec![
+375 -185
View File
@@ -7,10 +7,13 @@
//! pass-through to pwsh, cmd, bash, zsh, ... The user's default shell is
//! detected at startup and highlighted in the banner and the status line.
//!
//! Uses rustyline for line editing: Tab completion (commands, agents, aliases,
//! groups, flags, shell names), command history persisted in the state
//! directory, and graceful fallback to plain line input when no terminal is
//! available.
//! Uses reedline (Nushell's line editor) for editing: Nushell-style Tab
//! completion — a single match is inserted immediately, several matches open
//! an interactive menu under the prompt with the first choice highlighted;
//! further Tabs move the highlight to the next choice (Enter validates, Esc
//! closes, arrows navigate, typing filters live). Command history is persisted
//! in the state directory, and there is a graceful fallback to plain line
//! input when no terminal is available.
use crate::app::App;
use crate::cli::{Command, StartArgs};
@@ -23,12 +26,13 @@ use crate::shell::ShellSession;
use crate::theme::Theme;
use anyhow::{anyhow, Result};
use colored::Colorize;
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 nu_ansi_term::{Color, Style};
use reedline::{
default_emacs_keybindings, ColumnarMenu, Completer, Emacs, FileBackedHistory, KeyCode,
KeyModifiers, MenuBuilder, MenuTextStyle, Prompt, PromptEditMode, PromptHistorySearch,
Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion,
};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
@@ -55,9 +59,6 @@ pub struct AmCompleter {
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
/// Active color theme (used by the boxed menu highlight; follows the
/// app theme when it is switched with the REPL 'theme <name>' command).
theme: std::cell::Cell<&'static crate::theme::Theme>,
}
/// One-line descriptions for the Tab completion menu.
@@ -142,15 +143,9 @@ impl AmCompleter {
}
completer.agents.sort();
completer.agents.dedup();
completer.theme.set(app.theme());
completer
}
/// Point the menu at a new theme (after 'theme <name>' in the REPL).
pub fn set_theme(&self, theme: &'static crate::theme::Theme) {
self.theme.set(theme);
}
pub fn from_catalog(
catalog: &crate::catalog::Catalog,
aliases: &BTreeMap<String, String>,
@@ -192,7 +187,6 @@ impl AmCompleter {
descriptions.insert(a.name.clone(), truncate_desc(&desc));
}
Self {
theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
@@ -329,147 +323,186 @@ impl AmCompleter {
}
impl Completer for AmCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
let (start, cands) = self.candidates_for(line, pos);
let pairs = if cands.len() < 2 {
// A single match is inserted immediately (no menu).
cands
.into_iter()
.map(|c| Pair {
display: c.clone(),
replacement: c,
})
.collect()
} else {
self.boxed_pairs(&cands)
};
Ok((start, pairs))
}
}
impl AmCompleter {
/// Build the boxed Tab menu: one candidate per box line, so rustyline's
/// single-column layout renders the whole menu as one white-framed
/// table. Decorative lines (borders, header) replace the typed word
/// with the common prefix, so completion behaves exactly as before.
fn boxed_pairs(&self, cands: &[String]) -> Vec<Pair> {
let rows: Vec<(String, String)> = cands
.iter()
.map(|c| (c.clone(), self.descriptions.get(c).cloned().unwrap_or_default()))
.collect();
let has_desc = rows.iter().any(|(_, d)| !d.is_empty());
let lines = crate::output::completion_box(&rows);
let lcp = common_prefix(cands);
let n = cands.len();
let data_start = if has_desc { 3 } else { 1 };
// Pad every line to the terminal width: rustyline then lays the
// menu out in one column, one box line per row.
let width = crate::output::terminal_width()
.unwrap_or(120)
.max(lines.iter().map(|l| l.chars().count()).max().unwrap_or(0));
lines
.into_iter()
.enumerate()
.map(|(i, line)| {
let replacement = if i >= data_start && i < data_start + n {
cands[i - data_start].clone()
} else {
lcp.clone()
};
let display = format!(
"{line}{}",
" ".repeat(width.saturating_sub(line.chars().count()))
);
Pair {
display,
replacement,
}
.map(|c| Suggestion {
value: c.clone(),
description: self.descriptions.get(&c).cloned(),
span: Span::new(start, pos),
..Default::default()
})
.collect()
}
}
/// Longest common prefix of the candidates (chars, not bytes).
fn common_prefix(items: &[String]) -> String {
let mut iter = items.iter();
let Some(first) = iter.next() else {
return String::new();
};
let mut prefix = first.clone();
for s in iter {
while !s.starts_with(&prefix) && !prefix.is_empty() {
prefix.pop();
}
if prefix.is_empty() {
break;
}
}
prefix
}
// ---------------------------------------------------------------------------
// Line editor (reedline) and Nushell-style completion menu
// ---------------------------------------------------------------------------
impl Hinter for AmCompleter {
type Hint = String;
/// The REPL prompt, unchanged from the rustyline era. While a completion
/// menu is open, reedline replaces the (empty) indicator with the menu
/// marker "| ", like Nushell.
struct AmPrompt;
fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
None
impl Prompt for AmPrompt {
fn render_prompt_left(&self) -> Cow<'_, str> {
Cow::Borrowed("❯ ")
}
}
impl Highlighter for AmCompleter {
/// Style the boxed completion menu: borders dim, the header row in the
/// theme header style, candidate names in the accent color.
fn highlight_candidate<'c>(
fn render_prompt_right(&self) -> Cow<'_, str> {
Cow::Borrowed("")
}
fn render_prompt_indicator(&self, _mode: PromptEditMode) -> Cow<'_, str> {
Cow::Borrowed("")
}
fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
Cow::Borrowed(" ")
}
fn render_prompt_history_search_indicator(
&self,
candidate: &'c str,
completion: rustyline::CompletionType,
) -> std::borrow::Cow<'c, str> {
use std::borrow::Cow;
let _ = completion;
match candidate.chars().next() {
Some('╭') | Some('╰') | Some('├') => Cow::Owned(self.theme.get().frame(candidate)),
Some('│') => {
let cells: Vec<&str> = candidate.split('│').collect();
if cells.len() >= 4 {
if cells[1].trim() == "name" && cells[2].trim() == "description" {
Cow::Owned(self.theme.get().hdr(candidate))
} else {
let bar = self.theme.get().frame("│");
Cow::Owned(format!(
"{}{}{}{}{}",
bar,
self.theme.get().acc(cells[1]),
bar,
cells[2],
bar
))
}
} else if cells.len() == 3 {
// Single-column menu (no descriptions, e.g. the theme
// names): paint the bars and the name.
let bar = self.theme.get().frame("│");
Cow::Owned(format!(
"{}{}{}",
bar,
self.theme.get().acc(cells[1]),
bar
))
} else {
Cow::Borrowed(candidate)
}
}
_ => Cow::Borrowed(candidate),
}
_search: PromptHistorySearch,
) -> Cow<'_, str> {
Cow::Borrowed("(reverse-i-search)'")
}
}
impl Validator for AmCompleter {}
impl Helper for AmCompleter {}
/// Map an ANSI basic color code (30-37, 90-97) onto the nu-ansi-term
/// named palette.
fn basic_color(n: u8) -> Option<Color> {
match n {
30 => Some(Color::Black),
31 => Some(Color::Red),
32 => Some(Color::Green),
33 => Some(Color::Yellow),
34 => Some(Color::Blue),
35 => Some(Color::Purple),
36 => Some(Color::Cyan),
37 => Some(Color::White),
90 => Some(Color::DarkGray),
91 => Some(Color::LightRed),
92 => Some(Color::LightGreen),
93 => Some(Color::LightYellow),
94 => Some(Color::LightBlue),
95 => Some(Color::LightPurple),
96 => Some(Color::LightCyan),
97 => Some(Color::LightGray),
_ => None,
}
}
/// Map one theme SGR string ("1;38;5;81") onto a nu-ansi-term Style so the
/// completion menu can follow the active theme. Unknown parameters are
/// ignored; the empty string (mono theme) yields the default style.
fn sgr_to_style(sgr: &str) -> Style {
let toks: Vec<&str> = sgr.split(';').collect();
let mut style = Style::default();
let mut i = 0;
while i < toks.len() {
match toks[i] {
"1" => style = style.bold(),
"2" => style = style.dimmed(),
"3" => style = style.italic(),
"4" => style = style.underline(),
"7" => style = style.reverse(),
"38" if toks.get(i + 1) == Some(&"5") => {
if let Some(n) = toks.get(i + 2).and_then(|t| t.parse::<u8>().ok()) {
style = style.fg(Color::Fixed(n));
}
i += 2;
}
"39" => style = style.fg(Color::Default),
other => {
if let Some(c) = other.parse::<u8>().ok().and_then(basic_color) {
style = style.fg(c);
}
}
}
i += 1;
}
style
}
/// Completion menu colors derived from the active theme: the highlighted
/// choice is the theme accent in reverse video (like Nushell's default),
/// the other choices are dimmed and the descriptions use the info color.
/// With the mono theme the accent is plain, so only the reverse-video
/// highlight remains.
fn menu_text_style(theme: &crate::theme::Theme, color: bool) -> MenuTextStyle {
let accent = if color {
sgr_to_style(theme.accent)
} else {
Style::default()
};
MenuTextStyle {
selected_text_style: accent.reverse(),
text_style: if color {
sgr_to_style(theme.dim)
} else {
Style::default()
},
description_style: if color {
sgr_to_style(theme.info)
} else {
Style::default()
},
selected_match_style: accent.reverse().underline(),
match_style: Style::default().underline(),
}
}
/// Build the reedline line editor:
///
/// * Nushell-style Tab: the first press opens the completion menu with the
/// first choice highlighted; every further press moves the highlight to
/// the next choice (Enter validates, Esc closes, the arrow keys navigate
/// and typing filters the list live). Shift+Tab moves backwards.
/// * Quick completions: a single match is inserted immediately, without
/// opening the menu.
/// * Command history persisted in the state directory (history.txt).
/// * The completion menu styled with the active theme (the REPL 'theme
/// <name>' command rebuilds the editor so the menu follows).
fn build_line_editor(app: &App, history_path: &std::path::Path) -> Result<Reedline> {
let mut keybindings = default_emacs_keybindings();
keybindings.add_binding(
KeyModifiers::NONE,
KeyCode::Tab,
ReedlineEvent::UntilFound(vec![
ReedlineEvent::Menu("completion_menu".to_string()),
ReedlineEvent::MenuNext,
]),
);
keybindings.add_binding(
KeyModifiers::SHIFT,
KeyCode::BackTab,
ReedlineEvent::UntilFound(vec![
ReedlineEvent::Menu("completion_menu".to_string()),
ReedlineEvent::MenuPrevious,
]),
);
let history = FileBackedHistory::with_file(1000, history_path.to_path_buf())
.map_err(|e| anyhow!("cannot open the command history: {e}"))?;
let color = app.color();
let ts = menu_text_style(app.theme(), color);
let menu = ColumnarMenu::default()
.with_name("completion_menu")
.with_text_style(ts.text_style)
.with_selected_text_style(ts.selected_text_style)
.with_description_text_style(ts.description_style)
.with_selected_match_text_style(ts.selected_match_style)
.with_match_text_style(ts.match_style);
Ok(Reedline::create()
.with_completer(Box::new(AmCompleter::new(app)))
.with_quick_completions(true)
.with_history(Box::new(history))
.with_menu(ReedlineMenu::EngineCompleter(Box::new(menu)))
.with_edit_mode(Box::new(Emacs::new(keybindings)))
.with_ansi_colors(color))
}
// ---------------------------------------------------------------------------
// Banner (Hermes-style)
@@ -998,38 +1031,63 @@ pub fn run(app: &App) -> Result<i32> {
}
fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
rustyline::Editor::new()?;
rl.set_helper(Some(AmCompleter::new(app)));
// Nushell-style completion: a single match is inserted immediately;
// otherwise Tab inserts the longest common prefix and the menu lists the
// candidates with their descriptions.
use rustyline::config::{CompletionType, Configurer};
let _ = rl.set_completion_type(CompletionType::List);
let history_path = app.paths.state_file.with_file_name("history.txt");
if rl.load_history(&history_path).is_err() {
// First run: no history yet.
// With piped stdin (tests, scripts, CI) the line editor cannot read
// console keys — reedline would block on the console instead of the
// pipe. Fall back to plain line input (run() handles the fallback).
if !std::io::stdin().is_terminal() {
return Err(anyhow!("stdin is not a terminal"));
}
let history_path = app.paths.state_file.with_file_name("history.txt");
let mut rl = build_line_editor(app, &history_path)?;
let prompt = AmPrompt;
let started = Instant::now();
let mut last: Option<DataTable> = None;
let mut last_duration: Option<Duration> = None;
let mut first_read = true;
let mut theme: &'static crate::theme::Theme = app.theme();
loop {
print_status(app, started, last_duration);
match rl.readline("❯ ") {
Ok(line) => {
let signal = match rl.read_line(&prompt) {
Ok(signal) => signal,
Err(e) => {
// No usable terminal (e.g. AM_FORCE_REPL with piped stdin):
// fall back to plain line input like before.
if first_read {
return Err(anyhow!("line editor unavailable: {e}"));
}
app.log.warn(&format!("input error: {e}"));
break;
}
};
first_read = false;
let line = match signal {
Signal::Success(line) => line,
// Like rustyline: Ctrl-C aborts the current line, Ctrl-D (EOF on
// an empty line) ends the session.
Signal::CtrlC => continue,
Signal::CtrlD => break,
Signal::HostCommand(_) | Signal::ExternalBreak(_) => continue,
// Signal is non-exhaustive: stay in the REPL for future signals.
_ => continue,
};
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
continue;
}
let _ = rl.add_history_entry(trimmed.as_str());
let t = Instant::now();
let result = handle_input(app, session, &mut last, &trimmed);
last_duration = Some(t.elapsed());
record_history(app, sid, &trimmed, &result, t.elapsed());
// 'theme <name>' may have switched the app theme: repoint the
// completion menu at the new palette.
if let Some(h) = rl.helper_mut() {
h.set_theme(app.theme());
// 'theme <name>' may have switched the app theme: rebuild the editor
// so the completion menu follows the new palette (the history is
// file-backed and re-read from disk).
if !std::ptr::eq(theme, app.theme()) {
theme = app.theme();
let _ = rl.sync_history();
match build_line_editor(app, &history_path) {
Ok(new_rl) => rl = new_rl,
Err(e) => app.log.warn(&format!("cannot rebuild the editor: {e:#}")),
}
}
match result {
Ok(true) => break,
@@ -1037,15 +1095,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i
Err(e) => 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);
let _ = rl.sync_history();
Ok(0)
}
@@ -1181,7 +1231,10 @@ fn handle_line(
}
return Ok(false);
}
let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?;
// Windows: split with backslashes as literal path separators (the Unix
// shell_words splitter would strip them from 'cd dev\git\...'). Unix
// keeps the classic backslash-escaping semantics.
let tokens = split_command(line)?;
if tokens.is_empty() {
return Ok(false);
}
@@ -1620,8 +1673,7 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
.collect();
let mut table: Option<DataTable> = None;
for part in &parts {
let tokens =
shell_words::split(part).map_err(|e| anyhow!("cannot parse command: {e}"))?;
let tokens = split_command(part)?;
if tokens.is_empty() {
continue;
}
@@ -1681,6 +1733,52 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
Ok(false)
}
/// Split a command line into tokens.
///
/// On Windows, backslashes are literal path separators: 'cd dev\git\...'
/// must reach change_dir intact, and double quotes group tokens ("" escapes
/// a quote), like cmd/PowerShell. The Unix shell_words splitter would eat
/// every backslash as an escape character, breaking Windows paths.
/// On Unix, the classic backslash-escaping rules are kept.
fn split_command(line: &str) -> Result<Vec<String>> {
#[cfg(windows)]
{
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
let mut chars = line.chars().peekable();
let mut in_quotes = false;
while let Some(c) = chars.next() {
match c {
'"' => {
if in_quotes && chars.peek() == Some(&'"') {
chars.next();
cur.push('"');
} else {
in_quotes = !in_quotes;
}
}
c if c.is_whitespace() && !in_quotes => {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if in_quotes {
anyhow::bail!("unterminated quote");
}
if !cur.is_empty() {
tokens.push(cur);
}
Ok(tokens)
}
#[cfg(not(windows))]
{
shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))
}
}
/// Run one system command through the active shell (inherited stdio).
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
app.log.cmd(&format!("{}: {line}", session.current.name));
@@ -1977,18 +2075,79 @@ mod tests {
}
#[test]
fn completion_menu_paints_single_column_bars() {
let c = completer();
c.set_theme(crate::theme::find("ocean").unwrap());
let row = "│ ocean │";
let out = c.highlight_candidate(row, rustyline::CompletionType::List);
fn completer_produces_suggestions_with_spans() {
// The reedline completer maps (start, candidates) onto suggestions
// whose span covers exactly the typed word.
let mut c = completer();
let suggs = c.complete("inst", 5);
assert!(!suggs.is_empty());
for s in &suggs {
assert_eq!(s.span.start, 0, "completes from the line start");
assert_eq!(s.span.end, 5);
assert!(s.value.starts_with("inst"), "value: {}", s.value);
}
let suggs = c.complete("install cl", 11);
assert_eq!(suggs[0].span.start, 8);
assert_eq!(suggs[0].span.end, 11);
assert!(suggs.iter().all(|s| s.value.starts_with("cl")));
}
#[test]
fn completer_suggestions_carry_descriptions() {
let mut c = completer();
let suggs = c.complete("star", 5);
assert!(suggs.iter().any(|s| {
s.value == "start" && s.description.as_deref() == Some("start an agent")
}));
// Path candidates (cd) have no description: the menu shows them in
// columnar layout instead of the single description column. The
// absolute path keeps the test independent from the process cwd.
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
let line = format!("cd {}", dir.path().display());
let suggs = c.complete(&line, line.len());
let sep = std::path::MAIN_SEPARATOR;
let expected = format!("{}{sep}", dir.path().display());
assert!(
out.contains("\x1b[38;5;75m"),
"bars not painted with the frame color: {out}"
suggs.iter().any(|s| s.value == expected),
"expected {expected}: {suggs:?}"
);
assert!(suggs.iter().all(|s| s.description.is_none()));
}
#[test]
fn sgr_theme_strings_map_to_ansi_term_styles() {
// "1;38;5;81" = bold + 256-color 81 (ocean accent).
let s = sgr_to_style("1;38;5;81");
assert!(s.is_bold, "accent should be bold");
let fg = s.foreground.expect("accent should set a color");
assert_eq!(fg, Color::Fixed(81));
// Basic ANSI colors ("36" = cyan) map onto the named palette.
let s = sgr_to_style("36");
assert_eq!(s.foreground, Some(Color::Cyan));
// The mono theme uses empty SGR strings: default style, no color.
let s = sgr_to_style("");
assert!(!s.is_bold);
assert_eq!(s.foreground, None);
}
#[test]
fn menu_styles_follow_the_theme() {
let ocean = crate::theme::find("ocean").unwrap();
let ts = menu_text_style(ocean, true);
assert!(
out.contains("\x1b[1;38;5;81m"),
"name not painted with the accent color: {out}"
ts.selected_text_style.is_reverse,
"the highlighted choice must be in reverse video"
);
assert_eq!(
ts.selected_text_style.foreground,
Some(Color::Fixed(81)),
"the highlighted choice uses the accent color"
);
assert_eq!(
ts.description_style.foreground,
Some(Color::Fixed(117)),
"descriptions use the theme info color"
);
}
@@ -2051,4 +2210,35 @@ mod tests {
assert!(s.ends_with("here"));
assert!(s.chars().count() <= 9);
}
#[cfg(windows)]
#[test]
fn split_command_keeps_windows_backslashes() {
// The core regression: 'cd dev\git\...' must not lose its
// backslashes (they are path separators on Windows, not escapes).
let tokens = split_command("cd dev\\git\\Rust\\agent-manager\\").unwrap();
assert_eq!(tokens, vec!["cd", "dev\\git\\Rust\\agent-manager\\"]);
let tokens = split_command("ls dev\\AI\\").unwrap();
assert_eq!(tokens, vec!["ls", "dev\\AI\\"]);
}
#[cfg(windows)]
#[test]
fn split_command_handles_quotes_and_whitespace() {
let tokens = split_command("cd \"my dir\"\\src").unwrap();
assert_eq!(tokens, vec!["cd", "my dir\\src"]);
let tokens = split_command("echo \"a\"\"b\"").unwrap();
assert_eq!(tokens, vec!["echo", "a\"b"]);
assert!(split_command("echo \"unterminated").is_err());
assert!(split_command(" ").unwrap().is_empty());
}
#[cfg(not(windows))]
#[test]
fn split_command_keeps_unix_escaping() {
let tokens = split_command("ls foo\\ bar").unwrap();
assert_eq!(tokens, vec!["ls", "foo bar"]);
let tokens = split_command("cd dev/git").unwrap();
assert_eq!(tokens, vec!["cd", "dev/git"]);
}
}