Renommage AJEAN (3/3) : textes, aide, README et variables d'environnement

Surface visible passee en AJEAN : aide de la CLI, messages, invite du chat
terminal, commentaires, README, metadonnees Windows du binaire
(InternalName / OriginalFilename).

Les reglages s'appellent desormais AJEAN_* (AJEAN_HOME, AJEAN_MODEL_DIRS,
AJEAN_DL_CONNS, AJEAN_LINK_URL, AJEAN_ACME_EMAIL, AJEAN_SERVICE), avec
l'ancien nom JEAN_* toujours lu en second via envAny(). Ces variables sont
posees a la main dans des shells, des units, des docker-compose et des
scripts de deploiement qu'on ne peut pas retrouver : cesser de les lire
ferait retomber silencieusement un reglage sur sa valeur par defaut.

Migration one-shot du dossier de donnees retentee dans la fenetre ou
l'installateur Windows a arrete toutes les instances, seule fenetre ou le
rename peut aboutir. Le chemin du binaire est recalcule juste apres, sinon
on reinstallerait dans le dossier qu'on vient de deplacer.

Deux choses volontairement NON renommees, parce que les renommer casserait
des utilisateurs sans rien apporter :

- "model": "jean", le nom expose sur l'endpoint compatible OpenAI. Des
  integrations externes le configurent en dur.
- les cles localStorage (jean-theme, jean.sys...). Les renommer
  reinitialiserait theme, preferences d'affichage et prompt systeme dans le
  navigateur de chaque utilisateur.

Le plist launchd recevait /usr/local/bin/jean en dur : le chemin est
desormais injecte, sinon le daemon macOS aurait refuse de demarrer apres le
renommage.

index.html regenere depuis ui/src via go generate.
This commit is contained in:
nathaninline committed 2026-08-04 14:16:12 +02:00
1 parent 3720b90173
commit 993be881d5
44 files changed
+319 -236

No files matched your search

