diff --git a/BACKLOG.md b/BACKLOG.md index d75c507..efd6fc4 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -248,6 +248,30 @@ Config MCP de référence : --- +## EPIC 7 — Absorption d'agent-vm dans le bundle (vendoring) + +### T7.1 🔴 Vendoriser agent-vm dans vendor/vm/ ✅ implémenté +**But :** arrêter de cloner agent-vm depuis GitHub à chaque install. Copier les scripts au commit 6f20194 dans `vendor/vm/` du repo albert-code. Plus de clone, plus de dépendance réseau pour le moteur d'isolation. +**DoD :** `vendor/vm/` contient `agent-vm.sh`, `agent-vm.setup.sh`, `runtime.example.sh`, `LICENSE` (MIT, attribution Sylvain Zimmer), `VERSION`. Le repo albert-code est auto-suffisant. `bin/albert-code` + `install.sh` : `AGENT_VM_DIR` pointe vers `$SELF_DIR/vendor/vm`, plus de `AGENT_VM_REPO`. + +### T7.2 🔴 Supprimer le shim et le nom « agent-vm » du PATH utilisateur ✅ implémenté +**But :** agent-vm n'est plus une commande utilisateur. Albert Code l'appelle en interne via `_vm()` helper après l'avoir sourcé. Plus de shim `agent-vm` sur le PATH, plus de sourçage dans `~/.zshrc`. +**DoD :** `install_agent_vm()` ne clone plus, ne pose plus de shim, ne modifie plus le rc. Vérifie seulement la présence de `vendor/vm/agent-vm.sh`. Nettoyage non-destructif : si un ancien shim ou sourçage traîne, propose de les retirer. + +### T7.3 🔴 VM de base OpenCode-only (--preinstall) ✅ implémenté +**But :** `agent-vm setup` installait tous les harnais (Claude, Codex, Vibe, OpenCode). Albert Code n'a besoin que d'OpenCode. Passage de `--preinstall=node,gh,chromium,opencode`. +**DoD :** `check_base_vm()` et `phase_run()` appellent `_vm setup --preinstall=node,gh,chromium,opencode`. Migration : si une VM de base multi-harnais existe, l'utilisateur peut la garder ou la refaire. + +### T7.4 🔴 chrome-devtools au niveau MCP, pas global VM ✅ implémenté +**But :** agent-vm écrivait chrome-devtools dans le `opencode.json` global de la VM. Ce MCP doit être géré au niveau projet (opt-in au setup, avec flags headless). +**DoD :** `vendor/vm/agent-vm.setup.sh` patché : le bloc OpenCode chrome-devtools est désactivé par défaut (`INSTALL_OPENCODE_MCP=0`). `config/opencode.template.json` et `scaffold_opencode_json` ajoutent `--headless=true --isolated=true` à la commande chrome-devtools. + +### T7.5 🟠 UI sans « agent-vm » pour l'utilisateur ✅ implémenté +**But :** l'utilisateur ne voit plus « agent-vm » dans les messages, juste des références à « la VM isolée » / « le moteur de VM d'Albert Code ». +**DoD :** tous les messages utilisateur dans `lib/phases.sh`, `lib/ui.sh`, `install.sh`, `README.md` sont reformulés. Le nom « agent-vm » reste dans les commentaires de code, la LICENSE et `vendor/vm/`. + +--- + ## EPIC 6 — Interface 3 verbes & simplification profils `<- AC-R014, AC-R015, AC-R016, AC-R017` ### T6.1 🔴 Commande `albert-code` à 3 verbes `<- AC-R014` diff --git a/FEEDBACK.md b/FEEDBACK.md index 78fa6ec..3ba3f14 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -11,6 +11,8 @@ --- +| AC-R035 | ⚙️ | 🟠 | Absorption d'agent-vm dans le bundle : le code est vendored sous `vendor/vm/`, plus de clone GitHub, plus de shim `agent-vm` sur le PATH. Installation OpenCode-only (`--preinstall=node,gh,chromium,opencode`). chrome-devtools MCP géré au niveau projet (pas global VM). | Chantier technique 2026-07-07 | ✅ traité | `BACKLOG.md` EPIC 7 · `TESTS.md` S39 | + ## Registre | ID | Type | Sév | Retour | Source | Statut | Renvoi | diff --git a/README.md b/README.md index 49127ba..8aae5f1 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ ⚠️ PROJET EXPÉRIMENTAL ⚠️ -**Stack d'agentic coding souveraine pour l'État, en une commande : OpenCode + agent-vm + Albert API + skills de l'État + MCP.** +**Stack d'agentic coding souveraine pour l'État, en une commande : OpenCode + VM isolée + Albert API + skills de l'État + MCP.** Albert Code assemble des briques existantes pour coder avec une IA souveraine, isolée, avec les standards de l'administration embarqués : - **[Albert API](https://albert.api.etalab.gouv.fr)** : modèles souverains de l'État (hébergement SecNumCloud), provider OpenAI-compatible. -- **[agent-vm](https://github.com/sylvinus/agent-vm)** : sandbox Lima jetable. L'agent tourne en autonomie sans accès à l'hôte. +- **[agent-vm](https://github.com/sylvinus/agent-vm)** (vendored) : sandbox Lima jetable. L'agent tourne en autonomie sans accès à l'hôte. - **[OpenCode](https://opencode.ai)** : le harness (assistant de code en terminal). - **[Skills de l'État](https://github.com/etalab-ia/skills)** : DSFR, accessibilité (RGAA), sécurité, data.gouv (à la carte, choisis au setup). - **MCP** : data.gouv, context7, playwright, chrome-devtools (à la carte, choisis au setup). @@ -40,15 +40,15 @@ cd ~/albert-code ./install.sh ``` -> Le chemin d'installation ne doit **pas contenir d'espace** (contrainte agent-vm/Lima, qui monte le répertoire de travail dans la VM). +> Le chemin d'installation ne doit **pas contenir d'espace** (contrainte du moteur de VM/Lima, qui monte le répertoire de travail dans la VM). Après installation, tu disposes de la commande `albert-code` à 3 verbes : | Verbe | Action | |---|---| -| `albert-code install` | **1ʳᵉ fois** : bootstrap le poste (Lima, agent-vm, clés, skills). | +| `albert-code install` | **1ʳᵉ fois** : bootstrap le poste (Lima, VM isolée, clés, skills). | | `albert-code setup` | **Par projet** : configure un projet (AGENTS.md + opencode.json + choix skills/MCP). | -| `albert-code run` | **Lancement** : crée la VM de base si absente, puis ouvre la bulle agent-vm. | +| `albert-code run` | **Lancement** : crée la VM de base si absente, puis ouvre la VM isolée. | `install.sh` est **idempotent** et **non-destructif** : il amorce le poste (Phase A) et pose le shim `albert-code`. Ensuite, c'est `albert-code setup` puis `albert-code run`. @@ -71,7 +71,7 @@ Les commandes exactes (avec tes valeurs) s'affichent à la fin de `albert-code s ### Ressources de la VM -Les défauts d'agent-vm (1 CPU / 3 GiB / 10 GiB) sont trop justes pour un agent de code. Albert Code applique par défaut `4 CPU / 8 GiB / 32 GiB`, surchargeables par variable d'environnement : +Les défauts du moteur de VM (1 CPU / 3 GiB / 10 GiB) sont trop justes pour un agent de code. Albert Code applique par défaut `4 CPU / 8 GiB / 32 GiB`, surchargeables par variable d'environnement : ```bash AC_VM_CPUS=8 AC_VM_MEMORY=16 AC_VM_DISK=64 ./install.sh @@ -79,15 +79,15 @@ AC_VM_CPUS=8 AC_VM_MEMORY=16 AC_VM_DISK=64 ./install.sh | Variable | Défaut | Rôle | |---|---|---| -| `AC_VM_CPUS` | `4` | CPU alloués (s'applique au lancement `agent-vm opencode`). | +| `AC_VM_CPUS` | `4` | CPU alloués (s'applique au lancement de la VM). | | `AC_VM_MEMORY` | `8` (GiB) | RAM allouée (s'applique au lancement). | -| `AC_VM_DISK` | `32` (GiB) | Disque, fixé au 1er `agent-vm setup`, ne peut ensuite que grandir. | +| `AC_VM_DISK` | `32` (GiB) | Disque, fixé au 1er lancement, ne peut ensuite que grandir. | **Garde-fou hôte** (lecture seule, macOS + Linux) : `install.sh` détecte les ressources de ta machine (`sysctl`/`nproc`) et ne propose jamais plus de ~la moitié du CPU/RAM hôte, même si `AC_VM_*` demande plus — pour ne pas sur-allouer sur un petit poste. Le disque n'est jamais rogné (sparse : alloué à l'usage, pas d'un coup) ; un avertissement s'affiche si l'espace libre est insuffisant. ### Push & PR depuis la VM -Par défaut, l'agent peut **committer** dans la VM mais **ni pusher ni ouvrir de PR** : agent-vm isole la bulle de tes credentials hôte (aucun SSH, aucun token). Pour l'autoriser, fournis un **token GitHub dédié**. +Par défaut, l'agent peut **committer** dans la VM mais **ni pusher ni ouvrir de PR** : la VM isole la bulle de tes credentials hôte (aucun SSH, aucun token). Pour l'autoriser, fournis un **token GitHub dédié**. 1. **Crée un PAT fine-grained** sur [github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens) : - *Repository access* → seulement les dépôts que l'agent doit toucher (pas « All repositories »). @@ -105,13 +105,13 @@ Par défaut, l'agent peut **committer** dans la VM mais **ni pusher ni ouvrir de # --- /albert-code --- ``` Utilise ton **email noreply GitHub** ([settings/emails](https://github.com/settings/emails)) pour ne pas exposer ton email perso dans l'historique git. -3. **Relance la bulle** (`agent-vm opencode`). Le runtime du bundle branche alors automatiquement le credential helper (`gh auth setup-git`) et pose l'identité git. `git push` et `gh pr create` marchent depuis la VM. +3. **Relance la bulle** (`albert-code run`). Le runtime du bundle branche alors automatiquement le credential helper (`gh auth setup-git`) et pose l'identité git. `git push` et `gh pr create` marchent depuis la VM. > Le token vit dans une bulle exposée au prompt-injection : garde-le **fine-grained, scopé, révocable**, et **relis chaque PR avant merge**. Un contenu malveillant pourrait pousser l'agent à en abuser dans la limite de sa portée — d'où les permissions minimales. ## Sécurité -- **Isolation noyau (Lima)** : l'agent n'a aucun accès à tes clés SSH, credentials, cookies ou sessions de l'hôte. La VM est jetable (`agent-vm rm`). +- **Isolation noyau (Lima)** : l'agent n'a aucun accès à tes clés SSH, credentials, cookies ou sessions de l'hôte. La VM est jetable. - **Secrets** : les clés (`ALBERT_API_KEY`, `CONTEXT7_API_KEY`) ne sont jamais versionnées ni loggées ; elles sont persistées dans le `~/.zshenv` de la VM en `chmod 600`. - **Risque résiduel (prompt-injection)** : un contenu malveillant (page web, issue, fichier) pourrait pousser l'agent à exfiltrer une clé par le réseau. Bonnes pratiques : clé Albert **dédiée et révocable** (pas ta clé maître), revue humaine de chaque PR avant merge, pas de données sensibles confiées à l'agent. @@ -129,16 +129,15 @@ Par défaut, l'agent peut **committer** dans la VM mais **ni pusher ni ouvrir de Pour changer le modèle par défaut d'un projet, édite `model` dans son `opencode.json`. - **Config** : `opencode.json` de **portée projet** (jamais le global de l'utilisateur, qui peut avoir d'autres providers). - **Skills** : `etalab-ia/skills` cloné dans un cache (`~/.config/opencode/.albert-skills-cache`) et symliqué dans le dossier scanné par OpenCode. Au `setup`, chaque skill est proposée en Y/N avec son objectif. La sélection est écrite dans `.albert-code/skills.txt` à la racine du projet. Au boot de la VM, `sync_skills` ne symlinke que les skills sélectionnées puis réconcilie (retire les symlinks des skills non sélectionnées, sans jamais toucher les skills perso). Sans manifeste `.albert-code/skills.txt`, toutes les skills sont installées (rétrocompat). Mise à jour à chaque démarrage de VM. -- **MCP** : les 4 connecteurs sont désormais **tous opt-in**. Au `setup`, chaque MCP est proposé en Y/N avec son objectif : `data-gouv` (accès aux données publiques), `context7` (doc à jour des librairies, clé API requise via https://context7.com/plans), `playwright` (navigateur headless), `chrome-devtools` (debug navigateur). Seuls les MCP acceptés sont écrits dans `opencode.json` du projet (`enabled:false` par défaut). Note : le MCP `chrome-devtools` peut aussi apparaître dans OpenCode même si non coché — il est préinstallé par agent-vm (outil d'isolation en amont) et n'est pas sous le contrôle d'Albert Code. +- **MCP** : les 4 connecteurs sont désormais **tous opt-in**. Au `setup`, chaque MCP est proposé en Y/N avec son objectif : `data-gouv` (accès aux données publiques), `context7` (doc à jour des librairies, clé API requise via https://context7.com/plans), `playwright` (navigateur headless), `chrome-devtools` (debug navigateur). Seuls les MCP acceptés sont écrits dans `opencode.json` du projet (`enabled:false` par défaut). Note : le MCP `chrome-devtools` peut aussi apparaître dans OpenCode même si non coché — il est préinstallé par le moteur d'isolation en amont et n'est pas sous le contrôle d'Albert Code. - **Conventions** : `AGENTS.md` depuis `templates/AGENTS.default.md` (sécurité, plan mode, task management, code quality, git, accessibilité). Si le projet a déjà son `AGENTS.md`, il est conservé. Docs : [OpenCode](https://opencode.ai/docs/fr) · [Albert API](https://doc.incubateur.net/alliance/albert-api) · [agent-vm](https://github.com/sylvinus/agent-vm) · [Skills État](https://github.com/etalab-ia/skills) ## Dépannage -- **`agent-vm: command not found` juste après l'install** → le shim est posé sur le PATH. Si vraiment absent, ouvre un nouveau terminal pour recharger le PATH. -- **`Base VM not found. Run 'agent-vm setup' first.`** → lance `agent-vm setup --disk 32` une seule fois (l'install le propose aussi automatiquement). -- **Je suis dans OpenCode mais pas connecté à Albert (pas de `/models`, `/mcp`, `/skills`)** → tu as lancé `agent-vm opencode` (ou `albert-code run`) dans un dossier **sans `opencode.json`** (ex. le dépôt albert-code lui-même, ou un projet jamais scaffoldé). Scaffolde d'abord : `cd && albert-code setup`, puis relance `albert-code run`. +- **`Base VM not found`** → lance `albert-code run` une fois ; la VM de base se crée automatiquement. +- **Je suis dans OpenCode mais pas connecté à Albert (pas de `/models`, `/mcp`, `/skills`)** → tu as lancé `albert-code run` dans un dossier **sans `opencode.json`** (ex. le dépôt albert-code lui-même, ou un projet jamais scaffoldé). Scaffolde d'abord : `cd && albert-code setup`, puis relance `albert-code run`. - **Mon projet a déjà un `opencode.json`** → il est **conservé** (non-destructif). Vérifie qu'il contient le provider albert ; sinon Albert n'est pas câblé — ajoute à la main le bloc `provider.albert` + `model`/`small_model`. ## Désinstallation @@ -153,7 +152,7 @@ Retire le bloc albert-code du runtime VM, le cache et les symlinks skills. Prés Issues et PR bienvenues. Le dépôt suit ses propres conventions dans [`AGENTS.md`](AGENTS.md) ; le contexte et les décisions sont dans [`docs/PLAN.md`](docs/PLAN.md). -> **Développer Albert Code lui-même** : `cp config/opencode.template.json opencode.json` (déjà gitignoré) puis `agent-vm opencode` — `install.sh` ne scaffolde pas son propre dépôt. +> **Développer Albert Code lui-même** : `cp config/opencode.template.json opencode.json` (déjà gitignoré) puis lance `albert-code run` — `install.sh` ne scaffolde pas son propre dépôt. --- diff --git a/TESTS.md b/TESTS.md index f47a844..8ac9ae8 100644 --- a/TESTS.md +++ b/TESTS.md @@ -205,6 +205,21 @@ et le bloc marqueur a disparu. - Une description absente retourne chaîne vide (l'afficheur utilise « (aucune description) »). **Validé le :** — (non exécuté). +## S39 — Absorption agent-vm : vendoring, OpenCode-only, chrome-devtools projet, pas de nom agent-vm dans l'UI ☐ + +**Préconditions :** dépôt albert-code sur branche `feat/absorb-agent-vm`. + +**Étapes :** +1. Vérifier `vendor/vm/agent-vm.sh` + `vendor/vm/agent-vm.setup.sh` + `vendor/vm/VERSION` + `vendor/vm/LICENSE` présents. +2. `bash bin/albert-code install --dry-run` — vérifier qu'aucun message ne contient « agent-vm » (sauf commentaires de code). +3. `bash bin/albert-code setup --dry-run` — idem. +4. `bash bin/albert-code run --dry-run` — idem. +5. Vérifier que `config/opencode.template.json` contient `--headless=true --isolated=true` pour chrome-devtools. +6. Vérifier que `vendor/vm/agent-vm.setup.sh` contient `INSTALL_OPENCODE_MCP="${AGENT_VM_INSTALL_OPENCODE_MCP:-0}"`. +7. `bash tests/check_no_personal_paths.sh` — exit 0. + +**Attendu :** aucun message utilisateur ne contient « agent-vm ». La VM de base se crée avec `--preinstall=node,gh,chromium,opencode`. chrome-devtools a les flags headless, n'est plus injecté globalement par agent-vm. Vérification chemin personnel OK. + --- ## Critères d'acceptation v1 (Definition of Done globale) diff --git a/bin/albert-code b/bin/albert-code index 09eccc0..35b165d 100755 --- a/bin/albert-code +++ b/bin/albert-code @@ -13,19 +13,18 @@ set -euo pipefail SELF_DIR="$(cd "$(dirname "$(dirname "${BASH_SOURCE[0]}")")" && pwd)" LIB_DIR="$SELF_DIR/lib" -source "$LIB_DIR/ui.sh" -source "$LIB_DIR/phases.sh" - # --- Ressources VM (surchargeables) ------------------------------------------- AC_VM_CPUS="${AC_VM_CPUS:-4}" AC_VM_MEMORY="${AC_VM_MEMORY:-8}" AC_VM_DISK="${AC_VM_DISK:-32}" -AGENT_VM_DIR="${AGENT_VM_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/agent-vm}" -AGENT_VM_REPO="https://github.com/sylvinus/agent-vm.git" +AGENT_VM_DIR="${AGENT_VM_DIR:-$SELF_DIR/vendor/vm}" RUNTIME_VM_FILE="$HOME/.agent-vm/runtime.sh" ZSHENV="$HOME/.zshenv" +source "$LIB_DIR/ui.sh" +source "$LIB_DIR/phases.sh" + # --- Parsing des arguments ----------------------------------------------------- while [ $# -gt 0 ]; do case "$1" in diff --git a/config/opencode.template.json b/config/opencode.template.json index 005a76e..7da770f 100644 --- a/config/opencode.template.json +++ b/config/opencode.template.json @@ -56,7 +56,7 @@ }, "chrome-devtools": { "type": "local", - "command": ["npx", "-y", "chrome-devtools-mcp@latest"], + "command": ["npx", "-y", "chrome-devtools-mcp@latest", "--headless=true", "--isolated=true"], "enabled": false } }, diff --git a/install.sh b/install.sh index b56a16a..c00b7ce 100755 --- a/install.sh +++ b/install.sh @@ -9,7 +9,7 @@ # # Après installation, utilise : # albert-code setup → configurer un projet -# albert-code run → lancer la bulle agent-vm +# albert-code run → lancer la VM isolée # # Non-destructif : ne réinstalle rien déjà présent, n'écrase aucune config. # Compatible bash 3.2 (macOS). @@ -30,24 +30,23 @@ while [ $# -gt 0 ]; do shift done -# Source les phases (contient phase_a, phase_b, phase_run) -if [ -f "$LIB_DIR/phases.sh" ]; then - source "$LIB_DIR/phases.sh" -else - err "lib/phases.sh introuvable. Albert Code est-il complet ?" - exit 1 -fi - # --- Ressources VM ----------------------------------------------------------- AC_VM_CPUS="${AC_VM_CPUS:-4}" AC_VM_MEMORY="${AC_VM_MEMORY:-8}" AC_VM_DISK="${AC_VM_DISK:-32}" -AGENT_VM_DIR="${AGENT_VM_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/agent-vm}" -AGENT_VM_REPO="https://github.com/sylvinus/agent-vm.git" +AGENT_VM_DIR="${AGENT_VM_DIR:-$SELF_DIR/vendor/vm}" RUNTIME_VM_FILE="$HOME/.agent-vm/runtime.sh" ZSHENV="$HOME/.zshenv" +# Source les phases (contient phase_a, phase_b, phase_run) +if [ -f "$LIB_DIR/phases.sh" ]; then + source "$LIB_DIR/phases.sh" +else + err "lib/phases.sh introuvable. Albert Code est-il complet ?" + exit 1 +fi + # --- Détection ancien installeur (albert-code() dans shell rc) ----------------- _detect_old_albert_code_function() { local rc_file="$1" @@ -79,7 +78,7 @@ _remove_old_albert_code_function() { } # --- Exécution (shim + Phase A, rétrocompat) ---------------------------------- -# Le shim doit être posé AVANT la VM de base (fragile). Si agent-vm setup +# Le shim doit être posé AVANT la VM de base (fragile). Si la création de VM # échoue (429, réseau, etc.), la commande `albert-code` est quand même # disponible pour un essai ultérieur. Cf. AC-R021. banner @@ -106,4 +105,4 @@ echo title "C'est prêt. Bon code avec Albert." echo info "Utilise « albert-code setup » pour configurer un projet," -info "ou « albert-code run » pour lancer la bulle." +info "ou « albert-code run » pour lancer la VM isolée." diff --git a/lib/phases.sh b/lib/phases.sh index e102b05..33999c5 100644 --- a/lib/phases.sh +++ b/lib/phases.sh @@ -3,14 +3,38 @@ # Albert Code — phases d'installation, sourçables par install.sh ET bin/albert-code. # ----------------------------------------------------------------------------- # Ce fichier définit les 3 phases : -# phase_a() — bootstrap hôte : agent-vm, clés, skills, runtime VM. +# phase_a() — bootstrap hôte : moteur VM, clés, skills, runtime VM. # phase_b() — scaffold projet : AGENTS.md + opencode.json + runtime + choix skills/MCP. -# phase_run() — lancement de la bulle agent-vm. +# phase_run() — lancement de la VM isolée. # # Nécessite que lib/ui.sh ait été sourcé avant (pour apply_*, confirm, etc.). # Variables requises : SELF_DIR, LIB_DIR, AGENT_VM_DIR, AC_VM_*. # ============================================================================= +# ============================================================================= +# Source agent-vm.sh (vendored) — rend la fonction agent-vm() disponible +# en interne pour _vm() sans shim ni PATH user. +# ============================================================================= +_agent_vm_sourced=0 +if [ -f "$AGENT_VM_DIR/agent-vm.sh" ]; then + source "$AGENT_VM_DIR/agent-vm.sh" 2>/dev/null && _agent_vm_sourced=1 +fi + +# _vm : wrapper interne pour appeler agent-vm (setup, opencode, etc.) +# sans dépendre d'un shim sur le PATH. Usage : _vm setup --disk 32 +_vm() { + if [ "$_agent_vm_sourced" -eq 1 ]; then + # Le vendored (agent-vm.sh) est écrit pour bash récent : sous set -u de + # bash 3.2 (macOS), l'expansion d'un tableau vide "${arr[@]}" plante en + # "unbound variable". On l'exécute dans un sous-shell permissif (comme le + # faisait l'ancien shim), sans toucher au vendored. + ( set +u +e +o pipefail; agent-vm "$@" ) + else + err "agent-vm.sh introuvable dans $AGENT_VM_DIR — réinstalle Albert Code." + return 1 + fi +} + # ============================================================================= # Phase A — Bootstrap hôte (idempotent) # ============================================================================= @@ -18,17 +42,16 @@ phase_a() { title "Phase A — Bootstrap de ton poste" echo - # A.1 Pédagogie agent-vm + # A.1 Pédagogie isolation echo - title "À propos de l'isolation (agent-vm)" - info "Albert Code tourne dans une bulle isolée : une VM légère (Lima)." - info "Pourquoi ? Garantir que le modèle ne touche QU'À ton code —" - info "pas à tes fichiers perso, clés SSH, cookies, sessions navigateur." - info "Ça permet aussi de le laisser tourner en autonomie (mode YOLO)" - info "sans valider une permission toutes les 2 minutes." + title "À propos de l'isolation" + info "Albert Code fait tourner OpenCode dans une VM isolée (moteur Lima) :" + info "le modèle ne touche qu'à ton code, pas à tes fichiers perso," + info "clés SSH, cookies ou sessions navigateur." + info "Tu peux le laisser tourner en autonomie en toute sécurité." info "Ce qu'on va installer :" info " • Lima (moteur de VM)" - info " • agent-vm (couche d'isolation)" + info " • Le moteur d'isolation d'Albert Code" info " • Une clé Albert API dédiée révocable" echo @@ -42,7 +65,7 @@ phase_a() { esac if ! require_cmd "lima" 2>/dev/null; then - warn "Lima absent — agent-vm en a besoin pour créer la bulle isolée." + warn "Lima absent — le moteur de VM en a besoin pour créer la bulle isolée." if confirm "Installer Lima maintenant (via Homebrew) ?"; then if ! command -v brew >/dev/null 2>&1; then err "Homebrew absent. Installe Lima manuellement : https://lima-vm.io/docs/installation/" @@ -115,7 +138,7 @@ phase_a() { if [ -n "$gh_id" ] && [ -n "$gh_login" ]; then git_name="$gh_id" git_email_def="${gh_login}+${gh_id}@users.noreply.github.com" - ok "Compte GitHub identifié : ${gh_id} (noreply : ${gh_email_def})" + ok "Compte GitHub identifié : ${gh_id} (noreply : ${git_email_def})" fi fi @@ -151,7 +174,7 @@ phase_a() { echo - # A.5 agent-vm (clone + source dans le shell rc) + # A.5 Source le moteur de VM vendored (agent-vm) install_agent_vm # A.6 Runtime VM (~/.agent-vm/runtime.sh) — exporte les clés dans la VM @@ -160,14 +183,14 @@ phase_a() { # A.7 Skills État (côté hôte, pour OpenCode hors VM) sync_skills_host - # A.8 OpenCode s'exécute exclusivement dans la bulle agent-vm - info "OpenCode s'exécute dans la bulle agent-vm — rien à installer sur ton poste." + # A.8 OpenCode s'exécute exclusivement dans la VM isolée + info "OpenCode s'exécute dans la VM isolée — rien à installer sur ton poste." # A.9 Ressources VM (détection hôte + garde-fou, lecture seule) compute_effective_vm_resources check_disk_space_warning - # A.10 VM de base agent-vm (préalable obligatoire à `agent-vm opencode`) + # A.10 VM de base (préalable obligatoire) check_base_vm echo @@ -211,7 +234,7 @@ phase_b() { scaffold_skills_selection echo - # B.4 [4/4] .agent-vm.runtime.sh (runtime de référence) + # B.4 [4/4] Runtime de la VM title "[4/4] Runtime VM" copy_template "runtime/agent-vm.runtime.sh" "./.agent-vm.runtime.sh" "runtime VM (sync skills + clés)" apply "chmod +x .agent-vm.runtime.sh" chmod +x "./.agent-vm.runtime.sh" 2>/dev/null || true @@ -233,7 +256,7 @@ phase_b() { } # ============================================================================= -# phase_run — Lancement de la bulle agent-vm +# phase_run — Lancement de la VM isolée # ============================================================================= phase_run() { title "Albert Code — lancement" @@ -246,19 +269,13 @@ phase_run() { # Créer la VM de base si nécessaire if ! base_vm_exists; then info "Création de la VM de base nécessaire…" - if confirm "Créer la VM de base maintenant (agent-vm setup --disk ${AC_VM_DISK}) ?"; then - echo - info "Tu entres maintenant dans le wizard agent-vm (en anglais)." - info "C'est normal : agent-vm est l'outil d'isolation open source" - info "sur lequel s'appuie Albert Code. Valide les logiciels" - info "proposés par défaut (Python, Node, Docker, Chromium, gh, OpenCode)." - echo - apply "créer la VM de base (agent-vm setup --disk ${AC_VM_DISK})" agent-vm setup --disk "${AC_VM_DISK}" || { - warn "Création de la VM de base échouée — lance : agent-vm setup --disk ${AC_VM_DISK}" + if confirm "Créer la VM de base maintenant ?"; then + apply "créer la VM de base (setup VM isolée)" _vm setup --preinstall=node,gh,chromium,opencode --disk "${AC_VM_DISK}" || { + warn "Création de la VM de base échouée." return 1 } else - warn "VM de base absente. Lance d'abord : agent-vm setup --disk ${AC_VM_DISK}" + warn "VM de base absente." return 1 fi fi @@ -266,9 +283,9 @@ phase_run() { # Lancer la VM echo info "Ouverture de la bulle isolée…" - info " agent-vm --cpus ${EFF_CPUS} --memory ${EFF_MEM} --disk ${AC_VM_DISK} opencode" + info " Albert Code lance OpenCode dans la VM" echo - apply "lancer agent-vm" agent-vm --cpus "${EFF_CPUS}" --memory "${EFF_MEM}" --disk "${AC_VM_DISK}" opencode + apply "lancer la VM isolée" _vm --cpus "${EFF_CPUS}" --memory "${EFF_MEM}" --disk "${AC_VM_DISK}" opencode } # ============================================================================= @@ -281,20 +298,13 @@ check_base_vm() { return 0 fi if base_vm_exists; then - ok "VM de base agent-vm déjà créée" + ok "VM de base déjà créée" return 0 fi echo - warn "VM de base absente — lance %s une fois avant %s." "agent-vm setup --disk ${AC_VM_DISK}" "agent-vm opencode" - if confirm "Créer la VM de base maintenant (agent-vm setup --disk ${AC_VM_DISK}, ~plusieurs minutes) ?"; then - echo - info "Tu entres maintenant dans le wizard agent-vm (en anglais)." - info "C'est normal : agent-vm est l'outil d'isolation open source" - info "sur lequel s'appuie Albert Code. Valide les logiciels" - info "proposés par défaut (Python, Node, Docker, Chromium, gh, OpenCode)." - echo - apply "créer la VM de base (agent-vm setup --disk ${AC_VM_DISK})" agent-vm setup --disk "${AC_VM_DISK}" || { - warn "Création de la VM de base échouée — tu pourras la créer plus tard avec : agent-vm setup --disk ${AC_VM_DISK}" + if confirm "Créer la VM de base maintenant (~plusieurs minutes) ?"; then + apply "créer la VM de base (setup VM isolée)" _vm setup --preinstall=node,gh,chromium,opencode --disk "${AC_VM_DISK}" || { + warn "Création de la VM de base échouée — tu pourras la créer plus tard." } fi } @@ -304,55 +314,51 @@ base_vm_exists() { command -v limactl >/dev/null 2>&1 && limactl list -q 2>/dev/null | grep -q '^agent-vm-base$' } -# install_agent_vm — clone + shim +# install_agent_vm — vérifie que le bundle vendored est présent (plus de clone). install_agent_vm() { - if command -v agent-vm >/dev/null 2>&1; then - ok "agent-vm déjà installé" - return 0 + if [ ! -f "$AGENT_VM_DIR/agent-vm.sh" ]; then + err "agent-vm.sh introuvable dans $AGENT_VM_DIR — le bundle est incomplet." + exit 1 fi - - local shim_file="" _d="" _ifs_save="$IFS" - IFS=':' - for _d in /opt/homebrew/bin /usr/local/bin $PATH; do - [ -z "$_d" ] && continue - [ -f "$_d/agent-vm" ] && [ -x "$_d/agent-vm" ] && { shim_file="$_d/agent-vm"; break; } - done - IFS="$_ifs_save" - [ -n "$shim_file" ] && ok "shim agent-vm déjà présent" - if [ -f "$AGENT_VM_DIR/agent-vm.sh" ]; then - ok "agent-vm déjà cloné dans $AGENT_VM_DIR" + if [ "$_agent_vm_sourced" -eq 0 ]; then + # Si le source a échoué (bash 3.2) on ressource ici + source "$AGENT_VM_DIR/agent-vm.sh" 2>/dev/null && _agent_vm_sourced=1 + fi + if [ "$_agent_vm_sourced" -eq 1 ]; then + ok "Moteur de VM prêt (vendored dans $AGENT_VM_DIR)" else - info "Clonage d'agent-vm…" - apply_mkdir "créer $(dirname "$AGENT_VM_DIR")" "$(dirname "$AGENT_VM_DIR")" - with_spinner "Clonage d'agent-vm" git clone --depth 1 --quiet "$AGENT_VM_REPO" "$AGENT_VM_DIR" - if [ "$DRY_RUN" -eq 0 ] && [ -d "$AGENT_VM_DIR/.git" ]; then - ok "agent-vm cloné dans $AGENT_VM_DIR" - fi + err "Échec du chargement du moteur de VM ($AGENT_VM_DIR/agent-vm.sh) — bundle incomplet." + exit 1 fi - local rc="" - case "${SHELL##*/}" in - zsh) rc="$HOME/.zshrc" ;; - bash) rc="$HOME/.bashrc" ;; - *) rc="$HOME/.profile" ;; - esac - apply_touch "créer $rc si absent" "$rc" - if ! file_contains "$rc" "agent-vm.sh"; then - apply_append "sourcer agent-vm dans $rc" "$rc" \ - "# Albert Code — agent-vm" - apply_append "sourcer agent-vm dans $rc (ligne source)" "$rc" \ - "[ -f \"$AGENT_VM_DIR/agent-vm.sh\" ] && source \"$AGENT_VM_DIR/agent-vm.sh\"" - fi - install_shim "agent-vm" "$AGENT_VM_DIR/agent-vm.sh" - - if [ "$DRY_RUN" -eq 0 ]; then - if command -v agent-vm >/dev/null 2>&1; then - ok "agent-vm utilisable (shim sur le PATH)" - else - if [ "$SHIM_PATH_ADDED" -eq 1 ]; then - warn "~/.local/bin a été ajouté au PATH — ouvre un nouveau terminal ou source ~/.zshenv." + + # Nettoyage non-destructif : si un ancien shim agent-vm ou un sourçage + # dans le rc traîne, proposer de les retirer. + if command -v agent-vm >/dev/null 2>&1; then + local _vm_real + _vm_real="$(command -v agent-vm 2>/dev/null)" + # Si c'est un shim exécutable (pas la fonction shell), proposer le retrait + if [ -x "$_vm_real" ] && [ -f "$_vm_real" ]; then + warn "Ancien shim du moteur de VM détecté : $_vm_real" + if confirm "Retirer l'ancien shim (Albert Code utilise désormais le moteur vendored) ?"; then + rm -f "$_vm_real" + ok "Ancien shim retiré" fi fi fi + + # Vérifier les lignes de sourçage dans les rc (installations pré-vendor) + for _rc_file in "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.profile"; do + [ -f "$_rc_file" ] || continue + if file_contains "$_rc_file" "agent-vm.sh"; then + warn "Ancienne ligne de sourçage du moteur de VM trouvée dans $_rc_file" + if confirm "Retirer la ligne de sourçage obsolète de $_rc_file ?"; then + _tmp="$(mktemp)" + grep -v "agent-vm.sh" "$_rc_file" > "$_tmp" || true + mv "$_tmp" "$_rc_file" + ok "Ligne retirée de $_rc_file" + fi + fi + done } # ensure_vm_runtime — ~/.agent-vm/runtime.sh @@ -617,7 +623,7 @@ scaffold_opencode_json() { if [ "$mcp_chrome" = "true" ]; then [ "$first" = false ] && content=$content',' first=false - content=$content'"chrome-devtools":{"type":"local","command":["npx","-y","chrome-devtools-mcp@latest"],"enabled":true}' + content=$content'"chrome-devtools":{"type":"local","command":["npx","-y","chrome-devtools-mcp@latest","--headless=true","--isolated=true"],"enabled":true}' fi content=$content'},' diff --git a/lib/ui.sh b/lib/ui.sh index 41b5edf..9d60969 100644 --- a/lib/ui.sh +++ b/lib/ui.sh @@ -433,7 +433,7 @@ Ce script amorce ton poste (Phase A) puis pose le shim « albert-code ». Après installation, utilise : albert-code setup → configurer un projet (Phase B) - albert-code run → lancer la bulle agent-vm + albert-code run → lancer la VM isolée Options : --dry-run Affiche chaque action sans l'exécuter. Aucun fichier n'est écrit. @@ -442,15 +442,15 @@ Options : Variables d'environnement (sandbox) : HOME Redirige ~/.zshenv, ~/.config/opencode, etc. OPENCODE_CONFIG_DIR Dossier de config OpenCode (défaut: ~/.config/opencode). - AGENT_VM_DIR Dossier d'installation d'agent-vm (défaut: \${XDG_DATA_HOME:-~/.local/share}/agent-vm). - SHIM_BIN_DIR Dossier du shim agent-vm (défaut: sonde /opt/homebrew/bin, + AGENT_VM_DIR Dossier du moteur de VM vendored (défaut: \$SELF_DIR/vendor/vm). + SHIM_BIN_DIR Dossier du shim (défaut: sonde /opt/homebrew/bin, /usr/local/bin puis \$PATH, sinon ~/.local/bin). Ressources VM (surchargeables) : AC_VM_CPUS CPU alloués à la VM (défaut: 4). AC_VM_MEMORY RAM en GiB allouée à la VM (défaut: 8). - AC_VM_DISK Disque en GiB de la VM, fixé au 1er `agent-vm setup`, - ne peut que grandir ensuite (défaut: 32). + AC_VM_DISK Disque en GiB de la VM, fixé au 1er lancement, + ne peut que grandir ensuite (défaut: 32). Garde-fou : CPU/RAM effectifs jamais > ~moitié des ressources hôte détectées (sysctl/nproc, lecture seule). USAGE @@ -461,9 +461,9 @@ usage_albert_code() { Albert Code — assistant de code IA souverain Usage : - albert-code install → 1ʳᵉ fois : bootstrap ton poste (Lima, agent-vm, clés) + albert-code install → 1ʳᵉ fois : bootstrap ton poste (Lima, VM isolée, clés) albert-code setup → configure un projet (AGENTS.md + opencode.json + skills) - albert-code run → lance la bulle isolée agent-vm + albert-code run → lance la VM isolée albert-code --help → cette aide Options : @@ -486,7 +486,7 @@ USAGE # usage_runtime : texte d'aide pour runtime/agent-vm.runtime.sh. usage_runtime() { cat <<'USAGE' -Albert Code — runtime VM (agent-vm) +Albert Code — runtime VM Usage: ./.agent-vm.runtime.sh [--dry-run] [--help] diff --git a/runtime/agent-vm.runtime.sh b/runtime/agent-vm.runtime.sh index ef635ff..33cb56b 100755 --- a/runtime/agent-vm.runtime.sh +++ b/runtime/agent-vm.runtime.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # ============================================================================= -# Albert Code — runtime de référence (agent-vm). +# Albert Code — runtime de référence (VM). # ----------------------------------------------------------------------------- # Ce script est copié à la racine du projet cible sous le nom `.agent-vm.runtime.sh`. -# Il s'exécute DANS la VM agent-vm à chaque démarrage, juste après `~/.agent-vm/runtime.sh`. +# Il s'exécute DANS la VM à chaque démarrage, juste après `~/.agent-vm/runtime.sh`. # # Rôles : # 1. Persister ALBERT_API_KEY (et CONTEXT7_API_KEY) dans le ~/.zshenv de la VM @@ -22,7 +22,7 @@ while [ $# -gt 0 ]; do --dry-run) DRY_RUN=1 ;; --help|-h) cat <<'USAGE' -Albert Code — runtime VM (agent-vm) +Albert Code — runtime VM Usage: ./.agent-vm.runtime.sh [--dry-run] [--help] diff --git a/uninstall.sh b/uninstall.sh index e971994..0f4f0a1 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -23,7 +23,7 @@ SKILLS_DIR="$HOME/.config/opencode/skills" SKILLS_CACHE="$HOME/.config/opencode/.albert-skills-cache" RUNTIME_VM_FILE="$HOME/.agent-vm/runtime.sh" ZSHENV="$HOME/.zshenv" -AGENT_VM_DIR="${AGENT_VM_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/agent-vm}" +AGENT_VM_DIR="${AGENT_VM_DIR:-$SELF_DIR/vendor/vm}" AC_MARKER="# --- albert-code : clés VM ---" banner @@ -131,7 +131,7 @@ for _check_rc in "$RC_FILE_ALBERT" "$HOME/.zshenv"; do fi done -# 5. Sourcing agent-vm dans le shell rc +# 5. Ancien sourçage agent-vm dans le shell rc (migration) rc="" case "${SHELL##*/}" in zsh) rc="$HOME/.zshrc" ;; @@ -147,15 +147,14 @@ if [ -f "$rc" ] && file_contains "$rc" "agent-vm.sh"; then fi fi -# 6. agent-vm (clone + VMs + shim) — optionnel, lourd -if [ -d "$AGENT_VM_DIR" ] && confirm "Supprimer agent-vm ($AGENT_VM_DIR) et ses VMs ?"; then - # Retirer le shim d'abord (avant que la fonction disparaisse) - if command -v agent-vm >/dev/null 2>&1; then - agent-vm destroy-all 2>/dev/null || true +# 6. VMs Lima (optionnel) — les VMs créées par agent-vm +if command -v limactl >/dev/null 2>&1 && limactl list -q 2>/dev/null | grep -q '^agent-vm-'; then + if confirm "Supprimer toutes les VMs agent-vm (Lima) ?"; then + if command -v agent-vm >/dev/null 2>&1; then + agent-vm destroy-all 2>/dev/null || true + fi + ok "VMs agent-vm supprimées" fi - remove_shim "agent-vm" 2>/dev/null || true - rm -rf "$AGENT_VM_DIR" - ok "agent-vm supprimé (code + shim)" fi diff --git a/vendor/vm/LICENSE b/vendor/vm/LICENSE new file mode 100644 index 0000000..e8c3ec9 --- /dev/null +++ b/vendor/vm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sylvain Zimmer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/vm/VERSION b/vendor/vm/VERSION new file mode 100644 index 0000000..3b1d679 --- /dev/null +++ b/vendor/vm/VERSION @@ -0,0 +1,2 @@ +sylvinus/agent-vm @ 6f20194 (vendored) +Upstream: https://github.com/sylvinus/agent-vm diff --git a/vendor/vm/agent-vm.setup.sh b/vendor/vm/agent-vm.setup.sh new file mode 100644 index 0000000..7bab92e --- /dev/null +++ b/vendor/vm/agent-vm.setup.sh @@ -0,0 +1,286 @@ +#!/usr/bin/env bash +# +# agent-vm.setup.sh: Package installation script that runs inside the base VM +# Part of https://github.com/sylvinus/agent-vm +# +# This script is executed inside the VM during "agent-vm setup". +# + +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive + +# Component toggles. The host wizard prepends `export` lines for these before +# piping the script in. Members of the default install set (everything except +# Ruby/Rust/Go) default to 1 so running this script standalone (without the +# wizard) produces the same install you'd get from `agent-vm setup --preinstall=default`. +INSTALL_PYTHON="${AGENT_VM_INSTALL_PYTHON:-1}" +INSTALL_NODE="${AGENT_VM_INSTALL_NODE:-1}" +INSTALL_RUBY="${AGENT_VM_INSTALL_RUBY:-0}" +INSTALL_RUST="${AGENT_VM_INSTALL_RUST:-0}" +INSTALL_GOLANG="${AGENT_VM_INSTALL_GOLANG:-0}" +INSTALL_DOCKER="${AGENT_VM_INSTALL_DOCKER:-1}" +INSTALL_CHROMIUM="${AGENT_VM_INSTALL_CHROMIUM:-1}" +INSTALL_GH="${AGENT_VM_INSTALL_GH:-1}" +INSTALL_CLAUDE="${AGENT_VM_INSTALL_CLAUDE:-1}" +INSTALL_OPENCODE="${AGENT_VM_INSTALL_OPENCODE:-1}" +INSTALL_CODEX="${AGENT_VM_INSTALL_CODEX:-1}" +INSTALL_VIBE="${AGENT_VM_INSTALL_VIBE:-1}" +INSTALL_OPENCODE_MCP="${AGENT_VM_INSTALL_OPENCODE_MCP:-0}" # chrome-devtools MCP global ; Albert Code gère le MCP au niveau projet + +# Several installers (Claude Code, Vibe, …) check PATH at install time and +# print a "~/.local/bin is not in your PATH" warning otherwise. The persistent +# PATH lives in ~/.zshrc / ~/.zshenv (added below), so once the user opens a +# VM shell it's fine — but this bash script runs under a fresh session that +# doesn't see those edits yet. Export it here so installers stay quiet. +export PATH="$HOME/.local/bin:$PATH" + +# Disable needrestart's interactive prompts +sudo mkdir -p /etc/needrestart/conf.d +echo '$nrconf{restart} = '"'"'a'"'"';' | sudo tee /etc/needrestart/conf.d/no-prompt.conf > /dev/null + +# Base packages always installed: core CLI tools plus the dev libraries needed +# to compile Ruby/Python/Node versions via mise (kept here so that toggling a +# language off doesn't strip the libs the user may still want to build with). +echo "Installing base packages..." +sudo apt-get update +sudo apt-get install -y \ + git curl jq zsh \ + wget build-essential \ + ripgrep fd-find htop \ + unzip zip \ + ca-certificates \ + iptables \ + libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev + +if [[ "$INSTALL_PYTHON" == "1" ]]; then + echo "Installing Python 3..." + sudo apt-get install -y python3 python3-pip python3-venv +fi + +if [[ "$INSTALL_RUBY" == "1" ]]; then + echo "Installing Ruby..." + sudo apt-get install -y ruby-full +fi + +if [[ "$INSTALL_GOLANG" == "1" ]]; then + echo "Installing Go..." + sudo apt-get install -y golang-go +fi + +if [[ "$INSTALL_RUST" == "1" ]]; then + # Rustup is the canonical Rust installer. --no-modify-path keeps it from + # editing ~/.profile/~/.bashrc — we add ~/.cargo/bin to zsh's PATH below. + echo "Installing Rust..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain stable + echo 'export PATH=$HOME/.cargo/bin:$PATH' >> ~/.zshrc + echo 'export PATH=$HOME/.cargo/bin:$PATH' >> ~/.zshenv +fi + +# Set zsh as default shell +sudo chsh -s /usr/bin/zsh "$(whoami)" + +# Always set the VM prompt and put ~/.local/bin on PATH (mise installs there; +# Vibe's installer puts `vibe`/`vibe-acp` there too). +echo 'export PS1="vm:%1~%% "' >> ~/.zshrc +echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.zshrc +echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.zshenv + +# Auto-source ~/.agent-vm.env if present. The host pushes ~/.agent-vm/env into +# this path on every `agent-vm` invocation (see _agent_vm_ensure_running), so +# tokens/API keys defined there propagate to every shell in the VM. `set -a` +# auto-exports each KEY=value line, so the file content stays a plain dotenv. +echo '[ -f "$HOME/.agent-vm.env" ] && { set -a; . "$HOME/.agent-vm.env"; set +a; }' >> ~/.zshenv + +# Install mise (polyglot version manager for Ruby, Python, Node, etc.). +# Always installed so users can `mise install ruby@latest`, etc., even when +# they've opted out of preinstalled Node. +echo "Installing mise..." +curl https://mise.run | sh +echo 'eval "$(~/.local/bin/mise activate zsh)"' >> ~/.zshrc +echo 'eval "$(~/.local/bin/mise activate zsh)"' >> ~/.zshenv + +if [[ "$INSTALL_DOCKER" == "1" ]]; then + # Install Docker from official repo (includes docker compose) + echo "Installing Docker..." + sudo install -m 0755 -d /etc/apt/keyrings + sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc + sudo chmod a+r /etc/apt/keyrings/docker.asc + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + sudo apt-get update + sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + sudo usermod -aG docker "$(whoami)" +fi + +if [[ "$INSTALL_NODE" == "1" ]]; then + # Install Node.js 24 LTS (needed for MCP servers and Codex CLI) + echo "Installing Node.js 24..." + curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - + sudo apt-get install -y nodejs +fi + +if [[ "$INSTALL_CHROMIUM" == "1" ]]; then + # Install Chromium and dependencies for headless browsing + echo "Installing Chromium..." + sudo apt-get install -y chromium fonts-liberation xvfb + sudo ln -sf /usr/bin/chromium /usr/bin/google-chrome + sudo ln -sf /usr/bin/chromium /usr/bin/google-chrome-stable + sudo mkdir -p /opt/google/chrome + sudo ln -sf /usr/bin/chromium /opt/google/chrome/chrome +fi + +if [[ "$INSTALL_GH" == "1" ]]; then + # Install GitHub CLI from official repo + echo "Installing GitHub CLI..." + sudo mkdir -p -m 755 /etc/apt/keyrings + wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null + sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt-get update + sudo apt-get install -y gh +fi + +if [[ "$INSTALL_CLAUDE" == "1" ]]; then + echo "Installing Claude Code..." + curl -fsSL https://claude.ai/install.sh | bash + echo 'export PATH=$HOME/.claude/local/bin:$PATH' >> ~/.zshrc + echo 'export PATH=$HOME/.claude/local/bin:$PATH' >> ~/.zshenv + + # Enforce full autonomy via *managed* settings (highest precedence), not the + # user's ~/.claude/. A user can bind-mount or overwrite their own ~/.claude/ + # dir freely; this system-level policy is untouched and always wins. + # + # Why not just rely on the `claude --dangerously-skip-permissions` launch flag: + # Claude Code relaunches itself in-process on self-update and on the first-run + # fullscreen-TUI opt-in, and the relaunched process drops the CLI flag (see + # https://github.com/anthropics/claude-code/issues/72479), reverting to the + # "ask" permission mode. Settings are re-read on every (re)launch, so encoding + # the policy here makes it survive those relaunches. `tui: fullscreen` also + # pins fullscreen from the first launch, so the opt-in relaunch never fires. + echo "Configuring Claude managed settings (bypass permissions + fullscreen)..." + sudo mkdir -p /etc/claude-code + cat << 'JSON' | sudo tee /etc/claude-code/managed-settings.json > /dev/null +{ + "permissions": { "defaultMode": "bypassPermissions" }, + "tui": "fullscreen" +} +JSON +fi + +if [[ "$INSTALL_OPENCODE" == "1" ]]; then + echo "Installing OpenCode..." + curl -fsSL https://opencode.ai/install | bash + echo 'export PATH=$HOME/.opencode/bin:$PATH' >> ~/.zshrc + echo 'export PATH=$HOME/.opencode/bin:$PATH' >> ~/.zshenv +fi + +if [[ "$INSTALL_CODEX" == "1" ]]; then + if [[ "$INSTALL_NODE" != "1" ]]; then + echo "Skipping Codex CLI: requires Node.js (re-run setup with Node.js enabled)." >&2 + else + echo "Installing Codex CLI..." + sudo npm i -g @openai/codex + fi +fi + +if [[ "$INSTALL_VIBE" == "1" ]]; then + # Vibe installs `uv` and the `vibe`/`vibe-acp` commands into ~/.local/bin. + # PATH was already exported at the top of this script so the installer + # doesn't abort on its own PATH check. + echo "Installing Mistral Vibe..." + curl -LsSf https://mistral.ai/vibe/install.sh | bash +fi + +# Chrome DevTools MCP runs via `npx` and controls the local Chromium binary, so +# only configure it when both dependencies and at least one target agent exist. +if [[ "$INSTALL_NODE" == "1" && "$INSTALL_CHROMIUM" == "1" ]]; then + if [[ "$INSTALL_CLAUDE" == "1" ]]; then + echo "Configuring Chrome MCP server for Claude..." + CONFIG="$HOME/.claude.json" + if [ -f "$CONFIG" ]; then + jq '.mcpServers["chrome-devtools"] = { + "command": "npx", + "args": ["-y", "chrome-devtools-mcp@latest", "--headless=true", "--isolated=true"] + }' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG" + else + cat > "$CONFIG" << 'JSON' +{ + "mcpServers": { + "chrome-devtools": { + "command": "npx", + "args": ["-y", "chrome-devtools-mcp@latest", "--headless=true", "--isolated=true"] + } + } +} +JSON + fi + fi + + if [[ "$INSTALL_OPENCODE_MCP" == "1" && "$INSTALL_OPENCODE" == "1" ]]; then + echo "Configuring Chrome MCP server for OpenCode..." + OPENCODE_CONFIG_DIR="$HOME/.config/opencode" + mkdir -p "$OPENCODE_CONFIG_DIR" + OPENCODE_CONFIG="$OPENCODE_CONFIG_DIR/opencode.json" + if [ -f "$OPENCODE_CONFIG" ]; then + jq '.mcp["chrome-devtools"] = { + "type": "local", + "command": ["npx", "-y", "chrome-devtools-mcp@latest", "--headless=true", "--isolated=true"], + "enabled": true + }' "$OPENCODE_CONFIG" > "$OPENCODE_CONFIG.tmp" && mv "$OPENCODE_CONFIG.tmp" "$OPENCODE_CONFIG" + else + cat > "$OPENCODE_CONFIG" << 'JSON' +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "chrome-devtools": { + "type": "local", + "command": ["npx", "-y", "chrome-devtools-mcp@latest", "--headless=true", "--isolated=true"], + "enabled": true + } + } +} +JSON + fi + fi + + if [[ "$INSTALL_VIBE" == "1" ]]; then + # Vibe uses TOML; append an array-of-tables entry (valid even if the wizard + # later writes to the same file). Guard against duplicates on repeated runs. + echo "Configuring Chrome MCP server for Vibe..." + VIBE_CONFIG_DIR="$HOME/.vibe" + mkdir -p "$VIBE_CONFIG_DIR" + VIBE_CONFIG="$VIBE_CONFIG_DIR/config.toml" + if ! grep -q 'name = "chrome-devtools"' "$VIBE_CONFIG" 2>/dev/null; then + cat >> "$VIBE_CONFIG" << 'TOML' + +[[mcp_servers]] +name = "chrome-devtools" +transport = "stdio" +command = "npx" +args = ["-y", "chrome-devtools-mcp@latest", "--headless=true", "--isolated=true"] +TOML + fi + fi + + if [[ "$INSTALL_CODEX" == "1" ]]; then + # Codex CLI uses TOML at ~/.codex/config.toml with [mcp_servers.NAME] + # tables. Append the chrome-devtools entry; guard against duplicates on + # repeated setup runs. + echo "Configuring Chrome MCP server for Codex..." + CODEX_CONFIG_DIR="$HOME/.codex" + mkdir -p "$CODEX_CONFIG_DIR" + CODEX_CONFIG="$CODEX_CONFIG_DIR/config.toml" + if ! grep -q '\[mcp_servers\.chrome-devtools\]' "$CODEX_CONFIG" 2>/dev/null; then + cat >> "$CODEX_CONFIG" << 'TOML' + +[mcp_servers.chrome-devtools] +command = "npx" +args = ["-y", "chrome-devtools-mcp@latest", "--headless=true", "--isolated=true"] +TOML + fi + fi +elif [[ "$INSTALL_CLAUDE" == "1" || "$INSTALL_OPENCODE" == "1" || "$INSTALL_CODEX" == "1" || "$INSTALL_VIBE" == "1" ]]; then + echo "Skipping Chrome MCP config: requires Node.js and Chromium." >&2 +fi + +echo "VM setup complete." diff --git a/vendor/vm/agent-vm.sh b/vendor/vm/agent-vm.sh new file mode 100644 index 0000000..4f9019c --- /dev/null +++ b/vendor/vm/agent-vm.sh @@ -0,0 +1,1447 @@ +#!/usr/bin/env bash +# +# agent-vm: Run AI coding agents inside sandboxed Lima VMs +# Part of https://github.com/sylvinus/agent-vm +# +# Source this file in your shell config: +# source /path/to/agent-vm/agent-vm.sh +# +# Usage: +# agent-vm setup - Create the base VM template (run once) +# agent-vm claude - Run Claude Code in a persistent VM for cwd +# agent-vm opencode - Run OpenCode in a persistent VM for cwd +# agent-vm codex - Run Codex CLI in a persistent VM for cwd +# agent-vm vibe - Run Mistral Vibe in a persistent VM for cwd +# agent-vm shell - Open a shell in the persistent VM for cwd +# (alias: 'sh'; add -c "..." for a one-shot command) +# agent-vm stop - Stop the VM for cwd +# agent-vm rm - Stop and delete the VM for cwd +# agent-vm list - List all agent-vm VMs +# agent-vm status - Show status of all VMs (current dir marked with >) +# agent-vm help - Show help +# + +AGENT_VM_TEMPLATE="agent-vm-base" +AGENT_VM_STATE_DIR="${HOME}/.agent-vm" +AGENT_VM_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +# Prompt for a value with a default. Reads from /dev/tty so this still works +# when called inside command substitution. Writes the prompt to stderr and the +# answer (or the default if the user just pressed Enter) to stdout. +_agent_vm_ask() { + local prompt="$1" default="$2" reply="" + printf ' %s [%s]: ' "$prompt" "$default" >&2 + IFS= read -r reply /dev/null || reply="" + printf '%s\n' "${reply:-$default}" +} + +# Yes/no prompt. Second arg is the default: Y or N (case-insensitive). Prints +# 1 (yes) or 0 (no) to stdout. Empty input picks the default. +_agent_vm_ask_yn() { + local prompt="$1" default="${2:-Y}" reply="" indicator + case "$default" in + [Yy]*) indicator="[Y/n]"; default=Y ;; + *) indicator="[y/N]"; default=N ;; + esac + printf ' %s %s: ' "$prompt" "$indicator" >&2 + IFS= read -r reply /dev/null || reply="" + reply="${reply:-$default}" + case "$reply" in + [Yy]*) printf '1\n' ;; + *) printf '0\n' ;; + esac +} + +# Prompt for a positive integer with default. Re-prompts on invalid input. +# Used for disk/memory/cpus where a typo (e.g. "10G") would otherwise produce +# a cryptic limactl error several seconds later. +_agent_vm_ask_int() { + local prompt="$1" default="$2" reply + while true; do + reply=$(_agent_vm_ask "$prompt" "$default") + if [[ "$reply" =~ ^[1-9][0-9]*$ ]]; then + printf '%s\n' "$reply" + return 0 + fi + printf ' (must be a positive integer, e.g. 10 — got: %s)\n' "$reply" >&2 + done +} + +# Validate a positive-integer arg from the CLI (no retry — fail fast). +_agent_vm_validate_int() { + local name="$1" val="$2" + if [[ ! "$val" =~ ^[1-9][0-9]*$ ]]; then + echo "Error: $name must be a positive integer (got: '$val')" >&2 + return 1 + fi +} + +# Check Linux prerequisites Lima needs to spin up a QEMU+KVM VM. macOS uses +# different backends (vz/qemu-via-brew) so this is a no-op there. Returns +# non-zero with actionable install/permission hints when something's missing — +# without this, the user gets a generic `Error: Failed to create base VM` and +# has to dig into `~/.lima//ha.stderr.log` to figure out why. +_agent_vm_check_linux_prereqs() { + [[ "$(uname -s)" == "Linux" ]] || return 0 + + local arch_bin arch_pkg + case "$(uname -m)" in + x86_64) arch_bin="qemu-system-x86_64"; arch_pkg="qemu-system-x86" ;; + aarch64|arm64) arch_bin="qemu-system-aarch64"; arch_pkg="qemu-system-arm" ;; + *) arch_bin="qemu-system-$(uname -m)"; arch_pkg="qemu-system" ;; + esac + + local errs=0 + + if ! command -v "$arch_bin" &>/dev/null; then + echo "Error: Lima needs '$arch_bin' on PATH (not found)." >&2 + echo " Install with: sudo apt-get install $arch_pkg" >&2 + errs=1 + fi + + if [[ ! -e /dev/kvm ]]; then + echo "Error: /dev/kvm does not exist (KVM unavailable)." >&2 + echo " Hardware virtualization may be disabled in BIOS, or the kernel" >&2 + echo " lacks KVM support (nested virt in a guest VM, etc.)." >&2 + errs=1 + elif [[ ! -r /dev/kvm || ! -w /dev/kvm ]]; then + echo "Error: /dev/kvm exists but you don't have read/write access." >&2 + if ! id -nG 2>/dev/null | grep -qw kvm; then + echo " Fix: sudo usermod -aG kvm \"\$USER\"" >&2 + echo " Then log out and back in (or run 'newgrp kvm') so the new" >&2 + echo " group membership takes effect." >&2 + else + echo " You're already in the kvm group but /dev/kvm denies access." >&2 + echo " Check ownership/mode: ls -l /dev/kvm" >&2 + fi + errs=1 + fi + + [[ $errs -eq 0 ]] +} + +# Lima leaves a partial state dir behind if `limactl create` is interrupted +# (Ctrl-C before lima.yaml is written). After that every subsequent limactl +# call on that name dies with `open ~/.lima//lima.yaml: no such file or +# directory` — pre-emptively clean the dir so the next setup/start works. +_agent_vm_clean_partial_state() { + local vm_name="$1" + local lima_dir="$HOME/.lima/$vm_name" + if [[ -d "$lima_dir" ]] && [[ ! -f "$lima_dir/lima.yaml" ]]; then + echo "Detected partial VM state at $lima_dir (no lima.yaml) — cleaning up." >&2 + rm -rf "$lima_dir" + fi +} + +# Generate a deterministic VM name for a directory +_agent_vm_name() { + local dir="${1:-$(pwd)}" + local hash + hash=$(echo -n "$dir" | shasum -a 256 | cut -c1-8) + local base + base=$(basename "$dir" | tr -cs 'a-zA-Z0-9' '-' | sed 's/^-//;s/-$//') + echo "agent-vm-${base}-${hash}" +} + +# Check if a VM exists (any state) +_agent_vm_exists() { + limactl list -q 2>/dev/null | grep -q "^${1}$" +} + +# Check if a VM is running +_agent_vm_running() { + limactl list --format '{{.Name}} {{.Status}}' 2>/dev/null | grep -q "^${1} Running$" +} + +# Stage a single host file at via hardlink, falling back to copy if the +# source and destination live on different filesystems. Hardlinking keeps the +# content live-synced with the host (same inode) without exposing the source's +# parent directory to the VM. The copy fallback preserves the no-exposure +# property but loses live sync until the next VM (re)start. +_agent_vm_stage_file() { + local src="$1" dst="$2" + mkdir -p "$(dirname "$dst")" 2>/dev/null || return 1 + rm -f "$dst" + if ln "$src" "$dst" 2>/dev/null; then + return 0 + fi + if cp -p "$src" "$dst" 2>/dev/null; then + echo "Warning: Staged '${src}' via copy (cross-filesystem hardlink failed); live host changes will not propagate until VM (re)start." >&2 + return 0 + fi + return 1 +} + +# Remove all per-VM state files (version marker, terminfo cache, file mount +# cache, staging dirs). Called after a VM is deleted or before it is re-cloned +# via --reset. +_agent_vm_cleanup_state() { + local vm_name="$1" + rm -f "$AGENT_VM_STATE_DIR/.agent-vm-version-${vm_name}" + rm -f "$AGENT_VM_STATE_DIR/.agent-vm-term-${vm_name}" + rm -f "$AGENT_VM_STATE_DIR/.agent-vm-file-mounts-${vm_name}" + rm -rf "$AGENT_VM_STATE_DIR/file-mounts/${vm_name}" +} + +# Build the .mounts JSON array for a VM. The first entry is always the project +# dir (writable). Additional entries come from ~/.agent-vm/volumes, parsed as +# Docker-Compose-ish `source[:destination][:mode]` (mode ∈ {ro,rw}, default ro). +# +# Side effects: stages any file mounts as hardlinks under +# ~/.agent-vm/file-mounts// and persists the file mount metadata to +# ~/.agent-vm/.agent-vm-file-mounts- so subsequent starts can re-apply the +# inside-VM bind mounts without re-parsing the volumes file. Stdout: the +# mounts JSON array (consumed by `limactl edit --set ".mounts = ..."`). +_agent_vm_build_mounts_json() { + local vm_name="$1" host_dir="$2" + local mounts_json="[{\"location\": \"${host_dir}\", \"writable\": true}" + local mounts_file="$AGENT_VM_STATE_DIR/volumes" + local file_mount_entries=() + local file_mounts_cache="$AGENT_VM_STATE_DIR/.agent-vm-file-mounts-${vm_name}" + + if [[ -f "$mounts_file" ]]; then + local staging_idx=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" # strip comments + line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace + line="${line%"${line##*[![:space:]]}"}" # trim trailing whitespace + [[ -z "$line" ]] && continue + # Parse source[:destination][:mode] syntax (like docker compose volumes). + # The trailing mode segment is only recognized when it equals "ro" or + # "rw" — anything else is treated as a destination path. + local src="$line" dst="" mode="ro" + if [[ "$line" == *:ro || "$line" == *:rw ]]; then + mode="${line##*:}" + line="${line%:*}" + fi + if [[ "$line" == *:* ]]; then + src="${line%%:*}" + dst="${line#*:}" + else + src="$line" + fi + src="${src/#\~/$HOME}" # expand ~ + # Reject characters that would break JSON interpolation below or the + # pipe-separated cache format used for file mounts. + if [[ "$src" == *[$'"\\\n|']* || "$dst" == *[$'"\\\n|']* ]]; then + echo "Warning: Mount entry '${line}' (from ~/.agent-vm/volumes) contains invalid characters (quote/backslash/newline/pipe), skipping." >&2 + continue + fi + if [[ ! -e "$src" ]]; then + echo "Warning: Mount path '${src}' (from ~/.agent-vm/volumes) does not exist, skipping." >&2 + continue + fi + if [[ -f "$src" ]]; then + if [[ "$mode" == "rw" ]]; then + echo "Warning: Mount entry '${line}' (from ~/.agent-vm/volumes) requests rw on a file; only directories support rw. Mount the parent directory instead. Skipping." >&2 + continue + fi + # File mount: hardlink the source into a per-VM host staging dir so + # the VM sees only this file (never the source's parent). Lima mounts + # the staging dir via virtiofs; a bind mount inside the VM (applied + # after boot) exposes the file at its final destination. + local filename + filename="$(basename "$src")" + local file_staging_dir="$AGENT_VM_STATE_DIR/file-mounts/${vm_name}/${staging_idx}" + local host_staging="${file_staging_dir}/${filename}" + if ! _agent_vm_stage_file "$src" "$host_staging"; then + echo "Warning: Failed to stage '${src}', skipping." >&2 + rm -rf "$file_staging_dir" + continue + fi + local staging_mount="/tmp/.agent-vm-file-mounts/${staging_idx}" + local bind_dst="${dst:-${src}}" + file_mount_entries+=("${src}|${host_staging}|${staging_mount}/${filename}|${bind_dst}") + mounts_json+=", {\"location\": \"${file_staging_dir}\", \"mountPoint\": \"${staging_mount}\", \"writable\": false}" + staging_idx=$((staging_idx + 1)) + continue + fi + if [[ ! -d "$src" ]]; then + echo "Warning: Mount path '${src}' (from ~/.agent-vm/volumes) is not a regular file or directory, skipping." >&2 + continue + fi + local writable="false" + [[ "$mode" == "rw" ]] && writable="true" + if [[ -n "$dst" ]]; then + mounts_json+=", {\"location\": \"${src}\", \"mountPoint\": \"${dst}\", \"writable\": ${writable}}" + else + mounts_json+=", {\"location\": \"${src}\", \"writable\": ${writable}}" + fi + done < "$mounts_file" + fi + mounts_json+="]" + + rm -f "$file_mounts_cache" + if [[ ${#file_mount_entries[@]} -gt 0 ]]; then + printf '%s\n' "${file_mount_entries[@]}" > "$file_mounts_cache" + fi + + printf '%s' "$mounts_json" +} + +# Print VM resource details (CPUs, memory, disk) +_agent_vm_print_resources() { + local vm_name="$1" + local info + info=$(limactl list --format '{{.Name}}|{{.CPUs}}|{{.Memory}}|{{.Disk}}' 2>/dev/null | grep "^${vm_name}|" | head -1) + if [[ -n "$info" ]]; then + local cpus mem_bytes disk_bytes + IFS='|' read -r _ cpus mem_bytes disk_bytes <<< "$info" + local mem_gib=$((mem_bytes / 1073741824)) + local disk_gib=$((disk_bytes / 1073741824)) + echo " Resources: CPUs: ${cpus}, Memory: ${mem_gib} GiB, Disk: ${disk_gib} GiB" + fi +} + +# Ensure the VM for cwd exists and is running, creating/starting as needed +# Usage: _agent_vm_ensure_running [--disk GB] [--memory GB] [--reset] +_agent_vm_ensure_running() { + local vm_name="$1" + local host_dir="$2" + shift 2 + local disk="" memory="" cpus="" reset="" offline="" rdonly="" git_ro="" + while [[ $# -gt 0 ]]; do + case "$1" in + --disk) disk="$2"; shift 2 ;; + --memory|--ram) memory="$2"; shift 2 ;; + --cpus) cpus="$2"; shift 2 ;; + --reset) reset=1; shift ;; + --offline) offline=1; shift ;; + --readonly) rdonly=1; shift ;; + --git-read-only|--git-ro) git_ro=1; shift ;; + *) shift ;; + esac + done + + # Lima's host mount cannot share a path containing whitespace: the mount + # fails silently and the VM starts with a bare, root-owned mountpoint, so + # every write into the project (e.g. creating .claude) fails with + # "Permission denied". Fail fast with an actionable message instead. + if [[ "$host_dir" == *[[:space:]]* ]]; then + echo "Error: project path contains whitespace, which Lima cannot mount:" >&2 + echo " $host_dir" >&2 + echo "Rename the directory to remove spaces (e.g. with '-'), then retry." >&2 + return 1 + fi + + # Recover from an interrupted previous run that left ~/.lima// without + # a lima.yaml — otherwise every limactl call on this name aborts with a + # cryptic "no such file or directory". + _agent_vm_clean_partial_state "$vm_name" + + if ! limactl list -q 2>/dev/null | grep -q "^${AGENT_VM_TEMPLATE}$"; then + echo "Error: Base VM not found. Run 'agent-vm setup' first." >&2 + return 1 + fi + + # Destroy existing VM if --reset was requested + if [[ -n "$reset" ]] && _agent_vm_exists "$vm_name"; then + echo "Resetting VM '$vm_name'..." + limactl stop "$vm_name" &>/dev/null + limactl delete "$vm_name" --force &>/dev/null + _agent_vm_cleanup_state "$vm_name" + fi + + local is_new_vm="" + local file_mount_entries=() + local file_mounts_cache="$AGENT_VM_STATE_DIR/.agent-vm-file-mounts-${vm_name}" + + if ! _agent_vm_exists "$vm_name"; then + is_new_vm=1 + echo "Creating VM '$vm_name'..." + limactl clone "$AGENT_VM_TEMPLATE" "$vm_name" --tty=false &>/dev/null + # Apply mount and resource settings via edit after clone + # Mount and memory/cpus are applied separately from disk, because + # Lima rejects the entire edit if disk shrinking is attempted. + local mounts_json + mounts_json=$(_agent_vm_build_mounts_json "$vm_name" "$host_dir") + local edit_args=() + edit_args+=(--set ".mounts = ${mounts_json}") + [[ -n "$memory" ]] && edit_args+=(--memory "$memory") + [[ -n "$cpus" ]] && edit_args+=(--cpus "$cpus") + (cd /tmp && limactl edit "$vm_name" "${edit_args[@]}") &>/dev/null + if [[ -n "$disk" ]]; then + if ! (cd /tmp && limactl edit "$vm_name" --disk "$disk") &>/dev/null; then + echo "Warning: Cannot set disk to ${disk} GiB (shrinking is not supported). Re-run 'agent-vm setup --disk ${disk}' for a smaller base." >&2 + fi + fi + _agent_vm_print_resources "$vm_name" + # Record which base version this VM was cloned from + local base_ver="$AGENT_VM_STATE_DIR/.agent-vm-base-version" + if [[ -f "$base_ver" ]]; then + cp "$base_ver" "$AGENT_VM_STATE_DIR/.agent-vm-version-${vm_name}" + fi + elif [[ -n "$disk" || -n "$memory" || -n "$cpus" ]]; then + # Auto-resize existing VM if --disk, --memory, or --cpus changed + if _agent_vm_running "$vm_name"; then + echo "VM '$vm_name' is currently running. It must be stopped to apply new resource settings." + printf "Stop the VM and apply changes? [y/N] " >&2 + local reply="" + IFS= read -r reply /dev/null || reply="" + if [[ ! "$reply" =~ ^[Yy]$ ]]; then + echo "Aborted. Starting with current settings." + return 0 + fi + echo "Stopping VM..." + limactl stop "$vm_name" &>/dev/null + fi + echo "Updating VM resources..." + # Don't touch .mounts here — those are baked in at creation (including any + # entries from ~/.agent-vm/volumes). Re-setting them would clobber extras. + local edit_args=() + [[ -n "$memory" ]] && edit_args+=(--memory "$memory") + [[ -n "$cpus" ]] && edit_args+=(--cpus "$cpus") + local edit_output + if ! edit_output=$(cd /tmp && limactl edit "$vm_name" "${edit_args[@]}" 2>&1); then + echo "Error: Failed to update VM resources:" >&2 + echo "$edit_output" >&2 + return 1 + fi + if [[ -n "$disk" ]]; then + if ! edit_output=$(cd /tmp && limactl edit "$vm_name" --disk "$disk" 2>&1); then + echo "Warning: Cannot set disk to ${disk} GiB (shrinking is not supported). Re-run 'agent-vm setup --disk ${disk}' for a smaller base." >&2 + fi + fi + _agent_vm_print_resources "$vm_name" + fi + + # Warn if this VM was cloned from an older base + local base_ver="$AGENT_VM_STATE_DIR/.agent-vm-base-version" + local vm_ver="$AGENT_VM_STATE_DIR/.agent-vm-version-${vm_name}" + if [[ -f "$base_ver" ]] && { [[ ! -f "$vm_ver" ]] || [[ "$(cat "$base_ver")" != "$(cat "$vm_ver")" ]]; }; then + echo "Warning: Base VM has been updated since this VM was cloned. Use --reset to re-clone from the new base." >&2 + fi + + if ! _agent_vm_running "$vm_name"; then + echo "Starting VM '$vm_name'..." + local start_log + if ! start_log=$(limactl start "$vm_name" 2>&1); then + echo "Error: Failed to start VM '$vm_name'." >&2 + echo "--- limactl start output ---" >&2 + echo "$start_log" >&2 + echo "Full log: ~/.lima/$vm_name/ha.stderr.log" >&2 + return 1 + fi + fi + + # Sanity-check that the project directory is actually shared and writable. + # A silently-failed or stale mount leaves a bare, root-owned mountpoint; + # without this check every later write (runtime scripts, the agent itself) + # fails with a confusing cascade of "Permission denied" instead of one clear + # error. Runs before the optional --readonly remount below, so it reflects + # the mount itself, not the requested read-only restriction. + # + # If the mount is broken, try to self-heal once: re-apply the mount config + # (Lima only allows editing a stopped VM) and restart. This repairs a stale + # or silently-missing mount without forcing a full --reset, and adds no + # overhead on the common path where the mount is already healthy. + if ! limactl shell "$vm_name" test -w "$host_dir" &>/dev/null; then + echo "Project mount is not writable; repairing..." >&2 + limactl stop "$vm_name" &>/dev/null + # Rebuild the full mounts JSON so any ~/.agent-vm/volumes entries are + # preserved across the repair (a plain project-dir-only set would silently + # drop them). + local repair_mounts_json + repair_mounts_json=$(_agent_vm_build_mounts_json "$vm_name" "$host_dir") + (cd /tmp && limactl edit "$vm_name" \ + --set ".mounts = ${repair_mounts_json}") &>/dev/null + limactl start "$vm_name" &>/dev/null + if ! limactl shell "$vm_name" test -w "$host_dir" &>/dev/null; then + echo "Error: project directory is still not writable inside the VM:" >&2 + echo " $host_dir" >&2 + echo "The host mount failed to attach. Try 'agent-vm --reset '" >&2 + echo "to re-clone the VM from the base template." >&2 + return 1 + fi + fi + + # Install the host's terminfo entry inside the VM so non-standard terminals + # (xterm-ghostty, xterm-kitty, …) work correctly. Without this, zsh/ZLE + # can't decode keys → broken backspace, arrows, etc. Cached per-VM so we only + # pay the limactl shell roundtrip when $TERM actually changes. + local term_cache="$AGENT_VM_STATE_DIR/.agent-vm-term-${vm_name}" + if [[ -n "${TERM:-}" ]] && [[ "$(cat "$term_cache" 2>/dev/null)" != "$TERM" ]] \ + && infocmp -x "$TERM" &>/dev/null; then + if infocmp -x "$TERM" | limactl shell "$vm_name" sudo tic -x - &>/dev/null; then + echo "$TERM" > "$term_cache" + else + echo "Warning: failed to install '$TERM' terminfo inside VM." >&2 + fi + fi + + # Push ~/.agent-vm/env (a dotenv-style file of tokens / API keys) into the VM + # at $HOME/.agent-vm.env on every start, so updates on the host propagate + # without --reset. The base VM's ~/.zshenv auto-sources it via `set -a`, so + # the contents stay a plain KEY=value file (no `export` needed). `umask 077` + # creates the file mode-600 since it usually holds secrets. + if [ -f "$AGENT_VM_STATE_DIR/env" ]; then + if ! limactl shell "$vm_name" sh -c 'umask 077 && rm -f "$HOME/.agent-vm.env" && cat > "$HOME/.agent-vm.env"' \ + < "$AGENT_VM_STATE_DIR/env" 2>/dev/null; then + echo "Warning: failed to push ~/.agent-vm/env into VM '$vm_name'." >&2 + fi + fi + + # Run per-user runtime script if it exists + if [ -f "$AGENT_VM_STATE_DIR/runtime.sh" ]; then + echo "Running user runtime setup..." + limactl shell --workdir "$host_dir" "$vm_name" zsh -l < "$AGENT_VM_STATE_DIR/runtime.sh" + fi + + # Run project-specific runtime script if it exists + if [ -f "${host_dir}/.agent-vm.runtime.sh" ]; then + echo "Running project runtime setup..." + limactl shell --workdir "$host_dir" "$vm_name" zsh -l < "${host_dir}/.agent-vm.runtime.sh" + fi + + # Apply per-session restrictions + if [[ -n "$offline" ]]; then + echo "Enabling offline mode..." + limactl shell "$vm_name" sudo iptables -F OUTPUT 2>/dev/null + limactl shell "$vm_name" sudo iptables -A OUTPUT -o lo -j ACCEPT + limactl shell "$vm_name" sudo iptables -A OUTPUT -d 10.0.0.0/8 -j ACCEPT + limactl shell "$vm_name" sudo iptables -A OUTPUT -d 172.16.0.0/12 -j ACCEPT + limactl shell "$vm_name" sudo iptables -A OUTPUT -d 192.168.0.0/16 -j ACCEPT + limactl shell "$vm_name" sudo iptables -P OUTPUT DROP + fi + + if [[ -n "$rdonly" ]]; then + # `mount -o remount,ro` on the host share fails silently when Lima uses + # a FUSE-backed mount (sshfs/virtiofs in some configurations) — FUSE + # rejects the `remount` option and the FS stays writable. Self-bind the + # directory first so we have a plain Linux bind-mount layer to remount, + # which works regardless of what's underneath. + echo "Mounting project directory as read-only..." + if ! limactl shell "$vm_name" sudo mount --bind "$host_dir" "$host_dir" \ + || ! limactl shell "$vm_name" sudo mount -o remount,ro,bind "$host_dir"; then + echo "Error: Failed to mount project directory as read-only." >&2 + return 1 + fi + fi + + if [[ -n "$git_ro" ]] && [[ -d "$host_dir/.git" ]]; then + echo "Mounting .git directory as read-only..." + if ! limactl shell "$vm_name" sudo mount --bind "$host_dir/.git" "$host_dir/.git" \ + || ! limactl shell "$vm_name" sudo mount -o remount,ro,bind "$host_dir/.git"; then + echo "Error: Failed to mount .git directory as read-only." >&2 + return 1 + fi + fi + + # Load file mount entries from the cache. _agent_vm_build_mounts_json writes + # them there (from its own local scope) for both new and existing VMs, so we + # always read them back here to drive the inside-VM bind mounts below. + if [[ -f "$file_mounts_cache" ]]; then + local entry + while IFS= read -r entry; do + [[ -n "$entry" ]] && file_mount_entries+=("$entry") + done < "$file_mounts_cache" + + # For existing VMs, refresh host-side hardlinks so atomic-rename edits on the + # host propagate after a VM restart (ln/cp against the cached staging path). + # New VMs just staged fresh copies in _agent_vm_build_mounts_json, so there + # is nothing to refresh. + if [[ -z "$is_new_vm" ]]; then + local host_src host_staging _bind_src _bind_dst + for entry in "${file_mount_entries[@]}"; do + IFS='|' read -r host_src host_staging _bind_src _bind_dst <<< "$entry" + [[ -z "$host_staging" ]] && continue + if [[ ! -e "$host_src" ]]; then + echo "Warning: Mount source '${host_src}' no longer exists; VM will see the last-staged copy." >&2 + continue + fi + _agent_vm_stage_file "$host_src" "$host_staging" \ + || echo "Warning: Failed to refresh staged '${host_src}'; VM may see stale content." >&2 + done + fi + fi + + # Apply inside-VM bind mounts so each staged file appears at its final path. + # Lima re-mounts staging dirs on each start, but the bind onto the final dest + # is ephemeral. Batched into one limactl shell call (roundtrips cost ~1-2s) + # and made idempotent so re-runs on a running VM are cheap no-ops. + if [[ ${#file_mount_entries[@]} -gt 0 ]]; then + local file_bind_payload=() + local _host_src _host_staging bind_src bind_dst + for entry in "${file_mount_entries[@]}"; do + IFS='|' read -r _host_src _host_staging bind_src bind_dst <<< "$entry" + [[ -n "$bind_src" && -n "$bind_dst" ]] && file_bind_payload+=("${bind_src}|${bind_dst}") + done + if [[ ${#file_bind_payload[@]} -gt 0 ]]; then + echo "Mounting individual files..." + # Paths are passed as positional args (single-quoted script) so entries + # containing quotes or metacharacters cannot be interpreted as shell code. + limactl shell "$vm_name" sudo bash -c ' + set -e + for entry in "$@"; do + bind_src="${entry%%|*}" + bind_dst="${entry#*|}" + if ! findmnt -no TARGET "$bind_dst" >/dev/null 2>&1; then + mkdir -p "$(dirname "$bind_dst")" && touch "$bind_dst" + mount --bind "$bind_src" "$bind_dst" + mount -o remount,ro,bind "$bind_dst" + fi + done + ' -- "${file_bind_payload[@]}" + fi + fi +} + +agent-vm() { + local vm_opts=() + # Parse global options before the subcommand + while [[ $# -gt 0 ]]; do + case "$1" in + --disk) + vm_opts+=(--disk "$2"); shift 2 ;; + --disk=*) + vm_opts+=(--disk "${1#*=}"); shift ;; + --memory|--ram) + vm_opts+=(--memory "$2"); shift 2 ;; + --memory=*|--ram=*) + vm_opts+=(--memory "${1#*=}"); shift ;; + --cpus) + vm_opts+=(--cpus "$2"); shift 2 ;; + --cpus=*) + vm_opts+=(--cpus "${1#*=}"); shift ;; + --reset) + vm_opts+=(--reset); shift ;; + --offline) + vm_opts+=(--offline); shift ;; + --readonly) + vm_opts+=(--readonly); shift ;; + --git-read-only|--git-ro) + vm_opts+=(--git-read-only); shift ;; + --rm) + vm_opts+=(--rm); shift ;; + *) + break ;; + esac + done + + local cmd="${1:-help}" + shift 2>/dev/null || true + + # Every command except help/setup needs limactl present. (setup installs it + # itself; help needs nothing.) Without this, stop/list/status/etc. would fail + # with confusing empty output instead of a clear, actionable message. + case "$cmd" in + help|--help|-h|setup) ;; + *) + if ! command -v limactl &>/dev/null; then + echo "Error: limactl (Lima) not found. Run 'agent-vm setup' first, or install" >&2 + echo "it from https://lima-vm.io/docs/installation/" >&2 + return 1 + fi ;; + esac + + case "$cmd" in + setup) + _agent_vm_setup "${vm_opts[@]}" "$@" + ;; + claude) + _agent_vm_claude "${vm_opts[@]}" "$@" + ;; + opencode) + _agent_vm_opencode "${vm_opts[@]}" "$@" + ;; + codex) + _agent_vm_codex "${vm_opts[@]}" "$@" + ;; + vibe) + _agent_vm_vibe "${vm_opts[@]}" "$@" + ;; + shell|sh) + _agent_vm_shell "${vm_opts[@]}" "$@" + ;; + run) + _agent_vm_run "${vm_opts[@]}" "$@" + ;; + stop) + _agent_vm_stop "$@" + ;; + rm|destroy) + _agent_vm_destroy "$@" + ;; + destroy-all) + _agent_vm_destroy_all "$@" + ;; + list) + _agent_vm_list "$@" + ;; + status) + _agent_vm_status "$@" + ;; + help|--help|-h) + _agent_vm_help + ;; + *) + echo "Unknown command: $cmd" >&2 + echo "Run 'agent-vm help' for usage." >&2 + return 1 + ;; + esac +} + +_agent_vm_help() { + cat << 'EOF' +Usage: agent-vm [options] [args] + +Commands: + setup Create the base VM template (run once) + claude [args] Run Claude Code in the VM for the current directory + opencode [args] Run OpenCode in the VM for the current directory + codex [args] Run Codex CLI in the VM for the current directory + vibe [args] Run Mistral Vibe in the VM for the current directory + shell, sh Open a shell in the VM. Add -c "..." to run a one-shot + command via login zsh and exit. + run [args] Run a command in the VM (no shell — for pipes/redirects + use 'shell -c "..."' instead; pass --tty for TUIs like + opencode, vibe, htop, etc.) + stop Stop the VM for the current directory + rm Stop and delete the VM for the current directory + destroy-all Stop and delete all agent-vm VMs + list List all agent-vm VMs + status Show status of all VMs (current dir marked with >) + help Show this help + +VM options (for claude, opencode, codex, vibe, shell, run): + --disk GB VM disk size (default: 10) + --memory GB VM memory (default: 3) + --cpus N Number of CPUs (default: 1) + --reset Destroy and re-clone the VM from the base template + --offline Block outbound internet (keeps host/VM communication) + --readonly Mount the project directory as read-only + --git-read-only Mount .git directory as read-only (allows git diff/log but not commit/stash) + --rm Automatically destroy the VM after the command exits + +Examples: + agent-vm setup # Create base VM + agent-vm claude # Run Claude in a VM + agent-vm opencode # Run OpenCode in a VM + agent-vm codex # Run Codex in a VM + agent-vm vibe # Run Mistral Vibe in a VM + agent-vm --disk 50 --memory 16 --cpus 8 claude # Custom resources + agent-vm --reset claude # Fresh VM from base template + agent-vm --rm claude # Destroy VM after Claude exits + agent-vm --offline claude # No internet access + agent-vm --readonly shell # Read-only project mount + agent-vm --git-ro claude # Protect .git from writes + agent-vm shell # Shell into the VM + agent-vm sh -c "ls -la | grep config" # One-shot command via login zsh + agent-vm run npm install # Run a command in the VM + agent-vm run --tty opencode -p "..." # Run a TUI with PTY allocated + agent-vm claude -p "fix lint errors" # Pass args to claude + +VMs are persistent and unique per directory. Running "agent-vm shell" or +"agent-vm claude" in the same directory will reuse the same VM. + +Customization: + ~/.agent-vm/env Shared env vars / tokens (dotenv-style; + auto-loaded into every VM shell) + ~/.agent-vm/volumes Extra host paths to mount in VMs (one per + line, supports both directories and files) + ~/.agent-vm/setup.sh Per-user setup (runs during "agent-vm setup") + ~/.agent-vm/runtime.sh Per-user runtime (runs on each VM start) + /.agent-vm.runtime.sh Per-project runtime (runs on each VM start) + +More info: https://github.com/sylvinus/agent-vm +EOF +} + +_agent_vm_setup() { + local disk=10 + local memory=3 + local cpus=1 + local preinstall="" + local preinstall_seen="" + # Defaults match the "default install" set: every component on EXCEPT the + # opt-in languages (Ruby, Rust, Go). These apply when --preinstall isn't + # passed and either the wizard's first prompt is accepted or stdin is not a + # terminal (e.g. CI). `--preinstall=all` turns everything on; + # `--preinstall=default,rust` composes the default set with an opt-in. + local install_python=1 install_node=1 + local install_ruby=0 install_rust=0 install_golang=0 + local install_docker=1 install_chromium=1 install_gh=1 + local install_claude=1 install_opencode=1 install_codex=1 install_vibe=1 + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + cat << 'EOF' +Usage: agent-vm setup [options] + +Create a base VM template with dev tools and agents pre-installed. Runs an +interactive wizard by default; the first prompt offers a "default install" +(everything except the opt-in languages Ruby, Rust, Go). Answer 'n' for +per-component prompts. Pass --preinstall=... to skip the wizard and pick a +specific subset non-interactively. When stdin is not a terminal (e.g. CI), +the wizard is skipped automatically and the default set is installed. + +Options: + --disk GB VM disk size (default: 10) + --memory GB VM memory (default: 3) + --cpus N Number of CPUs (default: 1) + --preinstall=LIST Comma-separated list of tools to preinstall in the base + VM image (skips the wizard). Anything not listed is + skipped. Use: + 'default' for the default set + (everything except Ruby, Rust, Go), + 'all' for everything, + 'none' for nothing. + Available names: + python, node, ruby, rust, golang, docker, chromium, + gh, claude, opencode, codex, vibe + Selecting codex, or chromium with any AI agent, also + installs node because those paths require npm/npx. + Examples: + --preinstall=default,rust # default set plus Rust + --preinstall=python,docker,claude + --help Show this help +EOF + return 0 + ;; + --disk) + disk="$2" + shift 2 + _agent_vm_validate_int --disk "$disk" || return 1 + ;; + --disk=*) + disk="${1#*=}" + shift + _agent_vm_validate_int --disk "$disk" || return 1 + ;; + --memory|--ram) + memory="$2" + shift 2 + _agent_vm_validate_int --memory "$memory" || return 1 + ;; + --memory=*|--ram=*) + memory="${1#*=}" + shift + _agent_vm_validate_int --memory "$memory" || return 1 + ;; + --cpus) + cpus="$2" + shift 2 + _agent_vm_validate_int --cpus "$cpus" || return 1 + ;; + --cpus=*) + cpus="${1#*=}" + shift + _agent_vm_validate_int --cpus "$cpus" || return 1 + ;; + --preinstall) + preinstall_seen=1 + # Only consume the next argument as the value when it is an actual + # value, not another option. Bare `--preinstall` falls back to the + # default set (handled below) without swallowing e.g. a following + # `--disk`. + if [[ -n "$2" && "$2" != -* ]]; then + preinstall="$2" + shift 2 + else + shift + fi + ;; + --preinstall=*) + preinstall="${1#*=}" + preinstall_seen=1 + shift + ;; + --reset|--offline|--readonly|--git-read-only|--git-ro) + shift ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: agent-vm setup [--disk GB] [--memory GB] [--cpus N] [--preinstall=LIST]" >&2 + return 1 + ;; + esac + done + + # Apply --preinstall=LIST: start with everything OFF and turn on what's + # listed. 'default' / 'all' / 'none' are shortcuts. Setting this also + # bypasses the interactive wizard below. `--preinstall=` with no value + # (or `--preinstall` swallowed by a following flag) falls back to the + # default set — explicitly opting into the wizard's recommended install. + # Pass `--preinstall=none` if you really want nothing. + if [[ -n "$preinstall_seen" ]]; then + install_python=0 install_node=0 install_ruby=0 install_rust=0 install_golang=0 + install_docker=0 install_chromium=0 install_gh=0 + install_claude=0 install_opencode=0 install_codex=0 install_vibe=0 + [[ -z "$preinstall" ]] && preinstall="default" + # Iterate the comma-list portably across bash and zsh by appending a + # trailing comma and peeling off one token per iteration. + local rest="${preinstall}," f + while [[ -n "$rest" ]]; do + f="${rest%%,*}" + rest="${rest#*,}" + # Trim whitespace. Use bash/zsh-portable substitutions only. + f="${f# }"; f="${f% }" + [[ -z "$f" ]] && continue + case "$f" in + all) + install_python=1 install_node=1 install_ruby=1 + install_rust=1 install_golang=1 + install_docker=1 install_chromium=1 install_gh=1 + install_claude=1 install_opencode=1 install_codex=1 install_vibe=1 + ;; + default) + install_python=1 install_node=1 + install_docker=1 install_chromium=1 install_gh=1 + install_claude=1 install_opencode=1 install_codex=1 install_vibe=1 + ;; + none) ;; # explicit no-op token; with the all-off reset above, + # `--preinstall=none` ships nothing. + python) install_python=1 ;; + node) install_node=1 ;; + ruby) install_ruby=1 ;; + rust) install_rust=1 ;; + golang) install_golang=1 ;; + docker) install_docker=1 ;; + chromium) install_chromium=1 ;; + gh) install_gh=1 ;; + claude) install_claude=1 ;; + opencode) install_opencode=1 ;; + codex) install_codex=1 ;; + vibe) install_vibe=1 ;; + *) + echo "Unknown preinstall name: $f (names are lowercase)" >&2 + echo "Valid: python, node, ruby, rust, golang, docker, chromium, gh, claude, opencode, codex, vibe, default, all, none" >&2 + return 1 + ;; + esac + done + fi + + # Fail-fast checks before the (potentially long) wizard so the user doesn't + # answer 15 prompts only to be told their host is missing Lima or KVM. + if ! command -v limactl &>/dev/null; then + if command -v brew &>/dev/null; then + echo "Installing Lima..." + brew install lima + else + echo "Error: Lima is required. Install from https://lima-vm.io/docs/installation/" >&2 + return 1 + fi + fi + + _agent_vm_check_linux_prereqs || return 1 + + # Interactive wizard, unless --preinstall was passed or no terminal is + # attached (e.g. running under CI). Defaults shown in [] are prefilled from + # any --disk/--memory/--cpus flags the user already passed, so they can + # confirm or override. Components default to the "default install" set + # (everything except Ruby/Rust/Go). The first prompt offers that whole set + # as a one-tap shortcut — answer 'n' for per-component prompts. + if [[ -z "$preinstall_seen" ]] && [[ -r /dev/tty ]]; then + printf '\nagent-vm setup wizard\n' >&2 + printf '─────────────────────\n\n' >&2 + printf 'These settings apply to the base VM image. Every per-project VM is\n' >&2 + printf 'cloned from it, so anything preinstalled here is available in all\n' >&2 + printf 'future agent VMs. You can still install extra tools inside any\n' >&2 + printf 'individual VM later (e.g. via `agent-vm shell`).\n\n' >&2 + printf 'For more: https://github.com/sylvinus/agent-vm\n\n' >&2 + + # Software first — the more interesting choice for most users. + printf 'Software\n' >&2 + printf '────────\n' >&2 + printf ' Install: Python, Node.js, Docker, Chromium, gh,\n' >&2 + printf ' Claude Code, OpenCode, Codex CLI, Mistral Vibe\n' >&2 + printf ' Skip: Ruby, Rust, Go\n\n' >&2 + local use_default_software + use_default_software=$(_agent_vm_ask_yn "Use this default" Y) + if [[ "$use_default_software" != "1" ]]; then + printf '\nAI coding agents\n' >&2 + printf '────────────────\n' >&2 + install_claude=$(_agent_vm_ask_yn "Claude Code" Y) + install_opencode=$(_agent_vm_ask_yn "OpenCode" Y) + install_codex=$(_agent_vm_ask_yn "Codex CLI" Y) + install_vibe=$(_agent_vm_ask_yn "Mistral Vibe" Y) + + printf '\nSystem tools\n' >&2 + printf '────────────\n' >&2 + install_docker=$(_agent_vm_ask_yn "Docker" Y) + install_chromium=$(_agent_vm_ask_yn "Chromium (headless browser)" Y) + install_gh=$(_agent_vm_ask_yn "GitHub CLI (gh)" Y) + + local node_forced_reason="" + if [[ "$install_codex" == "1" ]]; then + node_forced_reason="Codex CLI requires Node.js" + elif [[ "$install_chromium" == "1" && ( "$install_claude" == "1" || "$install_opencode" == "1" || "$install_vibe" == "1" ) ]]; then + node_forced_reason="Chrome DevTools MCP uses npx" + fi + + printf '\nLanguages\n' >&2 + printf '─────────\n' >&2 + install_python=$(_agent_vm_ask_yn "Python 3" Y) + if [[ -n "$node_forced_reason" ]]; then + install_node=1 + printf 'Node.js 24: yes (%s)\n' "$node_forced_reason" >&2 + else + install_node=$(_agent_vm_ask_yn "Node.js 24" Y) + fi + install_ruby=$(_agent_vm_ask_yn "Ruby" Y) + install_rust=$(_agent_vm_ask_yn "Rust" Y) + install_golang=$(_agent_vm_ask_yn "Go" Y) + fi + + # Resources second — same pattern, accept-in-one-go shortcut. Current + # values reflect any --disk/--memory/--cpus already passed on the CLI. + # These are starting values: any later `agent-vm` command can resize the + # per-project VM with --disk/--memory/--cpus. + printf '\nDefault resources\n' >&2 + printf '─────────────────\n' >&2 + printf '(per-VM override with --disk / --memory / --cpus on any agent-vm command)\n\n' >&2 + printf ' Disk %s GB\n' "$disk" >&2 + printf ' Memory %s GB\n' "$memory" >&2 + printf ' CPUs %s\n\n' "$cpus" >&2 + local use_default_resources + use_default_resources=$(_agent_vm_ask_yn "Use these defaults" Y) + if [[ "$use_default_resources" != "1" ]]; then + disk=$(_agent_vm_ask_int "Disk size in GB" "$disk") + memory=$(_agent_vm_ask_int "Memory in GB" "$memory") + cpus=$(_agent_vm_ask_int "Number of CPUs" "$cpus") + fi + printf '\n' >&2 + fi + + if [[ "$install_chromium" == "1" ]]; then + local wants_chrome_mcp=0 + [[ "$install_claude" == "1" || "$install_opencode" == "1" || "$install_codex" == "1" || "$install_vibe" == "1" ]] && wants_chrome_mcp=1 + if [[ "$wants_chrome_mcp" == "1" && "$install_node" != "1" ]]; then + echo "Enabling Node.js because Chrome DevTools MCP uses npx." >&2 + install_node=1 + fi + fi + + if [[ "$install_codex" == "1" && "$install_node" != "1" ]]; then + echo "Enabling Node.js because Codex CLI requires npm." >&2 + install_node=1 + fi + + _agent_vm_clean_partial_state "$AGENT_VM_TEMPLATE" + + limactl stop "$AGENT_VM_TEMPLATE" &>/dev/null + limactl delete "$AGENT_VM_TEMPLATE" --force &>/dev/null + + echo "Creating base VM..." + local create_args=( + --set '.mounts=[]' + --disk="$disk" + --memory="$memory" + --cpus="$cpus" + --tty=false + ) + local create_log + if ! create_log=$(limactl create --name="$AGENT_VM_TEMPLATE" template:debian-13 "${create_args[@]}" 2>&1); then + echo "Error: Failed to create base VM." >&2 + echo "--- limactl create output ---" >&2 + echo "$create_log" >&2 + return 1 + fi + + _agent_vm_print_resources "$AGENT_VM_TEMPLATE" + + local start_log + if ! start_log=$(limactl start "$AGENT_VM_TEMPLATE" 2>&1); then + echo "Error: Failed to start base VM." >&2 + echo "--- limactl start output ---" >&2 + echo "$start_log" >&2 + echo "Full log: ~/.lima/$AGENT_VM_TEMPLATE/ha.stderr.log" >&2 + return 1 + fi + + # Run the setup script inside the VM. Component selections are passed by + # prepending `export` lines to the script on stdin — keeps the integration + # to one knob (env vars) and avoids quoting headaches with `limactl shell + # env KEY=VAL`. The setup script's defaults for each flag match the host + # wizard's "default install" set (Ruby/Rust/Go off, everything else on), + # so invoking the in-VM script standalone — without these exports — still + # produces the same default install. + echo "Installing packages inside VM..." + if [[ ! -r "${AGENT_VM_SCRIPT_DIR}/agent-vm.setup.sh" ]]; then + echo "Error: Setup script not found at ${AGENT_VM_SCRIPT_DIR}/agent-vm.setup.sh" >&2 + return 1 + fi + { + printf 'export AGENT_VM_INSTALL_PYTHON=%s\n' "$install_python" + printf 'export AGENT_VM_INSTALL_NODE=%s\n' "$install_node" + printf 'export AGENT_VM_INSTALL_RUBY=%s\n' "$install_ruby" + printf 'export AGENT_VM_INSTALL_RUST=%s\n' "$install_rust" + printf 'export AGENT_VM_INSTALL_GOLANG=%s\n' "$install_golang" + printf 'export AGENT_VM_INSTALL_DOCKER=%s\n' "$install_docker" + printf 'export AGENT_VM_INSTALL_CHROMIUM=%s\n' "$install_chromium" + printf 'export AGENT_VM_INSTALL_GH=%s\n' "$install_gh" + printf 'export AGENT_VM_INSTALL_CLAUDE=%s\n' "$install_claude" + printf 'export AGENT_VM_INSTALL_OPENCODE=%s\n' "$install_opencode" + printf 'export AGENT_VM_INSTALL_CODEX=%s\n' "$install_codex" + printf 'export AGENT_VM_INSTALL_VIBE=%s\n' "$install_vibe" + cat "${AGENT_VM_SCRIPT_DIR}/agent-vm.setup.sh" + } | limactl shell "$AGENT_VM_TEMPLATE" bash -l || { echo "Error: Setup script failed." >&2; return 1; } + + # Run user's custom setup script if it exists + local user_setup="$AGENT_VM_STATE_DIR/setup.sh" + if [ -f "$user_setup" ]; then + echo "Running custom setup from $user_setup..." + limactl shell "$AGENT_VM_TEMPLATE" zsh -l < "$user_setup" || { echo "Error: Custom setup script failed." >&2; return 1; } + fi + + limactl stop "$AGENT_VM_TEMPLATE" &>/dev/null + + # Record base VM version so we can warn about stale clones + mkdir -p "$AGENT_VM_STATE_DIR" + date +%s > "$AGENT_VM_STATE_DIR/.agent-vm-base-version" + + echo "" + echo "Base VM ready. Try one of these in any project directory:" + echo " agent-vm shell" + [[ "$install_claude" == "1" ]] && echo " agent-vm claude" + [[ "$install_opencode" == "1" ]] && echo " agent-vm opencode" + [[ "$install_codex" == "1" ]] && echo " agent-vm codex" + [[ "$install_vibe" == "1" ]] && echo " agent-vm vibe" + echo "" + echo "Note: Existing VMs were not updated. Use --reset to re-clone them from the new base." +} + +_agent_vm_claude() { + local vm_opts=() + local args=() + local rm="" + while [[ $# -gt 0 ]]; do + case "$1" in + --disk) vm_opts+=(--disk "$2"); shift 2 ;; + --memory|--ram) vm_opts+=(--memory "$2"); shift 2 ;; + --cpus) vm_opts+=(--cpus "$2"); shift 2 ;; + --reset) vm_opts+=(--reset); shift ;; + --offline) vm_opts+=(--offline); shift ;; + --readonly) vm_opts+=(--readonly); shift ;; + --git-read-only|--git-ro) vm_opts+=(--git-read-only); shift ;; + --rm) rm=1; shift ;; + *) args+=("$1"); shift ;; + esac + done + local host_dir + host_dir="$(pwd)" + local vm_name + vm_name="$(_agent_vm_name "$host_dir")" + + _agent_vm_ensure_running "$vm_name" "$host_dir" "${vm_opts[@]}" || return 1 + _agent_vm_print_resources "$vm_name" + + local exit_code=0 + limactl shell --workdir "$host_dir" "$vm_name" -- claude --dangerously-skip-permissions "${args[@]}" + exit_code=$? + [[ -n "$rm" ]] && { echo "Removing VM..."; _agent_vm_destroy; } + return $exit_code +} + +_agent_vm_opencode() { + local vm_opts=() + local args=() + local rm="" + while [[ $# -gt 0 ]]; do + case "$1" in + --disk) vm_opts+=(--disk "$2"); shift 2 ;; + --memory|--ram) vm_opts+=(--memory "$2"); shift 2 ;; + --cpus) vm_opts+=(--cpus "$2"); shift 2 ;; + --reset) vm_opts+=(--reset); shift ;; + --offline) vm_opts+=(--offline); shift ;; + --readonly) vm_opts+=(--readonly); shift ;; + --git-read-only|--git-ro) vm_opts+=(--git-read-only); shift ;; + --rm) rm=1; shift ;; + *) args+=("$1"); shift ;; + esac + done + local host_dir + host_dir="$(pwd)" + local vm_name + vm_name="$(_agent_vm_name "$host_dir")" + + _agent_vm_ensure_running "$vm_name" "$host_dir" "${vm_opts[@]}" || return 1 + _agent_vm_print_resources "$vm_name" + + # --auto auto-approves permission prompts that aren't explicitly denied, + # giving full autonomy (safe inside the sandbox). This is OpenCode's shipped + # equivalent of a "yolo" mode; the proposed --yolo flag was never merged. + local exit_code=0 + limactl shell --tty --workdir "$host_dir" "$vm_name" opencode --auto "${args[@]}" + exit_code=$? + [[ -n "$rm" ]] && { echo "Removing VM..."; _agent_vm_destroy; } + return $exit_code +} + +_agent_vm_codex() { + local vm_opts=() + local args=() + local rm="" + while [[ $# -gt 0 ]]; do + case "$1" in + --disk) vm_opts+=(--disk "$2"); shift 2 ;; + --memory|--ram) vm_opts+=(--memory "$2"); shift 2 ;; + --cpus) vm_opts+=(--cpus "$2"); shift 2 ;; + --reset) vm_opts+=(--reset); shift ;; + --offline) vm_opts+=(--offline); shift ;; + --readonly) vm_opts+=(--readonly); shift ;; + --git-read-only|--git-ro) vm_opts+=(--git-read-only); shift ;; + --rm) rm=1; shift ;; + *) args+=("$1"); shift ;; + esac + done + local host_dir + host_dir="$(pwd)" + local vm_name + vm_name="$(_agent_vm_name "$host_dir")" + + _agent_vm_ensure_running "$vm_name" "$host_dir" "${vm_opts[@]}" || return 1 + _agent_vm_print_resources "$vm_name" + + local exit_code=0 + limactl shell --workdir "$host_dir" "$vm_name" codex --dangerously-bypass-approvals-and-sandbox "${args[@]}" + exit_code=$? + [[ -n "$rm" ]] && { echo "Removing VM..."; _agent_vm_destroy; } + return $exit_code +} + +_agent_vm_vibe() { + local vm_opts=() + local args=() + local rm="" + while [[ $# -gt 0 ]]; do + case "$1" in + --disk) vm_opts+=(--disk "$2"); shift 2 ;; + --memory|--ram) vm_opts+=(--memory "$2"); shift 2 ;; + --cpus) vm_opts+=(--cpus "$2"); shift 2 ;; + --reset) vm_opts+=(--reset); shift ;; + --offline) vm_opts+=(--offline); shift ;; + --readonly) vm_opts+=(--readonly); shift ;; + --git-read-only|--git-ro) vm_opts+=(--git-read-only); shift ;; + --rm) rm=1; shift ;; + *) args+=("$1"); shift ;; + esac + done + local host_dir + host_dir="$(pwd)" + local vm_name + vm_name="$(_agent_vm_name "$host_dir")" + + _agent_vm_ensure_running "$vm_name" "$host_dir" "${vm_opts[@]}" || return 1 + _agent_vm_print_resources "$vm_name" + + # Vibe is a full-screen TUI, so allocate a tty (like opencode). + # --agent auto-approve gives full autonomy (safe inside the sandbox). + local exit_code=0 + limactl shell --tty --workdir "$host_dir" "$vm_name" vibe --agent auto-approve "${args[@]}" + exit_code=$? + [[ -n "$rm" ]] && { echo "Removing VM..."; _agent_vm_destroy; } + return $exit_code +} + +_agent_vm_shell() { + local vm_opts=() + local rm="" + local cmd_string="" + while [[ $# -gt 0 ]]; do + case "$1" in + --disk) vm_opts+=(--disk "$2"); shift 2 ;; + --memory|--ram) vm_opts+=(--memory "$2"); shift 2 ;; + --cpus) vm_opts+=(--cpus "$2"); shift 2 ;; + --reset) vm_opts+=(--reset); shift ;; + --offline) vm_opts+=(--offline); shift ;; + --readonly) vm_opts+=(--readonly); shift ;; + --git-read-only|--git-ro) vm_opts+=(--git-read-only); shift ;; + --rm) rm=1; shift ;; + -c|--command) + if [[ $# -lt 2 || -z "$2" ]]; then + echo "Error: -c/--command requires a command string." >&2 + return 1 + fi + cmd_string="$2"; shift 2 ;; + *) shift ;; + esac + done + local host_dir + host_dir="$(pwd)" + local vm_name + vm_name="$(_agent_vm_name "$host_dir")" + + _agent_vm_ensure_running "$vm_name" "$host_dir" "${vm_opts[@]}" || return 1 + _agent_vm_print_resources "$vm_name" + + local exit_code=0 + if [[ -n "$cmd_string" ]]; then + # One-shot: run the command via a login zsh so ~/.zshenv (mise, PATH, + # ~/.agent-vm.env, …) is loaded. No "Type 'exit'..." chatter. + limactl shell --workdir "$host_dir" "$vm_name" zsh -l -c "$cmd_string" + exit_code=$? + else + echo "VM: $vm_name | Dir: $host_dir" + if [[ -n "$rm" ]]; then + echo "Type 'exit' to leave. VM will be destroyed after exit." + else + echo "Type 'exit' to leave (VM keeps running). Use 'agent-vm stop' to stop it." + fi + limactl shell --workdir "$host_dir" "$vm_name" zsh -l + exit_code=$? + fi + [[ -n "$rm" ]] && { echo "Removing VM..."; _agent_vm_destroy; } + return $exit_code +} + +_agent_vm_run() { + local vm_opts=() + local args=() + local rm="" + local tty_flag="" + while [[ $# -gt 0 ]]; do + case "$1" in + --disk) vm_opts+=(--disk "$2"); shift 2 ;; + --memory|--ram) vm_opts+=(--memory "$2"); shift 2 ;; + --cpus) vm_opts+=(--cpus "$2"); shift 2 ;; + --reset) vm_opts+=(--reset); shift ;; + --offline) vm_opts+=(--offline); shift ;; + --readonly) vm_opts+=(--readonly); shift ;; + --git-read-only|--git-ro) vm_opts+=(--git-read-only); shift ;; + --rm) rm=1; shift ;; + --tty) tty_flag=1; shift ;; + *) args+=("$1"); shift ;; + esac + done + if [[ ${#args[@]} -eq 0 ]]; then + echo "Usage: agent-vm run [--tty] [args]" >&2 + return 1 + fi + local host_dir + host_dir="$(pwd)" + local vm_name + vm_name="$(_agent_vm_name "$host_dir")" + + _agent_vm_ensure_running "$vm_name" "$host_dir" "${vm_opts[@]}" || return 1 + _agent_vm_print_resources "$vm_name" + + # `--tty` forces limactl to allocate a pseudo-terminal in the VM, which + # full-screen TUIs (opencode, vibe, htop, …) need to render correctly when + # invoked through `agent-vm run`. Without it, line-mode tools work fine but + # ncurses-style UIs break. + local shell_opts=(--workdir "$host_dir") + [[ -n "$tty_flag" ]] && shell_opts+=(--tty) + + local exit_code=0 + limactl shell "${shell_opts[@]}" "$vm_name" "${args[@]}" + exit_code=$? + [[ -n "$rm" ]] && { echo "Removing VM..."; _agent_vm_destroy; } + return $exit_code +} + +_agent_vm_stop() { + local host_dir + host_dir="$(pwd)" + local vm_name + vm_name="$(_agent_vm_name "$host_dir")" + + if ! _agent_vm_exists "$vm_name"; then + echo "No VM found for this directory." >&2 + return 1 + fi + + echo "Stopping VM '$vm_name'..." + limactl stop "$vm_name" &>/dev/null + echo "VM stopped." +} + +_agent_vm_destroy() { + local host_dir + host_dir="$(pwd)" + local vm_name + vm_name="$(_agent_vm_name "$host_dir")" + + if ! _agent_vm_exists "$vm_name"; then + echo "No VM found for this directory." >&2 + return 1 + fi + + echo "Stopping and deleting VM '$vm_name'..." + limactl stop "$vm_name" &>/dev/null + limactl delete "$vm_name" --force &>/dev/null + _agent_vm_cleanup_state "$vm_name" + echo "VM destroyed." +} + +_agent_vm_destroy_all() { + local vms + vms=$(limactl list -q 2>/dev/null | grep "^agent-vm-" || true) + if [[ -z "$vms" ]]; then + echo "No agent-vm VMs found." + return 0 + fi + echo "This will destroy the following VMs:" + echo "$vms" + printf "Continue? [y/N] " >&2 + local reply="" + IFS= read -r reply /dev/null || reply="" + if [[ ! "$reply" =~ ^[Yy]$ ]]; then + echo "Aborted." + return 0 + fi + echo "$vms" | while read -r vm; do + echo "Destroying $vm..." + limactl stop "$vm" &>/dev/null + limactl delete "$vm" --force &>/dev/null + _agent_vm_cleanup_state "$vm" + done + echo "All VMs destroyed." +} + +_agent_vm_list() { + limactl list | head -1 + limactl list | grep "^agent-vm-" || echo "(no VMs)" +} + +_agent_vm_status() { + local host_dir + host_dir="$(pwd)" + local current_vm_name + current_vm_name="$(_agent_vm_name "$host_dir")" + + local header + header=$(limactl list | head -1) + + echo "VMs (current directory: $host_dir):" + echo "" + echo "$header" | sed 's/^/ /' + limactl list | grep "^agent-vm-" | while read -r line; do + local vm_name + vm_name=$(echo "$line" | awk '{print $1}') + if [[ "$vm_name" == "$current_vm_name" ]]; then + echo "$line" | sed 's/^/> /' + else + echo "$line" | sed 's/^/ /' + fi + done || echo " (no VMs)" +} + +# When the file is executed directly (`./agent-vm.sh setup`) rather than +# sourced, dispatch to the agent-vm function so it doesn't silently no-op. +# Sourcing remains the canonical install path because it makes `agent-vm` +# available as a shell function across all future commands. Detection is +# shell-specific: +# bash: BASH_SOURCE[0] differs from $0 when sourced +# zsh: ZSH_EVAL_CONTEXT contains ':file' when sourced +_agent_vm_is_sourced() { + if [[ -n "${BASH_VERSION:-}" ]]; then + [[ "${BASH_SOURCE[0]}" != "$0" ]] + elif [[ -n "${ZSH_VERSION:-}" ]]; then + [[ "${ZSH_EVAL_CONTEXT:-}" == *:file* ]] + else + return 0 # unknown shell — assume sourced and don't auto-run + fi +} + +if ! _agent_vm_is_sourced; then + unset -f _agent_vm_is_sourced + agent-vm "$@" + # Preserve agent-vm's exit code — without the explicit exit, the script + # would end on the implicit `unset -f` below, which always returns 0 and + # would mask command failures (`./agent-vm.sh bad-cmd; echo $?` → 0). + exit $? +fi +unset -f _agent_vm_is_sourced diff --git a/vendor/vm/runtime.example.sh b/vendor/vm/runtime.example.sh new file mode 100644 index 0000000..8b952ed --- /dev/null +++ b/vendor/vm/runtime.example.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# ============================================================================= +# runtime.example.sh — Template for ~/.agent-vm/runtime.sh +# ============================================================================= +# +# This file runs inside every agent-vm on each start, before the per-project +# .agent-vm.runtime.sh script. Copy it to ~/.agent-vm/runtime.sh and uncomment +# the sections you need. +# +# To get started: +# cp runtime.example.sh ~/.agent-vm/runtime.sh +# # Edit the file with your own values +# chmod +x ~/.agent-vm/runtime.sh + + +# ============================================================================= +# 1. SSH authentication for GitHub +# ============================================================================= +# +# Embed your SSH private key (base64-encoded) so the VM can push/pull over SSH. +# +# To encode your key: +# cat ~/.ssh/id_ed25519 | base64 +# +# Paste the output below: + +# SSH_KEY_B64="" +# mkdir -p ~/.ssh && chmod 700 ~/.ssh +# echo "$SSH_KEY_B64" | base64 -d > ~/.ssh/id_ed25519 +# chmod 600 ~/.ssh/id_ed25519 +# ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null + + +# ============================================================================= +# 2. Git configuration +# ============================================================================= + +# git config --global user.name "Your Name" +# git config --global user.email "you@example.com" + +# Force SSH for all GitHub remotes (avoids HTTPS credential prompts) +# git config --global url."git@github.com:".insteadOf "https://github.com/" + + +# ============================================================================= +# 3. GitHub CLI authentication +# ============================================================================= +# +# Required for creating PRs, commenting on issues, etc. from inside the VM. +# (If you only need `gh` for CLI calls, setting GH_TOKEN in ~/.agent-vm/env is +# simpler — see "Sharing tokens" in the README.) +# +# To create a token: https://github.com/settings/tokens +# Scopes needed: repo, read:org +# +# echo "" | gh auth login --with-token + + +# ============================================================================= +# 4. Claude Code skills +# ============================================================================= +# +# Clone shared skills into the global skills directory. +# These will be available in all projects. + +# mkdir -p ~/.claude/skills +# git clone git@github.com:your-org/claude-skills.git ~/.claude/skills/your-org-skills + +# You can also install skills into the current project's directory. +# These will only be available when working in that project. + +# PROJECT_DIR="$(pwd)" +# mkdir -p "$PROJECT_DIR/.claude/skills" +# git clone git@github.com:your-org/project-skills.git "$PROJECT_DIR/.claude/skills/project-skills" + + +# ============================================================================= +# 5. MCP servers +# ============================================================================= +# +# Add MCP servers available to Claude Code in all projects (--scope user). +# +# claude mcp add --scope user my-mcp-server npx -y my-mcp-server@latest + + +# ============================================================================= +# 6. Claude Code status line +# ============================================================================= +# +# Install a custom status line command in ~/.claude/settings.json. +# The command output is displayed at the bottom of the Claude Code interface. +# +# For example, to show the current git branch: +# +# cat > /tmp/statusline-patch.json << 'PATCH' +# {"statusLine": {"command": "git branch --show-current 2>/dev/null || echo ''"}} +# PATCH +# +# if [ -f ~/.claude/settings.json ]; then +# jq -s '.[0] * .[1]' ~/.claude/settings.json /tmp/statusline-patch.json > /tmp/settings-merged.json +# mv /tmp/settings-merged.json ~/.claude/settings.json +# else +# mkdir -p ~/.claude +# cp /tmp/statusline-patch.json ~/.claude/settings.json +# fi +# rm -f /tmp/statusline-patch.json