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
This commit is contained in:
+688
@@ -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
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -32,8 +32,9 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"flate2",
|
"flate2",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"nu-ansi-term",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rustyline",
|
"reedline",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -191,6 +192,9 @@ name = "bitflags"
|
|||||||
version = "2.13.1"
|
version = "2.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
@@ -272,12 +276,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg_aliases"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -368,15 +366,6 @@ dependencies = [
|
|||||||
"roff",
|
"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]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -471,6 +460,7 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rustix",
|
"rustix",
|
||||||
|
"serde",
|
||||||
"signal-hook",
|
"signal-hook",
|
||||||
"signal-hook-mio",
|
"signal-hook-mio",
|
||||||
"winapi",
|
"winapi",
|
||||||
@@ -632,12 +622,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "euclid"
|
name = "euclid"
|
||||||
version = "0.22.14"
|
version = "0.22.14"
|
||||||
@@ -1036,6 +1020,15 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -1174,7 +1167,7 @@ version = "1.1.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nix 0.29.0",
|
"nix",
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1227,18 +1220,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -1247,7 +1228,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cfg_aliases 0.2.2",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
@@ -1262,6 +1243,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"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]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -1601,16 +1591,16 @@ dependencies = [
|
|||||||
"compact_str",
|
"compact_str",
|
||||||
"critical-section",
|
"critical-section",
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"itertools",
|
"itertools 0.14.0",
|
||||||
"kasuari",
|
"kasuari",
|
||||||
"lru",
|
"lru",
|
||||||
"palette",
|
"palette",
|
||||||
"serde",
|
"serde",
|
||||||
"strum",
|
"strum 0.28.0",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-truncate",
|
"unicode-truncate",
|
||||||
"unicode-width 0.2.2",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1666,14 +1656,14 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
"indoc",
|
"indoc",
|
||||||
"instability",
|
"instability",
|
||||||
"itertools",
|
"itertools 0.14.0",
|
||||||
"line-clipping",
|
"line-clipping",
|
||||||
"ratatui-core",
|
"ratatui-core",
|
||||||
"serde",
|
"serde",
|
||||||
"strum",
|
"strum 0.28.0",
|
||||||
"time",
|
"time",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width 0.2.2",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1685,6 +1675,26 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -1797,26 +1807,6 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
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]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -1992,19 +1982,49 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
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]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
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]]
|
[[package]]
|
||||||
name = "strum"
|
name = "strum"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -2155,7 +2175,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"memmem",
|
"memmem",
|
||||||
"nix 0.29.0",
|
"nix",
|
||||||
"num-derive",
|
"num-derive",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"ordered-float",
|
"ordered-float",
|
||||||
@@ -2262,6 +2282,12 @@ version = "0.1.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -2280,17 +2306,11 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
|
checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itertools",
|
"itertools 0.14.0",
|
||||||
"unicode-segmentation",
|
"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]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2366,6 +2386,15 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vte"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vtparse"
|
name = "vtparse"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.4.1"
|
version = "0.4.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -25,7 +25,8 @@ semver = "1"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
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"
|
sha2 = "0.10"
|
||||||
shell-words = "1"
|
shell-words = "1"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
|
|||||||
@@ -227,16 +227,21 @@ PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
|
|||||||
❯ ls | where size > 1mb # filtre la dernière table (>, <, >=, <=, ==, !=, =~)
|
❯ ls | where size > 1mb # filtre la dernière table (>, <, >=, <=, ==, !=, =~)
|
||||||
❯ ps | where name =~ am # =~ cherche dans le texte
|
❯ ps | where name =~ am # =~ cherche dans le texte
|
||||||
❯ ls | get name # sélectionne une colonne
|
❯ 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 # affiche le shell courant + les shells disponibles
|
||||||
❯ shell bash # change le shell de la session
|
❯ shell bash # change le shell de la session
|
||||||
❯ !ls · !list # force l'exécution système (même nom qu'une commande am)
|
❯ !ls · !list # force l'exécution système (même nom qu'une commande am)
|
||||||
❯ cd ~/projets # change le répertoire de la session (persistant)
|
❯ cd ~/projets # change le répertoire de la session (persistant)
|
||||||
❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
|
❯ /help # commandes slash : /help · /version · /exit · /shell <nom>
|
||||||
|
|
||||||
Le menu Tab affiche une **description** à côté de chaque candidat
|
La complétion Tab est un **menu interactif façon Nushell** : une
|
||||||
(commandes et agents), comme Nushell, et **cycle** entre les propositions à
|
correspondance unique est insérée directement ; plusieurs correspondances
|
||||||
chaque Tab.
|
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
|
Pour rendre le choix permanent : `settings.default_shell: pwsh` dans
|
||||||
`config.yaml` (voir `am config path`).
|
`config.yaml` (voir `am config path`).
|
||||||
|
|||||||
+5
-5
@@ -56,7 +56,7 @@ doit signaler aucune dépendance manquante.
|
|||||||
|
|
||||||
Une fois toutes les archives dans `dist/` :
|
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 :
|
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/homebrew/am.rb` → à servir via un tap, ou à soumettre
|
||||||
- `dist/rpm/am.spec` → rpmbuild -bb (avec am-linux-x86_64.tar.gz
|
- `dist/rpm/am.spec` → rpmbuild -bb (avec am-linux-x86_64.tar.gz
|
||||||
dans ~/rpmbuild/SOURCES)
|
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
|
## 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) :
|
archives. Ou via l'API (jeton Gitea requis) :
|
||||||
|
|
||||||
curl -X POST -H "Authorization: token <JETON>" \
|
curl -X POST -H "Authorization: token <JETON>" \
|
||||||
-H "Content-Type: application/json" \
|
-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
|
https://git.dracodev.net/api/v1/repos/Projets/agent-manager/releases
|
||||||
|
|
||||||
curl -X POST -H "Authorization: token <JETON>" \
|
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)
|
## 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)
|
## Conventions de nommage (attendues par les installateurs et self-update)
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
.el .ds Aq '
|
.el .ds Aq '
|
||||||
.TH am-self-update 1 "self-update "
|
.TH am-self-update 1 "self-update "
|
||||||
.SH NAME
|
.SH NAME
|
||||||
self\-update \- Update agent\-manager itself from GitHub Releases
|
self\-update \- Update agent\-manager itself from the latest release
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
\fBself\-update\fR [\fB\-\-check\fR] [\fB\-\-to\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
\fBself\-update\fR [\fB\-\-check\fR] [\fB\-\-to\fR] [\fB\-h\fR|\fB\-\-help\fR]
|
||||||
.SH DESCRIPTION
|
.SH DESCRIPTION
|
||||||
Update agent\-manager itself from GitHub Releases
|
Update agent\-manager itself from the latest release
|
||||||
.SH OPTIONS
|
.SH OPTIONS
|
||||||
.TP
|
.TP
|
||||||
\fB\-\-check\fR
|
\fB\-\-check\fR
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.ie \n(.g .ds Aq \(aq
|
.ie \n(.g .ds Aq \(aq
|
||||||
.el .ds Aq '
|
.el .ds Aq '
|
||||||
.TH am 1 "am 0.4.1"
|
.TH am 1 "am 0.4.2"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
am \- agent\-manager (am) — manage local AI coding agents
|
am \- agent\-manager (am) — manage local AI coding agents
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
@@ -163,7 +163,7 @@ am\-completion(1)
|
|||||||
Generate a shell completion script
|
Generate a shell completion script
|
||||||
.TP
|
.TP
|
||||||
am\-self\-update(1)
|
am\-self\-update(1)
|
||||||
Update agent\-manager itself from GitHub Releases
|
Update agent\-manager itself from the latest release
|
||||||
.TP
|
.TP
|
||||||
am\-self\-uninstall(1)
|
am\-self\-uninstall(1)
|
||||||
Remove agent\-manager and everything it created from this machine
|
Remove agent\-manager and everything it created from this machine
|
||||||
@@ -174,4 +174,4 @@ Export the configuration and installation state (backup)
|
|||||||
am\-import(1)
|
am\-import(1)
|
||||||
Import a previously exported configuration and state
|
Import a previously exported configuration and state
|
||||||
.SH VERSION
|
.SH VERSION
|
||||||
v0.4.1
|
v0.4.2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Renders the packaging templates (scoop, winget, homebrew, rpm) with the
|
# Renders the packaging templates (scoop, winget, homebrew, rpm) with the
|
||||||
# version and the sha256 of every release archive.
|
# 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(
|
param(
|
||||||
[Parameter(Mandatory = $true)][string]$Version,
|
[Parameter(Mandatory = $true)][string]$Version,
|
||||||
[string]$DistDir = "dist"
|
[string]$DistDir = "dist"
|
||||||
|
|||||||
@@ -223,6 +223,9 @@ mod tests {
|
|||||||
p.probe_cache_file = dir.join("probe-cache.json");
|
p.probe_cache_file = dir.join("probe-cache.json");
|
||||||
p.config_dir = Some(dir.join("config"));
|
p.config_dir = Some(dir.join("config"));
|
||||||
app.paths = p;
|
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
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,61 +235,6 @@ pub fn kv_table(
|
|||||||
out
|
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
|
// Tables
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -457,40 +402,6 @@ fn pad(s: &str, width: usize) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn kv_table_is_aligned_with_and_without_colors() {
|
fn kv_table_is_aligned_with_and_without_colors() {
|
||||||
let rows = vec![
|
let rows = vec![
|
||||||
|
|||||||
+382
-192
@@ -7,10 +7,13 @@
|
|||||||
//! pass-through to pwsh, cmd, bash, zsh, ... The user's default shell is
|
//! 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.
|
//! detected at startup and highlighted in the banner and the status line.
|
||||||
//!
|
//!
|
||||||
//! Uses rustyline for line editing: Tab completion (commands, agents, aliases,
|
//! Uses reedline (Nushell's line editor) for editing: Nushell-style Tab
|
||||||
//! groups, flags, shell names), command history persisted in the state
|
//! completion — a single match is inserted immediately, several matches open
|
||||||
//! directory, and graceful fallback to plain line input when no terminal is
|
//! an interactive menu under the prompt with the first choice highlighted;
|
||||||
//! available.
|
//! 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::app::App;
|
||||||
use crate::cli::{Command, StartArgs};
|
use crate::cli::{Command, StartArgs};
|
||||||
@@ -23,12 +26,13 @@ use crate::shell::ShellSession;
|
|||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use rustyline::completion::{Completer, Pair};
|
use nu_ansi_term::{Color, Style};
|
||||||
use rustyline::error::ReadlineError;
|
use reedline::{
|
||||||
use rustyline::highlight::Highlighter;
|
default_emacs_keybindings, ColumnarMenu, Completer, Emacs, FileBackedHistory, KeyCode,
|
||||||
use rustyline::hint::Hinter;
|
KeyModifiers, MenuBuilder, MenuTextStyle, Prompt, PromptEditMode, PromptHistorySearch,
|
||||||
use rustyline::validate::Validator;
|
Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion,
|
||||||
use rustyline::{Context, Helper};
|
};
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::io::{IsTerminal, Write};
|
use std::io::{IsTerminal, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -55,9 +59,6 @@ pub struct AmCompleter {
|
|||||||
/// Short descriptions shown next to candidates in the Tab menu
|
/// Short descriptions shown next to candidates in the Tab menu
|
||||||
/// (Nushell-style).
|
/// (Nushell-style).
|
||||||
descriptions: BTreeMap<String, String>,
|
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.
|
/// One-line descriptions for the Tab completion menu.
|
||||||
@@ -142,15 +143,9 @@ impl AmCompleter {
|
|||||||
}
|
}
|
||||||
completer.agents.sort();
|
completer.agents.sort();
|
||||||
completer.agents.dedup();
|
completer.agents.dedup();
|
||||||
completer.theme.set(app.theme());
|
|
||||||
completer
|
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(
|
pub fn from_catalog(
|
||||||
catalog: &crate::catalog::Catalog,
|
catalog: &crate::catalog::Catalog,
|
||||||
aliases: &BTreeMap<String, String>,
|
aliases: &BTreeMap<String, String>,
|
||||||
@@ -192,7 +187,6 @@ impl AmCompleter {
|
|||||||
descriptions.insert(a.name.clone(), truncate_desc(&desc));
|
descriptions.insert(a.name.clone(), truncate_desc(&desc));
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
theme: std::cell::Cell::new(crate::theme::default_theme()),
|
|
||||||
commands: vec![
|
commands: vec![
|
||||||
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update",
|
"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",
|
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||||
@@ -329,147 +323,186 @@ impl AmCompleter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Completer for AmCompleter {
|
impl Completer for AmCompleter {
|
||||||
type Candidate = Pair;
|
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
|
||||||
|
|
||||||
fn complete(
|
|
||||||
&self,
|
|
||||||
line: &str,
|
|
||||||
pos: usize,
|
|
||||||
_ctx: &Context<'_>,
|
|
||||||
) -> rustyline::Result<(usize, Vec<Pair>)> {
|
|
||||||
let (start, cands) = self.candidates_for(line, pos);
|
let (start, cands) = self.candidates_for(line, pos);
|
||||||
let pairs = if cands.len() < 2 {
|
cands
|
||||||
// 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()
|
.into_iter()
|
||||||
.enumerate()
|
.map(|c| Suggestion {
|
||||||
.map(|(i, line)| {
|
value: c.clone(),
|
||||||
let replacement = if i >= data_start && i < data_start + n {
|
description: self.descriptions.get(&c).cloned(),
|
||||||
cands[i - data_start].clone()
|
span: Span::new(start, pos),
|
||||||
} else {
|
..Default::default()
|
||||||
lcp.clone()
|
|
||||||
};
|
|
||||||
let display = format!(
|
|
||||||
"{line}{}",
|
|
||||||
" ".repeat(width.saturating_sub(line.chars().count()))
|
|
||||||
);
|
|
||||||
Pair {
|
|
||||||
display,
|
|
||||||
replacement,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Longest common prefix of the candidates (chars, not bytes).
|
// ---------------------------------------------------------------------------
|
||||||
fn common_prefix(items: &[String]) -> String {
|
// Line editor (reedline) and Nushell-style completion menu
|
||||||
let mut iter = items.iter();
|
// ---------------------------------------------------------------------------
|
||||||
let Some(first) = iter.next() else {
|
|
||||||
return String::new();
|
/// The REPL prompt, unchanged from the rustyline era. While a completion
|
||||||
};
|
/// menu is open, reedline replaces the (empty) indicator with the menu
|
||||||
let mut prefix = first.clone();
|
/// marker "| ", like Nushell.
|
||||||
for s in iter {
|
struct AmPrompt;
|
||||||
while !s.starts_with(&prefix) && !prefix.is_empty() {
|
|
||||||
prefix.pop();
|
impl Prompt for AmPrompt {
|
||||||
}
|
fn render_prompt_left(&self) -> Cow<'_, str> {
|
||||||
if prefix.is_empty() {
|
Cow::Borrowed("❯ ")
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Hinter for AmCompleter {
|
fn render_prompt_right(&self) -> Cow<'_, str> {
|
||||||
type Hint = String;
|
Cow::Borrowed("")
|
||||||
|
|
||||||
fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Highlighter for AmCompleter {
|
fn render_prompt_indicator(&self, _mode: PromptEditMode) -> Cow<'_, str> {
|
||||||
/// Style the boxed completion menu: borders dim, the header row in the
|
Cow::Borrowed("")
|
||||||
/// theme header style, candidate names in the accent color.
|
}
|
||||||
fn highlight_candidate<'c>(
|
|
||||||
|
fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
|
||||||
|
Cow::Borrowed(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_prompt_history_search_indicator(
|
||||||
&self,
|
&self,
|
||||||
candidate: &'c str,
|
_search: PromptHistorySearch,
|
||||||
completion: rustyline::CompletionType,
|
) -> Cow<'_, str> {
|
||||||
) -> std::borrow::Cow<'c, str> {
|
Cow::Borrowed("(reverse-i-search)'")
|
||||||
use std::borrow::Cow;
|
}
|
||||||
let _ = completion;
|
}
|
||||||
match candidate.chars().next() {
|
|
||||||
Some('╭') | Some('╰') | Some('├') => Cow::Owned(self.theme.get().frame(candidate)),
|
/// Map an ANSI basic color code (30-37, 90-97) onto the nu-ansi-term
|
||||||
Some('│') => {
|
/// named palette.
|
||||||
let cells: Vec<&str> = candidate.split('│').collect();
|
fn basic_color(n: u8) -> Option<Color> {
|
||||||
if cells.len() >= 4 {
|
match n {
|
||||||
if cells[1].trim() == "name" && cells[2].trim() == "description" {
|
30 => Some(Color::Black),
|
||||||
Cow::Owned(self.theme.get().hdr(candidate))
|
31 => Some(Color::Red),
|
||||||
} else {
|
32 => Some(Color::Green),
|
||||||
let bar = self.theme.get().frame("│");
|
33 => Some(Color::Yellow),
|
||||||
Cow::Owned(format!(
|
34 => Some(Color::Blue),
|
||||||
"{}{}{}{}{}",
|
35 => Some(Color::Purple),
|
||||||
bar,
|
36 => Some(Color::Cyan),
|
||||||
self.theme.get().acc(cells[1]),
|
37 => Some(Color::White),
|
||||||
bar,
|
90 => Some(Color::DarkGray),
|
||||||
cells[2],
|
91 => Some(Color::LightRed),
|
||||||
bar
|
92 => Some(Color::LightGreen),
|
||||||
))
|
93 => Some(Color::LightYellow),
|
||||||
}
|
94 => Some(Color::LightBlue),
|
||||||
} else if cells.len() == 3 {
|
95 => Some(Color::LightPurple),
|
||||||
// Single-column menu (no descriptions, e.g. the theme
|
96 => Some(Color::LightCyan),
|
||||||
// names): paint the bars and the name.
|
97 => Some(Color::LightGray),
|
||||||
let bar = self.theme.get().frame("│");
|
_ => None,
|
||||||
Cow::Owned(format!(
|
}
|
||||||
"{}{}{}",
|
}
|
||||||
bar,
|
|
||||||
self.theme.get().acc(cells[1]),
|
/// Map one theme SGR string ("1;38;5;81") onto a nu-ansi-term Style so the
|
||||||
bar
|
/// completion menu can follow the active theme. Unknown parameters are
|
||||||
))
|
/// ignored; the empty string (mono theme) yields the default style.
|
||||||
} else {
|
fn sgr_to_style(sgr: &str) -> Style {
|
||||||
Cow::Borrowed(candidate)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => Cow::Borrowed(candidate),
|
|
||||||
}
|
}
|
||||||
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Validator for AmCompleter {}
|
|
||||||
impl Helper for AmCompleter {}
|
/// 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)
|
// Banner (Hermes-style)
|
||||||
@@ -998,54 +1031,71 @@ pub fn run(app: &App) -> Result<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
|
fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
|
||||||
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
|
// With piped stdin (tests, scripts, CI) the line editor cannot read
|
||||||
rustyline::Editor::new()?;
|
// console keys — reedline would block on the console instead of the
|
||||||
rl.set_helper(Some(AmCompleter::new(app)));
|
// pipe. Fall back to plain line input (run() handles the fallback).
|
||||||
// Nushell-style completion: a single match is inserted immediately;
|
if !std::io::stdin().is_terminal() {
|
||||||
// otherwise Tab inserts the longest common prefix and the menu lists the
|
return Err(anyhow!("stdin is not a terminal"));
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
|
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 started = Instant::now();
|
||||||
let mut last: Option<DataTable> = None;
|
let mut last: Option<DataTable> = None;
|
||||||
let mut last_duration: Option<Duration> = None;
|
let mut last_duration: Option<Duration> = None;
|
||||||
|
let mut first_read = true;
|
||||||
|
let mut theme: &'static crate::theme::Theme = app.theme();
|
||||||
loop {
|
loop {
|
||||||
print_status(app, started, last_duration);
|
print_status(app, started, last_duration);
|
||||||
match rl.readline("❯ ") {
|
let signal = match rl.read_line(&prompt) {
|
||||||
Ok(line) => {
|
Ok(signal) => signal,
|
||||||
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());
|
|
||||||
}
|
|
||||||
match result {
|
|
||||||
Ok(true) => break,
|
|
||||||
Ok(false) => {}
|
|
||||||
Err(e) => app.log.error(&format!("{e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(ReadlineError::Interrupted) => continue,
|
|
||||||
Err(ReadlineError::Eof) => break,
|
|
||||||
Err(e) => {
|
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}"));
|
app.log.warn(&format!("input error: {e}"));
|
||||||
break;
|
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 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: 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,
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => app.log.error(&format!("{e:#}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = rl.save_history(&history_path);
|
let _ = rl.sync_history();
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1181,7 +1231,10 @@ fn handle_line(
|
|||||||
}
|
}
|
||||||
return Ok(false);
|
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() {
|
if tokens.is_empty() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -1620,8 +1673,7 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
|
|||||||
.collect();
|
.collect();
|
||||||
let mut table: Option<DataTable> = None;
|
let mut table: Option<DataTable> = None;
|
||||||
for part in &parts {
|
for part in &parts {
|
||||||
let tokens =
|
let tokens = split_command(part)?;
|
||||||
shell_words::split(part).map_err(|e| anyhow!("cannot parse command: {e}"))?;
|
|
||||||
if tokens.is_empty() {
|
if tokens.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1681,6 +1733,52 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
|
|||||||
Ok(false)
|
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).
|
/// Run one system command through the active shell (inherited stdio).
|
||||||
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
|
||||||
app.log.cmd(&format!("{}: {line}", session.current.name));
|
app.log.cmd(&format!("{}: {line}", session.current.name));
|
||||||
@@ -1977,18 +2075,79 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completion_menu_paints_single_column_bars() {
|
fn completer_produces_suggestions_with_spans() {
|
||||||
let c = completer();
|
// The reedline completer maps (start, candidates) onto suggestions
|
||||||
c.set_theme(crate::theme::find("ocean").unwrap());
|
// whose span covers exactly the typed word.
|
||||||
let row = "│ ocean │";
|
let mut c = completer();
|
||||||
let out = c.highlight_candidate(row, rustyline::CompletionType::List);
|
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!(
|
assert!(
|
||||||
out.contains("\x1b[38;5;75m"),
|
suggs.iter().any(|s| s.value == expected),
|
||||||
"bars not painted with the frame color: {out}"
|
"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!(
|
assert!(
|
||||||
out.contains("\x1b[1;38;5;81m"),
|
ts.selected_text_style.is_reverse,
|
||||||
"name not painted with the accent color: {out}"
|
"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.ends_with("here"));
|
||||||
assert!(s.chars().count() <= 9);
|
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"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user