+55 -55
View File
@@ -7,7 +7,7 @@
AJEAN fournit tout ce qui entoure le modèle : l'interface de chat, les outils de l'assistant, la gestion du service et celle du matériel. Le moteur d'inférence est [llama.cpp](https://github.com/ggml-org/llama.cpp), qu'AJEAN compile lui-même pour la machine sur laquelle il tourne.
```
télécharger le binaire → jean llamacpp install → jean edit → jean start → c'est parti
télécharger le binaire → ajean llamacpp install → ajean edit → ajean start → c'est parti
```
Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur. Vous obtenez une interface de chat complète et un endpoint compatible OpenAI pour vos outils tiers.
@@ -18,7 +18,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur
**Un assistant, pas seulement un modèle.** L'interface web offre un chat avec raisonnement affiché, mémoire persistante, compactage automatique du contexte quand la conversation s'allonge, prompt système éditable, et des réglages d'apparence synchronisés entre appareils.
**Des outils réels.** `jean agent on` active d'un seul coup toutes les capacités du modèle sur la machine :
**Des outils réels.** `ajean agent on` active d'un seul coup toutes les capacités du modèle sur la machine :
| Outil | Rôle |
|---|---|
@@ -28,13 +28,13 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur
| web_* | recherche et lecture de pages, via Crawl4AI |
| mcp__* | les outils des serveurs MCP configurés |
**Le matériel et le moteur, gérés pour vous.** `jean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU fonctionne), ROCm, Metal, Vulkan, ou repli CPU. `jean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre.
**Le matériel et le moteur, gérés pour vous.** `ajean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU fonctionne), ROCm, Metal, Vulkan, ou repli CPU. `ajean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre.
**Un service, pas un script.** `jean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas).
**Un service, pas un script.** `ajean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas).
**Plusieurs modèles, un clic.** Les presets gardent chacun leur configuration complète ; basculer de l'un à l'autre recharge le modèle sans toucher à un fichier. Les `.gguf` peuvent vivre sur n'importe quel disque.
**Accessible de partout.** `jean link` ouvre une connexion sortante vers [ajean.link](https://ajean.link), donc aucun port à ouvrir, et cela fonctionne même en CGNAT. Le chat est chiffré de bout en bout : le relais ne voit jamais les conversations.
**Accessible de partout.** `ajean link` ouvre une connexion sortante vers [ajean.link](https://ajean.link), donc aucun port à ouvrir, et cela fonctionne même en CGNAT. Le chat est chiffré de bout en bout : le relais ne voit jamais les conversations.
## Démarrage
@@ -42,8 +42,8 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur
```bash
curl -L -o jean https://github.com/nathaninline/ajean/releases/latest/download/jean-linux-amd64
chmod +x jean
sudo mv jean /usr/local/bin/jean
chmod +x ajean
sudo mv ajean /usr/local/bin/ajean
```
(Autres cibles sur la page [Releases](../../releases) : Linux, macOS et Windows, en amd64 et arm64.)
@@ -51,8 +51,8 @@ sudo mv jean /usr/local/bin/jean
### 2. Installation et compilation du moteur
```bash
sudo jean install # unité systemd, sudoers, dossiers
jean llamacpp install # compile llama.cpp pour le GPU présent
sudo ajean install # unité systemd, sudoers, dossiers
ajean llamacpp install # compile llama.cpp pour le GPU présent
```
Nécessite `git` et `cmake`, plus le toolkit de l'accélérateur (CUDA, ROCm…) pour l'accélération GPU.
@@ -60,10 +60,10 @@ Nécessite `git` et `cmake`, plus le toolkit de l'accélérateur (CUDA, ROCm…)
### 3. Démarrage
```bash
jean edit # régler MODEL=/chemin/vers/le-modele.gguf
jean start
jean test # vérifier que le modèle répond
jean web # http://<hôte>:8090
ajean edit # régler MODEL=/chemin/vers/le-modele.gguf
ajean start
ajean test # vérifier que le modèle répond
ajean web # http://<hôte>:8090
```
## Commandes
@@ -78,7 +78,7 @@ Service :
start | stop | restart gérer le service
status | logs état / logs en direct
enable | disable démarrage au boot
edit éditer $JEAN_HOME/config.env
edit éditer $AJEAN_HOME/config.env
test | bench [N] vérifier que le modèle répond / mesurer les tok/s
vram | gpu [index…] VRAM / choix des GPU (gpu all = tous)
set-api-key [clé] protéger le moteur d'inférence (Bearer)
@@ -107,7 +107,7 @@ Installation :
## Configuration
Tout vit sous **`$JEAN_HOME`** (`/etc/jean` sous Linux/macOS, `%ProgramData%\jean` sous Windows). Le service lit `config.env` :
Tout vit sous **`$AJEAN_HOME`** (`/etc/ajean` sous Linux/macOS, `%ProgramData%\ajean` sous Windows). Le service lit `config.env` :
| Clé | Signification | Défaut |
|-----|---------------|--------|
@@ -119,7 +119,7 @@ Tout vit sous **`$JEAN_HOME`** (`/etc/jean` sous Linux/macOS, `%ProgramData%\jea
| `BATCH` / `UBATCH` | batch / micro-batch | `2048` / `512` |
| `THREADS` / `THREADS_BATCH` | threads CPU | `0` (auto) |
| `KV_TYPE` (`_K` / `_V`) | quantization du cache KV | — |
| `CUDA_VISIBLE_DEVICES` | GPU utilisés (réglé par `jean gpu`) | tous |
| `CUDA_VISIBLE_DEVICES` | GPU utilisés (réglé par `ajean gpu`) | tous |
| `REASONING` | passthrough du mode raisonnement | — |
| `REASONING_BUDGET` | plafond de tokens de réflexion ; `-1` = illimité | `-1` |
| `COMPACT` | compactage automatique du contexte (`off` pour couper) | activé |
@@ -127,31 +127,31 @@ Tout vit sous **`$JEAN_HOME`** (`/etc/jean` sous Linux/macOS, `%ProgramData%\jea
| `CRAWL4AI_URL` / `CRAWL4AI_KEY` | serveur d'accès internet | — |
| `EXTRA_ARGS` | ajouté tel quel à la ligne de commande du moteur | — |
La clé API (`jean set-api-key`) vit dans `$JEAN_HOME/.api_key`, à part, afin de survivre aux changements de preset.
La clé API (`ajean set-api-key`) vit dans `$AJEAN_HOME/.api_key`, à part, afin de survivre aux changements de preset.
**Modèles sur un autre disque.** Les `.gguf` n'ont pas à résider dans `$JEAN_HOME` : dans l'éditeur de preset de l'interface, section *Modèle → Dossiers de modèles*, ajoutez le dossier voulu. Ses modèles apparaissent dans la liste, groupés par dossier. La liste est enregistrée dans `model_dirs.json`, donc conservée d'un preset à l'autre.
**Modèles sur un autre disque.** Les `.gguf` n'ont pas à résider dans `$AJEAN_HOME` : dans l'éditeur de preset de l'interface, section *Modèle → Dossiers de modèles*, ajoutez le dossier voulu. Ses modèles apparaissent dans la liste, groupés par dossier. La liste est enregistrée dans `model_dirs.json`, donc conservée d'un preset à l'autre.
### Variables d'environnement
| Variable | Rôle | Défaut |
|----------|------|--------|
| `JEAN_HOME` | racine des données | `/etc/jean`, `%ProgramData%\jean` |
| `JEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — |
| `JEAN_SERVICE` | nom de l'unité systemd | `jean` |
| `AJEAN_HOME` | racine des données | `/etc/ajean`, `%ProgramData%\ajean` |
| `AJEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — |
| `JEAN_SERVICE` | nom de l'unité systemd | `ajean` |
| `HF_TOKEN` | token Hugging Face pour les modèles privés | — |
| `JEAN_DL_CONNS` | connexions parallèles au téléchargement | — |
| `EDITOR` | éditeur pour `jean edit` | `nano` / `notepad` |
| `AJEAN_DL_CONNS` | connexions parallèles au téléchargement | — |
| `EDITOR` | éditeur pour `ajean edit` | `nano` / `notepad` |
## Les capacités de l'IA
### Mémoire
L'IA tient des pages Markdown sous `$JEAN_HOME/MEMORY/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent :
L'IA tient des pages Markdown sous `$AJEAN_HOME/MEMORY/`, relues et mises à jour entre les sessions. Trois modes, indépendants du mode agent :
```bash
jean memory always # (défaut) elle cherche avant de répondre et enregistre d'elle-même
jean memory ondemand # outils disponibles, mais utilisés seulement sur demande
jean memory off # mémoire coupée
ajean memory always # (défaut) elle cherche avant de répondre et enregistre d'elle-même
ajean memory ondemand # outils disponibles, mais utilisés seulement sur demande
ajean memory off # mémoire coupée
```
### Accès internet
@@ -160,9 +160,9 @@ Par défaut, l'IA n'a pas accès au web. En branchant un serveur [Crawl4AI](http
```bash
docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest
jean internet url http://localhost:11235
jean internet on
jean internet status
ajean internet url http://localhost:11235
ajean internet on
ajean internet status
```
Les outils web ne sont proposés au modèle que si le mode agent est actif, l'accès internet activé **et** le serveur joignable. Sinon ils n'existent pas, et le modèle ne peut donc pas les inventer.
@@ -171,7 +171,7 @@ Les outils web ne sont proposés au modèle que si le mode agent est actif, l'ac
AJEAN parle le [Model Context Protocol](https://modelcontextprotocol.io) : on y branche des serveurs tiers (fichiers, bases de données, API…) et leurs outils s'ajoutent à ceux de l'IA, nommés `mcp__<serveur>__<outil>`.
La configuration se fait depuis l'interface web (section *Serveurs MCP*) ou en éditant `$JEAN_HOME/mcp.json`, au **même format que Claude Desktop**, si bien qu'un fichier existant se réutilise tel quel :
La configuration se fait depuis l'interface web (section *Serveurs MCP*) ou en éditant `$AJEAN_HOME/mcp.json`, au **même format que Claude Desktop**, si bien qu'un fichier existant se réutilise tel quel :
```json
{
@@ -186,38 +186,38 @@ Les transports **stdio** et **HTTP** sont pris en charge. Comme le terminal, un
## Windows
- **Pas de systemd** : `jean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés, il faut passer par une tâche planifiée.
- `JEAN_HOME` vaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`).
- `jean install` crée seulement le dossier de données et un `config.env` de départ.
- **Pas de systemd** : `ajean start` lance le service en arrière-plan (suivi par fichier PID) ; `stop`, `restart`, `status` et `logs` agissent dessus, sans droits administrateur. `enable` / `disable` ne sont pas gérés, il faut passer par une tâche planifiée.
- `AJEAN_HOME` vaut `%ProgramData%\ajean` (repli `%LOCALAPPDATA%\ajean`).
- `ajean install` crée seulement le dossier de données et un `config.env` de départ.
- Le terminal de l'IA passe par `cmd.exe`. Elle le sait, et écrit ses fichiers par l'outil dédié plutôt que par le shell, ce qui lui permet de produire des scripts contenant des guillemets.
```powershell
jean install
jean edit # BIN=...\llama-server.exe et MODEL=...\modele.gguf
jean start
jean status
ajean install
ajean edit # BIN=...\llama-server.exe et MODEL=...\modele.gguf
ajean start
ajean status
```
`jean llamacpp install` compile également sous Windows si `git` et `cmake` sont présents ; sinon, récupérer un `llama-server.exe` pré-compilé et pointer `BIN` dessus.
`ajean llamacpp install` compile également sous Windows si `git` et `cmake` sont présents ; sinon, récupérer un `llama-server.exe` pré-compilé et pointer `BIN` dessus.
## macOS
La page [Releases](../../releases) publie `Jean-macos-arm64.zip` / `Jean-macos-amd64.zip`, un bundle **`Jean.app`**. Dézipper, glisser dans *Applications*, ouvrir : l'interface démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de fenêtre de Terminal, pas d'icône dans le Dock.
La page [Releases](../../releases) publie `AJEAN-macos-arm64.zip` / `AJEAN-macos-amd64.zip`, un bundle **`AJEAN.app`**. Dézipper, glisser dans *Applications*, ouvrir : l'interface démarre sur `http://localhost:8090`, s'ouvre dans le navigateur, et l'icône se pose dans la **barre de menus**. Pas de fenêtre de Terminal, pas d'icône dans le Dock.
L'application n'est signée qu'en ad-hoc : au premier lancement, faire **clic droit → Ouvrir**.
Pour un usage en ligne de commande, prendre le binaire nu `jean-darwin-arm64` : hors bundle, il conserve son comportement CLI. Les services passent par **launchd**.
Pour un usage en ligne de commande, prendre le binaire nu `ajean-darwin-arm64` : hors bundle, il conserve son comportement CLI. Les services passent par **launchd**.
## Accès distant via ajean.link
`jean link` ouvre une connexion **sortante** vers le relais : le serveur reste injoignable depuis l'extérieur, mais reste accessible de partout.
`ajean link` ouvre une connexion **sortante** vers le relais : le serveur reste injoignable depuis l'extérieur, mais reste accessible de partout.
```bash
jean link <token> # token fourni sur ajean.link
jean link code # code d'appairage à saisir dans le portail
ajean link <token> # token fourni sur ajean.link
ajean link code # code d'appairage à saisir dans le portail
```
Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Inutile de lancer `jean web`, l'interface est servie dans le tunnel. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI.
Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Inutile de lancer `ajean web`, l'interface est servie dans le tunnel. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI.
Il s'agit d'un service optionnel et payant ; tout le reste d'AJEAN est et restera open source et gratuit.
@@ -226,8 +226,8 @@ Il s'agit d'un service optionnel et payant ; tout le reste d'AJEAN est et rester
Le relais est conçu comme un **tube aveugle** : il transporte les données sans pouvoir les lire.
- **Chat chiffré de bout en bout** (X25519 + AES-GCM). La clé est dérivée du mot de passe via **OPAQUE** et ne quitte jamais le navigateur.
- **Empreinte vérifiée.** `jean link` affiche l'empreinte de la clé de la machine, à confirmer une fois dans le portail, ce qui défait toute tentative d'interception par le relais.
- **Appairage authentifié.** Un code à usage unique (`jean link code`) garantit qu'un seul navigateur autorisé pilote le serveur ; même compromis, le relais ne peut pas forger de commande.
- **Empreinte vérifiée.** `ajean link` affiche l'empreinte de la clé de la machine, à confirmer une fois dans le portail, ce qui défait toute tentative d'interception par le relais.
- **Appairage authentifié.** Un code à usage unique (`ajean link code`) garantit qu'un seul navigateur autorisé pilote le serveur ; même compromis, le relais ne peut pas forger de commande.
- **Code servi hors du relais.** Le portail provient d'une origine indépendante (GitHub Pages) : le relais ne peut pas injecter de code pour dérober la clé.
Reste visible du relais : des métadonnées techniques (machine en ligne, modèle chargé, VRAM), jamais le contenu des conversations.
@@ -240,10 +240,10 @@ Le VPS effectue un simple **passthrough SNI** : le TLS est terminé sur la machi
## API de pilotage
`jean web` expose une API HTTP pour piloter AJEAN à distance. À protéger avant toute exposition :
`ajean web` expose une API HTTP pour piloter AJEAN à distance. À protéger avant toute exposition :
```bash
jean set-web-key # génère une clé
ajean set-web-key # génère une clé
```
Chaque appel `/api/*` présente alors `Authorization: Bearer <clé>` :
@@ -257,7 +257,7 @@ Chaque appel `/api/*` présente alors `Authorization: Bearer <clé>` :
| POST | `/api/start` · `/api/stop` · `/api/restart` | piloter le service |
| POST | `/api/chat` `{"messages":[…]}` | chat (flux SSE) |
> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, placer un reverse-proxy HTTPS devant, ou utiliser `jean link`.
> ⚠️ La clé voyage en clair en HTTP. Pour une exposition publique, placer un reverse-proxy HTTPS devant, ou utiliser `ajean link`.
## Compiler depuis les sources
@@ -265,17 +265,17 @@ Go 1.25+. AJEAN est écrit à 100 % en Go, l'interface est embarquée via `go:em
```bash
git clone https://github.com/nathaninline/ajean.git
cd jean
CGO_ENABLED=0 go build -o jean ./cmd/jean
cd ajean
CGO_ENABLED=0 go build -o ajean ./cmd/ajean
```
> Compiler **AJEAN** ne demande que Go. Compiler le **moteur llama.cpp** demande `git`, `cmake` et le toolkit de l'accélérateur.
## Arborescence
- `cmd/jean/` : point d'entrée + ressources Windows (icône, versioninfo).
- `internal/jean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`.
- `internal/jean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/jean`.
- `cmd/ajean/` : point d'entrée + ressources Windows (icône, versioninfo).
- `internal/ajean/` : tout le code, fichiers préfixés par domaine (`web_*`, `chat_*`, `llm_*`, `backend_*`, `relay_*`, `sys_*`, `mcp_*`) ; carte dans `doc.go`.
- `internal/ajean/ui/` : interface web embarquée. **`index.html` est généré** : les sources vivent dans `ui/src/`. Pour modifier l'interface, éditer `ui/src/` puis lancer `go generate ./internal/ajean`.
## Licence
+1 -1
View File
@@ -9,7 +9,7 @@ Si vous étiez bloqué dans cet état, il n'y a rien à réparer : téléchargez
## Mise à jour
```
jean update
ajean update
```
Sous Windows, télécharger le fichier depuis la page des releases et le lancer fait le même travail.
+2 -2
View File
@@ -21,9 +21,9 @@
"StringFileInfo": {
"CompanyName": "AJEAN contributors",
"FileDescription": "AJEAN — gestionnaire mono-binaire de serveurs llama.cpp auto-hébergés",
"InternalName": "jean",
"InternalName": "ajean",
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "jean.exe",
"OriginalFilename": "ajean.exe",
"ProductName": "AJEAN",
"ProductVersion": "0.6.15",
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
+3 -3
View File
@@ -228,15 +228,15 @@ func hintMissingBuildDep(p buildPlan, cfgLog string) {
fmt.Printf(" Relance l'installeur du CUDA Toolkit (installation personnalisée) en cochant « CUDA → Visual Studio Integration »\n")
fmt.Printf(" (Visual Studio avec le workload C++ doit déjà être installé), ou copie les fichiers de\n")
fmt.Printf(" <toolkit>\\extras\\visual_studio_integration\\MSBuildExtensions vers\n")
fmt.Printf(" <VS>\\MSBuild\\Microsoft\\VC\\<version>\\BuildCustomizations, puis relance %s.\n", bold("jean llamacpp install"))
fmt.Printf(" <VS>\\MSBuild\\Microsoft\\VC\\<version>\\BuildCustomizations, puis relance %s.\n", bold("ajean llamacpp install"))
}
if p.backend == "vulkan" && strings.Contains(log, "SPIRV-Headers") {
fmt.Printf("\n%s dépendance manquante pour le backend %s : les en-têtes SPIR-V (paquet « SPIRV-Headers ») sont introuvables.\n",
yellow("[dépendance]"), green("Vulkan"))
if cmd := pkgInstallHint("spirv-headers"); cmd != "" {
fmt.Printf(" installe-les puis relance %s : %s\n", bold("jean llamacpp install"), bold(cmd))
fmt.Printf(" installe-les puis relance %s : %s\n", bold("ajean llamacpp install"), bold(cmd))
} else {
fmt.Printf(" installe le paquet de développement « SPIRV-Headers » de ta distribution, puis relance %s.\n", bold("jean llamacpp install"))
fmt.Printf(" installe le paquet de développement « SPIRV-Headers » de ta distribution, puis relance %s.\n", bold("ajean llamacpp install"))
}
}
}
+4 -4
View File
@@ -69,9 +69,9 @@ func maskAPIKey(k string) string {
// exposed on the internet, llama-server requires "Authorization: Bearer <clé>"
// for every call.
//
// jean set-api-key <clé> définit la clé
// jean set-api-key génère une clé aléatoire
// jean set-api-key "" supprime la protection
// ajean set-api-key <clé> définit la clé
// ajean set-api-key génère une clé aléatoire
// ajean set-api-key "" supprime la protection
func cmdSetAPIKey(args []string) error {
var key string
switch {
@@ -97,7 +97,7 @@ func cmdSetAPIKey(args []string) error {
fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
sc := bufio.NewScanner(os.Stdin)
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
fmt.Println(dim("[info] pense à lancer 'jean restart'"))
fmt.Println(dim("[info] pense à lancer 'ajean restart'"))
return nil
}
return serviceAction("restart")
+7 -7
View File
@@ -11,10 +11,10 @@ import (
// backend_gpu.go — sélection du/des GPU utilisés par llama-server.
//
// jean gpu liste les GPU et montre la sélection courante
// jean gpu 1 n'utilise que le GPU d'index 1
// jean gpu 0 1 utilise les GPU 0 et 1
// jean gpu all réinitialise (tous les GPU visibles)
// ajean gpu liste les GPU et montre la sélection courante
// ajean gpu 1 n'utilise que le GPU d'index 1
// ajean gpu 0 1 utilise les GPU 0 et 1
// ajean gpu all réinitialise (tous les GPU visibles)
//
// La sélection est stockée dans config.env sous CUDA_VISIBLE_DEVICES ; backend_serve.go
// l'exporte (avec CUDA_DEVICE_ORDER=PCI_BUS_ID pour que les index correspondent
@@ -53,7 +53,7 @@ func cmdGPU(args []string) error {
return fmt.Errorf("index GPU invalide: %q (attendu un nombre)", tok)
}
if n < 0 || n >= len(gpus) {
return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « jean gpu »", n, len(gpus)-1)
return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « ajean gpu »", n, len(gpus)-1)
}
idx = append(idx, strconv.Itoa(n))
}
@@ -102,7 +102,7 @@ func gpuList() error {
} else {
fmt.Printf(" Sélection : %s (CUDA_VISIBLE_DEVICES=%s)\n", bold(sel), sel)
}
fmt.Printf(" %s jean gpu <index…> pour choisir, jean gpu all pour réinitialiser\n", dim("→"))
fmt.Printf(" %s ajean gpu <index…> pour choisir, ajean gpu all pour réinitialiser\n", dim("→"))
return nil
}
@@ -120,7 +120,7 @@ func gpuSet(value string) error {
fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
sc := bufio.NewScanner(os.Stdin)
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
fmt.Println(dim("[info] pense à lancer 'jean restart'"))
fmt.Println(dim("[info] pense à lancer 'ajean restart'"))
return nil
}
return serviceAction("restart")
+11 -11
View File
@@ -9,12 +9,12 @@ import (
// backend_llamacpp.go — gestion du backend llama.cpp (clone, build, mise à jour).
//
// `jean llamacpp install` installe un build neuf, détecte automatiquement
// `ajean llamacpp install` installe un build neuf, détecte automatiquement
// l'accélérateur (CUDA / ROCm / Metal / CPU) et la
// compute capability du GPU, puis pointe BIN dessus.
// `jean llamacpp update` met à jour le dépôt existant (git pull) et recompile
// `ajean llamacpp update` met à jour le dépôt existant (git pull) et recompile
// avec la bonne config, sans intervention.
// `jean llamacpp status` montre le commit courant, le backend détecté et le
// `ajean llamacpp status` montre le commit courant, le backend détecté et le
// retard éventuel sur origin.
const llamacppRepoURL = "https://github.com/ggml-org/llama.cpp.git"
@@ -55,7 +55,7 @@ func cmdLlamacpp(args []string) error {
if err := SetConfigKey("BIN", bin); err != nil {
return fmt.Errorf("binaires installés mais échec écriture BIN dans config.env: %w", err)
}
fmt.Printf("%s BIN mis à jour dans %s — %s pour appliquer\n", green("✓"), confPath(), bold("jean restart"))
fmt.Printf("%s BIN mis à jour dans %s — %s pour appliquer\n", green("✓"), confPath(), bold("ajean restart"))
return nil
default:
return fmt.Errorf("sous-commande inconnue: %s (install | update | prebuilt | status)", sub)
@@ -161,7 +161,7 @@ func llamacppInstall(args []string) error {
if isDir(filepath.Join(repo, ".git")) {
if !force {
fmt.Printf("%s dépôt déjà présent dans %s\n", yellow("[info]"), repo)
fmt.Printf(" → %s pour le mettre à jour, ou --force pour repartir de zéro\n", bold("jean llamacpp update"))
fmt.Printf(" → %s pour le mettre à jour, ou --force pour repartir de zéro\n", bold("ajean llamacpp update"))
return nil
}
fmt.Printf("%s --force : suppression de %s\n", yellow("[info]"), repo)
@@ -208,7 +208,7 @@ func llamacppInstall(args []string) error {
}
fmt.Printf("%s BIN mis à jour dans %s\n", green("✓"), confPath())
fmt.Printf("\nProchaines étapes :\n 1. renseigne MODEL : %s\n 2. démarre : %s\n",
bold("jean edit"), bold("jean restart"))
bold("ajean edit"), bold("ajean restart"))
return nil
}
@@ -246,7 +246,7 @@ func llamacppUpdate(args []string) error {
repo := llamacppRepoDir()
if !isDir(filepath.Join(repo, ".git")) {
return fmt.Errorf("aucun dépôt llama.cpp trouvé (%s).\n → lance d'abord %s", repo, bold("jean llamacpp install"))
return fmt.Errorf("aucun dépôt llama.cpp trouvé (%s).\n → lance d'abord %s", repo, bold("ajean llamacpp install"))
}
fmt.Printf("%s dépôt : %s\n", cyan("▶"), repo)
@@ -317,14 +317,14 @@ func llamacppUpdate(args []string) error {
fmt.Printf("\n%s mis à jour : %s → %s\n", green("✓"), oldCommit, newCommit)
if noRestart {
fmt.Printf("%s --no-restart : pense à lancer %s\n", dim("[info]"), bold("jean restart"))
fmt.Printf("%s --no-restart : pense à lancer %s\n", dim("[info]"), bold("ajean restart"))
return nil
}
if svcWasUp {
fmt.Printf("%s redémarrage du service…\n", cyan("▶"))
return serviceAction("start")
}
fmt.Printf("%s service non démarré auparavant — lance %s quand tu veux\n", dim("[info]"), bold("jean start"))
fmt.Printf("%s service non démarré auparavant — lance %s quand tu veux\n", dim("[info]"), bold("ajean start"))
return nil
}
@@ -337,7 +337,7 @@ func llamacppStatus(args []string) error {
fmt.Printf("%s\n", bold("llama.cpp"))
fmt.Printf(" dépôt : %s\n", repo)
if !isDir(filepath.Join(repo, ".git")) {
fmt.Printf(" %s pas encore installé — %s\n", yellow("état"), bold("jean llamacpp install"))
fmt.Printf(" %s pas encore installé — %s\n", yellow("état"), bold("ajean llamacpp install"))
return nil
}
commit := gitOutput(repo, "log", "-1", "--format=%h %ci %s")
@@ -354,7 +354,7 @@ func llamacppStatus(args []string) error {
// Retard sur origin (best-effort, sans fetch réseau).
if branch != "" && branch != "HEAD" {
if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); behind != "" && behind != "0" {
fmt.Printf(" maj : %s commit(s) de retard sur origin/%s — %s\n", yellow(behind), branch, bold("jean llamacpp update"))
fmt.Printf(" maj : %s commit(s) de retard sur origin/%s — %s\n", yellow(behind), branch, bold("ajean llamacpp update"))
}
}
+2 -2
View File
@@ -36,7 +36,7 @@ func extraModelDirs() []string {
raw = append(raw, dirs...)
}
}
if v := strings.TrimSpace(os.Getenv("JEAN_MODEL_DIRS")); v != "" {
if v := envAny("AJEAN_MODEL_DIRS", "JEAN_MODEL_DIRS"); v != "" {
raw = append(raw, strings.Split(v, modelDirsListSep())...)
}
return dedupDirs(raw, AjeanHome())
@@ -216,5 +216,5 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) {
n := modelDirCount(d)
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0})
}
sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("JEAN_MODEL_DIRS") != ""})
sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": envAny("AJEAN_MODEL_DIRS", "JEAN_MODEL_DIRS") != ""})
}
+1 -1
View File
@@ -197,7 +197,7 @@ var dlClient = &http.Client{
// Overridable with JEAN_DL_CONNS (1 disables parallelism).
func dlConns() int {
n := 8
if v := strings.TrimSpace(os.Getenv("JEAN_DL_CONNS")); v != "" {
if v := envAny("AJEAN_DL_CONNS", "JEAN_DL_CONNS"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
n = p
}
+2 -2
View File
@@ -42,7 +42,7 @@ func cmdServe(args []string) error {
// Linux, PATH on Windows — handled inside execServer.
setLibraryPath(filepath.Dir(bin))
// Sélection GPU (jean gpu) : on filtre les devices visibles par llama-server.
// Sélection GPU (ajean gpu) : on filtre les devices visibles par llama-server.
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
@@ -104,7 +104,7 @@ func cmdServe(args []string) error {
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(AjeanHome())
fmt.Fprintf(os.Stderr, "[jean serve] %s model=%s port=%s\n",
fmt.Fprintf(os.Stderr, "[ajean serve] %s model=%s port=%s\n",
bin, filepath.Base(model), get("PORT", "8080"))
// Hand off to the llama-server process. On Unix this replaces the current
+1 -1
View File
@@ -78,7 +78,7 @@ func cmdAgent(args []string) error {
fmt.Printf(" %s %s\n", bold(p.Name), p.Title)
}
default:
return fmt.Errorf("usage: jean agent [on|off|status]")
return fmt.Errorf("usage: ajean agent [on|off|status]")
}
return nil
}
+5 -5
View File
@@ -12,14 +12,14 @@ import (
// First positional arg (if any) becomes the system prompt.
func cmdChat(args []string) error {
if !healthCheck() {
return fmt.Errorf("serveur injoignable sur :%d — jean start d'abord", LLMPort())
return fmt.Errorf("serveur injoignable sur :%d — ajean start d'abord", LLMPort())
}
sysPrompt := strings.Join(args, " ")
msgs := []Message{}
if sysPrompt != "" {
msgs = append(msgs, Message{Role: "system", Content: sysPrompt})
}
fmt.Printf("\n%s — /reset pour vider, /sys <prompt> pour changer le system, /quit ou Ctrl-D pour sortir\n", cyan("jean chat"))
fmt.Printf("\n%s — /reset pour vider, /sys <prompt> pour changer le system, /quit ou Ctrl-D pour sortir\n", cyan("ajean chat"))
if sysPrompt != "" {
fmt.Println(dim("system: " + sysPrompt))
}
@@ -65,7 +65,7 @@ func cmdChat(args []string) error {
inReason := false
var stats *StatsEvent
// Print the assistant prefix once; reasoning is shown inline with a tag.
fmt.Print(cyan("jean") + " > ")
fmt.Print(cyan("ajean") + " > ")
caps := globalCaps()
// Compaction proactive (façon Hermes) : on résume les vieux tours quand
// l'historique dépasse le seuil, au lieu d'imposer un /reset.
@@ -120,7 +120,7 @@ func cmdChat(args []string) error {
fmt.Print("\n")
inReason = false
}
fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("jean")+" >")
fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("ajean")+" >")
case ev.Reasoning != "":
if !inReason {
fmt.Print(magenta("[reasoning] ") + dim(""))
@@ -129,7 +129,7 @@ func cmdChat(args []string) error {
fmt.Print(dim(ev.Reasoning))
case ev.Content != "":
if inReason {
fmt.Print("\n" + cyan("jean") + " > ")
fmt.Print("\n" + cyan("ajean") + " > ")
inReason = false
}
full.WriteString(ev.Content)
+7 -7
View File
@@ -1,5 +1,5 @@
// chat_internet_tools.go — les 4 outils web exposés au modèle (web_search,
// web_open, web_read, web_grep) + la sous-commande `jean internet`.
// web_open, web_read, web_grep) + la sous-commande `ajean internet`.
package ajean
import (
@@ -251,7 +251,7 @@ func toolWebGrep(args map[string]any) string {
entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n"))
}
// ─── CLI : jean internet [on|off|status|url <url>] ──────────────────────────
// ─── CLI : ajean internet [on|off|status|url <url>] ──────────────────────────
func cmdInternet(args []string) error {
sub := ""
@@ -261,7 +261,7 @@ func cmdInternet(args []string) error {
switch sub {
case "on":
if crawl4aiURL() == "" {
return fmt.Errorf("configure d'abord l'URL : jean internet url <url>")
return fmt.Errorf("configure d'abord l'URL : ajean internet url <url>")
}
if err := setInternetEnabled(true); err != nil {
return err
@@ -274,7 +274,7 @@ func cmdInternet(args []string) error {
fmt.Println(green("[ok]") + " accès internet désactivé")
case "url":
if len(args) < 2 {
return fmt.Errorf("usage: jean internet url <url> (ex: http://localhost:11235)")
return fmt.Errorf("usage: ajean internet url <url> (ex: http://localhost:11235)")
}
u := strings.TrimRight(strings.TrimSpace(args[1]), "/")
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
@@ -286,7 +286,7 @@ func cmdInternet(args []string) error {
fmt.Printf("%s serveur Crawl4AI : %s\n", green("[ok]"), bold(u))
case "key":
if len(args) < 2 {
return fmt.Errorf("usage: jean internet key <clé> (vide pour l'enlever : jean internet key \"\")")
return fmt.Errorf("usage: ajean internet key <clé> (vide pour l'enlever : ajean internet key \"\")")
}
k := strings.TrimSpace(args[1])
if err := writeCrawlKey(k); err != nil {
@@ -308,7 +308,7 @@ func cmdInternet(args []string) error {
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
u := crawl4aiURL()
if u == "" {
fmt.Printf(" serveur : %s — configure : jean internet url <url>\n", dim("(non configuré)"))
fmt.Printf(" serveur : %s — configure : ajean internet url <url>\n", dim("(non configuré)"))
return nil
}
reach := red("injoignable")
@@ -318,7 +318,7 @@ func cmdInternet(args []string) error {
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
default:
return fmt.Errorf("usage: jean internet [on|off|status|url <url>]")
return fmt.Errorf("usage: ajean internet [on|off|status|url <url>]")
}
return nil
}
+2 -2
View File
@@ -372,7 +372,7 @@ func setMemMode(m MemMode) error {
return SetConfigKey("MEM_MODE", string(m))
}
// cmdMemory : jean memory [off|ondemand|always|status]
// cmdMemory : ajean memory [off|ondemand|always|status]
func cmdMemory(args []string) error {
sub := ""
if len(args) > 0 {
@@ -403,7 +403,7 @@ func cmdMemory(args []string) error {
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
return nil
default:
return fmt.Errorf("usage: jean memory [off|ondemand|always|status]")
return fmt.Errorf("usage: ajean memory [off|ondemand|always|status]")
}
m := memMode()
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
+4 -1
View File
@@ -62,9 +62,12 @@ func workspaceCandidates() []string {
}
c = append(c, filepath.Join(AjeanHome(), "workspace"))
if home, err := os.UserHomeDir(); err == nil && home != "" {
// Le nouveau nom d'abord, l'ancien ensuite : un dossier ~/jean/workspace
// déjà rempli par l'agent reste utilisable au lieu de repartir de zéro.
c = append(c, filepath.Join(home, "ajean", "workspace"))
c = append(c, filepath.Join(home, "jean", "workspace"))
}
c = append(c, filepath.Join(os.TempDir(), "jean-workspace"))
c = append(c, filepath.Join(os.TempDir(), "ajean-workspace"))
return c
}
+5 -5
View File
@@ -103,7 +103,7 @@ func setOAIPublic(on bool) error {
func oaiTLSConfig() *tls.Config {
certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(AjeanHome(), "certs")}
certmagic.DefaultACME.Agreed = true
certmagic.DefaultACME.Email = strings.TrimSpace(os.Getenv("JEAN_ACME_EMAIL"))
certmagic.DefaultACME.Email = envAny("AJEAN_ACME_EMAIL", "JEAN_ACME_EMAIL")
certmagic.DefaultACME.DisableHTTPChallenge = true // pas de :80 accessible (CGNAT) → TLS-ALPN uniquement
magic := certmagic.NewDefault()
magic.OnDemand = &certmagic.OnDemandConfig{
@@ -221,7 +221,7 @@ func selfSignedTLSConfig(host string) (*tls.Config, error) {
// cmdOAI pilote l'accès OpenAI public côté agent.
//
// jean oai serve [port] [host] (test local) termine le TLS sur :port avec un
// ajean oai serve [port] [host] (test local) termine le TLS sur :port avec un
// cert auto-signé et proxifie vers llama /v1.
func cmdOAI(args []string) error {
sub := ""
@@ -251,11 +251,11 @@ func cmdOAI(args []string) error {
if err != nil {
return err
}
fmt.Printf("[jean oai] front TLS (test, auto-signé) https://%s:%d/v1 → llama :%d\n", host, port, LLMPort())
fmt.Printf("[ajean oai] front TLS (test, auto-signé) https://%s:%d/v1 → llama :%d\n", host, port, LLMPort())
return runOAIFront(ln, tlsCfg)
default:
fmt.Println("usage: jean oai serve [port] [host] (front TLS de test → llama /v1)")
fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (jean link)")
fmt.Println("usage: ajean oai serve [port] [host] (front TLS de test → llama /v1)")
fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (ajean link)")
fmt.Println(" quand JEAN_LINK_ALLOW_OAI=1 ; cert Let's Encrypt via TLS-ALPN-01.")
return nil
}
+1 -1
View File
@@ -14,7 +14,7 @@ func cmdTest(args []string) error {
fmt.Printf("→ GET http://localhost:%d/health … ", port)
if !healthCheck() {
fmt.Println(red("ko"))
return fmt.Errorf("/health ne répond pas — jean start d'abord")
return fmt.Errorf("/health ne répond pas — ajean start d'abord")
}
fmt.Println(green("ok"))
+1 -1
View File
@@ -5,7 +5,7 @@ package ajean
//
// Modèle : cet agent a une paire X25519 long-terme (clé privée locale, jamais
// transmise). Sa clé publique est publiée au relais et son EMPREINTE est affichée
// au `jean link` — l'utilisateur la confirme dans le portail, ce qui défait tout
// au `ajean link` — l'utilisateur la confirme dans le portail, ce qui défait tout
// MITM du relais. Le navigateur dérive une racine R de son mot de passe (exportKey
// OPAQUE), la SCELLE vers la clé publique de l'agent (le relais ne peut pas
// l'ouvrir), puis chiffre/déchiffre le chat avec une clé dérivée de R.
+3 -3
View File
@@ -9,7 +9,7 @@ package ajean
// Modèle : l'utilisateur a une identité X25519 dérivée de façon déterministe de son
// mot de passe (exportKey OPAQUE → racine R → uPriv) — le relais ne connaît pas R,
// donc ne peut PAS reproduire cette identité. La clé publique uPub est APPAIRÉE une
// fois à l'agent via un code affiché par « jean link » (canal hors-bande : le log du
// fois à l'agent via un code affiché par « ajean link » (canal hors-bande : le log du
// serveur, que le relais ne voit pas ; le code voyage scellé vers l'agent). Ensuite,
// chaque requête est chiffrée avec K = SHA256(ECDH(uPriv, agentPriv) || "authchan"),
// liée à uPub+horodatage (AAD) et protégée contre le rejeu. Seul le vrai utilisateur
@@ -100,8 +100,8 @@ func authorizeUser(uPubHex string) error {
// ---- Codes d'appairage : à la demande, usage unique, TTL 10 min --------------
//
// Générés par « jean link code » (ou affichés par « jean link »), ils sont
// partagés avec le worker « jean link --foreground » (systemd, autre process) via
// Générés par « ajean link code » (ou affichés par « ajean link »), ils sont
// partagés avec le worker « ajean link --foreground » (systemd, autre process) via
// un fichier AjeanHome/.pair_codes — sinon les deux process ne s'accorderaient pas.
// Stockés HACHÉS (SHA-256) : le fichier ne révèle aucun code. Chaque code expire
// au bout de 10 min et est consommé (retiré) au premier appairage réussi.
+29 -29
View File
@@ -1,16 +1,16 @@
package ajean
// relay_link.go — `jean link <token>` : connecte ce serveur Jean au relais public
// relay_link.go — `ajean link <token>` : connecte ce serveur Jean au relais public
// (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur
// y accède depuis n'importe où sans ouvrir de port (CGNAT, box, etc.).
//
// Principe : l'agent ouvre un WebSocket vers le relais, l'authentifie avec le
// token d'abonnement, puis multiplexe (yamux) ce lien unique en un stream par
// requête navigateur. Chaque stream est reverse-proxyfié vers le `jean web`
// requête navigateur. Chaque stream est reverse-proxyfié vers le `ajean web`
// local. Keepalive + reconnexion automatique avec backoff.
//
// Le token est fourni par la boutique à l'achat. Il est mémorisé dans
// $JEAN_HOME/.link_token pour que `jean link` (sans argument) reprenne la
// $JEAN_HOME/.link_token pour que `ajean link` (sans argument) reprenne la
// connexion.
import (
@@ -87,7 +87,7 @@ func removeLinkToken() error {
// relayURL resolves the relay WebSocket endpoint (env override → default).
func relayURL() string {
if u := strings.TrimSpace(os.Getenv("JEAN_LINK_URL")); u != "" {
if u := envAny("AJEAN_LINK_URL", "JEAN_LINK_URL"); u != "" {
return u
}
return defaultRelayURL
@@ -111,12 +111,12 @@ func cmdLink(args []string) error {
}
switch sub {
case "":
// `jean link` seul : afficher l'aide des sous-commandes (NE démarre pas —
// `ajean link` seul : afficher l'aide des sous-commandes (NE démarre pas —
// éviter de prendre un mot pour un token et d'écraser le vrai).
printLinkHelp()
return nil
case "serve", "--foreground", "fg":
// Le worker réel (boucle de connexion au relais), pendant de `jean serve`.
// Le worker réel (boucle de connexion au relais), pendant de `ajean serve`.
// C'est ce que lance l'unité systemd ; il NE doit PAS rendre la main.
return runLinkForeground()
case "start":
@@ -124,14 +124,14 @@ func cmdLink(args []string) error {
case "status":
tok := readLinkToken()
if tok == "" {
fmt.Println(yellow("[info]") + " aucun token enregistré — lance: jean link <token>")
fmt.Println(yellow("[info]") + " aucun token enregistré — lance: ajean link <token>")
return nil
}
fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL())
if linkServiceActive() {
fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName())
} else {
fmt.Printf("%s service %s: arrêté (jean link pour démarrer)\n", yellow("[info]"), linkServiceName())
fmt.Printf("%s service %s: arrêté (ajean link pour démarrer)\n", yellow("[info]"), linkServiceName())
}
return nil
case "logout":
@@ -174,7 +174,7 @@ func cmdLink(args []string) error {
// on le signale au lieu d'un faux « démarré ».
func startLink(force bool) error {
if readLinkToken() == "" {
return fmt.Errorf("aucun token. Usage: jean link <token> (token fourni à l'achat sur la boutique)")
return fmt.Errorf("aucun token. Usage: ajean link <token> (token fourni à l'achat sur la boutique)")
}
switch {
case force:
@@ -182,7 +182,7 @@ func startLink(force bool) error {
return err
}
case linkServiceActive():
fmt.Printf("%s service %s déjà en cours — « jean link restart » pour le relancer\n", yellow("[info]"), linkServiceName())
fmt.Printf("%s service %s déjà en cours — « ajean link restart » pour le relancer\n", yellow("[info]"), linkServiceName())
default:
if err := linkServiceCtl("start"); err != nil {
return err
@@ -191,20 +191,20 @@ func startLink(force bool) error {
return linkPrintIdentity()
}
// printLinkHelp liste les sous-commandes de `jean link`.
// printLinkHelp liste les sous-commandes de `ajean link`.
func printLinkHelp() {
fmt.Print(`jean link — accès distant via le relais ajean.link
fmt.Print(`ajean link — accès distant via le relais ajean.link
Usage :
jean link <token> enregistre le token (1re fois / pour le changer) et démarre le lien
jean link start démarre le lien en arrière-plan (service)
jean link restart redémarre le service de lien
jean link stop arrête le service de lien
jean link status état du token et du service
jean link code génère un code d'appairage (valable 10 min, à usage unique)
jean link logout oublie le token enregistré
ajean link <token> enregistre le token (1re fois / pour le changer) et démarre le lien
ajean link start démarre le lien en arrière-plan (service)
ajean link restart redémarre le service de lien
ajean link stop arrête le service de lien
ajean link status état du token et du service
ajean link code génère un code d'appairage (valable 10 min, à usage unique)
ajean link logout oublie le token enregistré
Le token est fourni sur ajean.link. « jean link » seul affiche cette aide.
Le token est fourni sur ajean.link. « ajean link » seul affiche cette aide.
`)
}
@@ -218,7 +218,7 @@ func linkPrintIdentity() error {
code, err := newPairCode()
if err != nil {
fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), AjeanHome(), err)
fmt.Printf(" Réessaie : sudo jean link code\n")
fmt.Printf(" Réessaie : sudo ajean link code\n")
return nil
}
fmt.Printf(" Code d'appairage (valable 10 min, usage unique) : %s\n\n", bold(code))
@@ -230,17 +230,17 @@ func linkPrintIdentity() error {
func runLinkForeground() error {
token := readLinkToken()
if token == "" {
return fmt.Errorf("aucun token. Usage: jean link <token>")
return fmt.Errorf("aucun token. Usage: ajean link <token>")
}
fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL())
fmt.Printf(" (UI AJEAN + endpoint OpenAI servis dans le tunnel — pas besoin de 'jean web')\n")
fmt.Printf(" (UI AJEAN + endpoint OpenAI servis dans le tunnel — pas besoin de 'ajean web')\n")
if fp := e2eFingerprint(); fp != "" {
fmt.Printf("%s empreinte E2E : %s (code d'appairage : jean link code)\n", green("[e2e]"), bold(fp))
fmt.Printf("%s empreinte E2E : %s (code d'appairage : ajean link code)\n", green("[e2e]"), bold(fp))
}
// UNE seule instance du mux web → UNE seule conversation en mémoire, PARTAGÉE
// entre l'UI locale (:8090, jean.n27.fr) et le tunnel (app.ajean.link). Avant,
// `jean web` et `jean link` étaient deux process distincts avec chacun leur
// `ajean web` et `ajean link` étaient deux process distincts avec chacun leur
// historique → confusion. Désormais le process jean-link est l'unique
// propriétaire de la conversation et sert les DEUX surfaces.
webMux := newWebMux()
@@ -369,12 +369,12 @@ func killForeignLinkWorker() {
// serveLocalWebMux sert l'UI web locale sur :8090 avec le mux fourni — le MÊME
// que celui exposé dans le tunnel. Le process jean-link devient ainsi l'unique
// propriétaire de la conversation, partagée entre l'accès local et l'accès
// distant. Remplace le `jean web` autonome (à ne plus lancer séparément).
// distant. Remplace le `ajean web` autonome (à ne plus lancer séparément).
func serveLocalWebMux(mux *http.ServeMux) {
addr := "0.0.0.0:8090"
ln, err := net.Listen("tcp", addr)
if err != nil {
fmt.Printf("%s UI locale non démarrée (%s occupé ? tue le vieux « jean web ») : %v\n", yellow("[web]"), addr, err)
fmt.Printf("%s UI locale non démarrée (%s occupé ? tue le vieux « ajean web ») : %v\n", yellow("[web]"), addr, err)
return
}
fmt.Printf("%s UI locale sur http://%s (même conversation que le tunnel)\n", green("[web]"), addr)
@@ -410,7 +410,7 @@ func adoptLegacyLinkFile(ext string) string {
// linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est
// l'unité systemd jean-link (avec sudo non interactif si on n'est pas root) ;
// ailleurs — macOS et Windows, où il n'y a ni systemd ni droits root — on lance
// « jean link serve » en processus détaché suivi par un fichier PID, comme le
// « ajean link serve » en processus détaché suivi par un fichier PID, comme le
// fait déjà le service principal. Sans ça, l'accès distant restait définitivement
// « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne
// composait jamais le tunnel.
@@ -527,7 +527,7 @@ func linkUserSvcCtl(action string) error {
cmd := spawnDetached(self, "link", "serve")
cmd.Stdout, cmd.Stderr = logf, logf
if err := cmd.Start(); err != nil {
return fmt.Errorf("démarrage de « jean link serve »: %w", err)
return fmt.Errorf("démarrage de « ajean link serve »: %w", err)
}
pid := cmd.Process.Pid
// PID + binaire d'origine : la 2e ligne dit QUELLE copie de l'app a lancé ce
+17 -15
View File
@@ -1,5 +1,5 @@
// jean — single-binary LLM server manager + web UI for llama.cpp deployments.
// Point d'entrée réel : Main(), appelé par cmd/jean (les métadonnées Windows
// ajean — single-binary LLM server manager + web UI for llama.cpp deployments.
// Point d'entrée réel : Main(), appelé par cmd/ajean (les métadonnées Windows
// .syso/go:generate vivent là-bas, dans le dossier du package main).
package ajean
@@ -12,7 +12,7 @@ import (
const Version = "0.6.15"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() {
// Rattache la console du terminal parent si on est lancé depuis un shell
// (Windows : binaire GUI). Retourne false au double-clic (aucune console) →
@@ -37,7 +37,7 @@ func Main() {
// lance l'expérience « application » (UI web + navigateur + icône tray) plutôt
// que d'afficher l'aide. Le binaire étant compilé en sous-système GUI, il n'y a
// AUCUNE console à ce stade (donc plus de fenêtre noire). Lancé depuis un shell,
// `jean` sans argument garde son comportement d'aide.
// `ajean` sans argument garde son comportement d'aide.
if noArgs && !haveConsole {
mustExit(cmdApp(args))
return
@@ -96,7 +96,7 @@ func Main() {
case "uninstall":
mustExit(cmdUninstall(args))
case "version", "-v", "--version":
fmt.Println("jean", Version)
fmt.Println("ajean", Version)
case "help", "-h", "--help", "":
printHelp()
default:
@@ -107,9 +107,9 @@ func Main() {
}
func printHelp() {
fmt.Printf(`jean %s — manager llama.cpp + UI web (single binary)
fmt.Printf(`ajean %s — manager llama.cpp + UI web (single binary)
Usage: jean <commande> [args]
Usage: ajean <commande> [args]
Application:
app lance l'UI web + ouvre le navigateur (auto au double-clic du binaire)
@@ -118,9 +118,9 @@ Service:
start | stop | restart gérer le service (systemd sous Linux, processus en arrière-plan sous Windows)
status | logs état / logs en direct
enable | disable auto-démarrage au boot
edit éditer $JEAN_HOME/config.env
edit éditer $AJEAN_HOME/config.env
set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer
set-web-key [clé] protéger l'API de pilotage 'jean web'; vide = générer, "" = retirer
set-web-key [clé] protéger l'API de pilotage 'ajean web'; vide = générer, "" = retirer
vram utilisation GPU/VRAM (nvidia-smi)
gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous)
test vérifie que l'IA répond (health + completion)
@@ -141,7 +141,7 @@ Accès distant (ajean.link) :
link code génère un code d'appairage (valable 10 min, à usage unique) pour le portail
link status | logout état du lien / oublier le token
link (sans argument) affiche l'aide des sous-commandes link
link serve exécute le worker au premier plan (utilisé par jean-link.service ; pendant de 'jean serve')
link serve exécute le worker au premier plan (utilisé par jean-link.service ; pendant de 'ajean serve')
Mode agent:
agent [on|off|status] active TOUS les outils de l'IA (shell complet + mémoire) — un seul interrupteur
@@ -151,20 +151,21 @@ Backend llama.cpp :
llamacpp update git pull + recompile le backend existant (arrête/redémarre le service)
llamacpp status commit courant, backend détecté, retard sur origin
Entrypoint (utilisé par jean.service) :
Entrypoint (utilisé par ajean.service) :
serve lit config.env et exec le binaire llama-server
Installation:
where affiche où sont le binaire, la config, la mémoire et le dossier de travail de l'agent
install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config)
uninstall désinstaller
update [--check] mettre à jour jean depuis les releases GitHub (--check = signale sans installer)
update [--check] mettre à jour AJEAN depuis les releases GitHub (--check = signale sans installer)
Env:
JEAN_HOME racine (défaut: /etc/jean sur Linux/macOS, %%ProgramData%%\jean sur Windows)
EDITOR éditeur pour 'jean edit' (défaut: nano sur Unix, notepad sur Windows)
AJEAN_HOME racine (défaut: /etc/ajean sur Linux/macOS, %%ProgramData%%\ajean sur Windows)
JEAN_HOME reste accepté (héritage)
EDITOR éditeur pour 'ajean edit' (défaut: nano sur Unix, notepad sur Windows)
Config: $JEAN_HOME/config.env
Config: $AJEAN_HOME/config.env
`, Version)
}
@@ -234,6 +235,7 @@ func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") }
func legacyToolsFlag() string { return filepath.Join(AjeanHome(), ".tools_enabled") }
func apiKeyPath() string { return filepath.Join(AjeanHome(), ".api_key") }
func crawlKeyPath() string { return filepath.Join(AjeanHome(), ".crawl4ai_key") }
// serviceName est le nom de l'unité qui exécute llama-server. AJEAN_SERVICE et
// JEAN_SERVICE sont tous deux honorés : le second est posé à la main sur des
// machines qu'on ne voit pas, le renommage ne doit pas les casser.
+1 -1
View File
@@ -9,7 +9,7 @@ package ajean
// qui s'ouvre et se ferme. En contrepartie, un binaire GUI lancé depuis un
// terminal (cmd/PowerShell) n'écrit nulle part par défaut : on se rattache alors
// explicitement à la console du parent et on réouvre stdout/stderr/stdin dessus,
// pour que l'usage CLI (`jean web`, `jean status`, …) reste lisible.
// pour que l'usage CLI (`ajean web`, `ajean status`, …) reste lisible.
import (
"os"
+23
View File
@@ -0,0 +1,23 @@
package ajean
import (
"os"
"strings"
)
// envAny lit la première variable d'environnement renseignée parmi names.
//
// Sert au renommage jean → AJEAN : chaque réglage s'appelle désormais AJEAN_*,
// mais l'ancien nom JEAN_* reste lu en second. Ces variables sont posées à la
// main — dans un shell, un fichier d'unité, un docker-compose, un script de
// déploiement — et rien ne nous permet de les retrouver pour les réécrire. Les
// cesser de les lire, c'est casser silencieusement des installations qui
// marchaient, avec un réglage qui redevient sa valeur par défaut sans le dire.
func envAny(names ...string) string {
for _, n := range names {
if v := strings.TrimSpace(os.Getenv(n)); v != "" {
return v
}
}
return ""
}
+1 -1
View File
@@ -4,7 +4,7 @@ package ajean
import "os"
// appFirstRun : hors Windows, `jean app` est lancé depuis un terminal par
// appFirstRun : hors Windows, `ajean app` est lancé depuis un terminal par
// quelqu'un qui a déjà suivi l'installation — pas de double-clic à désambiguïser,
// donc pas de boîte de dialogue. On se contente de provisionner les données au
// premier lancement, comme avant. Ne relance jamais (renvoie toujours false).
+19 -2
View File
@@ -195,6 +195,7 @@ func runAsInstaller(target string) bool {
return launch(target) // l'instance en cours reprend la main (port occupé)
}
stopProcesses(running)
target = migrateThenResolveTarget(target)
if err := replaceInstalled(target); err != nil {
messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nAJEAN va redémarrer dans sa version actuelle.",
"AJEAN", mbIconInfo)
@@ -205,6 +206,7 @@ func runAsInstaller(target string) bool {
case cmp > 0:
// Mise à jour, application arrêtée : rien à décider, on remplace et on
// démarre. C'est le cas courant et il doit rester muet.
target = migrateThenResolveTarget(target)
_ = replaceInstalled(target)
ensureShortcuts(target)
return launch(target)
@@ -225,7 +227,22 @@ func verLabel(v string) string {
// replaceInstalled écrase le binaire installé par celui qu'on exécute. Le
// renommage préalable en .old permet de remplacer un exécutable encore ouvert
// (même mécanique que replaceBinary pour `jean update`).
// (même mécanique que replaceBinary pour `ajean update`).
// migrateThenResolveTarget tente la migration du dossier de données maintenant
// qu'aucune instance ne tient plus de handle, puis renvoie le chemin du binaire
// installé DANS le dossier résultant.
//
// Recalculer le chemin est indispensable et pas cosmétique : `target` a été
// calculé avant la migration, il désigne donc encore …\jean\bin\ajean.exe. Sans
// ce recalcul, on réinstallerait le binaire dans l'ancien dossier qu'on vient
// tout juste de déplacer, en recréant au passage une arborescence fantôme.
func migrateThenResolveTarget(target string) string {
if !retryHomeMigration() {
return target
}
return installedExePath()
}
func replaceInstalled(target string) error {
old := target + ".old"
_ = os.Remove(old)
@@ -321,7 +338,7 @@ func portBusy(port int) bool {
// posées par goversioninfo, cf cmd/jean/versioninfo.json), comme le fait
// l'onglet « Détails » des propriétés Windows.
//
// ⚠️ NE PAS remplacer par un `jean version` exécuté : le binaire est compilé en
// ⚠️ NE PAS remplacer par un `ajean version` exécuté : le binaire est compilé en
// sous-système GUI, il s'attache à la console du parent et n'écrit RIEN dans un
// tuyau. La sortie capturée est vide, donc la comparaison de versions échouerait
// toujours en silence et la mise à jour ne se ferait jamais (vérifié).
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// binaryVersion pilote la mise à jour automatique du binaire installé quand on
// lance le fichier téléchargé : si elle renvoie du vide, plus aucune mise à jour
// n'a lieu, et en silence. C'est exactement ce qui arrivait avec la première
// implémentation (exécuter `jean version`, dont la sortie est vide pour un
// implémentation (exécuter `ajean version`, dont la sortie est vide pour un
// binaire en sous-système GUI). D'où ces tests sur la plomberie syscall.
func TestBinaryVersionReadsResource(t *testing.T) {
// kernel32.dll porte toujours une ressource de version.
+32 -14
View File
@@ -17,7 +17,7 @@ import (
// sur « systemctl: command not found » / « /etc/default/jean », cf. issue #4).
// Implémentation prudente ; à valider sur une machine Apple.
const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart
const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart
# Le service launchd lit ce fichier et lance ton binaire llama.cpp.
# Chemins
@@ -38,9 +38,15 @@ NGL="999"
EXTRA_ARGS=""
`
// launchdPlistTemplate — champs formatés : Label, UserName, WorkingDirectory,
// JEAN_HOME, StandardOutPath, StandardErrorPath. KeepAlive/SuccessfulExit=false
// ≈ Restart=on-failure ; RunAtLoad relance au boot une fois chargé avec `-w`.
// launchdPlistTemplate — champs formatés : Label, chemin du binaire, UserName,
// WorkingDirectory, AJEAN_HOME, JEAN_HOME, StandardOutPath, StandardErrorPath.
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au
// boot une fois chargé avec `-w`.
//
// Le chemin du binaire est injecté et non codé en dur : il a changé avec le
// renommage, et un plist figé sur l'ancien chemin donnerait un daemon qui refuse
// de démarrer. Les deux variables d'environnement sont posées car le code lit
// encore JEAN_HOME en héritage.
const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
@@ -48,13 +54,13 @@ const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<key>Label</key><string>%s</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/jean</string>
<string>%s</string>
<string>serve</string>
</array>
<key>UserName</key><string>%s</string>
<key>WorkingDirectory</key><string>%s</string>
<key>EnvironmentVariables</key>
<dict><key>JEAN_HOME</key><string>%s</string></dict>
<dict><key>AJEAN_HOME</key><string>%s</string><key>JEAN_HOME</key><string>%s</string></dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict>
@@ -66,7 +72,7 @@ const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
func cmdInstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)")
return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)")
}
targetUser := os.Getenv("SUDO_USER")
if targetUser == "" {
@@ -108,7 +114,7 @@ func cmdInstall(args []string) error {
fmt.Printf(" %s écrit %s\n", green("✓"), conf)
}
// 3. Symlink idempotent vers /usr/local/bin/jean — même garde-fou que Linux
// 3. Symlink idempotent vers /usr/local/bin/ajean — même garde-fou que Linux
// (issue #5 : ne pas se lier sur soi-même si on tourne déjà depuis la cible).
self, err := os.Executable()
if err != nil {
@@ -117,7 +123,7 @@ func cmdInstall(args []string) error {
if rp, e := filepath.EvalSymlinks(self); e == nil {
self = rp
}
target := "/usr/local/bin/jean"
target := installedExePath()
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
@@ -137,6 +143,18 @@ func cmdInstall(args []string) error {
fmt.Printf(" %s %s -> %s\n", green("✓"), target, self)
}
// 3 bis. Alias hérité : `jean` doit rester tapable (voir sys_paths.go).
if alias := legacyExePath(); alias != target {
if rp, e := filepath.EvalSymlinks(alias); e != nil || rp != self {
_ = os.Remove(alias)
if err := os.Symlink(target, alias); err != nil {
fmt.Printf(" %s alias %s non créé (%v) — « ajean » reste disponible\n", yellow("[info]"), alias, err)
} else {
fmt.Printf(" %s %s -> %s (alias hérité)\n", green("✓"), alias, target)
}
}
}
// 4. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME.
// Les deux clés sont écrites : des scripts d'utilisateurs sourcent ce fichier
// et lisent encore $JEAN_HOME.
@@ -149,7 +167,7 @@ func cmdInstall(args []string) error {
// 5. LaunchDaemon plist (le log va sous JEAN_HOME, accessible au user cible)
logPath := filepath.Join(ajeanHome, svc+".log")
plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), targetUser, ajeanHome, ajeanHome, logPath, logPath)
plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), target, targetUser, ajeanHome, ajeanHome, ajeanHome, logPath, logPath)
plistPath := launchdPlistPath(svc)
if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil {
return err
@@ -162,16 +180,16 @@ func cmdInstall(args []string) error {
fmt.Println()
fmt.Printf("%s installation terminée.\n", green("[ok]"))
fmt.Printf("\nProchaines étapes :\n")
fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" jean edit"))
fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" ajean edit"))
fmt.Printf(" (renseigne BIN, MODEL, etc.)\n")
fmt.Printf(" 2. démarre le service: %s\n", bold("sudo jean start"))
fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web"))
fmt.Printf(" 2. démarre le service: %s\n", bold("sudo ajean start"))
fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" ajean web"))
return nil
}
func cmdUninstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("jean uninstall doit être exécuté en root")
return fmt.Errorf("ajean uninstall doit être exécuté en root")
}
svc := serviceName()
_ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(svc)).Run()
+8 -8
View File
@@ -11,7 +11,7 @@ import (
"strings"
)
const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart
const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart
# Le service systemd lit ce fichier et lance ton binaire llama.cpp.
# Chemins
@@ -61,7 +61,7 @@ WantedBy=multi-user.target
func cmdInstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)")
return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)")
}
targetUser := os.Getenv("SUDO_USER")
if targetUser == "" {
@@ -113,7 +113,7 @@ func cmdInstall(args []string) error {
}
target := installedExePath()
// Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a
// posé le binaire dans /usr/local/bin/jean puis lancé `sudo jean install`),
// posé le binaire dans /usr/local/bin/jean puis lancé `sudo ajean install`),
// ne surtout pas Remove+Symlink sur soi-même — ça effacerait le vrai binaire
// et créerait /usr/local/bin/jean -> /usr/local/bin/jean (« Too many levels
// of symbolic links »). On compare les chemins réels (résolus).
@@ -134,7 +134,7 @@ func cmdInstall(args []string) error {
fmt.Printf(" %s %s -> %s\n", green("✓"), target, self)
}
// 3 bis. Alias hérité : `jean` doit rester tapable. Des utilisateurs ont des
// 3 bis. Alias hérité : `ajean` doit rester tapable. Des utilisateurs ont des
// alias shell, des cron, des scripts de déploiement et des tutos qui appellent
// « jean » — on ne contrôle rien de tout ça, et un binaire renommé qui casse
// la commande d'hier est une régression pour eux. Le lien pointe sur le
@@ -185,16 +185,16 @@ func cmdInstall(args []string) error {
fmt.Println()
fmt.Printf("%s installation terminée.\n", green("[ok]"))
fmt.Printf("\nProchaines étapes :\n")
fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" jean edit"))
fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" ajean edit"))
fmt.Printf(" (renseigne BIN, MODEL, etc.)\n")
fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" jean start"))
fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web"))
fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" ajean start"))
fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" ajean web"))
return nil
}
func cmdUninstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("jean uninstall doit être exécuté en root")
return fmt.Errorf("ajean uninstall doit être exécuté en root")
}
svc := serviceName()
keepData := false
+8 -8
View File
@@ -12,12 +12,12 @@ import (
)
// On Windows there's no systemd unit, sudoers, or /usr/local/bin to populate.
// `jean install` simply provisions the data directory and a starter config; the
// `ajean install` simply provisions the data directory and a starter config; the
// service itself is managed by the PID-file supervisor in sys_service_windows.go
// (jean start / stop / status), which needs no admin rights.
// (ajean start / stop / status), which needs no admin rights.
const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart
# jean serve lit ce fichier et lance ton binaire llama.cpp.
const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart
# ajean serve lit ce fichier et lance ton binaire llama.cpp.
# Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent)
BIN="C:/llama.cpp/build/bin/Release/llama-server.exe"
@@ -59,7 +59,7 @@ func cmdInstall(args []string) error {
}
// 3. Install the binary into JEAN_HOME\bin and put that dir on the user PATH,
// so `jean` is callable from any shell (this is the Windows analogue of the
// so `ajean` is callable from any shell (this is the Windows analogue of the
// /usr/local/bin symlink the Unix installer creates).
binDir := filepath.Join(jeanHome, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
@@ -86,9 +86,9 @@ func cmdInstall(args []string) error {
fmt.Println()
fmt.Printf("%s installation terminée.\n", green("[ok]"))
fmt.Printf("\nProchaines étapes :\n")
fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("jean edit"))
fmt.Printf(" 2. démarre le service: %s\n", bold("jean start"))
fmt.Printf(" 3. UI web : %s\n", bold("jean web"))
fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("ajean edit"))
fmt.Printf(" 2. démarre le service: %s\n", bold("ajean start"))
fmt.Printf(" 3. UI web : %s\n", bold("ajean web"))
if onPath {
fmt.Printf("\n%s ouvre un NOUVEAU terminal pour que 'jean' soit reconnu (le PATH n'est lu qu'au démarrage du shell).\n", dim("[info]"))
} else {
+20
View File
@@ -50,6 +50,26 @@ func resolveDefaultHome() string {
return migrateHome(defaultAjeanHome(), legacyDefaultHome())
}
// retryHomeMigration relance la résolution — donc la migration — après avoir
// arrêté ce qui tournait. Renvoie true si le dossier a effectivement changé.
//
// Raison d'être : sous Windows, un rename de dossier échoue tant qu'un process
// y tient un handle. Or le service AJEAN détaché en tient en permanence, si bien
// que la migration tentée au démarrage échouerait indéfiniment et que la machine
// resterait sur l'ancien chemin. L'installateur, lui, a une fenêtre où tout est
// arrêté : c'est là qu'on retente.
//
// À N'APPELER QUE dans cette fenêtre, et avant que quoi que ce soit d'autre
// n'ait ouvert de fichier de données : réinitialiser le cache n'est ni atomique
// ni sûr vis-à-vis des goroutines, et surtout les chemins déjà calculés par
// l'appelant deviennent obsolètes (voir migrateThenResolveTarget).
func retryHomeMigration() bool {
before := migratedDefaultHome()
homeOnce = sync.Once{}
homePath = ""
return migratedDefaultHome() != before
}
// migrateHome contient toute la logique de migration, isolée des chemins réels
// de la plateforme pour être testable telle quelle. Renvoie le dossier à
// utiliser — le nouveau si la migration a réussi ou n'était pas nécessaire,
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// emplacements invisibles : on ne sait plus si le .exe qu'on a double-cliqué EST
// l'application ou seulement un installateur, ni où atterrissent la config, les
// modèles et les fichiers que l'agent écrit. On centralise donc la réponse ici,
// et on l'expose à la fois en ligne de commande (`jean where`) et dans l'UI.
// et on l'expose à la fois en ligne de commande (`ajean where`) et dans l'UI.
type ajeanPaths struct {
Home string `json:"home"` // JEAN_HOME : racine des données
+1 -1
View File
@@ -59,7 +59,7 @@ func isWritableDir(dir string) bool {
return true
}
// defaultEditor is used by `jean edit` when $EDITOR is unset.
// defaultEditor is used by `ajean edit` when $EDITOR is unset.
func defaultEditor() string { return "nano" }
// hideCmd : no-op sur Unix (pas de fenêtre de console à masquer).
+4 -4
View File
@@ -60,7 +60,7 @@ func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") }
// legacyDefaultHome is the pre-rename data root, migrated on first run.
func legacyDefaultHome() string { return filepath.Join(homeRoot(), "jean") }
// defaultEditor is used by `jean edit` when $EDITOR is unset.
// defaultEditor is used by `ajean edit` when $EDITOR is unset.
func defaultEditor() string { return "notepad" }
// openBrowser ouvre l'URL dans le navigateur par défaut. rundll32 évite les
@@ -130,7 +130,7 @@ func setLibraryPath(dir string) {
}
// execServer runs llama-server as a child process and waits for it. Windows has
// no exec() that replaces the current image, so `jean serve` stays alive as the
// no exec() that replaces the current image, so `ajean serve` stays alive as the
// parent (this is the detached process the service supervisor tracks).
// args[0] is the binary path; the rest are its arguments.
func execServer(bin string, args []string) error {
@@ -232,7 +232,7 @@ func msvcGenerator() string {
// ensureCompiler makes sure an MSVC C++ toolchain is available, installing the
// Visual Studio 2022 Build Tools (VCTools workload) via winget if not. This is a
// large download but keeps `jean llamacpp install` fully unattended on a bare
// large download but keeps `ajean llamacpp install` fully unattended on a bare
// Windows box.
func ensureCompiler() error {
if msvcInstallVersion() != "" {
@@ -354,7 +354,7 @@ Sans elle, CMake échoue sur « No CUDA toolset found ». Pour corriger, au choi
1. relance l'installeur du CUDA Toolkit (installation personnalisée) et coche « CUDA → Visual Studio Integration » — Visual Studio doit déjà être installé à ce moment-là ;
2. ou copie (en admin) les fichiers de
%s
vers le dossier BuildCustomizations ci-dessus, puis relance jean llamacpp install`, installPath, src)
vers le dossier BuildCustomizations ci-dessus, puis relance ajean llamacpp install`, installPath, src)
}
// cudaPathEnv returns the CUDA toolkit env vars the MSBuild CUDA integration
+1 -1
View File
@@ -28,7 +28,7 @@ func editConfig() error {
if err := cmd.Run(); err != nil {
return err
}
fmt.Println(dim("[info] jean restart pour appliquer"))
fmt.Println(dim("[info] ajean restart pour appliquer"))
return nil
}
+8 -8
View File
@@ -54,8 +54,8 @@ func serviceAction(action string) error {
svc := serviceName()
plist := launchdPlistPath(svc)
// Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où Jean tourne
// via Jean.app sans jamais passer par `sudo jean install` : on gère alors le
// service comme sous Windows, avec un `jean serve` détaché suivi par un fichier
// via Jean.app sans jamais passer par `sudo ajean install` : on gère alors le
// service comme sous Windows, avec un `ajean serve` détaché suivi par un fichier
// PID. Aucun droit root requis. Sans ça, `start` échouait sur un plist absent
// et l'UI affichait éternellement « service arrêté ».
if _, err := os.Stat(plist); err != nil {
@@ -117,7 +117,7 @@ func checkStarted(svc string) error {
fmt.Println(strings.Join(lines, "\n"))
}
fmt.Println("------------------------------------------------")
fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
return fmt.Errorf("service %s non démarré", svc)
}
@@ -144,7 +144,7 @@ func serviceIsActive() bool {
}
// ---------------------------------------------------------------------------
// Mode utilisateur (sans launchd) — même approche que Windows : `jean serve` est
// Mode utilisateur (sans launchd) — même approche que Windows : `ajean serve` est
// lancé détaché, son PID va dans JEAN_HOME/<svc>.pid et sa sortie dans
// JEAN_HOME/<svc>.log. Setsid le détache de notre session pour qu'il survive à
// la fermeture de Jean.app, et permet de tuer tout le groupe (llama-server
@@ -175,7 +175,7 @@ func userSvcAction(action string) error {
return nil
case "enable", "disable":
fmt.Printf("%s '%s' réclame un service système : lance %s pour installer le LaunchDaemon.\n",
yellow("[info]"), action, bold("sudo jean install"))
yellow("[info]"), action, bold("sudo ajean install"))
return nil
}
return fmt.Errorf("action inconnue: %s", action)
@@ -207,7 +207,7 @@ func userSvcStart() error {
cmd.Dir = AjeanHome()
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("démarrage de 'jean serve': %w", err)
return fmt.Errorf("démarrage de 'ajean serve': %w", err)
}
pid := cmd.Process.Pid
if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil {
@@ -221,14 +221,14 @@ func userCheckStarted(pid int) error {
time.Sleep(2 * time.Second)
if processAlive(pid) {
fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid)
fmt.Printf(" logs: %s (jean logs pour suivre)\n", dim(logFilePath()))
fmt.Printf(" logs: %s (ajean logs pour suivre)\n", dim(logFilePath()))
return nil
}
fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName())
fmt.Println("------------------------------------------------")
fmt.Print(tailFile(logFilePath(), 20))
fmt.Println("------------------------------------------------")
fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
_ = os.Remove(pidFilePath())
return fmt.Errorf("service %s non démarré", serviceName())
}
+1 -1
View File
@@ -55,7 +55,7 @@ func checkStarted(svc string) error {
logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output()
fmt.Print(string(logs))
fmt.Println("------------------------------------------------")
fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
return fmt.Errorf("service %s non démarré", svc)
}
+5 -5
View File
@@ -16,7 +16,7 @@ import (
)
// On Windows there is no systemd, so "the service" is a background copy of
// `jean serve` that this process launches detached. We track it with a PID file
// `ajean serve` that this process launches detached. We track it with a PID file
// and stream its output to a log file under JEAN_HOME. This needs no admin
// rights and no external tools beyond the always-present tasklist/taskkill.
@@ -72,14 +72,14 @@ func svcStart() error {
cmd.Stdout = logf
cmd.Stderr = logf
cmd.Dir = AjeanHome() // les chemins relatifs de config.env se résolvent depuis JEAN_HOME
// createNoWindow + HideWindow : le service enfant (`jean serve`) ne doit JAMAIS
// createNoWindow + HideWindow : le service enfant (`ajean serve`) ne doit JAMAIS
// faire clignoter de console noire quand Jean est lancé en mode app (double-clic).
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow,
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("démarrage de 'jean serve': %w", err)
return fmt.Errorf("démarrage de 'ajean serve': %w", err)
}
pid := cmd.Process.Pid
if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil {
@@ -94,14 +94,14 @@ func checkStarted(pid int) error {
time.Sleep(2 * time.Second)
if processAlive(pid) {
fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid)
fmt.Printf(" logs: %s (jean logs pour suivre)\n", dim(logFilePath()))
fmt.Printf(" logs: %s (ajean logs pour suivre)\n", dim(logFilePath()))
return nil
}
fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName())
fmt.Println("------------------------------------------------")
fmt.Print(tailFile(logFilePath(), 20))
fmt.Println("------------------------------------------------")
fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
_ = os.Remove(pidFilePath())
return fmt.Errorf("service %s non démarré", serviceName())
}
+1 -1
View File
@@ -3,7 +3,7 @@
package ajean
// sys_tray_windows.go — icône de Jean dans la zone de notification Windows (system
// tray). Quand on lance l'app (double-clic ou `jean app`), l'UI web s'ouvre dans
// tray). Quand on lance l'app (double-clic ou `ajean app`), l'UI web s'ouvre dans
// le navigateur ET une petite icône apparaît près de l'horloge : elle montre que
// Jean tourne et offre un menu « Ouvrir Jean » / « Quitter ».
//
+5 -5
View File
@@ -17,7 +17,7 @@ import (
"golang.org/x/mod/semver"
)
// jean update — met à jour le binaire depuis les releases GitHub du projet.
// ajean update — met à jour le binaire depuis les releases GitHub du projet.
// Périmètre minimal : compare la version, télécharge l'asset correspondant à
// l'OS/arch courant, remplace le binaire en place, puis affiche quoi redémarrer.
// AUCUN redémarrage de service automatique (choix volontaire, plus sûr).
@@ -109,7 +109,7 @@ type updateInfo struct {
}
// checkForUpdate interroge GitHub et compare à la version courante. Réutilisé
// par `jean update` (CLI) et par l'endpoint web /api/update.
// par `ajean update` (CLI) et par l'endpoint web /api/update.
func checkForUpdate() (updateInfo, error) {
info := updateInfo{Current: Version}
rel, err := fetchLatestRelease()
@@ -158,7 +158,7 @@ func applyUpdate() (string, error) {
// Contrôle des droits AVANT de télécharger : sans ça, l'échec survient sur le
// os.Create du fichier temporaire et l'utilisateur reçoit un « open
// /usr/local/bin/.ajean-update.tmp: permission denied » incompréhensible,
// notamment depuis le bouton de l'UI web (jean web lancé sans sudo).
// notamment depuis le bouton de l'UI web (ajean web lancé sans sudo).
if err := checkUpdateWritable(exe); err != nil {
return "", err
}
@@ -203,7 +203,7 @@ func updatePermissionError(exe string) error {
if runtime.GOOS == "windows" {
return fmt.Errorf("droits insuffisants pour remplacer %s — relance AJEAN en administrateur puis réessaie", exe)
}
return fmt.Errorf("droits insuffisants pour remplacer %s (le binaire appartient à root) — lance la mise à jour en ligne de commande : sudo jean update", exe)
return fmt.Errorf("droits insuffisants pour remplacer %s (le binaire appartient à root) — lance la mise à jour en ligne de commande : sudo ajean update", exe)
}
// checkUpdateWritable vérifie qu'on peut écrire dans le dossier du binaire, en
@@ -243,7 +243,7 @@ func cmdUpdate(args []string) error {
fmt.Printf("nouvelle version disponible : %s (actuelle : %s)\n", info.Latest, Version)
fmt.Printf(" %s\n", info.URL)
if checkOnly {
fmt.Println("lance 'jean update' pour l'installer.")
fmt.Println("lance 'ajean update' pour l'installer.")
return nil
}
fmt.Printf("téléchargement de %s…\n", updateAssetName())
+1 -1
View File
@@ -1256,7 +1256,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
</div>
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier jean.</div>
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier AJEAN.</div>
</div>
</div>
</div>
+1 -1
View File
@@ -428,7 +428,7 @@
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
</div>
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier jean.</div>
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier AJEAN.</div>
</div>
</div>
</div>
+5 -5
View File
@@ -11,7 +11,7 @@ import (
"strings"
)
// web_auth.go protège l'API de pilotage (jean web) quand elle est exposée sur
// web_auth.go protège l'API de pilotage (ajean web) quand elle est exposée sur
// internet — c.-à-d. l'API que tout client (navigateur, app mobile, script…)
// utilise pour switcher de preset, redémarrer le service, lire le status, etc.
//
@@ -70,9 +70,9 @@ func checkBearer(r *http.Request, key string) bool {
// cmdSetWebKey sets (or clears) the control-API key in $JEAN_HOME/.web_key.
//
// jean set-web-key <clé> définit la clé
// jean set-web-key génère une clé aléatoire
// jean set-web-key "" supprime la protection (API ouverte)
// ajean set-web-key <clé> définit la clé
// ajean set-web-key génère une clé aléatoire
// ajean set-web-key "" supprime la protection (API ouverte)
//
// Contrairement à set-api-key, aucun redémarrage n'est nécessaire : le serveur
// web relit la clé à chaque requête.
@@ -106,6 +106,6 @@ func cmdSetWebKey(args []string) error {
}
fmt.Printf("%s clé de pilotage enregistrée dans %s\n", green("[ok]"), webKeyPath())
fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
fmt.Printf(" (relance 'jean web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
fmt.Printf(" (relance 'ajean web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
return nil
}
+3 -3
View File
@@ -3,7 +3,7 @@ package ajean
// web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI
// web de Jean, pour éviter le terminal. Le panneau « Accès distant » ouvre une
// popup app.ajean.link/connect.html qui gère compte + paiement puis renvoie une
// clé de liaison (jl_…) ; l'UI la POSTe ici, et Jean fait le `jean link` tout
// clé de liaison (jl_…) ; l'UI la POSTe ici, et Jean fait le `ajean link` tout
// seul (écrit le token + (re)démarre le service).
//
// Ces routes ne sont accessibles qu'en LOCAL : à travers le tunnel du relais,
@@ -79,7 +79,7 @@ func handleLinkStart(w http.ResponseWriter, r *http.Request) {
}
// handleLinkDisconnect (POST /api/link/disconnect) : arrête le service et oublie
// la clé (équivalent `jean link stop` + `jean link logout`).
// la clé (équivalent `ajean link stop` + `ajean link logout`).
func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) {
_ = linkServiceCtl("stop")
_ = removeLinkToken()
@@ -89,7 +89,7 @@ func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) {
// handleLinkPairCode (POST /api/link/paircode) : génère un code d'appairage frais
// (usage unique, TTL 10 min) + l'empreinte E2E, à saisir UNE fois dans le portail
// distant lors de la première connexion (confirmation de la boîte noire). Évite le
// détour par `jean link code` en terminal.
// détour par `ajean link code` en terminal.
func handleLinkPairCode(w http.ResponseWriter, r *http.Request) {
code, err := newPairCode()
if err != nil {
+6 -6
View File
@@ -45,10 +45,10 @@ func cmdWeb(args []string) error {
return err
}
}
fmt.Printf("[jean web] http://%s (Ctrl-C pour arrêter)\n", addr)
fmt.Printf("[ajean web] http://%s (Ctrl-C pour arrêter)\n", addr)
if readWebKey() == "" {
fmt.Printf("%s API de pilotage NON protégée (aucune clé). Avant de l'exposer sur internet :\n", yellow("[!]"))
fmt.Printf(" %s\n", bold("jean set-web-key"))
fmt.Printf(" %s\n", bold("ajean set-web-key"))
} else {
fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]"))
}
@@ -56,13 +56,13 @@ func cmdWeb(args []string) error {
}
// newWebMux construit le routeur HTTP de l'UI web. Extrait de cmdWeb pour être
// réutilisé par `jean link`, qui sert ce même mux à travers le tunnel sans
// réutilisé par `ajean link`, qui sert ce même mux à travers le tunnel sans
// repasser par un écouteur TCP local.
var convLoadOnce sync.Once
func newWebMux() *http.ServeMux {
// Charge l'état de conversation persisté (une fois par process : jean web ET
// jean link serve appellent newWebMux).
// Charge l'état de conversation persisté (une fois par process : ajean web ET
// ajean link serve appellent newWebMux).
convLoadOnce.Do(LoadConversation)
// Pré-chauffe les serveurs MCP en tâche de fond : sinon le handshake (plusieurs
// secondes pour un serveur lancé via npx) est payé par le premier message.
@@ -120,7 +120,7 @@ func newWebMux() *http.ServeMux {
api("/api/apikey", handleAPIKey)
api("/api/oai/public", handleOAIPublic)
api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link)
api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → jean link
api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → ajean link
api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée
api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé
api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion