Modifications en cours
This commit is contained in:
parent
4562484f51
commit
8d6f5fbf1c
28 changed files with 1667 additions and 236 deletions
|
|
@ -1,13 +1,13 @@
|
||||||
---
|
---
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
# MODÈLE D'AGENT MAIA — créé par /newAgent
|
# MODÈLE D'AGENT MAIA — créé par /createAgent
|
||||||
# Modifiez ce fichier : changez la description et les trois sections du bas.
|
# Modifiez ce fichier : changez la description et les trois sections du bas.
|
||||||
# Les lignes qui commencent par # dans l'en-tête sont des commentaires.
|
# Les lignes qui commencent par # dans l'en-tête sont des commentaires.
|
||||||
# Pour activer une propriété facultative (exemples collés au #), supprimez simplement le # au début de sa ligne.
|
# Pour activer une propriété facultative (exemples collés au #), supprimez simplement le # au début de sa ligne.
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# name (OBLIGATOIRE) — le nom de l'agent. Il doit être identique au nom du dossier.
|
# name (OBLIGATOIRE) — le nom de l'agent. Il doit être identique au nom du dossier.
|
||||||
# /newAgent le renseigne pour vous : ne le modifiez pas, sauf si vous renommez aussi le dossier.
|
# /createAgent le renseigne pour vous : ne le modifiez pas, sauf si vous renommez aussi le dossier.
|
||||||
name: code_reviewer
|
name: code_reviewer
|
||||||
|
|
||||||
# description (OBLIGATOIRE) — une phrase courte : à quoi sert l'agent, et quand NE PAS l'utiliser.
|
# description (OBLIGATOIRE) — une phrase courte : à quoi sert l'agent, et quand NE PAS l'utiliser.
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,8 @@ MAIA découpe la commande aux séparateurs `&&`, `||`, `;`, `|` et aux retours
|
||||||
|
|
||||||
- Pour **`allow`** : **tous** les morceaux doivent être couverts par une règle. Un seul morceau non couvert, et MAIA redemande confirmation.
|
- Pour **`allow`** : **tous** les morceaux doivent être couverts par une règle. Un seul morceau non couvert, et MAIA redemande confirmation.
|
||||||
- Pour **`deny` et `ask`** : **un seul** morceau qui correspond suffit. Le découpage est même plus fin : il coupe aussi aux `$(…)`, aux backticks, aux parenthèses et aux redirections, pour retrouver une commande cachée dans `echo $(rm -rf x)`.
|
- Pour **`deny` et `ask`** : **un seul** morceau qui correspond suffit. Le découpage est même plus fin : il coupe aussi aux `$(…)`, aux backticks, aux parenthèses et aux redirections, pour retrouver une commande cachée dans `echo $(rm -rf x)`.
|
||||||
- Une commande qui contient une substitution (`$(…)`, backticks, `<(…)`) ou une redirection (`>`, `>>`) n'est **jamais** autorisée automatiquement par un `allow` : MAIA demande toujours.
|
- Une commande qui contient une substitution (`$(…)`, backticks, `<(…)`, `>(…)`) n'est **jamais** autorisée automatiquement par un `allow` : MAIA demande toujours.
|
||||||
|
- Une commande qui contient une **redirection de sortie** (`>`, `>>`, `2>`, `&>`…) n'est autorisée que si le fichier visé l'est aussi par une règle `Redirect` (§ 8). Sans règle `Redirect`, MAIA demande toujours.
|
||||||
|
|
||||||
### 4.2 Écrire un motif
|
### 4.2 Écrire un motif
|
||||||
|
|
||||||
|
|
@ -112,7 +113,8 @@ Résultats :
|
||||||
| `ls -la src` | autorisée |
|
| `ls -la src` | autorisée |
|
||||||
| `lsof` | confirmation (autre commande) |
|
| `lsof` | confirmation (autre commande) |
|
||||||
| `ls && rm -rf x` | confirmation (le morceau `rm -rf x` n'est pas couvert) |
|
| `ls && rm -rf x` | confirmation (le morceau `rm -rf x` n'est pas couvert) |
|
||||||
| `ls > sortie.txt` | confirmation (redirection) |
|
| `ls > sortie.txt` | confirmation (redirection vers un fichier, voir § 8) |
|
||||||
|
| `ls 2>&1` | autorisée (duplication de flux, aucun fichier) |
|
||||||
| `ls $(pwd)` | confirmation (substitution) |
|
| `ls $(pwd)` | confirmation (substitution) |
|
||||||
|
|
||||||
Pour autoriser plusieurs commandes de lecture :
|
Pour autoriser plusieurs commandes de lecture :
|
||||||
|
|
@ -198,17 +200,96 @@ Pour **demander confirmation** plutôt qu'interdire (ex. `git push`), utilisez `
|
||||||
|
|
||||||
Même avec `"Bash(git *)"` en `allow`, ces commandes redemandent confirmation.
|
Même avec `"Bash(git *)"` en `allow`, ces commandes redemandent confirmation.
|
||||||
|
|
||||||
## 8. Limites : `deny` n'est pas un bac à sable
|
## 8. Redirections (`>`, `>>`, `2>`, `&>`…)
|
||||||
|
|
||||||
|
Une redirection de sortie écrit dans un fichier **sans passer par l'outil `Write`**. Elle est donc contrôlée à part, avec des règles **`Redirect(chemin)`** (ou `Redirect` seul pour « toutes les redirections »). Le chemin suit la même syntaxe que `Read`/`Write` : relatif au dossier de lancement, `*` et `**` acceptés, un dossier couvre son contenu.
|
||||||
|
|
||||||
|
### 8.1 Comportement par défaut : on demande
|
||||||
|
|
||||||
|
Tant qu'aucune règle `Redirect` ne les autorise, les redirections vers un fichier **repassent en confirmation**, même si la commande elle-même est autorisée :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "permissions": { "allow": ["Bash(ls *)"] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
| Commande | Décision |
|
||||||
|
| --- | --- |
|
||||||
|
| `ls` | autorisée |
|
||||||
|
| `ls > out.txt` · `ls >> out.txt` · `ls 2> err.txt` · `ls &> all.txt` | confirmation |
|
||||||
|
| `ls 2>&1` · `ls >&2` | autorisées : ce sont des **duplications de flux**, elles n'écrivent dans aucun fichier |
|
||||||
|
| `echo "a > b"` (avec `Bash(echo *)`) | autorisée : un `>` entre guillemets n'est pas une redirection |
|
||||||
|
|
||||||
|
C'est la configuration la plus sûre : rien à faire pour **ne pas** autoriser les redirections.
|
||||||
|
|
||||||
|
### 8.2 Autoriser certaines redirections
|
||||||
|
|
||||||
|
Ajoutez des règles `Redirect(...)` dans `allow`. Le fichier visé doit être couvert **et** la commande doit être autorisée par ailleurs (`Redirect` seul n'autorise aucune commande) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ls *)", "Bash(npm run build *)",
|
||||||
|
"Redirect(/dev/null)",
|
||||||
|
"Redirect(logs/**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Commande | Décision |
|
||||||
|
| --- | --- |
|
||||||
|
| `ls > /dev/null` · `ls 2>/dev/null` | autorisées |
|
||||||
|
| `npm run build >> logs/build.log 2>&1` | autorisée |
|
||||||
|
| `ls > src/main.ts` | confirmation (fichier non couvert) |
|
||||||
|
| `ls > logs/a.log > src/x` | confirmation : **toutes** les cibles doivent être couvertes |
|
||||||
|
| `cat f > logs/a.log` | confirmation : `cat` n'est pas autorisé |
|
||||||
|
| `ls > logs/*.txt` · `ls > $HOME/x` · `ls > logs/../src/a` | confirmation : une cible qui dépend du shell (`*`, `?`, `[ ]`, `{ }`, `$`) n'est jamais considérée comme couverte |
|
||||||
|
|
||||||
|
Pour autoriser **toutes** les redirections (à réserver à un environnement jetable ou combiné à des `deny`) : `"Redirect"`.
|
||||||
|
|
||||||
|
### 8.3 Interdire ou toujours demander
|
||||||
|
|
||||||
|
`deny` et `ask` s'appliquent même si la commande est autorisée, et même avec `"Bash"` complet dans `allow` :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": ["Bash", "Redirect"],
|
||||||
|
"deny": ["Redirect(.env)", "Redirect(.git/**)", "Redirect(~/**)"],
|
||||||
|
"ask": ["Redirect(docs/**)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Commande | Décision |
|
||||||
|
| --- | --- |
|
||||||
|
| `echo x > .env` | refusée |
|
||||||
|
| `echo x >> .git/config` | refusée |
|
||||||
|
| `echo x >> ~/.bashrc` | refusée (le `~` est comparé tel qu'il est écrit : le motif `~/**` le reconnaît) |
|
||||||
|
| `echo y 2> .env` (placé dans une suite `a; b`) | refusée |
|
||||||
|
| `echo x > docs/a.md` | confirmation |
|
||||||
|
| `echo x > notes.txt` | autorisée |
|
||||||
|
|
||||||
|
### 8.4 À savoir
|
||||||
|
|
||||||
|
- `Redirect` ne concerne que les redirections de **sortie**. Une redirection d'entrée (`< fichier`) lit un fichier : elle n'est pas contrôlée (utilisez `Bash(cat .env)` ou `Read(.env)` en `deny` pour protéger une lecture).
|
||||||
|
- Une redirection sans commande (`> fichier`, qui vide un fichier), un guillemet non fermé ou une cible absente : confirmation.
|
||||||
|
- Les substitutions (`> $(…)`, `>(…)`) sont toujours en confirmation.
|
||||||
|
- D'autres commandes écrivent sans `>` : `tee`, `cp`, `mv`, `dd of=…`, `sed -i`. Autorisez-les (ou interdisez-les) explicitement avec des règles `Bash(...)`, par exemple `deny: ["Bash(tee *)", "Bash(* tee *)"]`.
|
||||||
|
- Les règles `Redirect` vont dans les mêmes fichiers `settings.json` que les autres et s'additionnent comme elles. Un sous-agent peut en ajouter dans la propriété `permissions` de son `AGENT.md`.
|
||||||
|
|
||||||
|
## 9. Limites : `deny` n'est pas un bac à sable
|
||||||
|
|
||||||
Les règles analysent le **texte** de la commande. Elles ne peuvent pas tout voir :
|
Les règles analysent le **texte** de la commande. Elles ne peuvent pas tout voir :
|
||||||
|
|
||||||
- `find . -delete`, `git clean -fd`, `truncate`, `mv x /dev/null`, `python -c "import os; os.remove('f')"` ou `perl -e …` suppriment des fichiers **sans** écrire `rm`. Ajoutez les règles correspondantes si elles vous importent (`Bash(find * -delete*)`, `Bash(git clean *)`), ou n'autorisez que les commandes dont vous avez besoin (liste `allow` courte) et laissez le reste en confirmation.
|
- `find . -delete`, `git clean -fd`, `truncate`, `mv x /dev/null`, `python -c "import os; os.remove('f')"` ou `perl -e …` suppriment des fichiers **sans** écrire `rm`. Ajoutez les règles correspondantes si elles vous importent (`Bash(find * -delete*)`, `Bash(git clean *)`), ou n'autorisez que les commandes dont vous avez besoin (liste `allow` courte) et laissez le reste en confirmation.
|
||||||
- Une commande lancée depuis un script (`bash script.sh`) n'est pas analysée : le contenu du script est invisible.
|
- Une commande lancée depuis un script (`bash script.sh`) n'est pas analysée : le contenu du script est invisible.
|
||||||
- Les `deny` sur les outils de fichiers (`Read(.env)`, `Write(.git/**)`) ne concernent que ces outils : `Bash(cat .env)` est une autre règle, à ajouter si besoin.
|
- Les `deny` sur les outils de fichiers (`Read(.env)`, `Write(.git/**)`) ne concernent que ces outils : `Bash(cat .env)` ou une redirection `> .env` sont d'autres règles (`Bash(...)`, `Redirect(...)`), à ajouter si besoin.
|
||||||
|
|
||||||
Approche conseillée : peu de `allow` (lecture seule, tests, build), quelques `ask` (publication, écriture hors projet), des `deny` pour ce qui ne doit jamais arriver (`rm`, `sudo`, `curl … | sh`), et **laisser le défaut** (confirmation) pour tout le reste.
|
Approche conseillée : peu de `allow` (lecture seule, tests, build), quelques `ask` (publication, écriture hors projet), des `deny` pour ce qui ne doit jamais arriver (`rm`, `sudo`, `curl … | sh`), et **laisser le défaut** (confirmation) pour tout le reste.
|
||||||
|
|
||||||
## 9. Sous-agents
|
## 10. Sous-agents
|
||||||
|
|
||||||
Un sous-agent hérite des permissions de l'agent principal. Sa définition (`AGENT.md`) peut en **ajouter** :
|
Un sous-agent hérite des permissions de l'agent principal. Sa définition (`AGENT.md`) peut en **ajouter** :
|
||||||
|
|
||||||
|
|
@ -220,7 +301,23 @@ permissions:
|
||||||
|
|
||||||
Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé par un sous-agent. Quand un sous-agent a besoin d'une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]`.
|
Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé par un sous-agent. Quand un sous-agent a besoin d'une confirmation, la question s'affiche dans l'interface principale, préfixée `[sous-agent nom]`.
|
||||||
|
|
||||||
## 10. Aide-mémoire
|
## 11. Serveurs MCP : `Mcp(serveur[,outil])`
|
||||||
|
|
||||||
|
Les serveurs MCP sont déclarés une fois pour toutes dans `~/.maia/mcp.json` (voir le README) ; **aucun n'est actif tant qu'une règle `allow` ne l'autorise**. Syntaxe : `Mcp(clé du serveur)` ou `Mcp(clé du serveur,outil)`.
|
||||||
|
|
||||||
|
| Règle | Effet |
|
||||||
|
| --- | --- |
|
||||||
|
| `allow: ["Mcp(github)"]` | active `github` ; tous ses outils sont autorisés sans confirmation |
|
||||||
|
| `deny: ["Mcp(github,delete_repository)"]` | l'outil n'est jamais proposé au modèle (`*` accepté : `Mcp(github,delete_*)`) |
|
||||||
|
| `ask: ["Mcp(github,merge_pull_request)"]` | cet outil demande confirmation malgré le `allow` du serveur |
|
||||||
|
| `allow: ["Mcp(github)"]` + `deny: ["Mcp(github,*)"]` | serveur **désactivé** : MAIA ne tente pas de s'y connecter |
|
||||||
|
| `allow: ["Mcp(*)"]` (= `Mcp(*,*)`) | active **tous** les serveurs déclarés dans `mcp.json`, tous leurs outils autorisés |
|
||||||
|
| `deny: ["Mcp(*,delete_repository)"]` | retire cet outil (motif accepté) sur tous les serveurs ; en `ask` : confirmation partout |
|
||||||
|
| `allow: ["Mcp(github,list_issues)"]` | active `github` ; seul cet outil est autorisé, les autres demandent confirmation |
|
||||||
|
|
||||||
|
Ces règles sont lues **avant** toute connexion ; deny > ask > allow, comme pour les autres outils. Mettre `allow` dans `~/.maia/settings.json` active le serveur pour tous les projets.
|
||||||
|
|
||||||
|
## 12. Aide-mémoire
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -230,6 +327,7 @@ Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé p
|
||||||
"Edit(src/**)",
|
"Edit(src/**)",
|
||||||
"Bash(ls *)", "Bash(git status)", "Bash(git diff *)",
|
"Bash(ls *)", "Bash(git status)", "Bash(git diff *)",
|
||||||
"Bash(npm run test:*)",
|
"Bash(npm run test:*)",
|
||||||
|
"Redirect(/dev/null)", "Redirect(logs/**)",
|
||||||
"WebFetch(domain:docs.python.org)"
|
"WebFetch(domain:docs.python.org)"
|
||||||
],
|
],
|
||||||
"ask": [
|
"ask": [
|
||||||
|
|
@ -238,7 +336,8 @@ Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé p
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(rm *)", "Bash(* rm *)",
|
"Bash(rm *)", "Bash(* rm *)",
|
||||||
"Bash(sudo *)", "Bash(* sudo *)",
|
"Bash(sudo *)", "Bash(* sudo *)",
|
||||||
"Read(.env)", "Write(.git/**)"
|
"Read(.env)", "Write(.git/**)",
|
||||||
|
"Redirect(.env)", "Redirect(.git/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -252,5 +351,8 @@ Les règles s'additionnent : un `deny` ou un `ask` hérité n'est jamais levé p
|
||||||
| autoriser une commande avec tube | une règle **par morceau** (§ 6) |
|
| autoriser une commande avec tube | une règle **par morceau** (§ 6) |
|
||||||
| toujours confirmer une commande | `ask: ["Bash(git push *)"]` |
|
| toujours confirmer une commande | `ask: ["Bash(git push *)"]` |
|
||||||
| interdire une commande partout, tubes compris | `deny: ["Bash(rm *)", "Bash(* rm *)"]` |
|
| interdire une commande partout, tubes compris | `deny: ["Bash(rm *)", "Bash(* rm *)"]` |
|
||||||
|
| autoriser les redirections vers `/dev/null` et `logs/` | `allow: ["Bash(ls *)", "Redirect(/dev/null)", "Redirect(logs/**)"]` (§ 8.2) |
|
||||||
|
| ne pas autoriser les redirections | ne rien ajouter : elles demandent confirmation (§ 8.1) |
|
||||||
|
| interdire d'écrire dans un fichier par `>` | `deny: ["Redirect(.env)"]` (§ 8.3) |
|
||||||
| protéger un fichier | `deny: ["Read(.env)", "Write(.env)", "Edit(.env)"]` |
|
| protéger un fichier | `deny: ["Read(.env)", "Write(.env)", "Edit(.env)"]` |
|
||||||
| limiter les sites web | `allow: ["WebFetch(domain:docs.python.org)"]` |
|
| limiter les sites web | `allow: ["WebFetch(domain:docs.python.org)"]` |
|
||||||
|
|
|
||||||
0
MAIA.md
0
MAIA.md
60
README.md
60
README.md
|
|
@ -59,8 +59,9 @@ Spécifications techniques détaillées (contrats, formats, constantes) : [`SPEC
|
||||||
| `/permissions` | affiche les règles de permissions chargées, par fichier : badges `ALLOW`, `ASK`, `DENY` suivis des règles |
|
| `/permissions` | affiche les règles de permissions chargées, par fichier : badges `ALLOW`, `ASK`, `DENY` suivis des règles |
|
||||||
| `/model` | recharge la liste des modèles LiteLLM et change de modèle (conversation conservée) |
|
| `/model` | recharge la liste des modèles LiteLLM et change de modèle (conversation conservée) |
|
||||||
| `/clear` | supprime le contexte de la session et recharge `MAIA.md` / `CLAUDE.md` et les skills, comme au démarrage (la session garde son nom) |
|
| `/clear` | supprime le contexte de la session et recharge `MAIA.md` / `CLAUDE.md` et les skills, comme au démarrage (la session garde son nom) |
|
||||||
| `/newAgent [nom]` | crée un sous-agent depuis le modèle `.ressources/AGENT.md` : demande le niveau (projet `./.maia/agents` ou global `~/.maia/agents`) puis le nom |
|
| `/createAgent [nom]` | crée un sous-agent depuis le modèle `.ressources/AGENT.md` : demande le niveau (projet `./.maia/agents` ou global `~/.maia/agents`) puis le nom |
|
||||||
| `/agents` | liste les sous-agents chargés (nom, origine, description) |
|
| `/agents` | liste les sous-agents chargés (nom, origine, description) |
|
||||||
|
| `/mcp` | liste les serveurs MCP activés : `OK` (vert) ou `FAIL` (rouge) |
|
||||||
| `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») |
|
| `/compact` | résume la conversation et repart de ce résumé (voir « Compactage ») |
|
||||||
| `/rename <nom>` · `/load [nom]` · `/new` | sessions (voir plus bas) |
|
| `/rename <nom>` · `/load [nom]` · `/new` | sessions (voir plus bas) |
|
||||||
| `/exit` (ou Ctrl+D) | quitte (la session est sauvegardée) |
|
| `/exit` (ou Ctrl+D) | quitte (la session est sauvegardée) |
|
||||||
|
|
@ -260,7 +261,7 @@ Format d'une règle : `Outil` (tous les appels de l'outil) ou `Outil(motif)` :
|
||||||
|
|
||||||
Précisions sur `Bash` :
|
Précisions sur `Bash` :
|
||||||
- commande composée (`&&`, `;`, `|`) : un `deny` ou un `ask` s'applique dès qu'**une** des parties correspond, y compris cachée dans `$(…)`, des backticks ou des parenthèses ; un `allow` ne s'applique que si **toutes** les parties sont couvertes ;
|
- commande composée (`&&`, `;`, `|`) : un `deny` ou un `ask` s'applique dès qu'**une** des parties correspond, y compris cachée dans `$(…)`, des backticks ou des parenthèses ; un `allow` ne s'applique que si **toutes** les parties sont couvertes ;
|
||||||
- un `allow` ne couvre jamais une commande avec redirection (`>`) ou substitution (`$(…)`, backticks) : elle repasse en `ask`.
|
- un `allow` ne couvre jamais une commande avec substitution (`$(…)`, backticks) : elle repasse en `ask` ; une redirection de sortie (`>`, `>>`, `2>`, `&>`) repasse en `ask` sauf si le fichier visé est autorisé par une règle `Redirect(chemin)` (`2>&1` n'écrit dans aucun fichier : sans effet). `deny: ["Redirect(.env)"]` interdit d'écrire dans ce fichier par redirection, même avec `Bash` autorisé. Détails : `GUIDE_PERMISSIONS.md` § 8.
|
||||||
|
|
||||||
Au démarrage, MAIA indique (`INFO`) les fichiers chargés et le nombre de règles, ou qu'aucune règle n'est définie ; un JSON invalide ou une règle mal formée donne un `WARN` et est ignoré. Les `deny` sont du « meilleur effort » : ils ne remplacent pas un vrai bac à sable. Les commandes `!` et les références `@fichier` tapées par l'utilisateur ne passent pas par ces règles.
|
Au démarrage, MAIA indique (`INFO`) les fichiers chargés et le nombre de règles, ou qu'aucune règle n'est définie ; un JSON invalide ou une règle mal formée donne un `WARN` et est ignoré. Les `deny` sont du « meilleur effort » : ils ne remplacent pas un vrai bac à sable. Les commandes `!` et les références `@fichier` tapées par l'utilisateur ne passent pas par ces règles.
|
||||||
|
|
||||||
|
|
@ -344,7 +345,7 @@ Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `star
|
||||||
|
|
||||||
MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle.
|
MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle.
|
||||||
|
|
||||||
Définition : un dossier par agent, avec un fichier `AGENT.md`, dans `./.maia/agents/<nom>/` (dossier de lancement) ou `~/.maia/agents/<nom>/` (utilisateur) ; le projet l'emporte sur l'utilisateur à nom égal. **Créer un agent** : `/newAgent` (ou `/newAgent mon_agent`) demande le niveau (Projet / Global) et le nom, puis copie le modèle `.ressources/AGENT.md` dans `<dossier agents>/<nom>/AGENT.md` en y mettant le bon `name:` (jamais d'écrasement d'un agent existant). Le modèle explique chaque propriété en commentaires (`#`) ; les propriétés facultatives y sont commentées, supprimez le `#` pour les activer. Tapez ensuite `/clear` pour charger le nouvel agent. Le dossier `.ressources` est celui de MAIA (racine du projet MAIA, ou `MAIA_RESSOURCES`), pas celui de votre projet. Les commentaires `# …` en fin de ligne de l'en-tête sont acceptés (mettez la valeur entre guillemets si elle contient ` #`).
|
Définition : un dossier par agent, avec un fichier `AGENT.md`, dans `./.maia/agents/<nom>/` (dossier de lancement) ou `~/.maia/agents/<nom>/` (utilisateur) ; le projet l'emporte sur l'utilisateur à nom égal. **Créer un agent** : `/createAgent` (ou `/createAgent mon_agent`) demande le niveau (Projet / Global) et le nom, puis copie le modèle `.ressources/AGENT.md` dans `<dossier agents>/<nom>/AGENT.md` en y mettant le bon `name:` (jamais d'écrasement d'un agent existant). Le modèle explique chaque propriété en commentaires (`#`) ; les propriétés facultatives y sont commentées, supprimez le `#` pour les activer. Tapez ensuite `/clear` pour charger le nouvel agent. Le dossier `.ressources` est celui de MAIA (racine du projet MAIA, ou `MAIA_RESSOURCES`), pas celui de votre projet. Les commentaires `# …` en fin de ligne de l'en-tête sont acceptés (mettez la valeur entre guillemets si elle contient ` #`).
|
||||||
|
|
||||||
Les agents chargés sont listés au démarrage (« Agents chargés : ») et avec `/agents`.
|
Les agents chargés sont listés au démarrage (« Agents chargés : ») et avec `/agents`.
|
||||||
|
|
||||||
|
|
@ -384,10 +385,57 @@ Pour chaque problème : catégorie (CRITICAL / HIGH / MEDIUM / LOW), description
|
||||||
- **Fin de mission** : un message INFO est affiché dans l'interface principale (durée, nombre d'appels d'outils) ; un échec est signalé par un avertissement et renvoyé à l'agent principal comme erreur d'outil. Délai maximal : 30 minutes.
|
- **Fin de mission** : un message INFO est affiché dans l'interface principale (durée, nombre d'appels d'outils) ; un échec est signalé par un avertissement et renvoyé à l'agent principal comme erreur d'outil. Délai maximal : 30 minutes.
|
||||||
- **Trace** (`trace: true`) : la conversation du sous-agent est enregistrée dans `./.maia/.sessions/` sous le nom `{session de l'agent principal}-{agent}`, ex. `session_2026-10-20_00_01_53-agent_1.json` (`_2`, `_3`… si le nom est pris). Sans `trace`, rien n'est enregistré.
|
- **Trace** (`trace: true`) : la conversation du sous-agent est enregistrée dans `./.maia/.sessions/` sous le nom `{session de l'agent principal}-{agent}`, ex. `session_2026-10-20_00_01_53-agent_1.json` (`_2`, `_3`… si le nom est pris). Sans `trace`, rien n'est enregistré.
|
||||||
|
|
||||||
|
## Serveurs MCP (distants)
|
||||||
|
|
||||||
|
MAIA peut utiliser les outils de serveurs **MCP** accessibles par une URL (Streamable HTTP, ou l'ancien transport SSE). Pas d'OAuth ; seuls les **outils** MCP sont utilisés (pas les ressources ni les prompts).
|
||||||
|
|
||||||
|
**1. Déclarer les serveurs (global uniquement)** : `~/.maia/mcp.json`. La clé est le nom du serveur.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"github": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://api.githubcopilot.com/mcp/",
|
||||||
|
"headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" },
|
||||||
|
"excludeTools": ["delete_repository"]
|
||||||
|
},
|
||||||
|
"docs": { "url": "https://docs.example.com/mcp", "timeout": 60 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Propriété | Rôle |
|
||||||
|
| --- | --- |
|
||||||
|
| `url` | obligatoire (`http://` ou `https://`) |
|
||||||
|
| `type` | `http` (défaut, Streamable HTTP) ou `sse` (ancien transport) |
|
||||||
|
| `headers` | en-têtes HTTP (jetons, clés d'API) |
|
||||||
|
| `timeout` | secondes, défaut 30 : connexion et chaque appel |
|
||||||
|
| `includeTools` / `excludeTools` | liste de noms d'outils (`*` accepté) retenus / retirés |
|
||||||
|
| `enabled` | `false` : le serveur est ignoré |
|
||||||
|
| `description` | commentaire libre |
|
||||||
|
|
||||||
|
`${VARIABLE}` est remplacé par la variable d'environnement (dans `url` et `headers`) ; une variable absente donne un avertissement et une valeur vide.
|
||||||
|
|
||||||
|
**2. Activer par projet** : **aucun serveur n'est actif par défaut**. Il faut l'autoriser dans le `settings.json` du projet :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "permissions": { "allow": ["Mcp(github)"], "deny": ["Mcp(github,delete_repository)"] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Mcp(serveur)` : active le serveur, tous ses outils sont autorisés sans confirmation ;
|
||||||
|
- `Mcp(serveur,outil)` : un outil (`*` accepté, ex. `Mcp(github,delete_*)`) ; en `deny` l'outil n'est jamais proposé au modèle ; en `ask` il demande confirmation ;
|
||||||
|
- `Mcp(*)` (= `Mcp(*,*)`) : tous les serveurs déclarés et tous leurs outils ; `Mcp(*,outil)` : cet outil sur tous les serveurs ;
|
||||||
|
- `allow: ["Mcp(github)"]` + `deny: ["Mcp(github,*)"]` : serveur **désactivé**, MAIA ne cherche pas à s'y connecter.
|
||||||
|
|
||||||
|
Les permissions sont traitées **avant** toute connexion ; seuls les serveurs activés sont ensuite contactés (en parallèle). Un serveur en échec n'empêche pas MAIA de démarrer : avertissement, état `FAIL`.
|
||||||
|
|
||||||
|
**3. Utilisation** : les outils s'appellent `mcp__serveur__outil`. `/mcp` liste les serveurs **activés** : bandeau vert `OK` s'il est connecté, bandeau rouge `FAIL` (avec la cause en grisé) sinon. Les connexions sont coupées à la sortie de MAIA (`/exit`, Ctrl+D, SIGTERM/SIGHUP) et refaites à chaque `/clear`, `/new`, `/load`, `/model` et `/compact`. Les sous-agents n'utilisent pas les outils MCP.
|
||||||
|
|
||||||
## Reste à faire
|
## Reste à faire
|
||||||
|
|
||||||
- Provider LiteLLM : validation sur un vrai serveur (streaming SSE et appels d'outils testés seulement sur des flux simulés) ; compatibilité de `@inkjs/ui` avec Ink 7 à confirmer à l'usage (l'affichage des badges a été vu fonctionner par l'utilisateur).
|
- Provider LiteLLM : validation sur un vrai serveur (streaming SSE et appels d'outils testés seulement sur des flux simulés) ; compatibilité de `@inkjs/ui` avec Ink 7 à confirmer à l'usage (l'affichage des badges a été vu fonctionner par l'utilisateur).
|
||||||
- Agent : hooks, serveurs MCP ; sous-agents : `reasoning_effort` du modèle non utilisé (l'effort borne les étapes), pas de sous-agent imbriqué.
|
- Agent : hooks ; MCP : OAuth, ressources/prompts, outils MCP pour les sous-agents ; sous-agents : `reasoning_effort` du modèle non utilisé (l'effort borne les étapes), pas de sous-agent imbriqué.
|
||||||
- Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : variables d'environnement et slash-commandes personnalisées (le code des commandes existe encore dans `packages/agent/src/commands.ts`, il n'est plus appelé).
|
- Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : variables d'environnement et slash-commandes personnalisées (le code des commandes existe encore dans `packages/agent/src/commands.ts`, il n'est plus appelé).
|
||||||
- `@fichier` dans le corps d'une commande personnalisée ; autocomplétion des chemins `@` à la saisie.
|
- `@fichier` dans le corps d'une commande personnalisée ; autocomplétion des chemins `@` à la saisie.
|
||||||
- Liste à cocher (choix multiples) : n'existe pas encore ; elle recevrait le badge `ASK`.
|
- Liste à cocher (choix multiples) : n'existe pas encore ; elle recevrait le badge `ASK`.
|
||||||
|
|
@ -441,4 +489,6 @@ Journal des évolutions, dans l'ordre :
|
||||||
40. **Guide des permissions** : `GUIDE_PERMISSIONS.md` (syntaxe, exemples simples et complexes, interdiction d'une commande cachée dans un tube, limites).
|
40. **Guide des permissions** : `GUIDE_PERMISSIONS.md` (syntaxe, exemples simples et complexes, interdiction d'une commande cachée dans un tube, limites).
|
||||||
41. **Signature dans la bannière** : « Arnaud VINCENT (avec ClaudeCode) » sous « Un seul agent, plusieurs modèles » (ligne séparée par un espace ; nom en #EEF527, ClaudeCode en #F57D27).
|
41. **Signature dans la bannière** : « Arnaud VINCENT (avec ClaudeCode) » sous « Un seul agent, plusieurs modèles » (ligne séparée par un espace ; nom en #EEF527, ClaudeCode en #F57D27).
|
||||||
42. **Lancement direct d'un sous-agent** : une saisie qui commence par `&` (`&Agent_1 tâche…`) exécute le sous-agent sans passer par le LLM ; son résumé est affiché et ajouté au contexte.
|
42. **Lancement direct d'un sous-agent** : une saisie qui commence par `&` (`&Agent_1 tâche…`) exécute le sous-agent sans passer par le LLM ; son résumé est affiché et ajouté au contexte.
|
||||||
43. **`/newAgent`** : création guidée d'un sous-agent (niveau projet ou global) à partir du modèle commenté `.ressources/AGENT.md` ; commentaires de fin de ligne acceptés dans l'en-tête de `AGENT.md`, commentaires HTML des sections ignorés.
|
43. **`/createAgent`** (ex-`/newAgent`) : création guidée d'un sous-agent (niveau projet ou global) à partir du modèle commenté `.ressources/AGENT.md` ; commentaires de fin de ligne acceptés dans l'en-tête de `AGENT.md`, commentaires HTML des sections ignorés.
|
||||||
|
44. **Règles `Redirect`** : autoriser, demander ou interdire les redirections de sortie (`>`, `>>`, `2>`, `&>`) fichier par fichier ; sans règle, elles demandent confirmation ; `2>&1` et les `>` entre guillemets ne comptent pas ; guide mis à jour (§ 8).
|
||||||
|
45. **Serveurs MCP distants** : déclaration globale `~/.maia/mcp.json` (expansion `${VAR}`, `type` http/sse, `headers`, `timeout`, `includeTools`/`excludeTools`), aucun serveur actif par défaut, activation par `Mcp(serveur)` dans le `settings.json`, `Mcp(serveur,outil)` pour retirer/confirmer un outil, `Mcp(serveur,*)` en `deny` désactive sans connexion ; connexion parallèle après traitement des permissions ; outils `mcp__serveur__outil` ; commande `/mcp` (bandeaux `OK` vert / `FAIL` rouge).
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ MAIA est un assistant IA en ligne de commande (CLI) :
|
||||||
- l'agent lit, cherche, modifie des fichiers et exécute des commandes dans le **dossier de lancement** grâce à des outils, soumis à des permissions ;
|
- l'agent lit, cherche, modifie des fichiers et exécute des commandes dans le **dossier de lancement** grâce à des outils, soumis à des permissions ;
|
||||||
- sessions persistantes, skills, outils personnalisés, exécution parallèle des outils, compactage du contexte.
|
- sessions persistantes, skills, outils personnalisés, exécution parallèle des outils, compactage du contexte.
|
||||||
|
|
||||||
Hors périmètre actuel : sous-agents / outil Task, hooks, serveurs MCP, slash-commandes personnalisées (code présent mais non appelé), autocomplétion des chemins `@`, listes à choix multiples (voir § 18).
|
Hors périmètre actuel : hooks, slash-commandes personnalisées (code présent mais non appelé), autocomplétion des chemins `@`, listes à choix multiples (voir § 18).
|
||||||
|
|
||||||
## 2. Pile technique
|
## 2. Pile technique
|
||||||
|
|
||||||
|
|
@ -295,7 +295,8 @@ Pour toute entrée invalide, l'outil lève une exception renvoyée comme erreur
|
||||||
|
|
||||||
- `deny` et `ask` s'appliquent dès qu'**une** partie de la commande correspond (`&&`, `;`, `|`, substitutions `$(…)`, backticks, parenthèses, redirections — découpe fine) ;
|
- `deny` et `ask` s'appliquent dès qu'**une** partie de la commande correspond (`&&`, `;`, `|`, substitutions `$(…)`, backticks, parenthèses, redirections — découpe fine) ;
|
||||||
- `allow` ne s'applique que si **toutes** les parties sont couvertes ;
|
- `allow` ne s'applique que si **toutes** les parties sont couvertes ;
|
||||||
- un `allow` ne couvre **jamais** une commande avec redirection (`>`) ou substitution : elle repasse en `ask`.
|
- un `allow` ne couvre **jamais** une commande avec substitution (`$(…)`, backticks, `<(…)`, `>(…)`) : elle repasse en `ask` ;
|
||||||
|
- **Redirections de sortie** : `extractRedirections` (guillemets et `\\` respectés ; préfixes `2`, `&` ; `>>`, `>|`, `>&fichier` ; `2>&1`, `>&2`, `>&-` = duplications sans fichier) donne la commande sans redirections et les fichiers visés. Règles `Redirect` (sans motif : toutes ; `Redirect(chemin)` : motif de chemin relatif au dossier de lancement, comme `Write`). `allow` : la commande dépurée doit être couverte (règles `Bash`) ET chaque cible couverte par un `Redirect` allow ; une cible dynamique (`$ ` * ? [ ] { }`) n'est couverte que par `Redirect` sans motif ; analyse incertaine (guillemet non fermé, cible absente, `>(`) → `ask`. `deny` / `ask` : une cible qui correspond à un `Redirect(motif)` suffit, quelle que soit la commande autorisée. Sans règle `Redirect` : toute redirection vers un fichier repasse en `ask`.
|
||||||
|
|
||||||
Les `deny` sont du « meilleur effort », pas un bac à sable. Les commandes `!` et les références `@` saisies par l'utilisateur **ne passent pas** par ces règles.
|
Les `deny` sont du « meilleur effort », pas un bac à sable. Les commandes `!` et les références `@` saisies par l'utilisateur **ne passent pas** par ces règles.
|
||||||
|
|
||||||
|
|
@ -527,7 +528,7 @@ La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie activ
|
||||||
| `./.maia/skills/` · `<parent>/.maia/skills/` | skills |
|
| `./.maia/skills/` · `<parent>/.maia/skills/` | skills |
|
||||||
| `./.maia/tools/<nom>/TOOL.json` | outils personnalisés |
|
| `./.maia/tools/<nom>/TOOL.json` | outils personnalisés |
|
||||||
| `./.maia/agents/<nom>/AGENT.md` · `~/.maia/agents/<nom>/AGENT.md` | sous-agents |
|
| `./.maia/agents/<nom>/AGENT.md` · `~/.maia/agents/<nom>/AGENT.md` | sous-agents |
|
||||||
| `<racine de MAIA>/.ressources/AGENT.md` | modèle d'agent de `/newAgent` (ressource privée de MAIA ; `MAIA_RESSOURCES` : autre dossier) |
|
| `<racine de MAIA>/.ressources/AGENT.md` | modèle d'agent de `/createAgent` (ressource privée de MAIA ; `MAIA_RESSOURCES` : autre dossier) |
|
||||||
| `./.maia/.sessions/<nom>.json` | sessions (et traces de sous-agents `<session>-<agent>.json`) |
|
| `./.maia/.sessions/<nom>.json` | sessions (et traces de sous-agents `<session>-<agent>.json`) |
|
||||||
| `./MAIA.md` sinon `./CLAUDE.md` | contexte de départ |
|
| `./MAIA.md` sinon `./CLAUDE.md` | contexte de départ |
|
||||||
| `MAIA_MODEL`, `MAIA_CWD`, `INIT_CWD`, `LITELLM_API_KEY`, `MAIA_NO_MARKDOWN`, `MAIA_RESSOURCES` | variables d'environnement |
|
| `MAIA_MODEL`, `MAIA_CWD`, `INIT_CWD`, `LITELLM_API_KEY`, `MAIA_NO_MARKDOWN`, `MAIA_RESSOURCES` | variables d'environnement |
|
||||||
|
|
@ -549,11 +550,11 @@ La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie activ
|
||||||
- Compactage automatique contrôlé aux frontières de tour uniquement (§ 14.3).
|
- Compactage automatique contrôlé aux frontières de tour uniquement (§ 14.3).
|
||||||
- Parallélisme : tâches asynchrones, 8 simultanées, confirmations une par une.
|
- Parallélisme : tâches asynchrones, 8 simultanées, confirmations une par une.
|
||||||
- Sous-agents : l'effort borne les étapes (pas de `reasoning_effort` envoyé au modèle) ; pas d'imbrication ; une trace de sous-agent rechargée avec `/load` reprend la conversation dans l'agent principal (prompt système différent) ; chaque sous-agent recharge ses outils/skills/permissions depuis le disque (une modification en cours de session n'est pas vue par l'agent principal avant `/clear`).
|
- Sous-agents : l'effort borne les étapes (pas de `reasoning_effort` envoyé au modèle) ; pas d'imbrication ; une trace de sous-agent rechargée avec `/load` reprend la conversation dans l'agent principal (prompt système différent) ; chaque sous-agent recharge ses outils/skills/permissions depuis le disque (une modification en cours de session n'est pas vue par l'agent principal avant `/clear`).
|
||||||
- Non réalisés : hooks, serveurs MCP, variables d'environnement et slash-commandes personnalisées sous `.maia` (code de `commands.ts` présent mais non appelé), `@fichier` dans le corps d'un prompt de skill, autocomplétion des chemins `@`, listes à cocher, icônes Nerd Fonts.
|
- Non réalisés : hooks, OAuth MCP, variables d'environnement et slash-commandes personnalisées sous `.maia` (code de `commands.ts` présent mais non appelé), `@fichier` dans le corps d'un prompt de skill, autocomplétion des chemins `@`, listes à cocher, icônes Nerd Fonts.
|
||||||
|
|
||||||
## 19. Tests
|
## 19. Tests
|
||||||
|
|
||||||
Suites vitest (fichiers `*.test.ts`) : sous-agents (`agents.test.ts` : en-tête, sections, chargement, modèle, outil, initialisation restreinte ; `core/src/subagent.test.ts` : résumé final, budget, trace ; `cli/src/subagent-parent.test.ts` : lanceur avec faux processus), boucle agent et permissions (`agent.test.ts`), exécuteur d'outils parallèle (`tool-runner.test.ts` : durée, ordre, confirmations séquentielles, refus, `lockKey`, plafond de 8), comptage et taille de contexte (`context.test.ts`), compactage et noms `__n` (`compact.test.ts`), sessions, `!` (`shell.test.ts`), `@` (`fileref.test.ts`), skills, outils (chemins, Glob/Grep, TodoWrite, WebFetch, CurrentModel), outils personnalisés (31 tests dont exécution réelle de scripts), initialisation de l'agent, provider LiteLLM (corps de requête, SSE), interface (store, barre de contexte, pixels, couleurs, spinner, markdown), dossier de lancement.
|
Suites vitest (fichiers `*.test.ts`) : serveurs MCP (`mcp.test.ts` : `mcp.json`, expansion `${VAR}`, règles `Mcp(...)`, faux serveur HTTP en JSON, SSE et ancien transport SSE, filtrage des outils, statuts OK/FAIL, aucune connexion si désactivé), sous-agents (`agents.test.ts` : en-tête, sections, chargement, modèle, outil, initialisation restreinte ; `core/src/subagent.test.ts` : résumé final, budget, trace ; `cli/src/subagent-parent.test.ts` : lanceur avec faux processus), boucle agent et permissions (`agent.test.ts`), exécuteur d'outils parallèle (`tool-runner.test.ts` : durée, ordre, confirmations séquentielles, refus, `lockKey`, plafond de 8), comptage et taille de contexte (`context.test.ts`), compactage et noms `__n` (`compact.test.ts`), sessions, `!` (`shell.test.ts`), `@` (`fileref.test.ts`), skills, outils (chemins, Glob/Grep, TodoWrite, WebFetch, CurrentModel), outils personnalisés (31 tests dont exécution réelle de scripts), initialisation de l'agent, provider LiteLLM (corps de requête, SSE), interface (store, barre de contexte, pixels, couleurs, spinner, markdown), dossier de lancement.
|
||||||
|
|
||||||
Commandes : `pnpm install`, `pnpm test`, `pnpm typecheck`. Les suites ont été écrites et vérifiées par un lanceur de test minimal dans l'environnement de développement (sans vitest, Ink ni LiteLLM réels) ; leur exécution complète est à faire dans l'environnement du projet.
|
Commandes : `pnpm install`, `pnpm test`, `pnpm typecheck`. Les suites ont été écrites et vérifiées par un lanceur de test minimal dans l'environnement de développement (sans vitest, Ink ni LiteLLM réels) ; leur exécution complète est à faire dans l'environnement du projet.
|
||||||
|
|
||||||
|
|
@ -579,9 +580,9 @@ En-tête YAML (sous-ensemble : clés simples, listes `- a` ou `[a, b]`, table im
|
||||||
|
|
||||||
Corps : trois sections obligatoires, repérées par leurs titres Markdown (niveau libre, casse et accents ignorés ; « Instructions system(e) », « Missions », « Output ») ; une section s'arrête au titre suivant de même niveau ou plus haut, ou à une autre des trois sections ; les titres dans un bloc de code sont ignorés. Un agent invalide (nom, description, section manquante) est ignoré avec un avertissement précis. Journal au démarrage : « Agents chargés : » (un par ligne) ; commande `/agents`.
|
Corps : trois sections obligatoires, repérées par leurs titres Markdown (niveau libre, casse et accents ignorés ; « Instructions system(e) », « Missions », « Output ») ; une section s'arrête au titre suivant de même niveau ou plus haut, ou à une autre des trois sections ; les titres dans un bloc de code sont ignorés. Un agent invalide (nom, description, section manquante) est ignoré avec un avertissement précis. Journal au démarrage : « Agents chargés : » (un par ligne) ; commande `/agents`.
|
||||||
|
|
||||||
### 20.1b Création : `/newAgent` et le modèle `.ressources/AGENT.md`
|
### 20.1b Création : `/createAgent` et le modèle `.ressources/AGENT.md`
|
||||||
|
|
||||||
`/newAgent [nom]` (cli) : `ui.select` du niveau (« Projet » → `./.maia/agents`, « Global » → `~/.maia/agents`), nom en argument ou saisi (vide / Ctrl+C : annulation), `validateAgentName` (lettres, chiffres, `_ . -`, 64 caractères, pas de point initial), puis `createAgentFromTemplate` (agent) : lecture du modèle, `applyAgentName` (remplace uniquement la ligne `name:` de l'en-tête), création du dossier et écriture avec l'option `wx` (jamais d'écrasement : erreur « existe déjà »). Le modèle est cherché par `findResource` (cli/src/resources.ts) : `$MAIA_RESSOURCES`, sinon `<racine de MAIA>/.ressources` (3 niveaux au-dessus de `packages/cli/dist` ou `src`), sinon à côté du programme. Le modèle est un `AGENT.md` valide tel quel (name, description, effort, omitMd, trace actifs ; model, tools, permissions, skills en exemples `#propriété:` à décommenter) avec une explication par propriété en commentaires `#` et, dans le corps, des commentaires HTML par section.
|
`/createAgent [nom]` (cli) : `ui.select` du niveau (« Projet » → `./.maia/agents`, « Global » → `~/.maia/agents`), nom en argument ou saisi (vide / Ctrl+C : annulation), `validateAgentName` (lettres, chiffres, `_ . -`, 64 caractères, pas de point initial), puis `createAgentFromTemplate` (agent) : lecture du modèle, `applyAgentName` (remplace uniquement la ligne `name:` de l'en-tête), création du dossier et écriture avec l'option `wx` (jamais d'écrasement : erreur « existe déjà »). Le modèle est cherché par `findResource` (cli/src/resources.ts) : `$MAIA_RESSOURCES`, sinon `<racine de MAIA>/.ressources` (3 niveaux au-dessus de `packages/cli/dist` ou `src`), sinon à côté du programme. Le modèle est un `AGENT.md` valide tel quel (name, description, effort, omitMd, trace actifs ; model, tools, permissions, skills en exemples `#propriété:` à décommenter) avec une explication par propriété en commentaires `#` et, dans le corps, des commentaires HTML par section.
|
||||||
|
|
||||||
Lecture de l'en-tête : les commentaires de fin de ligne (` # …`, hors guillemets, `#` précédé d'un espace) sont retirés (`stripComment`) ; les commentaires HTML `<!-- … -->` des sections sont retirés avant l'envoi au modèle. Le nouvel agent est chargé au prochain démarrage, `/clear` ou `/new`.
|
Lecture de l'en-tête : les commentaires de fin de ligne (` # …`, hors guillemets, `#` précédé d'un espace) sont retirés (`stripComment`) ; les commentaires HTML `<!-- … -->` des sections sont retirés avant l'envoi au modèle. Le nouvel agent est chargé au prochain démarrage, `/clear` ou `/new`.
|
||||||
|
|
||||||
|
|
@ -622,7 +623,25 @@ Confirmations : l'interface de l'enfant n'affiche rien ; `confirm` est relayé a
|
||||||
|
|
||||||
Si `trace: true` (même en cas d'échec, s'il y a des messages) : `saveSubAgentTrace` écrit un `SavedSession` dans `./.maia/.sessions/` nommé `{session parente}-{agent}` (`subAgentSessionName` : `_2`, `_3`… si pris), avec les champs optionnels `agent` et `parentSession`. `/load` affiche « sous-agent x » dans la liste. Sans `trace`, rien n'est écrit.
|
Si `trace: true` (même en cas d'échec, s'il y a des messages) : `saveSubAgentTrace` écrit un `SavedSession` dans `./.maia/.sessions/` nommé `{session parente}-{agent}` (`subAgentSessionName` : `_2`, `_3`… si pris), avec les champs optionnels `agent` et `parentSession`. `/load` affiche « sous-agent x » dans la liste. Sans `trace`, rien n'est écrit.
|
||||||
|
|
||||||
## 21. Annexe — constantes
|
## 22. Serveurs MCP (`agent/src/mcp*.ts`)
|
||||||
|
|
||||||
|
**Fichiers** : `mcp-config.ts` (lecture de `~/.maia/mcp.json`, `expandEnv`), `mcp-rules.ts` (règles `Mcp(...)`, noms d'outils, sélection des serveurs : fonctions pures), `mcp-client.ts` (client JSON-RPC 2.0 minimal, transports Streamable HTTP et HTTP+SSE, analyseur SSE), `mcp.ts` (`setupMcp`, `createMcpTool`). Aucune dépendance ajoutée (client écrit à la main sur `fetch`).
|
||||||
|
|
||||||
|
**Déroulement** (`initializeAgent`, après le chargement des permissions) :
|
||||||
|
1. `loadMcpConfig(homeDir, env)` : `~/.maia/mcp.json` uniquement (ni projet, ni parent). Serveur invalide (clé hors `[A-Za-z0-9_-]` ou contenant `__`, `url` absente ou non http(s), `type` ≠ http/sse) : avertissement, ignoré. `enabled:false` : ignoré. `${VAR}` développé dans `url` et `headers`.
|
||||||
|
2. `selectMcpServers(rules, clés)` (le serveur `*` désigne tous les serveurs : `Mcp(*)`, `Mcp(*,outil)`) : un serveur est **activé** s'il a une règle allow `Mcp(clé…)` et aucune règle deny `Mcp(clé)` / `Mcp(clé,*)`. Règle sur une clé inconnue : avertissement. Aucune connexion avant ce tri.
|
||||||
|
3. Connexion **en parallèle** des serveurs activés (`initialize`, `notifications/initialized`, `tools/list` paginé). Échec (réseau, HTTP, délai, JSON-RPC) : statut `fail` + avertissement, les autres serveurs et MAIA continuent.
|
||||||
|
4. Outils : `mcp__<clé>__<outil>` (nom nettoyé en `[A-Za-z0-9_-]`, ≤ 64 caractères, doublons ignorés avec avertissement), `permission: "ask"` ; retirés : `excludeTools`, hors `includeTools`, règle deny `Mcp(clé,outil)`.
|
||||||
|
|
||||||
|
**Permissions** (`permissions.ts`) : une règle `Mcp(clé[,motif])` s'applique à tout appel d'outil `mcp__clé__outil` (allow = sans confirmation, ask, deny). L'ancienne forme `mcp__clé__outil(motif de valeur)` reste valide.
|
||||||
|
|
||||||
|
**Client** : `Mcp-Session-Id` mémorisé et renvoyé, `MCP-Protocol-Version: 2025-03-26`, `Accept: application/json, text/event-stream` ; réponse JSON ou flux SSE lue jusqu'à l'id attendu. Transport `sse` : `GET url`, évènement `endpoint`, POST vers cet endpoint, réponses lues sur le flux. Délai `timeout` (s) par requête ; pour `sse`, jusqu'à la réception de `endpoint`. Résultat d'outil : parties `text` concaténées (autres types signalés), `isError` → exception, tronqué à 100 000 caractères.
|
||||||
|
|
||||||
|
**Interface** : `/mcp` → `UIStore.showMcp` (`LogKind "mcp"`) : badge `OK` vert / `FAIL` rouge + nom ; cause de l'échec en grisé. `AgentInitResult.mcpServers` (états) et `close()` (fermeture des connexions : session suivante, `/exit`, Ctrl+D, SIGTERM/SIGHUP ; Streamable HTTP : `DELETE` de la session, SSE : fermeture du flux ; au plus 3 s en cas de signal).
|
||||||
|
|
||||||
|
**Limites** : pas d'OAuth ; outils seulement ; les sous-agents (processus enfants) n'ont pas les outils MCP ; connexions refaites à chaque redémarrage de session (`/clear`, `/new`, `/load`, `/model`, `/compact`) ; pas de reconnexion automatique en cours de session ; notifications serveur (`tools/list_changed`) ignorées.
|
||||||
|
|
||||||
|
## 23. Annexe — constantes
|
||||||
|
|
||||||
| Constante | Valeur | Lieu |
|
| Constante | Valeur | Lieu |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|
@ -649,3 +668,4 @@ Si `trace: true` (même en cas d'échec, s'il y a des messages) : `saveSubAgentT
|
||||||
| Délai maximal d'un sous-agent / délai avant `SIGKILL` | 30 min / 5 s | `subagent-parent.ts` |
|
| Délai maximal d'un sous-agent / délai avant `SIGKILL` | 30 min / 5 s | `subagent-parent.ts` |
|
||||||
| Tokens du résumé final d'un sous-agent | 4000 | `core/src/subagent.ts` |
|
| Tokens du résumé final d'un sous-agent | 4000 | `core/src/subagent.ts` |
|
||||||
| Aperçu d'un résultat d'outil / d'une sortie `!` | 600 / 4000 caractères | `store.ts` |
|
| Aperçu d'un résultat d'outil / d'une sortie `!` | 600 / 4000 caractères | `store.ts` |
|
||||||
|
| MCP : délai par défaut / résultat d'outil / longueur d'un nom d'outil | 30 s / 100 000 caractères / 64 | `mcp-config.ts`, `mcp-client.ts`, `mcp-rules.ts` |
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export function parseAgentSections(body: string): Partial<Record<SectionKey, str
|
||||||
if (h && (h[1]!.length <= level || sectionOf(h[2]!) !== null)) break;
|
if (h && (h[1]!.length <= level || sectionOf(h[2]!) !== null)) break;
|
||||||
content.push(next);
|
content.push(next);
|
||||||
}
|
}
|
||||||
// Les commentaires HTML (explications du modèle /newAgent) ne font pas partie des instructions
|
// Les commentaires HTML (explications du modèle /createAgent) ne font pas partie des instructions
|
||||||
found[key] = content.join("\n").replace(/<!--[\s\S]*?-->/g, "").trim();
|
found[key] = content.join("\n").replace(/<!--[\s\S]*?-->/g, "").trim();
|
||||||
}
|
}
|
||||||
return found;
|
return found;
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,7 @@ export { createAgentTool, type SubAgentLauncher } from "./subagent-tool";
|
||||||
export { CONTEXT_FILES, loadContextFile } from "./memory";
|
export { CONTEXT_FILES, loadContextFile } from "./memory";
|
||||||
export { customToolsDir, loadCustomTools } from "./custom-tools";
|
export { customToolsDir, loadCustomTools } from "./custom-tools";
|
||||||
export { agentsDirFor, applyAgentName, createAgentFromTemplate, validateAgentName, type AgentScope } from "./new-agent";
|
export { agentsDirFor, applyAgentName, createAgentFromTemplate, validateAgentName, type AgentScope } from "./new-agent";
|
||||||
|
export { setupMcp, createMcpTool, type McpServerStatus, type McpSetup } from "./mcp";
|
||||||
|
export { loadMcpConfig, expandEnv, mcpConfigPath, MCP_DEFAULT_TIMEOUT_S, type McpServerConfig } from "./mcp-config";
|
||||||
|
export { McpClient, parseSse, resultToText, type McpToolDef } from "./mcp-client";
|
||||||
|
export { selectMcpServers, mcpToolName, splitMcpToolName, parseMcpSpec, mcpSpecMatches, MCP_PREFIX } from "./mcp-rules";
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ describe("permissions au démarrage", () => {
|
||||||
const { project, logs, ctx } = await setup();
|
const { project, logs, ctx } = await setup();
|
||||||
await writeFile(join(project, "MAIA.md"), "x");
|
await writeFile(join(project, "MAIA.md"), "x");
|
||||||
const init = await initializeAgent(ctx);
|
const init = await initializeAgent(ctx);
|
||||||
expect(init.permissions?.check("Read", { path: "a" }, { cwd: project }).decision).toBe("ask");
|
expect(init.permissions?.check("Read", { path: "a" }, { cwd: project })?.decision).toBe("ask");
|
||||||
expect(logs.join("\n")).toContain("Aucune règle de permission");
|
expect(logs.join("\n")).toContain("Aucune règle de permission");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ describe("permissions au démarrage", () => {
|
||||||
JSON.stringify({ permissions: { allow: ["Bash(allow stop *)"], ask: ["Bash(allow ask *)"], deny: ["Bash(allow deny *)", "Read(./.deny.md)"] } }),
|
JSON.stringify({ permissions: { allow: ["Bash(allow stop *)"], ask: ["Bash(allow ask *)"], deny: ["Bash(allow deny *)", "Read(./.deny.md)"] } }),
|
||||||
);
|
);
|
||||||
const init = await initializeAgent(ctx);
|
const init = await initializeAgent(ctx);
|
||||||
const d = (t: string, i: Record<string, unknown>) => init.permissions?.check(t, i, { cwd: project }).decision;
|
const d = (t: string, i: Record<string, unknown>) => init.permissions?.check(t, i, { cwd: project })?.decision;
|
||||||
expect(d("Bash", { command: "allow stop" })).toBe("allow");
|
expect(d("Bash", { command: "allow stop" })).toBe("allow");
|
||||||
expect(d("Bash", { command: "allow ask now" })).toBe("ask");
|
expect(d("Bash", { command: "allow ask now" })).toBe("ask");
|
||||||
expect(d("Bash", { command: "allow deny" })).toBe("deny");
|
expect(d("Bash", { command: "allow deny" })).toBe("deny");
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { InitContext, ModeInitResult, Tool } from "@maia/core";
|
||||||
import { buildSubAgentPrompt, loadAgents, type AgentDefinition, type SubAgentInit } from "./agents";
|
import { buildSubAgentPrompt, loadAgents, type AgentDefinition, type SubAgentInit } from "./agents";
|
||||||
import { loadCustomTools } from "./custom-tools";
|
import { loadCustomTools } from "./custom-tools";
|
||||||
import { loadContextFile } from "./memory";
|
import { loadContextFile } from "./memory";
|
||||||
|
import { setupMcp, type McpServerStatus } from "./mcp";
|
||||||
import { createPermissionPolicy } from "./permissions";
|
import { createPermissionPolicy } from "./permissions";
|
||||||
import { loadPermissionSettings } from "./settings";
|
import { loadPermissionSettings } from "./settings";
|
||||||
import { createSkillTool, loadSkills, skillRoots, skillsPromptSection, toAgentSkills } from "./skills";
|
import { createSkillTool, loadSkills, skillRoots, skillsPromptSection, toAgentSkills } from "./skills";
|
||||||
|
|
@ -19,6 +20,10 @@ export interface AgentInitContext extends InitContext {
|
||||||
export interface AgentInitResult extends ModeInitResult {
|
export interface AgentInitResult extends ModeInitResult {
|
||||||
/** Sous-agents définis (./.maia/agents et ~/.maia/agents). */
|
/** Sous-agents définis (./.maia/agents et ~/.maia/agents). */
|
||||||
agents: AgentDefinition[];
|
agents: AgentDefinition[];
|
||||||
|
/** État des serveurs MCP activés (commande /mcp). */
|
||||||
|
mcpServers: McpServerStatus[];
|
||||||
|
/** Ferme les connexions MCP. */
|
||||||
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSystemPrompt(
|
function buildSystemPrompt(
|
||||||
|
|
@ -94,13 +99,22 @@ export async function initializeAgent(ctx: AgentInitContext): Promise<AgentInitR
|
||||||
if (agents.length > 0) ctx.log(["Agents chargés :", ...agents.map((a) => a.name)].join("\n"));
|
if (agents.length > 0) ctx.log(["Agents chargés :", ...agents.map((a) => a.name)].join("\n"));
|
||||||
const agentTools = ctx.launchSubAgent && agents.length > 0 ? [createAgentTool(agents, ctx.launchSubAgent)] : [];
|
const agentTools = ctx.launchSubAgent && agents.length > 0 ? [createAgentTool(agents, ctx.launchSubAgent)] : [];
|
||||||
|
|
||||||
// TODO : hooks, serveurs MCP.
|
// Serveurs MCP : déclarés dans ~/.maia/mcp.json, activés par les règles Mcp(...) ci-dessus, connectés ensuite
|
||||||
|
const mcp = await setupMcp(ctx.homeDir, settings.rules);
|
||||||
|
for (const w of mcp.warnings) ctx.warn(w);
|
||||||
|
if (mcp.statuses.length > 0) {
|
||||||
|
ctx.log(["Serveurs MCP :", ...mcp.statuses.map((s) => `${s.name} : ${s.status === "ok" ? `OK (${s.toolCount} outils)` : "FAIL"}`)].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO : hooks.
|
||||||
return {
|
return {
|
||||||
agents,
|
agents,
|
||||||
|
mcpServers: mcp.statuses,
|
||||||
|
close: mcp.close,
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
buildSystemPrompt(ctx.cwd, context, skillsPromptSection(skills), ctx.startContext) +
|
buildSystemPrompt(ctx.cwd, context, skillsPromptSection(skills), ctx.startContext) +
|
||||||
(agentTools.length > 0 ? agentsPromptSection(agents) : ""),
|
(agentTools.length > 0 ? agentsPromptSection(agents) : ""),
|
||||||
tools: [...builtinTools, ...(skills.length > 0 ? [createSkillTool(skills)] : []), ...agentTools, ...customTools],
|
tools: [...builtinTools, ...(skills.length > 0 ? [createSkillTool(skills)] : []), ...agentTools, ...customTools, ...mcp.tools],
|
||||||
commands: [],
|
commands: [],
|
||||||
skills: toAgentSkills(skills),
|
skills: toAgentSkills(skills),
|
||||||
permissions: createPermissionPolicy(settings.rules),
|
permissions: createPermissionPolicy(settings.rules),
|
||||||
|
|
@ -156,6 +170,8 @@ async function initializeSubAgent(ctx: AgentInitContext, sub: SubAgentInit): Pro
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agents: [],
|
agents: [],
|
||||||
|
mcpServers: [],
|
||||||
|
close: async () => undefined,
|
||||||
systemPrompt: buildSubAgentPrompt(sub, ctx.cwd, context, hasSkillTool ? skillsPromptSection(skills) : ""),
|
systemPrompt: buildSubAgentPrompt(sub, ctx.cwd, context, hasSkillTool ? skillsPromptSection(skills) : ""),
|
||||||
tools,
|
tools,
|
||||||
commands: [],
|
commands: [],
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ describe("permissions", () => {
|
||||||
expect(check("Write", { path: "a.txt" })?.decision).toBe("ask");
|
expect(check("Write", { path: "a.txt" })?.decision).toBe("ask");
|
||||||
expect(check("Glob", { pattern: "*.ts" })?.decision).toBe("ask");
|
expect(check("Glob", { pattern: "*.ts" })?.decision).toBe("ask");
|
||||||
expect(check("OutilInconnu", {})?.decision).toBe("ask");
|
expect(check("OutilInconnu", {})?.decision).toBe("ask");
|
||||||
expect(createPermissionPolicy({ allow: [], ask: [], deny: [] }).check("Read", { path: "a" }, { cwd: "/p" }).decision).toBe("ask");
|
expect(createPermissionPolicy({ allow: [], ask: [], deny: [] }).check("Read", { path: "a" }, { cwd: "/p" })?.decision).toBe("ask");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("commandes composées : deny/ask si une partie correspond, allow seulement si toutes sont couvertes", () => {
|
it("commandes composées : deny/ask si une partie correspond, allow seulement si toutes sont couvertes", () => {
|
||||||
|
|
@ -101,7 +101,7 @@ describe("permissions", () => {
|
||||||
|
|
||||||
it("outils non intégrés : règle sur le nom, ou motif comparé aux valeurs de l'entrée", () => {
|
it("outils non intégrés : règle sur le nom, ou motif comparé aux valeurs de l'entrée", () => {
|
||||||
const p = createPermissionPolicy({ allow: ["mcp__db__query(SELECT *)", "Skill(pdf)"], ask: [], deny: ["mcp__db__query(DROP *)", "mcp__db__drop"] });
|
const p = createPermissionPolicy({ allow: ["mcp__db__query(SELECT *)", "Skill(pdf)"], ask: [], deny: ["mcp__db__query(DROP *)", "mcp__db__drop"] });
|
||||||
const c = (t: string, i: Record<string, unknown>) => p.check(t, i, { cwd: "/p" }).decision;
|
const c = (t: string, i: Record<string, unknown>) => p.check(t, i, { cwd: "/p" })?.decision;
|
||||||
expect(c("mcp__db__query", { sql: "SELECT 1" })).toBe("allow");
|
expect(c("mcp__db__query", { sql: "SELECT 1" })).toBe("allow");
|
||||||
expect(c("mcp__db__query", { sql: "DROP TABLE x" })).toBe("deny");
|
expect(c("mcp__db__query", { sql: "DROP TABLE x" })).toBe("deny");
|
||||||
expect(c("mcp__db__query", { sql: "DELETE FROM x" })).toBe("ask");
|
expect(c("mcp__db__query", { sql: "DELETE FROM x" })).toBe("ask");
|
||||||
|
|
@ -112,7 +112,7 @@ describe("permissions", () => {
|
||||||
|
|
||||||
it("WebFetch par domaine", () => {
|
it("WebFetch par domaine", () => {
|
||||||
const p = createPermissionPolicy({ allow: ["WebFetch(domain:docs.example.com)"], ask: [], deny: ["WebFetch(domain:*.evil.com)"] });
|
const p = createPermissionPolicy({ allow: ["WebFetch(domain:docs.example.com)"], ask: [], deny: ["WebFetch(domain:*.evil.com)"] });
|
||||||
const c = (url: string) => p.check("WebFetch", { url }, { cwd: "/p" }).decision;
|
const c = (url: string) => p.check("WebFetch", { url }, { cwd: "/p" })?.decision;
|
||||||
expect(c("https://docs.example.com/a")).toBe("allow");
|
expect(c("https://docs.example.com/a")).toBe("allow");
|
||||||
expect(c("https://x.evil.com/")).toBe("deny");
|
expect(c("https://x.evil.com/")).toBe("deny");
|
||||||
expect(c("https://autre.org/")).toBe("ask");
|
expect(c("https://autre.org/")).toBe("ask");
|
||||||
|
|
|
||||||
340
packages/agent/src/mcp-client.ts
Normal file
340
packages/agent/src/mcp-client.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
// Client MCP distant minimal (JSON-RPC 2.0) : initialize, tools/list, tools/call. Pas d'OAuth, outils uniquement.
|
||||||
|
// Transports : Streamable HTTP (réponse JSON ou flux SSE à chaque POST) et ancien HTTP+SSE.
|
||||||
|
|
||||||
|
import type { McpServerConfig } from "./mcp-config";
|
||||||
|
|
||||||
|
const PROTOCOL_VERSION = "2025-03-26";
|
||||||
|
const MAX_RESULT_CHARS = 100_000;
|
||||||
|
|
||||||
|
export interface McpToolDef {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcMessage {
|
||||||
|
jsonrpc?: string;
|
||||||
|
id?: number | string | null;
|
||||||
|
method?: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { code?: number; message?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
// ───────────── SSE ─────────────
|
||||||
|
|
||||||
|
export interface SseEvent {
|
||||||
|
event: string;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Découpe un flux `text/event-stream` en évènements. */
|
||||||
|
export async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent> {
|
||||||
|
const reader = body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
const take = (block: string): SseEvent | null => {
|
||||||
|
let event = "message";
|
||||||
|
const data: string[] = [];
|
||||||
|
for (const line of block.split("\n")) {
|
||||||
|
if (line.startsWith(":")) continue;
|
||||||
|
const i = line.indexOf(":");
|
||||||
|
const field = i === -1 ? line : line.slice(0, i);
|
||||||
|
const value = i === -1 ? "" : line.slice(i + 1).replace(/^ /, "");
|
||||||
|
if (field === "event") event = value;
|
||||||
|
else if (field === "data") data.push(value);
|
||||||
|
}
|
||||||
|
return data.length > 0 ? { event, data: data.join("\n") } : null;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
|
||||||
|
let sep: number;
|
||||||
|
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
||||||
|
const ev = take(buffer.slice(0, sep));
|
||||||
|
buffer = buffer.slice(sep + 2);
|
||||||
|
if (ev) yield ev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const last = take(buffer);
|
||||||
|
if (last) yield last;
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── Transports ─────────────
|
||||||
|
|
||||||
|
interface Transport {
|
||||||
|
/** Envoie une requête et attend la réponse portant le même id. */
|
||||||
|
request(message: object, id: number, signal: AbortSignal): Promise<RpcMessage>;
|
||||||
|
notify(message: object, signal: AbortSignal): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcError(msg: RpcMessage): Error {
|
||||||
|
return new Error(`MCP : ${msg.error?.message ?? "erreur inconnue"}${msg.error?.code !== undefined ? ` (code ${msg.error.code})` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StreamableHttpTransport implements Transport {
|
||||||
|
private sessionId?: string;
|
||||||
|
constructor(
|
||||||
|
private readonly cfg: McpServerConfig,
|
||||||
|
private readonly fetchFn: FetchLike,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private headers(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
...this.cfg.headers,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json, text/event-stream",
|
||||||
|
"MCP-Protocol-Version": PROTOCOL_VERSION,
|
||||||
|
...(this.sessionId ? { "Mcp-Session-Id": this.sessionId } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(message: object, signal: AbortSignal): Promise<Response> {
|
||||||
|
const res = await this.fetchFn(this.cfg.url, { method: "POST", headers: this.headers(), body: JSON.stringify(message), signal });
|
||||||
|
const sid = res.headers.get("mcp-session-id");
|
||||||
|
if (sid) this.sessionId = sid;
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = (await res.text().catch(() => "")).slice(0, 200);
|
||||||
|
throw new Error(`HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}${text ? ` : ${text}` : ""}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(message: object, id: number, signal: AbortSignal): Promise<RpcMessage> {
|
||||||
|
const res = await this.post(message, signal);
|
||||||
|
const type = res.headers.get("content-type") ?? "";
|
||||||
|
if (type.includes("text/event-stream") && res.body) {
|
||||||
|
for await (const ev of parseSse(res.body)) {
|
||||||
|
let msg: RpcMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data) as RpcMessage;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (msg.id === id && (msg.result !== undefined || msg.error)) return msg;
|
||||||
|
}
|
||||||
|
throw new Error("flux SSE terminé sans réponse");
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as RpcMessage | RpcMessage[];
|
||||||
|
const msg = Array.isArray(json) ? json.find((m) => m.id === id) : json;
|
||||||
|
if (!msg) throw new Error("réponse JSON-RPC absente");
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async notify(message: object, signal: AbortSignal): Promise<void> {
|
||||||
|
const res = await this.post(message, signal);
|
||||||
|
await res.body?.cancel().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
if (!this.sessionId) return;
|
||||||
|
try {
|
||||||
|
await this.fetchFn(this.cfg.url, { method: "DELETE", headers: { ...this.cfg.headers, "Mcp-Session-Id": this.sessionId }, signal: AbortSignal.timeout(2000) });
|
||||||
|
} catch {
|
||||||
|
// fermeture au mieux
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ancien transport : GET (flux SSE permanent, évènement `endpoint`) puis POST des messages vers cet endpoint. */
|
||||||
|
class LegacySseTransport implements Transport {
|
||||||
|
private endpoint?: string;
|
||||||
|
private readonly pending = new Map<number, (m: RpcMessage) => void>();
|
||||||
|
private readonly abort = new AbortController();
|
||||||
|
private streamError?: Error;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly cfg: McpServerConfig,
|
||||||
|
private readonly fetchFn: FetchLike,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Ouvre le flux (qui reste ouvert ensuite) : le délai ne vaut que jusqu'à la réception de l'évènement `endpoint`. */
|
||||||
|
async open(timeoutMs: number): Promise<void> {
|
||||||
|
let timedOut = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
this.abort.abort();
|
||||||
|
}, timeoutMs);
|
||||||
|
let events: AsyncGenerator<SseEvent>;
|
||||||
|
try {
|
||||||
|
const res = await this.fetchFn(this.cfg.url, {
|
||||||
|
headers: { ...this.cfg.headers, Accept: "text/event-stream" },
|
||||||
|
signal: this.abort.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`);
|
||||||
|
events = parseSse(res.body);
|
||||||
|
// Le premier évènement utile est `endpoint` ; la suite est lue en arrière-plan
|
||||||
|
for (;;) {
|
||||||
|
const next = await events.next();
|
||||||
|
if (next.done) throw new Error("flux SSE fermé avant l'évènement endpoint");
|
||||||
|
if (next.value.event === "endpoint") {
|
||||||
|
this.endpoint = new URL(next.value.data.trim(), this.cfg.url).toString();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw timedOut ? new Error(`délai dépassé (${this.cfg.timeout} s)`) : err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
for await (const ev of events) {
|
||||||
|
if (ev.event !== "message") continue;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data) as RpcMessage;
|
||||||
|
if (typeof msg.id === "number") this.pending.get(msg.id)?.(msg);
|
||||||
|
} catch {
|
||||||
|
// évènement illisible ignoré
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.streamError = new Error("flux SSE fermé par le serveur");
|
||||||
|
} catch (err) {
|
||||||
|
this.streamError = err as Error;
|
||||||
|
}
|
||||||
|
for (const [id, resolve] of this.pending) resolve({ id, error: { message: this.streamError?.message ?? "flux SSE fermé" } });
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(message: object, signal: AbortSignal): Promise<void> {
|
||||||
|
if (!this.endpoint) throw new Error("endpoint SSE inconnu");
|
||||||
|
const res = await this.fetchFn(this.endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...this.cfg.headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(message),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`);
|
||||||
|
await res.body?.cancel().catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(message: object, id: number, signal: AbortSignal): Promise<RpcMessage> {
|
||||||
|
if (this.streamError) throw this.streamError;
|
||||||
|
const answer = new Promise<RpcMessage>((resolve, reject) => {
|
||||||
|
this.pending.set(id, resolve);
|
||||||
|
signal.addEventListener("abort", () => reject(signal.reason ?? new Error("annulé")), { once: true });
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await this.post(message, signal);
|
||||||
|
return await answer;
|
||||||
|
} finally {
|
||||||
|
this.pending.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(message: object, signal: AbortSignal): Promise<void> {
|
||||||
|
return this.post(message, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.abort.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── Client ─────────────
|
||||||
|
|
||||||
|
export function resultToText(result: unknown): { text: string; isError: boolean } {
|
||||||
|
const r = (result ?? {}) as { content?: unknown; isError?: unknown; structuredContent?: unknown };
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (Array.isArray(r.content)) {
|
||||||
|
for (const item of r.content as Record<string, unknown>[]) {
|
||||||
|
if (item.type === "text" && typeof item.text === "string") parts.push(item.text);
|
||||||
|
else if (item.type === "resource" && typeof item.resource === "object" && item.resource !== null) {
|
||||||
|
const res = item.resource as { text?: unknown; uri?: unknown };
|
||||||
|
parts.push(typeof res.text === "string" ? res.text : `[ressource ${String(res.uri ?? "")}]`);
|
||||||
|
} else parts.push(`[contenu ${String(item.type)}${typeof item.mimeType === "string" ? ` ${item.mimeType}` : ""} non affichable]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parts.length === 0 && r.structuredContent !== undefined) parts.push(JSON.stringify(r.structuredContent));
|
||||||
|
let text = parts.join("\n");
|
||||||
|
if (text.length > MAX_RESULT_CHARS) text = `${text.slice(0, MAX_RESULT_CHARS)}\n… (résultat tronqué à ${MAX_RESULT_CHARS} caractères)`;
|
||||||
|
return { text, isError: r.isError === true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class McpClient {
|
||||||
|
private transport?: Transport;
|
||||||
|
private nextId = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly config: McpServerConfig,
|
||||||
|
private readonly fetchFn: FetchLike = fetch,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private signal(extra?: AbortSignal): AbortSignal {
|
||||||
|
const timeout = AbortSignal.timeout(this.config.timeout * 1000);
|
||||||
|
return extra ? AbortSignal.any([timeout, extra]) : timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rpc(method: string, params: object | undefined, extra?: AbortSignal): Promise<unknown> {
|
||||||
|
if (!this.transport) throw new Error("client MCP non connecté");
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const signal = this.signal(extra);
|
||||||
|
try {
|
||||||
|
const msg = await this.transport.request({ jsonrpc: "2.0", id, method, params }, id, signal);
|
||||||
|
if (msg.error) throw rpcError(msg);
|
||||||
|
return msg.result;
|
||||||
|
} catch (err) {
|
||||||
|
if (signal.aborted && !extra?.aborted) throw new Error(`délai dépassé (${this.config.timeout} s)`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Négocie la session (initialize + notifications/initialized). */
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
if (this.config.type === "sse") {
|
||||||
|
const t = new LegacySseTransport(this.config, this.fetchFn);
|
||||||
|
try {
|
||||||
|
await t.open(this.config.timeout * 1000);
|
||||||
|
} catch (err) {
|
||||||
|
await t.close();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.transport = t;
|
||||||
|
} else {
|
||||||
|
this.transport = new StreamableHttpTransport(this.config, this.fetchFn);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.rpc("initialize", {
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "maia", version: "0.1.0" },
|
||||||
|
});
|
||||||
|
await this.transport.notify({ jsonrpc: "2.0", method: "notifications/initialized" }, this.signal());
|
||||||
|
} catch (err) {
|
||||||
|
await this.close();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTools(): Promise<McpToolDef[]> {
|
||||||
|
const tools: McpToolDef[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
for (let page = 0; page < 20; page++) {
|
||||||
|
const res = (await this.rpc("tools/list", cursor ? { cursor } : {})) as { tools?: McpToolDef[]; nextCursor?: string };
|
||||||
|
for (const t of res.tools ?? []) if (t && typeof t.name === "string") tools.push(t);
|
||||||
|
cursor = res.nextCursor;
|
||||||
|
if (!cursor) break;
|
||||||
|
}
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<string> {
|
||||||
|
const result = await this.rpc("tools/call", { name, arguments: args }, signal);
|
||||||
|
const { text, isError } = resultToText(result);
|
||||||
|
if (isError) throw new Error(text || "l'outil MCP a signalé une erreur");
|
||||||
|
return text === "" ? "(aucune sortie)" : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
const t = this.transport;
|
||||||
|
this.transport = undefined;
|
||||||
|
await t?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
139
packages/agent/src/mcp-config.ts
Normal file
139
packages/agent/src/mcp-config.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { isValidServerKey } from "./mcp-rules";
|
||||||
|
|
||||||
|
/** Délai par défaut (secondes) de connexion et d'appel d'un serveur MCP. */
|
||||||
|
export const MCP_DEFAULT_TIMEOUT_S = 30;
|
||||||
|
|
||||||
|
export interface McpServerConfig {
|
||||||
|
/** Clé du serveur dans `mcpServers` : nom utilisé dans les règles `Mcp(clé)` et dans les noms d'outils. */
|
||||||
|
key: string;
|
||||||
|
/** `http` : Streamable HTTP (défaut) ; `sse` : ancien transport HTTP+SSE. */
|
||||||
|
type: "http" | "sse";
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
/** Secondes. */
|
||||||
|
timeout: number;
|
||||||
|
includeTools?: string[];
|
||||||
|
excludeTools?: string[];
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chemin du fichier de déclaration des serveurs MCP : global uniquement. */
|
||||||
|
export const mcpConfigPath = (homeDir: string): string => join(resolve(homeDir), ".maia", "mcp.json");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remplace `${VARIABLE}` par la valeur de la variable d'environnement (chaîne vide si elle n'existe pas,
|
||||||
|
* le nom est alors ajouté à `missing`).
|
||||||
|
*/
|
||||||
|
export function expandEnv(text: string, env: Record<string, string | undefined>, missing: string[] = []): string {
|
||||||
|
return text.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name: string) => {
|
||||||
|
const v = env[name];
|
||||||
|
if (v === undefined) {
|
||||||
|
if (!missing.includes(name)) missing.push(name);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStringList = (v: unknown): v is string[] => Array.isArray(v) && v.every((x) => typeof x === "string");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit ~/.maia/mcp.json (`{ "mcpServers": { "<clé>": { url, type?, headers?, timeout?, includeTools?, excludeTools?, enabled?, description? } } }`).
|
||||||
|
* Fichier absent : normal. Serveur invalide : avertissement, il est ignoré. `enabled: false` : serveur ignoré sans avertissement.
|
||||||
|
*/
|
||||||
|
export async function loadMcpConfig(
|
||||||
|
homeDir: string,
|
||||||
|
env: Record<string, string | undefined> = process.env,
|
||||||
|
): Promise<{ servers: McpServerConfig[]; warnings: string[]; path: string }> {
|
||||||
|
const path = mcpConfigPath(homeDir);
|
||||||
|
const servers: McpServerConfig[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await readFile(path, "utf8");
|
||||||
|
} catch {
|
||||||
|
return { servers, warnings, path };
|
||||||
|
}
|
||||||
|
let json: unknown;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(`${path} : JSON invalide, serveurs MCP ignorés (${(err as Error).message})`);
|
||||||
|
return { servers, warnings, path };
|
||||||
|
}
|
||||||
|
const table = (json as { mcpServers?: unknown } | null)?.mcpServers;
|
||||||
|
if (table === undefined) return { servers, warnings, path };
|
||||||
|
if (typeof table !== "object" || table === null || Array.isArray(table)) {
|
||||||
|
warnings.push(`${path} : "mcpServers" doit être un objet { "<nom>": { "url": "…" } }`);
|
||||||
|
return { servers, warnings, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(table)) {
|
||||||
|
const where = `${path} : serveur « ${key} »`;
|
||||||
|
if (!isValidServerKey(key)) {
|
||||||
|
warnings.push(`${where} ignoré : nom invalide (lettres, chiffres, - et _ uniquement, sans « __ »)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
warnings.push(`${where} ignoré : la déclaration doit être un objet`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const c = value as Record<string, unknown>;
|
||||||
|
if (c.enabled === false) continue;
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
if (typeof c.url !== "string" || c.url.trim() === "") {
|
||||||
|
warnings.push(`${where} ignoré : "url" manquante`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const url = expandEnv(c.url.trim(), env, missing);
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("protocole");
|
||||||
|
} catch {
|
||||||
|
warnings.push(`${where} ignoré : "url" invalide (http:// ou https:// attendu)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = c.type === undefined ? "http" : c.type;
|
||||||
|
if (type !== "http" && type !== "sse") {
|
||||||
|
warnings.push(`${where} ignoré : "type" doit valoir "http" ou "sse"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (c.headers !== undefined) {
|
||||||
|
if (typeof c.headers !== "object" || c.headers === null || Array.isArray(c.headers)) {
|
||||||
|
warnings.push(`${where} : "headers" doit être un objet, ignoré`);
|
||||||
|
} else {
|
||||||
|
for (const [h, v] of Object.entries(c.headers)) {
|
||||||
|
if (typeof v === "string") headers[h] = expandEnv(v, env, missing);
|
||||||
|
else warnings.push(`${where} : en-tête « ${h} » ignoré (texte attendu)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let timeout = MCP_DEFAULT_TIMEOUT_S;
|
||||||
|
if (c.timeout !== undefined) {
|
||||||
|
if (typeof c.timeout === "number" && c.timeout > 0) timeout = c.timeout;
|
||||||
|
else warnings.push(`${where} : "timeout" doit être un nombre de secondes > 0, défaut ${MCP_DEFAULT_TIMEOUT_S} s`);
|
||||||
|
}
|
||||||
|
const lists: Pick<McpServerConfig, "includeTools" | "excludeTools"> = {};
|
||||||
|
for (const k of ["includeTools", "excludeTools"] as const) {
|
||||||
|
if (c[k] === undefined) continue;
|
||||||
|
if (isStringList(c[k])) lists[k] = c[k] as string[];
|
||||||
|
else warnings.push(`${where} : "${k}" doit être une liste de noms d'outils, ignoré`);
|
||||||
|
}
|
||||||
|
for (const name of missing) warnings.push(`${where} : variable d'environnement ${name} non définie (remplacée par du vide)`);
|
||||||
|
|
||||||
|
servers.push({
|
||||||
|
key,
|
||||||
|
type,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
timeout,
|
||||||
|
...lists,
|
||||||
|
description: typeof c.description === "string" ? c.description : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { servers, warnings, path };
|
||||||
|
}
|
||||||
109
packages/agent/src/mcp-rules.ts
Normal file
109
packages/agent/src/mcp-rules.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
// Règles `Mcp(serveur[,outil])` des settings.json : pures (aucune E/S), évaluées AVANT toute connexion.
|
||||||
|
//
|
||||||
|
// - `Mcp(github)` : le serveur « github » (clé de ~/.maia/mcp.json), tous ses outils ;
|
||||||
|
// - `Mcp(github,delete_repository)`: un outil précis du serveur ; l'outil accepte `*` (`Mcp(github,delete_*)`) ;
|
||||||
|
// - `Mcp(github,*)` : tous les outils (équivaut à `Mcp(github)`) ;
|
||||||
|
// - `Mcp(*)` = `Mcp(*,*)` : tous les serveurs déclarés et tous leurs outils ;
|
||||||
|
// - `Mcp(*,outil)` : cet outil (motif accepté) sur tous les serveurs.
|
||||||
|
|
||||||
|
import type { PermissionRules } from "./settings";
|
||||||
|
|
||||||
|
/** Préfixe des noms d'outils MCP exposés au modèle : `mcp__<serveur>__<outil>`. */
|
||||||
|
export const MCP_PREFIX = "mcp__";
|
||||||
|
/** Limite de longueur d'un nom d'outil côté API OpenAI. */
|
||||||
|
export const MAX_TOOL_NAME = 64;
|
||||||
|
|
||||||
|
/** Une clé de serveur est utilisable dans un nom d'outil : lettres, chiffres, `-` et `_` (jamais `__`). */
|
||||||
|
export function isValidServerKey(key: string): boolean {
|
||||||
|
return /^[A-Za-z0-9_-]+$/.test(key) && !key.includes("__");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nom d'outil compatible avec les API LLM : caractères hors `[A-Za-z0-9_-]` remplacés par `_`. */
|
||||||
|
export function sanitizeToolName(name: string): string {
|
||||||
|
return name.replace(/[^A-Za-z0-9_-]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mcpToolName = (server: string, tool: string): string => `${MCP_PREFIX}${server}__${sanitizeToolName(tool)}`;
|
||||||
|
|
||||||
|
/** `mcp__github__create_issue` → { server: "github", tool: "create_issue" } ; null si ce n'est pas un outil MCP. */
|
||||||
|
export function splitMcpToolName(name: string): { server: string; tool: string } | null {
|
||||||
|
if (!name.startsWith(MCP_PREFIX)) return null;
|
||||||
|
const rest = name.slice(MCP_PREFIX.length);
|
||||||
|
const i = rest.indexOf("__");
|
||||||
|
if (i <= 0) return null;
|
||||||
|
return { server: rest.slice(0, i), tool: rest.slice(i + 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpSpec {
|
||||||
|
server: string;
|
||||||
|
/** Motif d'outil (`*` autorisé) ; absent : tous les outils du serveur. */
|
||||||
|
tool?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `github` ou `github,delete_repository` (espaces tolérés autour de la virgule). */
|
||||||
|
export function parseMcpSpec(spec: string | undefined): McpSpec | null {
|
||||||
|
if (!spec) return null;
|
||||||
|
const i = spec.indexOf(",");
|
||||||
|
const server = (i === -1 ? spec : spec.slice(0, i)).trim();
|
||||||
|
const tool = i === -1 ? undefined : spec.slice(i + 1).trim();
|
||||||
|
if (!server) return null;
|
||||||
|
return { server, tool: tool || undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeRe = (text: string): string => text.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
|
||||||
|
function matchToolPattern(pattern: string, tool: string): boolean {
|
||||||
|
const re = new RegExp(`^${pattern.split("*").map((part) => escapeRe(sanitizeToolName(part))).join(".*")}$`);
|
||||||
|
return re.test(sanitizeToolName(tool));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La spécification `spec` (contenu de `Mcp(...)`) vise-t-elle cet appel d'outil `mcp__serveur__outil` ? */
|
||||||
|
export function mcpSpecMatches(spec: string | undefined, toolName: string): boolean {
|
||||||
|
const call = splitMcpToolName(toolName);
|
||||||
|
if (!call) return false;
|
||||||
|
if (!spec) return true; // `Mcp` seul : tous les serveurs
|
||||||
|
const parsed = parseMcpSpec(spec);
|
||||||
|
if (!parsed || (parsed.server !== "*" && parsed.server !== call.server)) return false;
|
||||||
|
return parsed.tool === undefined || matchToolPattern(parsed.tool, call.tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpSpecs = (rules: string[]): McpSpec[] =>
|
||||||
|
rules.flatMap((raw) => {
|
||||||
|
const m = /^Mcp\((.*)\)$/s.exec(raw.trim());
|
||||||
|
const spec = m ? parseMcpSpec(m[1]) : null;
|
||||||
|
return spec ? [spec] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Règle qui vise tout le serveur : `Mcp(s)` ou `Mcp(s,*)`. */
|
||||||
|
const sameServer = (s: McpSpec, key: string): boolean => s.server === "*" || s.server === key;
|
||||||
|
|
||||||
|
const wholeServer = (s: McpSpec): boolean => s.tool === undefined || s.tool === "*";
|
||||||
|
|
||||||
|
export interface McpServerSelection {
|
||||||
|
/** Serveurs à connecter : autorisés par une règle allow et non interdits en entier par une règle deny. */
|
||||||
|
enabled: string[];
|
||||||
|
/** Serveurs cités dans les règles Mcp(...) mais absents du fichier de déclaration. */
|
||||||
|
unknown: string[];
|
||||||
|
/** Outil retiré par une règle deny `Mcp(serveur,outil)` (jamais exposé au modèle). */
|
||||||
|
isToolDenied(server: string, tool: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détermine, avant toute connexion, quels serveurs déclarés (clés de mcp.json) sont activés.
|
||||||
|
* Par défaut aucun : il faut une règle allow `Mcp(serveur)` (ou `Mcp(serveur,outil)`) dans les settings.json.
|
||||||
|
* Une règle deny `Mcp(serveur)` / `Mcp(serveur,*)` désactive le serveur : on ne cherche pas à s'y connecter.
|
||||||
|
*/
|
||||||
|
export function selectMcpServers(rules: PermissionRules, declared: string[]): McpServerSelection {
|
||||||
|
const allow = mcpSpecs(rules.allow);
|
||||||
|
const deny = mcpSpecs(rules.deny);
|
||||||
|
const mentioned = new Set([...allow, ...deny, ...mcpSpecs(rules.ask)].map((s) => s.server));
|
||||||
|
const enabled = declared.filter(
|
||||||
|
(key) => allow.some((s) => sameServer(s, key)) && !deny.some((s) => sameServer(s, key) && wholeServer(s)),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
unknown: [...mentioned].filter((k) => k !== "*" && !declared.includes(k)),
|
||||||
|
isToolDenied: (server, tool) =>
|
||||||
|
deny.some((s) => sameServer(s, server) && (wholeServer(s) || (s.tool !== undefined && matchToolPattern(s.tool, tool)))),
|
||||||
|
};
|
||||||
|
}
|
||||||
316
packages/agent/src/mcp.test.ts
Normal file
316
packages/agent/src/mcp.test.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||||
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { expandEnv, loadMcpConfig } from "./mcp-config";
|
||||||
|
import { mcpSpecMatches, mcpToolName, selectMcpServers, splitMcpToolName } from "./mcp-rules";
|
||||||
|
import { setupMcp } from "./mcp";
|
||||||
|
import { createPermissionPolicy } from "./permissions";
|
||||||
|
|
||||||
|
const none = { allow: [] as string[], ask: [] as string[], deny: [] as string[] };
|
||||||
|
|
||||||
|
// ───────────── Faux serveur MCP ─────────────
|
||||||
|
|
||||||
|
type Mode = "json" | "sse-post" | "legacy" | "http500";
|
||||||
|
interface Fake {
|
||||||
|
url: string;
|
||||||
|
hits: number;
|
||||||
|
auth: (string | undefined)[];
|
||||||
|
calls: { name: string; args: unknown }[];
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{ name: "echo", description: "Renvoie le message", inputSchema: { type: "object", properties: { msg: { type: "string" } } } },
|
||||||
|
{ name: "delete_repository", description: "Dangereux", inputSchema: { type: "object", properties: {} } },
|
||||||
|
{ name: "boom", description: "Échoue", inputSchema: { type: "object", properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function rpcResult(fake: Fake, msg: { id?: number; method?: string; params?: Record<string, unknown> }): object | null {
|
||||||
|
if (msg.id === undefined) return null; // notification
|
||||||
|
if (msg.method === "initialize") return { jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fake", version: "1" } } };
|
||||||
|
if (msg.method === "tools/list") return { jsonrpc: "2.0", id: msg.id, result: { tools: TOOLS } };
|
||||||
|
if (msg.method === "tools/call") {
|
||||||
|
const name = String(msg.params?.name);
|
||||||
|
fake.calls.push({ name, args: msg.params?.arguments });
|
||||||
|
if (name === "boom") return { jsonrpc: "2.0", id: msg.id, result: { isError: true, content: [{ type: "text", text: "kaboom" }] } };
|
||||||
|
const text = `echo:${String((msg.params?.arguments as { msg?: string } | undefined)?.msg)}`;
|
||||||
|
return { jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }] } };
|
||||||
|
}
|
||||||
|
return { jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "méthode inconnue" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function body(req: IncomingMessage): Promise<string> {
|
||||||
|
let s = "";
|
||||||
|
for await (const c of req) s += c;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFake(mode: Mode): Promise<Fake> {
|
||||||
|
const streams: ServerResponse[] = [];
|
||||||
|
const fake: Fake = { url: "", hits: 0, auth: [], calls: [], close: async () => undefined };
|
||||||
|
const server: Server = createServer(async (req, res) => {
|
||||||
|
fake.hits++;
|
||||||
|
fake.auth.push(req.headers.authorization);
|
||||||
|
if (mode === "http500") {
|
||||||
|
res.writeHead(500, { "Content-Type": "text/plain" }).end("panne");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "legacy") {
|
||||||
|
if (req.method === "GET") {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/event-stream" });
|
||||||
|
res.write("event: endpoint\ndata: /messages?s=1\n\n");
|
||||||
|
streams.push(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = JSON.parse(await body(req));
|
||||||
|
res.writeHead(202).end("Accepted");
|
||||||
|
const out = rpcResult(fake, msg);
|
||||||
|
if (out) streams[0]?.write(`event: message\ndata: ${JSON.stringify(out)}\n\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
res.writeHead(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = JSON.parse(await body(req));
|
||||||
|
const out = rpcResult(fake, msg);
|
||||||
|
if (!out) {
|
||||||
|
res.writeHead(202).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "sse-post") {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Mcp-Session-Id": "abc" });
|
||||||
|
res.end(`event: message\ndata: ${JSON.stringify(out)}\n\n`);
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json", "Mcp-Session-Id": "abc" }).end(JSON.stringify(out));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||||
|
const port = (server.address() as AddressInfo).port;
|
||||||
|
fake.url = `http://127.0.0.1:${port}${mode === "legacy" ? "/sse" : "/mcp"}`;
|
||||||
|
fake.close = () =>
|
||||||
|
new Promise<void>((r) => {
|
||||||
|
for (const s of streams) s.end();
|
||||||
|
server.closeAllConnections();
|
||||||
|
server.close(() => r());
|
||||||
|
});
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function home(servers: object): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "maia-mcp-"));
|
||||||
|
await mkdir(join(dir, ".maia"), { recursive: true });
|
||||||
|
await writeFile(join(dir, ".maia", "mcp.json"), JSON.stringify({ mcpServers: servers }));
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── Configuration ─────────────
|
||||||
|
|
||||||
|
describe("mcp.json", () => {
|
||||||
|
it("expansion ${VAR} : valeur de l'environnement, vide + signalement si absente", () => {
|
||||||
|
const missing: string[] = [];
|
||||||
|
expect(expandEnv("Bearer ${TOKEN}", { TOKEN: "abc" }, missing)).toBe("Bearer abc");
|
||||||
|
expect(expandEnv("x${NOPE}y", {}, missing)).toBe("xy");
|
||||||
|
expect(missing).toEqual(["NOPE"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charge les serveurs avec valeurs par défaut et expansion dans url et headers", async () => {
|
||||||
|
const h = await home({
|
||||||
|
github: { type: "http", url: "https://api.example.com/mcp/", headers: { Authorization: "Bearer ${GITHUB_TOKEN}" }, excludeTools: ["delete_repository"] },
|
||||||
|
docs: { url: "https://docs.example.com/mcp", timeout: 60 },
|
||||||
|
});
|
||||||
|
const { servers, warnings } = await loadMcpConfig(h, { GITHUB_TOKEN: "t0k" });
|
||||||
|
expect(warnings).toEqual([]);
|
||||||
|
expect(servers.length).toBe(2);
|
||||||
|
expect(servers[0]!.headers.Authorization).toBe("Bearer t0k");
|
||||||
|
expect(servers[0]!.excludeTools).toEqual(["delete_repository"]);
|
||||||
|
expect(servers[0]!.timeout).toBe(30);
|
||||||
|
expect(servers[1]!.type).toBe("http");
|
||||||
|
expect(servers[1]!.timeout).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fichier absent : rien, sans avertissement", async () => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "maia-mcp-"));
|
||||||
|
const r = await loadMcpConfig(dir, {});
|
||||||
|
expect(r.servers).toEqual([]);
|
||||||
|
expect(r.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serveurs invalides ignorés avec avertissement ; enabled:false ignoré en silence", async () => {
|
||||||
|
const h = await home({
|
||||||
|
sansurl: { type: "http" },
|
||||||
|
mauvais_type: { url: "https://a.example/mcp", type: "ws" },
|
||||||
|
"a__b": { url: "https://a.example/mcp" },
|
||||||
|
ftp: { url: "ftp://a.example/mcp" },
|
||||||
|
off: { url: "https://a.example/mcp", enabled: false },
|
||||||
|
bon: { url: "https://a.example/mcp", headers: { X: "${ABSENT}" } },
|
||||||
|
});
|
||||||
|
const { servers, warnings } = await loadMcpConfig(h, {});
|
||||||
|
expect(servers.map((s) => s.key)).toEqual(["bon"]);
|
||||||
|
expect(warnings.length).toBe(5); // 4 serveurs refusés + variable absente
|
||||||
|
expect(warnings.some((w) => w.includes("ABSENT"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────── Règles Mcp(...) ─────────────
|
||||||
|
|
||||||
|
describe("règles Mcp(serveur[,outil])", () => {
|
||||||
|
it("noms d'outils : construction et découpage", () => {
|
||||||
|
expect(mcpToolName("github", "create_issue")).toBe("mcp__github__create_issue");
|
||||||
|
expect(mcpToolName("github", "a.b c")).toBe("mcp__github__a_b_c");
|
||||||
|
expect(splitMcpToolName("mcp__github__create_issue")).toEqual({ server: "github", tool: "create_issue" });
|
||||||
|
expect(splitMcpToolName("Bash")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("par défaut aucun serveur n'est activé ; allow Mcp(s) active ; allow d'un autre serveur n'active pas", () => {
|
||||||
|
expect(selectMcpServers(none, ["github", "docs"]).enabled).toEqual([]);
|
||||||
|
expect(selectMcpServers({ ...none, allow: ["Mcp(github)"] }, ["github", "docs"]).enabled).toEqual(["github"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allow Mcp(github) + deny Mcp(github,*) : serveur désactivé ; deny d'un outil : serveur actif", () => {
|
||||||
|
const off = selectMcpServers({ ...none, allow: ["Mcp(github)"], deny: ["Mcp(github,*)"] }, ["github"]);
|
||||||
|
expect(off.enabled).toEqual([]);
|
||||||
|
const on = selectMcpServers({ ...none, allow: ["Mcp(github)"], deny: ["Mcp(github,delete_repository)"] }, ["github"]);
|
||||||
|
expect(on.enabled).toEqual(["github"]);
|
||||||
|
expect(on.isToolDenied("github", "delete_repository")).toBe(true);
|
||||||
|
expect(on.isToolDenied("github", "create_issue")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("règle sur un serveur non déclaré : signalée", () => {
|
||||||
|
expect(selectMcpServers({ ...none, allow: ["Mcp(inconnu)"] }, ["github"]).unknown).toEqual(["inconnu"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serveur * : Mcp(*) et Mcp(*,*) = tous les serveurs et outils ; Mcp(*,outil) = cet outil partout", () => {
|
||||||
|
for (const rule of ["Mcp(*)", "Mcp(*,*)"]) {
|
||||||
|
expect(selectMcpServers({ ...none, allow: [rule] }, ["github", "docs"]).enabled).toEqual(["github", "docs"]);
|
||||||
|
expect(selectMcpServers({ ...none, allow: ["Mcp(github)"], deny: [rule] }, ["github", "docs"]).enabled).toEqual([]);
|
||||||
|
}
|
||||||
|
expect(selectMcpServers({ ...none, allow: ["Mcp(*)"], deny: ["Mcp(docs)"] }, ["github", "docs"]).enabled).toEqual(["github"]);
|
||||||
|
expect(selectMcpServers({ ...none, allow: ["Mcp(*)"] }, ["github"]).unknown).toEqual([]);
|
||||||
|
const sel = selectMcpServers({ ...none, allow: ["Mcp(*)"], deny: ["Mcp(*,delete_repository)"] }, ["github", "docs"]);
|
||||||
|
expect(sel.enabled).toEqual(["github", "docs"]);
|
||||||
|
expect(sel.isToolDenied("docs", "delete_repository")).toBe(true);
|
||||||
|
expect(sel.isToolDenied("docs", "search")).toBe(false);
|
||||||
|
const policy = createPermissionPolicy({ allow: ["Mcp(*)"], ask: ["Mcp(*,merge_*)"], deny: ["Mcp(*,drop)"] });
|
||||||
|
const check = (t: string): string => policy.check(t, {}, { cwd: "/p" })!.decision;
|
||||||
|
expect(check("mcp__a__search")).toBe("allow");
|
||||||
|
expect(check("mcp__b__merge_pr")).toBe("ask");
|
||||||
|
expect(check("mcp__b__drop")).toBe("deny");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("motifs d'outil avec *", () => {
|
||||||
|
expect(mcpSpecMatches("github,delete_*", "mcp__github__delete_repository")).toBe(true);
|
||||||
|
expect(mcpSpecMatches("github,delete_*", "mcp__github__create_issue")).toBe(false);
|
||||||
|
expect(mcpSpecMatches("github", "mcp__docs__search")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("politique : allow Mcp(s) autorise sans confirmation, deny d'un outil l'emporte, sans règle : confirmation", () => {
|
||||||
|
const policy = createPermissionPolicy({ allow: ["Mcp(github)"], ask: [], deny: ["Mcp(github,delete_repository)"] });
|
||||||
|
const check = (t: string): string => policy.check(t, {}, { cwd: "/p" })!.decision;
|
||||||
|
expect(check("mcp__github__create_issue")).toBe("allow");
|
||||||
|
expect(check("mcp__github__delete_repository")).toBe("deny");
|
||||||
|
expect(check("mcp__docs__search")).toBe("ask");
|
||||||
|
expect(check("Bash")).toBe("ask");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("politique : ask sur un outil précis l'emporte sur allow du serveur", () => {
|
||||||
|
const policy = createPermissionPolicy({ allow: ["Mcp(github)"], ask: ["Mcp(github,merge_pull_request)"], deny: [] });
|
||||||
|
expect(policy.check("mcp__github__merge_pull_request", {}, { cwd: "/p" })!.decision).toBe("ask");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────── Connexion (faux serveur) ─────────────
|
||||||
|
|
||||||
|
describe("connexion aux serveurs MCP", () => {
|
||||||
|
for (const mode of ["json", "sse-post", "legacy"] as const) {
|
||||||
|
it(`transport ${mode} : liste des outils, filtrage, appel d'outil, en-tête Authorization`, async () => {
|
||||||
|
const fake = await startFake(mode);
|
||||||
|
try {
|
||||||
|
const h = await home({
|
||||||
|
fake: { type: mode === "legacy" ? "sse" : "http", url: fake.url, headers: { Authorization: "Bearer ${TOK}" }, excludeTools: ["delete_repository"] },
|
||||||
|
});
|
||||||
|
const mcp = await setupMcp(h, { ...none, allow: ["Mcp(fake)"] }, { env: { TOK: "secret" } });
|
||||||
|
expect(mcp.warnings).toEqual([]);
|
||||||
|
expect(mcp.statuses).toEqual([{ name: "fake", status: "ok", toolCount: 2 }]);
|
||||||
|
expect(mcp.tools.map((t) => t.spec.name)).toEqual(["mcp__fake__echo", "mcp__fake__boom"]);
|
||||||
|
expect(fake.auth.every((a) => a === "Bearer secret")).toBe(true);
|
||||||
|
const echo = mcp.tools[0]!;
|
||||||
|
expect(echo.permission).toBe("ask");
|
||||||
|
expect(await echo.execute({ msg: "salut" }, { cwd: "/p" })).toBe("echo:salut");
|
||||||
|
expect(fake.calls).toEqual([{ name: "echo", args: { msg: "salut" } }]);
|
||||||
|
await mcp.close();
|
||||||
|
} finally {
|
||||||
|
await fake.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("un outil qui renvoie isError lève une erreur avec le texte du serveur", async () => {
|
||||||
|
const fake = await startFake("json");
|
||||||
|
try {
|
||||||
|
const h = await home({ fake: { url: fake.url } });
|
||||||
|
const mcp = await setupMcp(h, { ...none, allow: ["Mcp(fake)"] }, { env: {} });
|
||||||
|
let message = "";
|
||||||
|
try {
|
||||||
|
await mcp.tools.find((t) => t.spec.name === "mcp__fake__boom")!.execute({}, { cwd: "/p" });
|
||||||
|
} catch (err) {
|
||||||
|
message = (err as Error).message;
|
||||||
|
}
|
||||||
|
expect(message).toBe("kaboom");
|
||||||
|
await mcp.close();
|
||||||
|
} finally {
|
||||||
|
await fake.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("règle deny Mcp(s,outil) : l'outil n'est jamais exposé", async () => {
|
||||||
|
const fake = await startFake("json");
|
||||||
|
try {
|
||||||
|
const h = await home({ fake: { url: fake.url } });
|
||||||
|
const mcp = await setupMcp(h, { ...none, allow: ["Mcp(fake)"], deny: ["Mcp(fake,delete_repository)", "Mcp(fake,boom)"] }, { env: {} });
|
||||||
|
expect(mcp.tools.map((t) => t.spec.name)).toEqual(["mcp__fake__echo"]);
|
||||||
|
await mcp.close();
|
||||||
|
} finally {
|
||||||
|
await fake.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serveur non autorisé ou désactivé par Mcp(s,*) : aucune connexion tentée, absent de la liste", async () => {
|
||||||
|
const fake = await startFake("json");
|
||||||
|
try {
|
||||||
|
const h = await home({ fake: { url: fake.url } });
|
||||||
|
const sansRegle = await setupMcp(h, none, { env: {} });
|
||||||
|
expect(sansRegle.statuses).toEqual([]);
|
||||||
|
const desactive = await setupMcp(h, { ...none, allow: ["Mcp(fake)"], deny: ["Mcp(fake,*)"] }, { env: {} });
|
||||||
|
expect(desactive.statuses).toEqual([]);
|
||||||
|
expect(desactive.tools).toEqual([]);
|
||||||
|
expect(fake.hits).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await fake.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("échec (HTTP 500, serveur injoignable) : statut FAIL, avertissement, l'autre serveur reste OK", async () => {
|
||||||
|
const bad = await startFake("http500");
|
||||||
|
const good = await startFake("json");
|
||||||
|
try {
|
||||||
|
const h = await home({
|
||||||
|
panne: { url: bad.url },
|
||||||
|
absent: { url: "http://127.0.0.1:1/mcp" },
|
||||||
|
bon: { url: good.url },
|
||||||
|
});
|
||||||
|
const mcp = await setupMcp(h, { ...none, allow: ["Mcp(panne)", "Mcp(absent)", "Mcp(bon)"] }, { env: {} });
|
||||||
|
expect(mcp.statuses.map((s) => `${s.name}:${s.status}`)).toEqual(["panne:fail", "absent:fail", "bon:ok"]);
|
||||||
|
expect(mcp.statuses[0]!.error!.includes("500")).toBe(true);
|
||||||
|
expect(mcp.warnings.length).toBe(2);
|
||||||
|
expect(mcp.tools.length).toBe(3);
|
||||||
|
await mcp.close();
|
||||||
|
} finally {
|
||||||
|
await bad.close();
|
||||||
|
await good.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
104
packages/agent/src/mcp.ts
Normal file
104
packages/agent/src/mcp.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import type { Tool } from "@maia/core";
|
||||||
|
import { loadMcpConfig, type McpServerConfig } from "./mcp-config";
|
||||||
|
import { McpClient, type McpToolDef } from "./mcp-client";
|
||||||
|
import { MAX_TOOL_NAME, mcpToolName, sanitizeToolName, selectMcpServers } from "./mcp-rules";
|
||||||
|
import type { PermissionRules } from "./settings";
|
||||||
|
|
||||||
|
export interface McpServerStatus {
|
||||||
|
/** Clé du serveur (mcp.json). */
|
||||||
|
name: string;
|
||||||
|
status: "ok" | "fail";
|
||||||
|
error?: string;
|
||||||
|
toolCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpSetup {
|
||||||
|
tools: Tool[];
|
||||||
|
/** Un état par serveur ACTIVÉ (les serveurs non activés n'apparaissent pas). */
|
||||||
|
statuses: McpServerStatus[];
|
||||||
|
warnings: string[];
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesAny = (patterns: string[], tool: string): boolean =>
|
||||||
|
patterns.some((p) => new RegExp(`^${p.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(tool));
|
||||||
|
|
||||||
|
/** Outil du modèle pour un outil MCP : `mcp__serveur__outil`, confirmation demandée sauf règle allow. */
|
||||||
|
export function createMcpTool(client: McpClient, serverKey: string, def: McpToolDef): Tool {
|
||||||
|
return {
|
||||||
|
spec: {
|
||||||
|
name: mcpToolName(serverKey, def.name),
|
||||||
|
description: `[MCP ${serverKey}] ${def.description ?? def.name}`,
|
||||||
|
inputSchema: def.inputSchema ?? { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
permission: "ask",
|
||||||
|
describe: (input) => {
|
||||||
|
const text = JSON.stringify(input);
|
||||||
|
return `${serverKey} › ${def.name} ${text.length > 120 ? `${text.slice(0, 120)}…` : text}`;
|
||||||
|
},
|
||||||
|
execute: (input, ctx) => client.callTool(def.name, input, ctx.signal),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connecte un serveur ; ne lève jamais : l'échec est porté par le statut. */
|
||||||
|
async function connectServer(
|
||||||
|
cfg: McpServerConfig,
|
||||||
|
isToolDenied: (server: string, tool: string) => boolean,
|
||||||
|
fetchFn: typeof fetch | undefined,
|
||||||
|
): Promise<{ client: McpClient; tools: Tool[]; status: McpServerStatus; warnings: string[] }> {
|
||||||
|
const client = new McpClient(cfg, fetchFn);
|
||||||
|
const warnings: string[] = [];
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
const defs = await client.listTools();
|
||||||
|
const tools: Tool[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const def of defs) {
|
||||||
|
if (cfg.includeTools && !matchesAny(cfg.includeTools, def.name)) continue;
|
||||||
|
if (cfg.excludeTools && matchesAny(cfg.excludeTools, def.name)) continue;
|
||||||
|
if (isToolDenied(cfg.key, def.name)) continue;
|
||||||
|
const name = mcpToolName(cfg.key, def.name);
|
||||||
|
if (name.length > MAX_TOOL_NAME) {
|
||||||
|
warnings.push(`MCP ${cfg.key} : outil « ${def.name} » ignoré (nom trop long : ${name.length} > ${MAX_TOOL_NAME} caractères)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seen.has(name)) {
|
||||||
|
warnings.push(`MCP ${cfg.key} : outil « ${def.name} » ignoré (nom en double après nettoyage : ${sanitizeToolName(def.name)})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(name);
|
||||||
|
tools.push(createMcpTool(client, cfg.key, def));
|
||||||
|
}
|
||||||
|
return { client, tools, status: { name: cfg.key, status: "ok", toolCount: tools.length }, warnings };
|
||||||
|
} catch (err) {
|
||||||
|
await client.close();
|
||||||
|
const error = (err as Error).message;
|
||||||
|
warnings.push(`MCP ${cfg.key} : connexion impossible (${error})`);
|
||||||
|
return { client, tools: [], status: { name: cfg.key, status: "fail", error, toolCount: 0 }, warnings };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serveurs MCP : déclarations globales (~/.maia/mcp.json) → activation par les règles Mcp(...) des settings.json
|
||||||
|
* (traitée AVANT toute connexion) → connexion en parallèle aux seuls serveurs activés.
|
||||||
|
*/
|
||||||
|
export async function setupMcp(
|
||||||
|
homeDir: string,
|
||||||
|
rules: PermissionRules,
|
||||||
|
opts: { env?: Record<string, string | undefined>; fetch?: typeof fetch } = {},
|
||||||
|
): Promise<McpSetup> {
|
||||||
|
const { servers, warnings } = await loadMcpConfig(homeDir, opts.env ?? process.env);
|
||||||
|
const selection = selectMcpServers(rules, servers.map((s) => s.key));
|
||||||
|
for (const key of selection.unknown) warnings.push(`Règle Mcp(${key}…) : serveur « ${key} » absent de ~/.maia/mcp.json`);
|
||||||
|
|
||||||
|
const active = servers.filter((s) => selection.enabled.includes(s.key));
|
||||||
|
const results = await Promise.all(active.map((cfg) => connectServer(cfg, selection.isToolDenied, opts.fetch)));
|
||||||
|
return {
|
||||||
|
tools: results.flatMap((r) => r.tools),
|
||||||
|
statuses: results.map((r) => r.status),
|
||||||
|
warnings: [...warnings, ...results.flatMap((r) => r.warnings)],
|
||||||
|
close: async () => {
|
||||||
|
await Promise.all(results.map((r) => r.client.close()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,7 @@ async function setup() {
|
||||||
return { home, cwd, templatePath };
|
return { home, cwd, templatePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("/newAgent : création depuis le modèle", () => {
|
describe("/createAgent : création depuis le modèle", () => {
|
||||||
it("dossiers : global = ~/.maia/agents, projet = ./.maia/agents", () => {
|
it("dossiers : global = ~/.maia/agents, projet = ./.maia/agents", () => {
|
||||||
expect(agentsDirFor("global", "/p", "/h")).toBe("/h/.maia/agents");
|
expect(agentsDirFor("global", "/p", "/h")).toBe("/h/.maia/agents");
|
||||||
expect(agentsDirFor("projet", "/p", "/h")).toBe("/p/.maia/agents");
|
expect(agentsDirFor("projet", "/p", "/h")).toBe("/p/.maia/agents");
|
||||||
|
|
|
||||||
83
packages/agent/src/permissions-redirect.test.ts
Normal file
83
packages/agent/src/permissions-redirect.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createPermissionPolicy, extractRedirections } from "./permissions";
|
||||||
|
|
||||||
|
const decide = (rules: { allow?: string[]; ask?: string[]; deny?: string[] }, command: string): string =>
|
||||||
|
createPermissionPolicy({ allow: [], ask: [], deny: [], ...rules }).check("Bash", { command }, { cwd: "/p" })!.decision;
|
||||||
|
|
||||||
|
describe("extractRedirections", () => {
|
||||||
|
it("repère >, >>, >|, &>, 2> et leurs cibles ; retire la redirection de la commande", () => {
|
||||||
|
expect(extractRedirections("ls > out.txt")).toMatchObject({ targets: ["out.txt"], unknown: false });
|
||||||
|
expect(extractRedirections("ls >> logs/a.log").targets).toEqual(["logs/a.log"]);
|
||||||
|
expect(extractRedirections("ls >| f").targets).toEqual(["f"]);
|
||||||
|
expect(extractRedirections("cmd 2> err.txt").targets).toEqual(["err.txt"]);
|
||||||
|
expect(extractRedirections("cmd &> all.txt").targets).toEqual(["all.txt"]);
|
||||||
|
expect(extractRedirections("cmd >&all.txt").targets).toEqual(["all.txt"]);
|
||||||
|
expect(extractRedirections("ls>f").targets).toEqual(["f"]);
|
||||||
|
expect(extractRedirections("ls -la > out.txt").stripped.trim()).toBe("ls -la");
|
||||||
|
expect(extractRedirections("cmd 2>/dev/null | wc").stripped.replace(/\s+/g, " ").trim()).toBe("cmd | wc");
|
||||||
|
});
|
||||||
|
it("duplication de descripteur (2>&1, >&2) : aucun fichier", () => {
|
||||||
|
expect(extractRedirections("cmd 2>&1")).toMatchObject({ targets: [], unknown: false });
|
||||||
|
expect(extractRedirections("cmd >&2").targets).toEqual([]);
|
||||||
|
expect(extractRedirections("cmd > out.txt 2>&1").targets).toEqual(["out.txt"]);
|
||||||
|
});
|
||||||
|
it("un > entre guillemets ou échappé n'est pas une redirection ; guillemets autour de la cible retirés", () => {
|
||||||
|
expect(extractRedirections(`echo "a > b" 'c > d'`).targets).toEqual([]);
|
||||||
|
expect(extractRedirections("echo a \\> b").targets).toEqual([]);
|
||||||
|
expect(extractRedirections(`ls > "mon fichier.txt"`).targets).toEqual(["mon fichier.txt"]);
|
||||||
|
expect(extractRedirections("echo 2 > f").targets).toEqual(["f"]);
|
||||||
|
expect(extractRedirections("echo 2 > f").stripped.trim()).toBe("echo 2");
|
||||||
|
});
|
||||||
|
it("analyse incertaine : guillemet non fermé, cible absente, >( ", () => {
|
||||||
|
expect(extractRedirections(`echo "abc > f`).unknown).toBe(true);
|
||||||
|
expect(extractRedirections("ls >").unknown).toBe(true);
|
||||||
|
expect(extractRedirections("ls > (cat)").unknown).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("règles Redirect", () => {
|
||||||
|
it("sans règle Redirect : toute redirection de fichier repasse en confirmation (comportement par défaut)", () => {
|
||||||
|
expect(decide({ allow: ["Bash(ls *)"] }, "ls")).toBe("allow");
|
||||||
|
expect(decide({ allow: ["Bash(ls *)"] }, "ls > out.txt")).toBe("ask");
|
||||||
|
expect(decide({ allow: ["Bash(ls *)"] }, "ls >> out.txt")).toBe("ask");
|
||||||
|
expect(decide({ allow: ["Bash(ls *)"] }, "ls 2> err.txt")).toBe("ask");
|
||||||
|
});
|
||||||
|
it("2>&1 et un > entre guillemets ne demandent rien de plus", () => {
|
||||||
|
expect(decide({ allow: ["Bash(ls *)"] }, "ls 2>&1")).toBe("allow");
|
||||||
|
expect(decide({ allow: ["Bash(echo *)"] }, `echo "a > b"`)).toBe("allow");
|
||||||
|
});
|
||||||
|
it("allow Redirect(motif) : seules les cibles couvertes passent, la commande doit l'être aussi", () => {
|
||||||
|
const rules = { allow: ["Bash(ls *)", "Redirect(/dev/null)", "Redirect(logs/**)"] };
|
||||||
|
expect(decide(rules, "ls > /dev/null")).toBe("allow");
|
||||||
|
expect(decide(rules, "ls 2>/dev/null")).toBe("allow");
|
||||||
|
expect(decide(rules, "ls >> logs/a.log 2>&1")).toBe("allow");
|
||||||
|
expect(decide(rules, "ls > src/main.ts")).toBe("ask");
|
||||||
|
expect(decide(rules, "ls > logs/a.log > src/x")).toBe("ask"); // toutes les cibles doivent être couvertes
|
||||||
|
expect(decide(rules, "cat f > logs/a.log")).toBe("ask"); // cat n'est pas autorisé
|
||||||
|
expect(decide({ allow: ["Redirect(logs/**)"] }, "ls > logs/a.log")).toBe("ask"); // Redirect seul n'autorise pas la commande
|
||||||
|
});
|
||||||
|
it("allow Redirect (sans motif) : toutes les cibles ; cible dynamique ($VAR, glob) jamais couverte par un motif", () => {
|
||||||
|
expect(decide({ allow: ["Bash(ls *)", "Redirect"] }, "ls > n/importe/quoi")).toBe("allow");
|
||||||
|
const rules = { allow: ["Bash(ls *)", "Redirect(logs/**)"] };
|
||||||
|
expect(decide(rules, "ls > $HOME/x")).toBe("ask");
|
||||||
|
expect(decide(rules, "ls > logs/*.txt")).toBe("ask");
|
||||||
|
expect(decide(rules, "ls > logs/../src/a")).toBe("ask");
|
||||||
|
});
|
||||||
|
it("deny / ask Redirect(motif) : refus même si la commande est autorisée, même avec « Bash » complet", () => {
|
||||||
|
const rules = { allow: ["Bash", "Redirect"], deny: ["Redirect(.env)", "Redirect(.git/**)"], ask: ["Redirect(docs/**)"] };
|
||||||
|
expect(decide(rules, "echo x > .env")).toBe("deny");
|
||||||
|
expect(decide(rules, "echo x >> .git/config")).toBe("deny");
|
||||||
|
expect(decide(rules, "echo x | tee a; echo y 2> .env")).toBe("deny");
|
||||||
|
expect(decide(rules, "echo x > docs/a.md")).toBe("ask");
|
||||||
|
expect(decide(rules, "echo x > notes.txt")).toBe("allow");
|
||||||
|
});
|
||||||
|
it("deny Bash existant toujours actif ; substitution $(…) toujours en confirmation", () => {
|
||||||
|
expect(decide({ allow: ["Bash(ls *)", "Redirect"], deny: ["Bash(rm *)"] }, "ls > f; rm f")).toBe("deny");
|
||||||
|
expect(decide({ allow: ["Bash(ls *)", "Redirect"] }, "ls $(pwd) > f")).toBe("ask");
|
||||||
|
expect(decide({ allow: ["Bash(ls *)", "Redirect"] }, "ls > >(cat)")).toBe("ask");
|
||||||
|
});
|
||||||
|
it("redirection seule sans commande, ou guillemet non fermé : confirmation", () => {
|
||||||
|
expect(decide({ allow: ["Bash(ls *)", "Redirect"] }, "> f")).toBe("ask");
|
||||||
|
expect(decide({ allow: ["Bash(echo *)", "Redirect"] }, `echo "abc > f`)).toBe("ask");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||||
import picomatch from "picomatch";
|
import picomatch from "picomatch";
|
||||||
import type { PermissionPolicy } from "@maia/core";
|
import type { PermissionPolicy } from "@maia/core";
|
||||||
|
import { mcpSpecMatches } from "./mcp-rules";
|
||||||
import type { PermissionRules } from "./settings";
|
import type { PermissionRules } from "./settings";
|
||||||
|
|
||||||
interface Rule {
|
interface Rule {
|
||||||
|
|
@ -11,8 +12,101 @@ interface Rule {
|
||||||
|
|
||||||
const RULE_RE = /^([A-Za-z0-9_.-]+)(?:\((.*)\))?$/s;
|
const RULE_RE = /^([A-Za-z0-9_.-]+)(?:\((.*)\))?$/s;
|
||||||
const PATH_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]);
|
const PATH_TOOLS = new Set(["Read", "Write", "Edit", "Glob", "Grep"]);
|
||||||
/** Substitutions de commandes, redirections : une règle allow ne peut pas les couvrir de façon sûre. */
|
/** Substitutions de commandes et de processus : une règle allow ne peut pas les couvrir de façon sûre. */
|
||||||
const UNSAFE_SHELL = /\$\(|`|<\(|>/;
|
const UNSAFE_SHELL = /\$\(|`|<\(|>\(/;
|
||||||
|
|
||||||
|
// ----- Redirections de sortie (>, >>, >|, &>, 2>…) : règles `Redirect(motif de chemin)`
|
||||||
|
|
||||||
|
export interface Redirections {
|
||||||
|
/** Commande sans ses redirections (chaque redirection est remplacée par un espace). */
|
||||||
|
stripped: string;
|
||||||
|
/** Cibles (fichiers) des redirections de sortie, sans guillemets. Les duplications `2>&1`, `>&2` n'en ont pas. */
|
||||||
|
targets: string[];
|
||||||
|
/** true si l'analyse n'est pas sûre (guillemet non fermé, cible absente ou `>(`) : rien n'est autorisé automatiquement. */
|
||||||
|
unknown: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repère les redirections de sortie de la commande en respectant les guillemets simples et doubles et les `\`
|
||||||
|
* (un `>` entre guillemets n'est pas une redirection). `2>&1` / `>&2` (duplication d'un descripteur) ne désignent aucun fichier.
|
||||||
|
*/
|
||||||
|
export function extractRedirections(cmd: string): Redirections {
|
||||||
|
let stripped = "";
|
||||||
|
const targets: string[] = [];
|
||||||
|
let unknown = false;
|
||||||
|
const n = cmd.length;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const ch = cmd[i]!;
|
||||||
|
if (ch === "\\") {
|
||||||
|
stripped += ch + (cmd[i + 1] ?? "");
|
||||||
|
i += 2;
|
||||||
|
} else if (ch === "'") {
|
||||||
|
const j = cmd.indexOf("'", i + 1);
|
||||||
|
if (j === -1) return { stripped: stripped + cmd.slice(i), targets, unknown: true };
|
||||||
|
stripped += cmd.slice(i, j + 1);
|
||||||
|
i = j + 1;
|
||||||
|
} else if (ch === '"') {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < n && cmd[j] !== '"') j += cmd[j] === "\\" ? 2 : 1;
|
||||||
|
if (j >= n) return { stripped: stripped + cmd.slice(i), targets, unknown: true };
|
||||||
|
stripped += cmd.slice(i, j + 1);
|
||||||
|
i = j + 1;
|
||||||
|
} else if (ch === ">") {
|
||||||
|
// Préfixe du descripteur : « 2 » ou « & » (isolés, précédés d'un espace)
|
||||||
|
const m = /(?:^|\s)(\d+|&)$/.exec(stripped);
|
||||||
|
if (m) stripped = stripped.slice(0, stripped.length - m[1]!.length);
|
||||||
|
i++;
|
||||||
|
if (cmd[i] === ">" || cmd[i] === "|") i++; // >> et >|
|
||||||
|
if (cmd[i] === "&") {
|
||||||
|
const dup = /^&(\d+|-)/.exec(cmd.slice(i));
|
||||||
|
if (dup) {
|
||||||
|
i += dup[0].length; // 2>&1, >&2, >&- : pas de fichier
|
||||||
|
stripped += " ";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i++; // >&fichier : sortie et erreur vers le fichier
|
||||||
|
}
|
||||||
|
while (i < n && (cmd[i] === " " || cmd[i] === "\t")) i++;
|
||||||
|
if (cmd[i] === "(") {
|
||||||
|
unknown = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let target = "";
|
||||||
|
let quoted = false;
|
||||||
|
while (i < n && !/[\s;|&<>()]/.test(cmd[i]!)) {
|
||||||
|
const c = cmd[i]!;
|
||||||
|
if (c === "\\") {
|
||||||
|
target += cmd[i + 1] ?? "";
|
||||||
|
i += 2;
|
||||||
|
} else if (c === "'" || c === '"') {
|
||||||
|
const j = cmd.indexOf(c, i + 1);
|
||||||
|
if (j === -1) return { stripped, targets, unknown: true };
|
||||||
|
target += cmd.slice(i + 1, j);
|
||||||
|
quoted = true;
|
||||||
|
i = j + 1;
|
||||||
|
} else {
|
||||||
|
target += c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target === "" && !quoted) unknown = true;
|
||||||
|
else targets.push(target);
|
||||||
|
stripped += " ";
|
||||||
|
} else {
|
||||||
|
stripped += ch;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { stripped, targets, unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cible dont le chemin réel dépend du shell (variable, glob, accolades) : jamais couverte par un motif de chemin. */
|
||||||
|
const isDynamicTarget = (target: string): boolean => /[$`*?[\]{}]/.test(target);
|
||||||
|
|
||||||
|
const redirectRuleMatches = (rule: Rule, target: string, cwd: string): boolean =>
|
||||||
|
!rule.spec || matchPathSpec(rule.spec, targetPath({ path: target }, cwd));
|
||||||
|
|
||||||
function parseRules(raws: string[]): Rule[] {
|
function parseRules(raws: string[]): Rule[] {
|
||||||
const rules: Rule[] = [];
|
const rules: Rule[] = [];
|
||||||
|
|
@ -107,6 +201,8 @@ function matchGenericSpec(tool: string, spec: string, input: Record<string, unkn
|
||||||
|
|
||||||
/** Pour deny/ask : la règle s'applique si une partie de la commande (Bash) ou le chemin correspond. */
|
/** Pour deny/ask : la règle s'applique si une partie de la commande (Bash) ou le chemin correspond. */
|
||||||
function ruleApplies(rule: Rule, tool: string, input: Record<string, unknown>, cwd: string): boolean {
|
function ruleApplies(rule: Rule, tool: string, input: Record<string, unknown>, cwd: string): boolean {
|
||||||
|
// Outils MCP (`mcp__serveur__outil`) : règles `Mcp(serveur[,outil])`
|
||||||
|
if (rule.tool === "Mcp") return mcpSpecMatches(rule.spec, tool);
|
||||||
if (rule.tool !== tool) return false;
|
if (rule.tool !== tool) return false;
|
||||||
if (!rule.spec) return true;
|
if (!rule.spec) return true;
|
||||||
if (tool === "Bash") {
|
if (tool === "Bash") {
|
||||||
|
|
@ -121,16 +217,33 @@ function ruleApplies(rule: Rule, tool: string, input: Record<string, unknown>, c
|
||||||
return matchGenericSpec(tool, rule.spec, input);
|
return matchGenericSpec(tool, rule.spec, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Une redirection vers ce fichier est autorisée par une règle `Redirect` (ou `Redirect(motif)`). */
|
||||||
|
function redirectAllowed(target: string, allow: Rule[], cwd: string): boolean {
|
||||||
|
return allow.some((r) => !r.spec || (!isDynamicTarget(target) && redirectRuleMatches(r, target, cwd)));
|
||||||
|
}
|
||||||
|
|
||||||
/** Pour allow : TOUTES les parties d'une commande Bash doivent être couvertes (et sans redirection ni substitution). */
|
/** Pour allow : TOUTES les parties d'une commande Bash doivent être couvertes (et sans redirection ni substitution). */
|
||||||
function allowApplies(rules: Rule[], tool: string, input: Record<string, unknown>, cwd: string): boolean {
|
function allowApplies(
|
||||||
|
rules: Rule[],
|
||||||
|
tool: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
cwd: string,
|
||||||
|
redirectAllow: Rule[] = [],
|
||||||
|
): boolean {
|
||||||
|
if (rules.some((r) => r.tool === "Mcp" && mcpSpecMatches(r.spec, tool))) return true;
|
||||||
const mine = rules.filter((r) => r.tool === tool);
|
const mine = rules.filter((r) => r.tool === tool);
|
||||||
if (mine.length === 0) return false;
|
if (mine.length === 0) return false;
|
||||||
if (mine.some((r) => !r.spec)) return true; // ex. "Edit" : tous les usages de l'outil
|
if (mine.some((r) => !r.spec)) return true; // ex. "Edit" : tous les usages de l'outil
|
||||||
if (tool === "Bash") {
|
if (tool === "Bash") {
|
||||||
const cmd = typeof input.command === "string" ? input.command : "";
|
const cmd = typeof input.command === "string" ? input.command : "";
|
||||||
if (UNSAFE_SHELL.test(cmd)) return false;
|
const redir = extractRedirections(cmd);
|
||||||
const subs = splitCommands(cmd);
|
if (redir.unknown || UNSAFE_SHELL.test(cmd)) return false;
|
||||||
return subs.length > 0 && subs.every((sub) => mine.some((r) => matchBashSpec(r.spec!, sub)));
|
const subs = splitCommands(redir.stripped);
|
||||||
|
return (
|
||||||
|
subs.length > 0 &&
|
||||||
|
subs.every((sub) => mine.some((r) => matchBashSpec(r.spec!, sub))) &&
|
||||||
|
redir.targets.every((t) => redirectAllowed(t, redirectAllow, cwd))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (PATH_TOOLS.has(tool)) {
|
if (PATH_TOOLS.has(tool)) {
|
||||||
const target = targetPath(input, cwd);
|
const target = targetPath(input, cwd);
|
||||||
|
|
@ -153,14 +266,25 @@ export function createPermissionPolicy(rules: PermissionRules): PermissionPolicy
|
||||||
const deny = parseRules(rules.deny);
|
const deny = parseRules(rules.deny);
|
||||||
const ask = parseRules(rules.ask);
|
const ask = parseRules(rules.ask);
|
||||||
const allow = parseRules(rules.allow);
|
const allow = parseRules(rules.allow);
|
||||||
|
const redirectOf = (list: Rule[]): Rule[] => list.filter((r) => r.tool === "Redirect");
|
||||||
|
const redirectDeny = redirectOf(deny);
|
||||||
|
const redirectAsk = redirectOf(ask);
|
||||||
|
const redirectAllow = redirectOf(allow);
|
||||||
|
|
||||||
|
/** Règle Redirect (deny ou ask) qui vise un fichier de redirection de la commande Bash. */
|
||||||
|
const redirectHit = (list: Rule[], tool: string, input: Record<string, unknown>, cwd: string): Rule | undefined => {
|
||||||
|
if (tool !== "Bash" || list.length === 0) return undefined;
|
||||||
|
const { targets } = extractRedirections(typeof input.command === "string" ? input.command : "");
|
||||||
|
return list.find((r) => targets.some((t) => redirectRuleMatches(r, t, cwd)));
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
check(tool, input, { cwd }) {
|
check(tool, input, { cwd }) {
|
||||||
const d = deny.find((r) => ruleApplies(r, tool, input, cwd));
|
const d = deny.find((r) => r.tool !== "Redirect" && ruleApplies(r, tool, input, cwd)) ?? redirectHit(redirectDeny, tool, input, cwd);
|
||||||
if (d) return { decision: "deny", reason: `règle deny ${d.raw}` };
|
if (d) return { decision: "deny", reason: `règle deny ${d.raw}` };
|
||||||
const a = ask.find((r) => ruleApplies(r, tool, input, cwd));
|
const a = ask.find((r) => r.tool !== "Redirect" && ruleApplies(r, tool, input, cwd)) ?? redirectHit(redirectAsk, tool, input, cwd);
|
||||||
if (a) return { decision: "ask", reason: `règle ask ${a.raw}` };
|
if (a) return { decision: "ask", reason: `règle ask ${a.raw}` };
|
||||||
if (allowApplies(allow, tool, input, cwd)) return { decision: "allow" };
|
if (allowApplies(allow, tool, input, cwd, redirectAllow)) return { decision: "allow" };
|
||||||
return { decision: "ask", reason: "aucune règle : confirmation demandée par défaut" };
|
return { decision: "ask", reason: "aucune règle : confirmation demandée par défaut" };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import {
|
import {
|
||||||
SessionManager,
|
SessionManager,
|
||||||
|
|
@ -33,6 +32,7 @@ import {
|
||||||
validateAgentName,
|
validateAgentName,
|
||||||
type AgentDefinition,
|
type AgentDefinition,
|
||||||
type AgentScope,
|
type AgentScope,
|
||||||
|
type McpServerStatus,
|
||||||
} from "@maia/agent";
|
} from "@maia/agent";
|
||||||
import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers";
|
import { createProvider, loadModelChoices, loadSettings, settingsPath } from "@maia/providers";
|
||||||
import { InkUI } from "@maia/ui";
|
import { InkUI } from "@maia/ui";
|
||||||
|
|
@ -87,11 +87,12 @@ Usage : maia [options]
|
||||||
Sans cette option, la liste des modèles disponibles est proposée au démarrage.
|
Sans cette option, la liste des modèles disponibles est proposée au démarrage.
|
||||||
-h, --help Affiche cette aide
|
-h, --help Affiche cette aide
|
||||||
|
|
||||||
Commandes en session : /help /model /permissions /agents /newAgent /clear /compact /exit
|
Commandes en session : /help /model /permissions /mcp /agents /createAgent /clear /compact /exit
|
||||||
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
|
Sessions : /rename <nom> /load [nom] /new (sauvegarde automatique à la sortie, dans ./.maia/.sessions)
|
||||||
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
|
Skills de l'agent : §nom_du_skill [arguments] (§ seul : liste des skills)
|
||||||
Sous-agents : définis dans ./.maia/agents/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste
|
Sous-agents : définis dans ./.maia/agents/<nom>/AGENT.md ou ~/.maia/agents ; l'agent les lance (outil Agent) ; /agents : liste
|
||||||
&nom [tâche] : vous lancez vous-même un sous-agent (& seul : liste) ; son résumé est ajouté au contexte
|
&nom [tâche] : vous lancez vous-même un sous-agent (& seul : liste) ; son résumé est ajouté au contexte
|
||||||
|
Serveurs MCP : déclarés dans ~/.maia/mcp.json, activés par "Mcp(nom)" dans les permissions du settings.json ; /mcp : état
|
||||||
Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte)
|
Commande shell : !commande ou !'commande avec paramètres' (exécutée directement ; résultat ajouté au contexte)
|
||||||
Fichier référencé : @chemin/fichier ou @'chemin avec espaces' ou @ chemin/fichier (contenu injecté dans le contexte)`;
|
Fichier référencé : @chemin/fichier ou @'chemin avec espaces' ou @ chemin/fichier (contenu injecté dans le contexte)`;
|
||||||
|
|
||||||
|
|
@ -177,6 +178,9 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
|
|
||||||
// Sous-agents : processus autonomes lancés par l'outil Agent (la notification INFO de fin passe par ui.info)
|
// Sous-agents : processus autonomes lancés par l'outil Agent (la notification INFO de fin passe par ui.info)
|
||||||
let agents: AgentDefinition[] = [];
|
let agents: AgentDefinition[] = [];
|
||||||
|
// Serveurs MCP activés (état affiché par /mcp) et fermeture de leurs connexions
|
||||||
|
let mcpServers: McpServerStatus[] = [];
|
||||||
|
let closeMcp: () => Promise<void> = async () => undefined;
|
||||||
const launchSubAgent = createSubAgentLauncher({
|
const launchSubAgent = createSubAgentLauncher({
|
||||||
cwd,
|
cwd,
|
||||||
homeDir,
|
homeDir,
|
||||||
|
|
@ -207,6 +211,10 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
launchSubAgent,
|
launchSubAgent,
|
||||||
});
|
});
|
||||||
agents = init.agents;
|
agents = init.agents;
|
||||||
|
const previousClose = closeMcp;
|
||||||
|
mcpServers = init.mcpServers;
|
||||||
|
closeMcp = init.close;
|
||||||
|
void previousClose().catch(() => undefined); // connexions de la session précédente
|
||||||
const created = createSession({
|
const created = createSession({
|
||||||
provider: createProvider(modelSpec, settings),
|
provider: createProvider(modelSpec, settings),
|
||||||
init,
|
init,
|
||||||
|
|
@ -319,7 +327,12 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
const onExit = (): void => void saveSession();
|
const onExit = (): void => void saveSession();
|
||||||
const onSignal = (): void => {
|
const onSignal = (): void => {
|
||||||
saveSession();
|
saveSession();
|
||||||
process.exit(0);
|
// Ferme les connexions MCP (au plus 3 s), puis quitte
|
||||||
|
const timer = setTimeout(() => process.exit(0), 3000);
|
||||||
|
timer.unref?.();
|
||||||
|
void closeMcp()
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => process.exit(0));
|
||||||
};
|
};
|
||||||
process.on("exit", onExit);
|
process.on("exit", onExit);
|
||||||
process.on("SIGTERM", onSignal);
|
process.on("SIGTERM", onSignal);
|
||||||
|
|
@ -490,12 +503,16 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
ui.info([HELP, ...custom, ...skills].join("\n"));
|
ui.info([HELP, ...custom, ...skills].join("\n"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (name === "mcp") {
|
||||||
|
ui.showMcp(mcpServers.map((s) => ({ name: s.name, status: s.status, error: s.error })));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (name === "permissions") {
|
if (name === "permissions") {
|
||||||
ui.showPermissions(session.permissionSources);
|
ui.showPermissions(session.permissionSources);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (name.toLowerCase() === "newagent") {
|
if (name.toLowerCase() === "createagent") {
|
||||||
// /newAgent [nom] : crée ./.maia/agents/<nom>/AGENT.md (projet) ou ~/.maia/agents/<nom>/AGENT.md (global) depuis le modèle
|
// /createAgent [nom] : crée ./.maia/agents/<nom>/AGENT.md (projet) ou ~/.maia/agents/<nom>/AGENT.md (global) depuis le modèle
|
||||||
const template = findResource("AGENT.md");
|
const template = findResource("AGENT.md");
|
||||||
if (!template) {
|
if (!template) {
|
||||||
ui.error("Modèle d'agent introuvable : .ressources/AGENT.md (dans le dossier de MAIA, ou dossier indiqué par MAIA_RESSOURCES).");
|
ui.error("Modèle d'agent introuvable : .ressources/AGENT.md (dans le dossier de MAIA, ou dossier indiqué par MAIA_RESSOURCES).");
|
||||||
|
|
@ -742,6 +759,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
||||||
|
|
||||||
const finalName = saveSession();
|
const finalName = saveSession();
|
||||||
if (finalName) ui.info(`Session sauvegardée : ${finalName}`);
|
if (finalName) ui.info(`Session sauvegardée : ${finalName}`);
|
||||||
|
await closeMcp().catch(() => undefined); // coupe les connexions aux serveurs MCP avant de quitter
|
||||||
ui.outro("À bientôt");
|
ui.outro("À bientôt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { readdir, stat } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { pathToFileURL } from "node:url";
|
|
||||||
import type { Extension } from "./types";
|
|
||||||
import type { ModeRegistry } from "./registry";
|
|
||||||
|
|
||||||
/** Active les extensions : chacune enregistre ses modes via l'API. */
|
|
||||||
export async function activateExtensions(
|
|
||||||
registry: ModeRegistry,
|
|
||||||
extensions: Extension[],
|
|
||||||
onError: (message: string) => void = () => {},
|
|
||||||
): Promise<void> {
|
|
||||||
for (const ext of extensions) {
|
|
||||||
try {
|
|
||||||
await ext.activate({
|
|
||||||
registerMode: (mode) => registry.registerMode(mode, ext.id),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
onError(`Extension "${ext.id}" : échec d'activation (${(err as Error).message})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Découvre les extensions utilisateur : ~/.maia/extensions/<nom>/index.js
|
|
||||||
* (export default d'un objet Extension).
|
|
||||||
*/
|
|
||||||
export async function discoverUserExtensions(dir: string): Promise<Extension[]> {
|
|
||||||
let names: string[];
|
|
||||||
try {
|
|
||||||
names = await readdir(dir);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const found: Extension[] = [];
|
|
||||||
for (const name of names) {
|
|
||||||
const entry = join(dir, name, "index.js");
|
|
||||||
try {
|
|
||||||
await stat(entry);
|
|
||||||
const mod = await import(pathToFileURL(entry).href);
|
|
||||||
const ext = (mod.default ?? mod.extension) as Extension | undefined;
|
|
||||||
if (ext && typeof ext.activate === "function") found.push(ext);
|
|
||||||
} catch {
|
|
||||||
// extension illisible : ignorée
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import type { AgentMode } from "./types";
|
|
||||||
|
|
||||||
export class ModeRegistry {
|
|
||||||
private modes = new Map<string, { mode: AgentMode; source: string }>();
|
|
||||||
|
|
||||||
registerMode(mode: AgentMode, source: string): void {
|
|
||||||
if (this.modes.has(mode.id)) {
|
|
||||||
throw new Error(`Mode "${mode.id}" déjà enregistré`);
|
|
||||||
}
|
|
||||||
this.modes.set(mode.id, { mode, source });
|
|
||||||
}
|
|
||||||
|
|
||||||
getMode(id: string): AgentMode | undefined {
|
|
||||||
return this.modes.get(id)?.mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
listModes(): AgentMode[] {
|
|
||||||
return [...this.modes.values()].map((e) => e.mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type {
|
||||||
ConfirmPromptState,
|
ConfirmPromptState,
|
||||||
InputPromptState,
|
InputPromptState,
|
||||||
LogItem,
|
LogItem,
|
||||||
|
McpServerView,
|
||||||
PermissionFile,
|
PermissionFile,
|
||||||
PromptState,
|
PromptState,
|
||||||
SelectPromptState,
|
SelectPromptState,
|
||||||
|
|
@ -20,6 +21,32 @@ import type {
|
||||||
|
|
||||||
// ───────────── Journal ─────────────
|
// ───────────── Journal ─────────────
|
||||||
|
|
||||||
|
/** Serveurs MCP activés : bandeau OK (vert) ou FAIL (rouge) suivi du nom. */
|
||||||
|
function McpView({ servers }: { servers: McpServerView[] }) {
|
||||||
|
if (servers.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text dimColor>
|
||||||
|
Aucun serveur MCP activé. Déclarez-les dans ~/.maia/mcp.json, puis autorisez-les dans ./.maia/settings.json : "allow": ["Mcp(nom)"].
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{servers.map((s) => (
|
||||||
|
<Box key={s.name} flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Box marginRight={1} flexShrink={0}>
|
||||||
|
<Badge color={s.status === "ok" ? "green" : "red"}>{s.status === "ok" ? "OK" : "FAIL"}</Badge>
|
||||||
|
</Box>
|
||||||
|
<Text>{s.name}</Text>
|
||||||
|
</Box>
|
||||||
|
{s.status === "fail" && s.error ? <Text dimColor>{` ${s.error}`}</Text> : null}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const PERMISSION_KINDS = [
|
const PERMISSION_KINDS = [
|
||||||
{ key: "allow", label: "ALLOW", color: "green" },
|
{ key: "allow", label: "ALLOW", color: "green" },
|
||||||
{ key: "ask", label: "ASK", color: "yellow" },
|
{ key: "ask", label: "ASK", color: "yellow" },
|
||||||
|
|
@ -115,6 +142,8 @@ function LogLine({ item }: { item: LogItem }) {
|
||||||
return <Markdown text={item.text} />;
|
return <Markdown text={item.text} />;
|
||||||
case "permissions":
|
case "permissions":
|
||||||
return <PermissionsView files={item.permissions ?? []} />;
|
return <PermissionsView files={item.permissions ?? []} />;
|
||||||
|
case "mcp":
|
||||||
|
return <McpView servers={item.mcp ?? []} />;
|
||||||
case "shell":
|
case "shell":
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export { InkUI } from "./ink-ui";
|
export { InkUI } from "./ink-ui";
|
||||||
export { UIStore, type Choice, type LogItem, type LogKind, type PermissionFile, type UIState } from "./store";
|
export { UIStore, type Choice, type LogItem, type LogKind, type McpServerView, type PermissionFile, type UIState } from "./store";
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
||||||
import { render, type Instance } from "ink";
|
import { render, type Instance } from "ink";
|
||||||
import type { AgentUI } from "@maia/core";
|
import type { AgentUI } from "@maia/core";
|
||||||
import { App } from "./components";
|
import { App } from "./components";
|
||||||
import { UIStore, type Choice, type PermissionFile } from "./store";
|
import { UIStore, type Choice, type McpServerView, type PermissionFile } from "./store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface Ink (React pour CLI). Façade impérative au-dessus d'un UIStore : le CLI appelle des méthodes
|
* Interface Ink (React pour CLI). Façade impérative au-dessus d'un UIStore : le CLI appelle des méthodes
|
||||||
|
|
@ -96,6 +96,11 @@ export class InkUI implements AgentUI {
|
||||||
this.start().showPermissions(files);
|
this.start().showPermissions(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Serveurs MCP activés : nom et état (OK en vert, FAIL en rouge). */
|
||||||
|
showMcp(servers: McpServerView[]): void {
|
||||||
|
this.start().showMcp(servers);
|
||||||
|
}
|
||||||
|
|
||||||
/** Commande "!" de l'utilisateur et son résultat. */
|
/** Commande "!" de l'utilisateur et son résultat. */
|
||||||
onShell(command: string, output: string, status: string | null): void {
|
onShell(command: string, output: string, status: string | null): void {
|
||||||
this.start().shell(command, output, status);
|
this.start().shell(command, output, status);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,15 @@ describe("UIStore", () => {
|
||||||
expect(item.permissions).toEqual(files);
|
expect(item.permissions).toEqual(files);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("showMcp : un élément avec les serveurs MCP activés et leur état", () => {
|
||||||
|
const s = new UIStore();
|
||||||
|
const servers = [{ name: "github", status: "ok" as const }, { name: "docs", status: "fail" as const, error: "HTTP 500" }];
|
||||||
|
s.showMcp(servers);
|
||||||
|
const item = s.getSnapshot().items[0]!;
|
||||||
|
expect(item.kind).toBe("mcp");
|
||||||
|
expect(item.mcp).toEqual(servers);
|
||||||
|
});
|
||||||
|
|
||||||
it("notifie les abonnés et fournit un instantané stable entre deux changements", () => {
|
it("notifie les abonnés et fournit un instantané stable entre deux changements", () => {
|
||||||
const s = new UIStore();
|
const s = new UIStore();
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ export type LogKind =
|
||||||
| "user"
|
| "user"
|
||||||
| "assistant"
|
| "assistant"
|
||||||
| "shell"
|
| "shell"
|
||||||
| "permissions";
|
| "permissions"
|
||||||
|
| "mcp";
|
||||||
|
|
||||||
export interface LogItem {
|
export interface LogItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -26,6 +27,15 @@ export interface LogItem {
|
||||||
failed?: boolean;
|
failed?: boolean;
|
||||||
/** Règles de permissions par fichier (kind "permissions"). */
|
/** Règles de permissions par fichier (kind "permissions"). */
|
||||||
permissions?: PermissionFile[];
|
permissions?: PermissionFile[];
|
||||||
|
/** Serveurs MCP activés et leur état (kind "mcp"). */
|
||||||
|
mcp?: McpServerView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpServerView {
|
||||||
|
name: string;
|
||||||
|
status: "ok" | "fail";
|
||||||
|
/** Cause de l'échec (affichée en grisé sous le bandeau FAIL). */
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionFile {
|
export interface PermissionFile {
|
||||||
|
|
@ -146,6 +156,11 @@ export class UIStore {
|
||||||
this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) });
|
this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Serveurs MCP activés et leur état (commande /mcp). */
|
||||||
|
showMcp(servers: McpServerView[]): void {
|
||||||
|
this.commit({ items: this.withItem("mcp", "Serveurs MCP", { mcp: servers }) });
|
||||||
|
}
|
||||||
|
|
||||||
/** Contexte actuel de la conversation, en tokens. */
|
/** Contexte actuel de la conversation, en tokens. */
|
||||||
setContextUsed(tokens: number): void {
|
setContextUsed(tokens: number): void {
|
||||||
this.commit({ contextUsed: Math.max(0, Math.round(tokens)) });
|
this.commit({ contextUsed: Math.max(0, Math.round(tokens)) });
|
||||||
|
|
|
||||||
254
pnpm-lock.yaml
254
pnpm-lock.yaml
|
|
@ -171,7 +171,7 @@ importers:
|
||||||
version: 22.20.5
|
version: 22.20.5
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.3.0
|
specifier: ^8.3.0
|
||||||
version: 8.5.1(postcss@8.5.30)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3)
|
version: 8.5.1(postcss@8.5.31)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3)
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.19.0
|
specifier: ^4.19.0
|
||||||
version: 4.23.15
|
version: 4.23.15
|
||||||
|
|
@ -767,129 +767,143 @@ packages:
|
||||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.64.6':
|
'@rollup/rollup-android-arm-eabi@4.64.12':
|
||||||
resolution: {integrity: sha512-FkO19Fowv/OQeJZPVfdetJaI3/VpbZmBsnmAlR/rQ+b3QwfiGQiOpVegpSRH331kelvHTVw9hXYG3DwjAaRzyQ==}
|
resolution: {integrity: sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rollup/rollup-android-arm64@4.64.6':
|
'@rollup/rollup-android-arm64@4.64.12':
|
||||||
resolution: {integrity: sha512-si5/Etm6MphPc2xYHnk455Shls/hoMjTy3mhXge4OPmvCw7TwC4KP4fKokAlKLV4XaQvLvgSHfafPcblLMYYdQ==}
|
resolution: {integrity: sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
'@rollup/rollup-darwin-arm64@4.64.6':
|
'@rollup/rollup-darwin-arm64@4.64.12':
|
||||||
resolution: {integrity: sha512-PQCl3q7b0BgvVt72i2xrWM6I5KliwwVe4GpWW5QDGyUQawDS9aPZss/aGyFqBmxdRxlE8wDc9CaxiQ5DHRSp/Q==}
|
resolution: {integrity: sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rollup/rollup-darwin-x64@4.64.6':
|
'@rollup/rollup-darwin-x64@4.64.12':
|
||||||
resolution: {integrity: sha512-l9DFl1Itb1YYdSrP1K3U8ih44AOHa/p+Q4WMMDfGvH5qVWp+ajfgS1DlYo4yCRh8wJ2ZJv0Fqccf+ULAmMFjmg==}
|
resolution: {integrity: sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-arm64@4.64.6':
|
'@rollup/rollup-freebsd-arm64@4.64.12':
|
||||||
resolution: {integrity: sha512-bR4GR3205N9+od0+l5Dd9/iZTDpJkIe6d6Ydp2qbcQZJLmKBdYQQRp8gbWpbMt92twKJY66o1vK4NkoKQODIrA==}
|
resolution: {integrity: sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-x64@4.64.6':
|
'@rollup/rollup-freebsd-x64@4.64.12':
|
||||||
resolution: {integrity: sha512-XTVNaDzcFl2TeBglvxdGgsCv9JGtAGRAoJENmGBR/XvDH2mA2K0IXc2aMfP0dR8mexSXSKifJ85FixoXX1wfTw==}
|
resolution: {integrity: sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-gnueabihf@4.64.6':
|
'@rollup/rollup-linux-arm-gnueabihf@4.64.12':
|
||||||
resolution: {integrity: sha512-qWc6qbc+25ugG9vqPR9P422KJBbVfZ05mjcMggoq8tsfWP+eRfC+/tiDdIlRF5VL2pDVZWN9zAhWsmu7R1+7Dw==}
|
resolution: {integrity: sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.64.6':
|
'@rollup/rollup-linux-arm-musleabihf@4.64.12':
|
||||||
resolution: {integrity: sha512-V4Nwkp8844X/Xnww3PI1LYS0YPei8XQEVG70R+5MPZGO7AhYoun4hCN7Svs4U3wbQLZbwiz+9xRFhUZZdRl8Ww==}
|
resolution: {integrity: sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.64.6':
|
'@rollup/rollup-linux-arm64-gnu@4.64.12':
|
||||||
resolution: {integrity: sha512-xb72A1aPaaIsp+a88VtRae/ovIEXislbUMyp7b9AQQU7ZkP2m/+ik6DW9TQcJyw5R8/RIailF9zExYwkjR++UA==}
|
resolution: {integrity: sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.64.6':
|
'@rollup/rollup-linux-arm64-musl@4.64.12':
|
||||||
resolution: {integrity: sha512-pbcs1BVBwC43hZkjhjlF8Rx8m5G0KnhLZs9Azy/gqccP6UsNSzJr0hKGIAVPqzAH1O/NdPxxC9GVGb4msQg/aw==}
|
resolution: {integrity: sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.64.6':
|
'@rollup/rollup-linux-loong64-gnu@4.64.12':
|
||||||
resolution: {integrity: sha512-EKOC8os6Vsg487ZLKUqzLF99Jsmnq6tu7O7mAELakNpcA+8KadktFf/JYdRuHe/DzCUXmN38pUKu4XBcKlqGbQ==}
|
resolution: {integrity: sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.64.6':
|
'@rollup/rollup-linux-loong64-musl@4.64.12':
|
||||||
resolution: {integrity: sha512-v/SQ68GnSlr/HyjB79sNXesGDwkOPu9JjbHfmWaymCiM+nZsQ9TstG1KfA4MuEvTr2d3PoY8PhFxj9bt0e32JA==}
|
resolution: {integrity: sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.64.6':
|
'@rollup/rollup-linux-ppc64-gnu@4.64.12':
|
||||||
resolution: {integrity: sha512-G3pmliMQ7r15zFFNwBlaT/xTdSLi4lfgI7CUrQueyKYheNVhrd8JrWCE6Ll8xf4AA1RZFqq+mNJYntiElsVOyA==}
|
resolution: {integrity: sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.64.6':
|
'@rollup/rollup-linux-ppc64-musl@4.64.12':
|
||||||
resolution: {integrity: sha512-XQTB/IPoB/wG6x/wu9uomAt8YH61IAr5iZTubMiad/lu88sotq2ocz9hJd556+Jz2kIkF7ZQZIsUuWE3OWqtcA==}
|
resolution: {integrity: sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.64.6':
|
'@rollup/rollup-linux-riscv64-gnu@4.64.12':
|
||||||
resolution: {integrity: sha512-O5mpokth6+ydMuwiGtY8fIjvOBfH7u6SzmDNmZ3k6GK5C6iEAC2q2ZN/T5L9jYILx0IcCT0KnfMDihQ5yQFSBw==}
|
resolution: {integrity: sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.64.6':
|
'@rollup/rollup-linux-riscv64-musl@4.64.12':
|
||||||
resolution: {integrity: sha512-aIFxbAD9v3TvIaEDsKmukjcDg1JS4/tFXw8SHyQQgzOwzWCQ4cyDK7Ju1pQKqUXDFkEu/BZCFDCDATsKhQcB/Q==}
|
resolution: {integrity: sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.64.6':
|
'@rollup/rollup-linux-s390x-gnu@4.64.12':
|
||||||
resolution: {integrity: sha512-fuNo1UwBc6BxnHDOxxguQL/InhheMN3RJcJMb0b4xP8r5Q3CNCrJbVg0eCIFuJ4e/zAfjR2gYbOfbzO/G7CIyw==}
|
resolution: {integrity: sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.64.6':
|
'@rollup/rollup-linux-x64-gnu@4.64.12':
|
||||||
resolution: {integrity: sha512-05v6wFKDKJPZs9xlytVrrRu6IbRuoSvlH9xNwEOafXsKyjBFZPvAcZz2VIAB/M+lblYeKa9iOYC4RDVdfcAAaw==}
|
resolution: {integrity: sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.64.6':
|
'@rollup/rollup-linux-x64-musl@4.64.12':
|
||||||
resolution: {integrity: sha512-wJXKcdMjvkc/FaavZXNP1yW4i0pfOmhZAIWRSLG2yJ0PB0TWi7dkLErhX0u0vptDcXKKO/m1Y/YTeuyvb2MXwg==}
|
resolution: {integrity: sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.64.6':
|
'@rollup/rollup-openbsd-x64@4.64.12':
|
||||||
resolution: {integrity: sha512-cwg7/h0ThvJwy8CBkmACdiHAxRWpbsCmIsjOt+Zzx0hTnIcSUmKYEV03bGg+hD827OnwUsbKh1ZZDW754xWyBA==}
|
resolution: {integrity: sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [openbsd]
|
os: [openbsd]
|
||||||
|
|
||||||
'@rollup/rollup-openharmony-arm64@4.64.6':
|
'@rollup/rollup-openharmony-arm64@4.64.12':
|
||||||
resolution: {integrity: sha512-T0RkYIJbdYXJEQFw+tMBO09KDFdgmNxkHsof3wWAABrvnx15Yh8RytmrgYy9+q9cBeBNZF66MMQZC+5nzEz6vg==}
|
resolution: {integrity: sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openharmony]
|
os: [openharmony]
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.64.6':
|
'@rollup/rollup-win32-arm64-msvc@4.64.12':
|
||||||
resolution: {integrity: sha512-KlfpmF1AAIdAWxt8QQNClbpLFlAUfYOzqIuJ7IXEStMHpFRuH78RG6FjqPb3Rqhh0K89F5npvG+/i3Fjl6IveQ==}
|
resolution: {integrity: sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-ia32-msvc@4.64.6':
|
'@rollup/rollup-win32-ia32-msvc@4.64.12':
|
||||||
resolution: {integrity: sha512-MrTLjd7Jlt9/ZavJZYG61KQv9Iy9bzbY+yXy73Xvwoa2uP66wvDShkyBr5Chz/yrE6lbD/zHMuTmKQVNQqBApg==}
|
resolution: {integrity: sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw==}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-gnu@4.64.6':
|
'@rollup/rollup-win32-x64-gnu@4.64.12':
|
||||||
resolution: {integrity: sha512-8fWDH1CkPI8GdBKAxzHDza7UmgficsKo2+MVbvUgOGhB0629w5sHMg3Qu5y7TZsrYKHHWpz8fla4KvoT7WsO3Q==}
|
resolution: {integrity: sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-msvc@4.64.6':
|
'@rollup/rollup-win32-x64-msvc@4.64.12':
|
||||||
resolution: {integrity: sha512-9/c9PWdr5GCIHkkxiHM1HjNBQMVdftP4O8QTcoUAaRza2FSqJ4axbf8QAnBuE6BIGQDl71SaRPeDdDYid5ecTw==}
|
resolution: {integrity: sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
|
@ -1191,8 +1205,8 @@ packages:
|
||||||
js-tiktoken@1.0.21:
|
js-tiktoken@1.0.21:
|
||||||
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
|
||||||
|
|
||||||
lilconfig@3.1.3:
|
lilconfig@3.1.4:
|
||||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
resolution: {integrity: sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
|
|
@ -1415,8 +1429,8 @@ packages:
|
||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
postcss@8.5.30:
|
postcss@8.5.31:
|
||||||
resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
|
resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
react-reconciler@0.33.0:
|
react-reconciler@0.33.0:
|
||||||
|
|
@ -1441,8 +1455,8 @@ packages:
|
||||||
resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
|
resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
rollup@4.64.6:
|
rollup@4.64.12:
|
||||||
resolution: {integrity: sha512-fX1Vy+UNKdXn+xKDnu/HBXbHVr1AY8cSdQj9BU4kLv5aFCMqGpvrVbyUMmjN3DdzWhSAuTbcX8PCNEVaumthYA==}
|
resolution: {integrity: sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
|
@ -1948,79 +1962,79 @@ snapshots:
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.64.6':
|
'@rollup/rollup-android-arm-eabi@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-android-arm64@4.64.6':
|
'@rollup/rollup-android-arm64@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-darwin-arm64@4.64.6':
|
'@rollup/rollup-darwin-arm64@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-darwin-x64@4.64.6':
|
'@rollup/rollup-darwin-x64@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-arm64@4.64.6':
|
'@rollup/rollup-freebsd-arm64@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-freebsd-x64@4.64.6':
|
'@rollup/rollup-freebsd-x64@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-gnueabihf@4.64.6':
|
'@rollup/rollup-linux-arm-gnueabihf@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.64.6':
|
'@rollup/rollup-linux-arm-musleabihf@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.64.6':
|
'@rollup/rollup-linux-arm64-gnu@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.64.6':
|
'@rollup/rollup-linux-arm64-musl@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.64.6':
|
'@rollup/rollup-linux-loong64-gnu@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.64.6':
|
'@rollup/rollup-linux-loong64-musl@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.64.6':
|
'@rollup/rollup-linux-ppc64-gnu@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.64.6':
|
'@rollup/rollup-linux-ppc64-musl@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.64.6':
|
'@rollup/rollup-linux-riscv64-gnu@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.64.6':
|
'@rollup/rollup-linux-riscv64-musl@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.64.6':
|
'@rollup/rollup-linux-s390x-gnu@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.64.6':
|
'@rollup/rollup-linux-x64-gnu@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.64.6':
|
'@rollup/rollup-linux-x64-musl@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.64.6':
|
'@rollup/rollup-openbsd-x64@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-openharmony-arm64@4.64.6':
|
'@rollup/rollup-openharmony-arm64@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-arm64-msvc@4.64.6':
|
'@rollup/rollup-win32-arm64-msvc@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-ia32-msvc@4.64.6':
|
'@rollup/rollup-win32-ia32-msvc@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-gnu@4.64.6':
|
'@rollup/rollup-win32-x64-gnu@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rollup/rollup-win32-x64-msvc@4.64.6':
|
'@rollup/rollup-win32-x64-msvc@4.64.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@types/debug@4.1.13':
|
'@types/debug@4.1.13':
|
||||||
|
|
@ -2295,7 +2309,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
mlly: 1.8.2
|
mlly: 1.8.2
|
||||||
rollup: 4.64.6
|
rollup: 4.64.12
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
@ -2356,7 +2370,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
|
|
||||||
lilconfig@3.1.3: {}
|
lilconfig@3.1.4: {}
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|
||||||
|
|
@ -2734,14 +2748,14 @@ snapshots:
|
||||||
mlly: 1.8.2
|
mlly: 1.8.2
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
postcss-load-config@6.0.1(postcss@8.5.30)(tsx@4.23.15):
|
postcss-load-config@6.0.1(postcss@8.5.31)(tsx@4.23.15):
|
||||||
dependencies:
|
dependencies:
|
||||||
lilconfig: 3.1.3
|
lilconfig: 3.1.4
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
postcss: 8.5.30
|
postcss: 8.5.31
|
||||||
tsx: 4.23.15
|
tsx: 4.23.15
|
||||||
|
|
||||||
postcss@8.5.30:
|
postcss@8.5.31:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.20
|
nanoid: 3.3.20
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
@ -2763,36 +2777,36 @@ snapshots:
|
||||||
onetime: 5.1.2
|
onetime: 5.1.2
|
||||||
signal-exit: 3.0.7
|
signal-exit: 3.0.7
|
||||||
|
|
||||||
rollup@4.64.6:
|
rollup@4.64.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
|
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
|
||||||
'@rollup/rollup-android-arm-eabi': 4.64.6
|
'@rollup/rollup-android-arm-eabi': 4.64.12
|
||||||
'@rollup/rollup-android-arm64': 4.64.6
|
'@rollup/rollup-android-arm64': 4.64.12
|
||||||
'@rollup/rollup-darwin-arm64': 4.64.6
|
'@rollup/rollup-darwin-arm64': 4.64.12
|
||||||
'@rollup/rollup-darwin-x64': 4.64.6
|
'@rollup/rollup-darwin-x64': 4.64.12
|
||||||
'@rollup/rollup-freebsd-arm64': 4.64.6
|
'@rollup/rollup-freebsd-arm64': 4.64.12
|
||||||
'@rollup/rollup-freebsd-x64': 4.64.6
|
'@rollup/rollup-freebsd-x64': 4.64.12
|
||||||
'@rollup/rollup-linux-arm-gnueabihf': 4.64.6
|
'@rollup/rollup-linux-arm-gnueabihf': 4.64.12
|
||||||
'@rollup/rollup-linux-arm-musleabihf': 4.64.6
|
'@rollup/rollup-linux-arm-musleabihf': 4.64.12
|
||||||
'@rollup/rollup-linux-arm64-gnu': 4.64.6
|
'@rollup/rollup-linux-arm64-gnu': 4.64.12
|
||||||
'@rollup/rollup-linux-arm64-musl': 4.64.6
|
'@rollup/rollup-linux-arm64-musl': 4.64.12
|
||||||
'@rollup/rollup-linux-loong64-gnu': 4.64.6
|
'@rollup/rollup-linux-loong64-gnu': 4.64.12
|
||||||
'@rollup/rollup-linux-loong64-musl': 4.64.6
|
'@rollup/rollup-linux-loong64-musl': 4.64.12
|
||||||
'@rollup/rollup-linux-ppc64-gnu': 4.64.6
|
'@rollup/rollup-linux-ppc64-gnu': 4.64.12
|
||||||
'@rollup/rollup-linux-ppc64-musl': 4.64.6
|
'@rollup/rollup-linux-ppc64-musl': 4.64.12
|
||||||
'@rollup/rollup-linux-riscv64-gnu': 4.64.6
|
'@rollup/rollup-linux-riscv64-gnu': 4.64.12
|
||||||
'@rollup/rollup-linux-riscv64-musl': 4.64.6
|
'@rollup/rollup-linux-riscv64-musl': 4.64.12
|
||||||
'@rollup/rollup-linux-s390x-gnu': 4.64.6
|
'@rollup/rollup-linux-s390x-gnu': 4.64.12
|
||||||
'@rollup/rollup-linux-x64-gnu': 4.64.6
|
'@rollup/rollup-linux-x64-gnu': 4.64.12
|
||||||
'@rollup/rollup-linux-x64-musl': 4.64.6
|
'@rollup/rollup-linux-x64-musl': 4.64.12
|
||||||
'@rollup/rollup-openbsd-x64': 4.64.6
|
'@rollup/rollup-openbsd-x64': 4.64.12
|
||||||
'@rollup/rollup-openharmony-arm64': 4.64.6
|
'@rollup/rollup-openharmony-arm64': 4.64.12
|
||||||
'@rollup/rollup-win32-arm64-msvc': 4.64.6
|
'@rollup/rollup-win32-arm64-msvc': 4.64.12
|
||||||
'@rollup/rollup-win32-ia32-msvc': 4.64.6
|
'@rollup/rollup-win32-ia32-msvc': 4.64.12
|
||||||
'@rollup/rollup-win32-x64-gnu': 4.64.6
|
'@rollup/rollup-win32-x64-gnu': 4.64.12
|
||||||
'@rollup/rollup-win32-x64-msvc': 4.64.6
|
'@rollup/rollup-win32-x64-msvc': 4.64.12
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
@ -2875,7 +2889,7 @@ snapshots:
|
||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
tsup@8.5.1(postcss@8.5.30)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3):
|
tsup@8.5.1(postcss@8.5.31)(supports-color@10.2.2)(tsx@4.23.15)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
bundle-require: 5.1.0(esbuild@0.27.7)
|
bundle-require: 5.1.0(esbuild@0.27.7)
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
|
|
@ -2886,16 +2900,16 @@ snapshots:
|
||||||
fix-dts-default-cjs-exports: 1.0.1
|
fix-dts-default-cjs-exports: 1.0.1
|
||||||
joycon: 3.1.1
|
joycon: 3.1.1
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
postcss-load-config: 6.0.1(postcss@8.5.30)(tsx@4.23.15)
|
postcss-load-config: 6.0.1(postcss@8.5.31)(tsx@4.23.15)
|
||||||
resolve-from: 5.0.0
|
resolve-from: 5.0.0
|
||||||
rollup: 4.64.6
|
rollup: 4.64.12
|
||||||
source-map: 0.7.6
|
source-map: 0.7.6
|
||||||
sucrase: 3.35.1
|
sucrase: 3.35.1
|
||||||
tinyexec: 0.3.2
|
tinyexec: 0.3.2
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
tree-kill: 1.2.2
|
tree-kill: 1.2.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
postcss: 8.5.30
|
postcss: 8.5.31
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
|
|
@ -2963,8 +2977,8 @@ snapshots:
|
||||||
vite@5.4.21(@types/node@22.20.5):
|
vite@5.4.21(@types/node@22.20.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.30
|
postcss: 8.5.31
|
||||||
rollup: 4.64.6
|
rollup: 4.64.12
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.20.5
|
'@types/node': 22.20.5
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,4 @@
|
||||||
packages:
|
packages:
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: set this to true or false
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue