diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0b70821
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,21 @@
+# Copy this file to .env.local and fill in your values.
+# bun loads .env.local automatically when you run `bun run forge`.
+
+# === LLM provider for the builder ===
+# Default : Mistral Small via the Mistral cloud API.
+# Other examples below — uncomment one set.
+
+# --- Mistral cloud ---
+FORGE_BASE_URL=https://api.mistral.ai/v1
+FORGE_MODEL=mistral-small-latest
+FORGE_API_KEY=
+
+# --- OpenAI cloud ---
+# FORGE_BASE_URL=https://api.openai.com/v1
+# FORGE_MODEL=gpt-4o-mini
+# FORGE_API_KEY=sk-...
+
+# --- Local MLX server (mlx_lm.server on :8080) ---
+# FORGE_BASE_URL=http://127.0.0.1:8080/v1
+# FORGE_MODEL=mlx-community/Qwen2.5-7B-Instruct-4bit
+# FORGE_API_KEY=not-needed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bfe17ae..9fdd1df 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contributing to Agent Forge
-Thanks for your interest in this project. It is currently in **POC phase** — code contributions will open after the P1 milestone.
+Thanks for your interest in this project. It is currently in **POC phase** — code contributions will open after the P9 milestone (POC validated end-to-end). In the meantime, feedback and ideas via [issues](https://github.com/garniergeorges/agent-forge/issues) are very welcome.
## Project setup
diff --git a/README.fr.md b/README.fr.md
index 2f5c015..167913f 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -7,7 +7,7 @@
**Forgez, lancez et orchestrez des agents LLM en sandbox.**
[](./LICENSE)
- 
+ 

🇫🇷 Version française · [🇬🇧 English version](./README.md)
@@ -16,43 +16,151 @@
---
-> 🚧 **Statut — Phase de conception.** L'architecture est posée, le mockup interactif est fonctionnel. **Pas encore de code de production.** Le premier jalon exécutable (P1 — *Hello agent in Docker*) est le prochain livrable. Mettez une ⭐ pour suivre l'évolution.
+> 🚧 **Statut — POC, jalon P3 atteint.** Vous pouvez désormais lancer `bun run forge`, décrire un agent en français ou en anglais, regarder le builder rédiger l'`AGENT.md`, l'approuver, puis demander au builder d'exécuter cet agent — il monte son propre container Docker, streame la sortie, puis détruit la sandbox. Prochain jalon : P4 — tools natifs (Bash, FileRead, FileEdit, FileWrite, Grep, Glob).
## Qu'est-ce qu'Agent Forge ?
-Une CLI conversationnelle qui vous permet de **décrire** un projet logiciel en langage naturel et regarder une équipe d'agents LLM spécialisés le **construire** — chaque agent isolé dans un container Docker, coordonnés via [`claude-presence`](https://github.com/garniergeorges/claude-presence), avec une visualisation pixel art directement dans votre terminal.
+Une CLI conversationnelle où vous **décrivez** le logiciel à construire et un **builder LLM** conçoit, écrit et lance les agents qui le produisent — chaque agent isolé dans son propre container Docker, dans une TUI pixel art bâtie sur [Ink](https://github.com/vadimdemedes/ink).
+
+Le builder est la seule surface conversationnelle. Les sous-agents sont créés à la demande dans des sandboxes jetables ; les agents persistants et les teams multi-agents arrivent plus tard (P5 et P7).
-## Statut
+## Statut — ce qui marche aujourd'hui
+
+| Jalon | Périmètre | État |
+|---|---|---|
+| **P1** | Hello agent dans Docker (script host ↔ container ↔ round-trip LLM) | ✅ fait |
+| **P2** | CLI conversationnelle (REPL Ink, EN/FR, slash commands, switch provider) | ✅ fait |
+| **P3** | Le builder écrit l'`AGENT.md`, demande la permission, lance l'agent dans un container neuf, streame la sortie | ✅ fait |
+| P4 | Six tools natifs (Bash, FileRead, FileEdit, FileWrite, Grep, Glob) utilisables depuis la sandbox | suivant |
+| P5 | Sandbox durci + extraction d'artefacts vers le host | |
+| P6 | Skills enrichis (scaffolding projet, audits, fixes) | |
+| P7 | `TEAM.md` — exécutions multi-agents coordonnées | |
+| P8 | Dashboard pixel art (activité agents en direct) | |
+| P9 | ★ POC validé : démo Next.js + Laravel + QA de bout en bout | |
+
+## Démarrage rapide
+
+```bash
+# 1. Builder l'image Docker base (une seule fois, ~600 Mo, ~1 min)
+bash scripts/docker/build-base.sh
+
+# 2. Installer les deps JS et builder le bundle runtime
+bun install
+bun run --cwd packages/runtime build
+
+# 3. Configurer le provider LLM (cloud — recommandé)
+cp .env.example .env
+# éditer .env et renseigner FORGE_API_KEY=…
-🚧 **Phase POC.** Phase de conception active. **Pas encore de code de production.**
+# 4. Lancer le REPL builder
+bun run forge
+```
-Un mockup interactif complet existe (`demo-sprites/`), et l'architecture est entièrement préparée. Le premier jalon exécutable (P1 — *Hello agent in Docker*) arrive ensuite.
+Au premier lancement la CLI vous demande la langue (EN / FR), puis vous laisse au prompt conversationnel.
-## Tester le mockup
+### À quoi ressemble l'écran
+
+```
+ ▌▌ MISSION CONTROL ▐▐ 1 action
+
+ ╭──────────────────────────────────────────────────────────────╮
+ │ [DONE] write agents/haiku-writer/AGENT.md │
+ │ │
+ │ 1 --- │
+ │ 2 name: haiku-writer │
+ │ 3 description: Écrit un haïku en 5-7-5. │
+ │ 4 sandbox: │
+ │ 5 image: agent-forge/base:latest │
+ │ 6 timeout: 60s │
+ │ 7 maxTurns: 1 │
+ │ 8 --- │
+ │ … │
+ │ ✓ written /Users/vous/.agent-forge/agents/haiku-writer/… │
+ ╰──────────────────────────────────────────────────────────────╯
+
+ ▀▀▀
+ ▀▀▀▀
+ ▄ ▄ ▄
+
+ ▌▌ AGENT FORGE ▐▐ v0.0.0 accueil · nouvelle session session : nouvelle · model: mistral-small-latest
+ ─────────────────────────────────────────────────────────────────
+ ❯ crée un agent qui écrit des haïkus
+ ▸ Fait. L'agent est forgé. Je le lance ?
+
+ ❯ décrivez ce que vous voulez construire…
+ [⏎] envoyer [PgUp/PgDn] scroll [Ctrl+E] live [/help] commandes
+```
+
+La TUI est divisée en deux zones strictes :
+
+- **Zone haute (Mission Control)** — chaque action concrète du builder. Écritures de fichier, lancements de container, sortie d'agent. Coloration syntaxique, code couleur par statut (orange = en attente, vert = fait, rouge = échoué).
+- **Zone basse (Conversation)** — uniquement l'échange en langage naturel entre vous et le builder. Pas de code, pas de logs, pas d'internes.
+
+## Configuration du provider
+
+Agent Forge parle à n'importe quel endpoint chat **compatible OpenAI** via le [Vercel AI SDK](https://sdk.vercel.ai). Choisissez ce qui vous convient.
+
+### Mistral cloud (défaut — recommandé)
+
+Récupérez une clé sur . Le tier gratuit suffit pour le POC.
+
+```dotenv
+FORGE_BASE_URL=https://api.mistral.ai/v1
+FORGE_API_KEY=…
+FORGE_MODEL=mistral-small-latest
+```
+
+### OpenAI cloud
+
+```dotenv
+FORGE_BASE_URL=https://api.openai.com/v1
+FORGE_API_KEY=sk-…
+FORGE_MODEL=gpt-4o-mini
+```
+
+### Serveur MLX local (Apple Silicon, gratuit, sans clé)
```bash
-node demo-sprites/forge-mockup-v3.mjs
+python3 -m venv ~/.agent-forge/mlx-venv
+~/.agent-forge/mlx-venv/bin/pip install mlx-lm
+~/.agent-forge/mlx-venv/bin/hf download mlx-community/Qwen2.5-7B-Instruct-4bit
+~/.agent-forge/mlx-venv/bin/mlx_lm.server \
+ --model mlx-community/Qwen2.5-7B-Instruct-4bit --port 8080
```
-Parcourt les 7 écrans du produit : splash, welcome, chat, mission control, focus, hangar, completion. **Aucun appel LLM réel** — démo scriptée pour la validation UX.
+```dotenv
+FORGE_BASE_URL=http://host.docker.internal:8080/v1
+FORGE_MODEL=mlx-community/Qwen2.5-7B-Instruct-4bit
+```
-Appuyez sur `SPACE` pour avancer, `B` pour reculer, `R` pour redémarrer.
+Vous pouvez aussi switcher à la volée depuis le REPL : `/provider mistral`, `/model mistral-large-latest`, `/provider mlx`.
-## Concept
+## Une session typique
-Agent Forge unifie cinq primitives :
+1. **Décrire** — `> crée un agent qui écrit des haïkus sur un sujet donné`
+2. **Approuver** — le builder rédige un `AGENT.md`, Mission Control l'affiche, une fenêtre de permission demande `[Y] approuver [N] refuser [D] aperçu`. Tapez `Y`.
+3. **Lancer** — `> lance haiku-writer sur Docker`. Même fenêtre, même `Y`.
+4. **Regarder** — Mission Control streame la sortie du container en direct, le badge passe à `[DONE]`, le container est supprimé (`docker run --rm`).
-1. **CLI conversationnelle** — un builder LLM avec qui dialoguer
-2. **Skills** — instructions modulaires invocables à la demande
-3. **Tools** — capacités natives ou MCP appelables par l'agent
-4. **MCP** — extensibilité via Model Context Protocol
-5. **Teams multi-agents** — agents coordonnés dans une sandbox Docker partagée
+Chaque session est persistée dans `~/.agent-forge/sessions//transcript.jsonl`. `/sessions` liste les sessions, `/session` affiche l'id courante.
-Chaque agent tourne dans un container Docker isolé avec des limites de ressources strictes, une politique réseau, et un filesystem racine en lecture seule. La coordination inter-agents utilise [`claude-presence`](https://github.com/garniergeorges/claude-presence) MCP (broadcast + verrous coopératifs).
+## Slash commands utiles
+
+```
+/help affiche toutes les commandes
+/clear vide la vue (le contexte LLM est conservé)
+/reset vide la vue ET le contexte LLM
+/lang en|fr change la langue de l'interface
+/provider mlx | openai | anthropic | mistral
+/model change de modèle sur le provider actif
+/session affiche l'id de la session courante
+/sessions liste les sessions persistées
+/exit quitte
+```
## Architecture
@@ -61,34 +169,33 @@ Chaque agent tourne dans un container Docker isolé avec des limites de ressourc
│ HOST │
│ │
│ forge CLI (= le builder LLM) │
-│ ├─ skills internes │
-│ ├─ tools (Docker, Files) │
-│ └─ orchestre │
+│ ├─ TUI Ink (Mission Control + conversation) │
+│ ├─ Parser AGENT.md (frontmatter validé par Zod) │
+│ ├─ Tool FileWrite (sandboxé sous ~/.agent-forge) │
+│ └─ Tool DockerLaunch (lance des containers one-shot) │
└────────────────────┬────────────────────────────────────────┘
- │ docker run
+ │ docker run --rm -i
▼
┌─────────────────────────────────────────────────────────────┐
-│ CONTAINER (un par team) │
-│ agent-forge/fullstack:latest │
-│ │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ backend │ │ frontend │ │ qa │ │
-│ │ Process │ │ Process │ │ Process │ │
-│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
-│ └─── claude-presence MCP ───┘ │
+│ CONTAINER (un par run d'agent, jetable) │
+│ agent-forge/base:latest │
│ │
-│ /workspace/ filesystem partagé │
+│ Runtime Node ── lit /agent/AGENT.md comme system prompt │
+│ └─ reçoit le prompt utilisateur via stdin │
+│ └─ streame la réponse du LLM sur stdout │
└─────────────────────────────────────────────────────────────┘
```
+Les agents persistants (`docker exec`) et les teams multi-agents (un container, plusieurs process coordonnés via [`claude-presence`](https://github.com/garniergeorges/claude-presence)) arrivent en P5 et P7.
+
## Stack technique
-- **TypeScript** + runtime **Bun**
+- **TypeScript** + runtime **Bun** + **Bun workspaces**
- **Ink** (React pour terminaux) pour la TUI
-- `@anthropic-ai/sdk` — fournisseur LLM
-- `@modelcontextprotocol/sdk` — intégration MCP
-- `dockerode` — contrôle Docker
-- `zod` — validation de schémas
+- **Vercel AI SDK** (`ai`, `@ai-sdk/openai`) — appels LLM provider-agnostic
+- `zod` — validation du frontmatter `AGENT.md`
+- CLI `docker` via `child_process.spawn` (Bun + dockerode bloque sur l'attach)
+- `biome` pour lint/format
- Licence Apache 2.0
## Structure du repo
@@ -96,50 +203,23 @@ Chaque agent tourne dans un container Docker isolé avec des limites de ressourc
```
agent-forge/
├── packages/
-│ ├── core/ # builder LLM, Docker, interface tools, types
-│ ├── cli/ # le binaire `forge`
-│ ├── runtime/ # tourne dans le container
-│ └── tools-core/ # tools natifs (Bash, Read, Edit, ...)
-├── docker/ # Dockerfiles (base, fullstack)
-├── examples/ # exemples de teams et d'agents
-├── docs/ # documentation d'architecture
-├── scripts/ # helpers build/CI
-├── demo-sprites/ # mockup interactif (déjà exécutable)
+│ ├── core/ # builder LLM, schéma AGENT.md, config provider
+│ ├── cli/ # le binaire `forge` (REPL Ink + Mission Control)
+│ ├── runtime/ # bundle exécuté dans chaque container d'agent
+│ └── tools-core/ # FileWrite, DockerLaunch, …
+├── docker/ # Dockerfiles
+├── scripts/ # helpers de build (docker, hooks)
+├── demo-sprites/ # mockup interactif (référence UX)
└── assets/ # images du README
```
-## Roadmap (POC)
-
-```
-P1 Hello agent dans Docker
-P2 CLI conversationnelle (minimale)
-P3 Le builder lance l'agent qu'il vient de concevoir
-P4 Tools natifs (Bash, FileRead, FileEdit, FileWrite, Grep, Glob)
-P5 Sandbox durci + extraction des artefacts
-P6 Skills builder enrichis
-P7 TEAM.md (coordination multi-agents)
-P8 Dashboard TUI pixel art
-P9 ★ POC validé : démo Next.js + Laravel + QA fonctionnelle de bout en bout
-```
-
-Après le POC :
-
-```
-V1 Serveur API WebSocket
-V2 Auth + persistence d'état
-V3 SDK Python sur PyPI
-V4 Multi-tenant (si nécessaire)
-V5 Adaptateur serveur MCP
-V6 Release 1.0
-```
-
## Genèse
L'architecture de ce projet a été informée par une analyse technique publique d'un coding-agent de référence existant. L'analyse (~6 400 lignes, 13 documents) a extrait les patterns à conserver et les pièges à éviter. **Aucun code n'a été copié** — seuls les patterns architecturaux ont inspiré la conception.
## Contribuer
-Le projet est en phase de conception active. Les retours et idées sont les bienvenus via les [issues](https://github.com/garniergeorges/agent-forge/issues). Les contributions de code seront ouvertes après la livraison du jalon P1.
+Le projet est en phase POC active. Les retours et idées sont les bienvenus via les [issues](https://github.com/garniergeorges/agent-forge/issues). Les contributions de code seront ouvertes après le jalon P9 (POC validé).
## Licence
diff --git a/README.md b/README.md
index 6185434..c0f7711 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
**Forge, run, and orchestrate sandboxed LLM agents.**
[](./LICENSE)
- 
+ 

🇬🇧 English version · [🇫🇷 Version française](./README.fr.md)
@@ -16,43 +16,151 @@
---
-> 🚧 **Status — Design phase.** Architecture is complete, the interactive mockup is runnable. **No production code yet.** First runnable milestone (P1 — *Hello agent in Docker*) is the next deliverable. Star the repo to follow along.
+> 🚧 **Status — POC, milestone P3 reached.** You can now `bun run forge`, describe an agent in plain English or French, watch the builder draft the `AGENT.md`, approve it, then ask the builder to run that agent — it spins up its own Docker container, streams the output, and tears the sandbox down. Next milestone : P4 — native tools (Bash, FileRead, FileEdit, FileWrite, Grep, Glob).
## What is Agent Forge ?
-A conversational CLI that lets you **describe** a software project in natural language and watch a team of specialized LLM agents **build it** — each agent isolated in a Docker container, coordinating via [`claude-presence`](https://github.com/garniergeorges/claude-presence), with a pixel-art visualization in your terminal.
+A conversational CLI where you **describe** the software you want and a **builder LLM** designs, writes and launches the agents that produce it — each agent isolated in its own Docker container, with a pixel-art TUI built on [Ink](https://github.com/vadimdemedes/ink).
+
+The builder is the only conversational surface. Sub-agents are spawned on demand in disposable sandboxes ; long-running agents and multi-agent teams come later (P5 and P7).
-## Status
+## Status — what works today
+
+| Milestone | Scope | State |
+|---|---|---|
+| **P1** | Hello agent in Docker (host script ↔ container ↔ LLM round-trip) | ✅ done |
+| **P2** | Conversational CLI (REPL Ink, EN/FR, slash commands, provider switch) | ✅ done |
+| **P3** | Builder writes `AGENT.md`, asks for permission, launches the agent in a fresh container, streams its output | ✅ done |
+| P4 | Six native tools (Bash, FileRead, FileEdit, FileWrite, Grep, Glob) usable from inside the sandbox | next |
+| P5 | Hardened sandbox + artifact extraction back to host | |
+| P6 | Skills enriched (project scaffolding, audits, fixes) | |
+| P7 | `TEAM.md` — coordinated multi-agent runs | |
+| P8 | Pixel-art dashboard (live agent activity) | |
+| P9 | ★ POC validated : Next.js + Laravel + QA demo end-to-end | |
+
+## Quick start
+
+```bash
+# 1. Build the base Docker image (one-time, ~600 MB, ~1 min)
+bash scripts/docker/build-base.sh
+
+# 2. Install JS deps and build the runtime bundle
+bun install
+bun run --cwd packages/runtime build
+
+# 3. Configure your LLM provider (cloud — recommended)
+cp .env.example .env
+# edit .env and set FORGE_API_KEY=…
-🚧 **Phase POC.** Active design phase. **No production code yet.**
+# 4. Launch the builder REPL
+bun run forge
+```
-A complete interactive mockup exists (`demo-sprites/`), and the architecture is fully scaffolded. The first runnable milestone (P1 — *Hello agent in Docker*) comes next.
+On the first run the CLI asks you to pick a language (EN / FR), then drops you into the conversational prompt.
-## Try the mockup
+### What the screen looks like
+
+```
+ ▌▌ MISSION CONTROL ▐▐ 1 action
+
+ ╭──────────────────────────────────────────────────────────────╮
+ │ [DONE] write agents/haiku-writer/AGENT.md │
+ │ │
+ │ 1 --- │
+ │ 2 name: haiku-writer │
+ │ 3 description: Écrit un haïku en 5-7-5. │
+ │ 4 sandbox: │
+ │ 5 image: agent-forge/base:latest │
+ │ 6 timeout: 60s │
+ │ 7 maxTurns: 1 │
+ │ 8 --- │
+ │ … │
+ │ ✓ written /Users/you/.agent-forge/agents/haiku-writer/… │
+ ╰──────────────────────────────────────────────────────────────╯
+
+ ▀▀▀
+ ▀▀▀▀
+ ▄ ▄ ▄
+
+ ▌▌ AGENT FORGE ▐▐ v0.0.0 home · new session session : new · model: mistral-small-latest
+ ─────────────────────────────────────────────────────────────────
+ ❯ create an agent that writes haikus
+ ▸ Done. The agent is forged. Want me to run it ?
+
+ ❯ describe what you want to build…
+ [⏎] send [PgUp/PgDn] scroll [Ctrl+E] live [/help] commands
+```
+
+The TUI is split in two strict zones :
+
+- **Top zone (Mission Control)** — every concrete action the builder takes. File writes, container launches, agent output. Syntax-highlighted, status-coloured (orange = pending, green = done, red = failed).
+- **Bottom zone (Conversation)** — only the natural-language exchange between you and the builder. No code, no logs, no internals.
+
+## Provider configuration
+
+Agent Forge talks to any **OpenAI-compatible** chat endpoint via the [Vercel AI SDK](https://sdk.vercel.ai). Pick what fits.
+
+### Mistral cloud (default — recommended)
+
+Get a key at . The free tier is enough for the POC.
+
+```dotenv
+FORGE_BASE_URL=https://api.mistral.ai/v1
+FORGE_API_KEY=…
+FORGE_MODEL=mistral-small-latest
+```
+
+### OpenAI cloud
+
+```dotenv
+FORGE_BASE_URL=https://api.openai.com/v1
+FORGE_API_KEY=sk-…
+FORGE_MODEL=gpt-4o-mini
+```
+
+### Local MLX server (Apple Silicon, free, no key)
```bash
-node demo-sprites/forge-mockup-v3.mjs
+python3 -m venv ~/.agent-forge/mlx-venv
+~/.agent-forge/mlx-venv/bin/pip install mlx-lm
+~/.agent-forge/mlx-venv/bin/hf download mlx-community/Qwen2.5-7B-Instruct-4bit
+~/.agent-forge/mlx-venv/bin/mlx_lm.server \
+ --model mlx-community/Qwen2.5-7B-Instruct-4bit --port 8080
```
-Walks through the 7 screens of the product : splash, welcome, chat, mission control, focus, hangar, completion. **No real LLM calls** — scripted demo for UX validation.
+```dotenv
+FORGE_BASE_URL=http://host.docker.internal:8080/v1
+FORGE_MODEL=mlx-community/Qwen2.5-7B-Instruct-4bit
+```
-Press `SPACE` to advance, `B` to go back, `R` to restart.
+You can also switch on the fly inside the REPL : `/provider mistral`, `/model mistral-large-latest`, `/provider mlx`.
-## Concept
+## A typical session
-Agent Forge unifies five primitives :
+1. **Describe** — `> create an agent that writes haikus on a given topic`
+2. **Approve** — the builder drafts an `AGENT.md`, Mission Control shows it, a permission dialog asks `[Y] approve [N] decline [D] preview`. Press `Y`.
+3. **Run** — `> run haiku-writer on Docker`. Same dialog, same `Y`.
+4. **Watch** — Mission Control streams the container output live, the badge flips to `[DONE]`, the container is removed (`docker run --rm`).
-1. **Conversational CLI** — a builder LLM you dialogue with
-2. **Skills** — modular instructions invocable on demand
-3. **Tools** — native or MCP capabilities your agent can call
-4. **MCP** — extensibility via Model Context Protocol
-5. **Multi-agent teams** — coordinated agents in a shared Docker sandbox
+Every session is persisted to `~/.agent-forge/sessions//transcript.jsonl`. Use `/sessions` to list, `/session` to show the current id.
-Every agent runs in an isolated Docker container with strict resource limits, network policy, and read-only root filesystem. Inter-agent coordination uses [`claude-presence`](https://github.com/garniergeorges/claude-presence) MCP (broadcast + advisory locks).
+## Useful slash commands
+
+```
+/help show all commands
+/clear clear the view (LLM context kept)
+/reset clear view AND LLM context
+/lang en|fr switch UI language
+/provider mlx | openai | anthropic | mistral
+/model switch model on the active provider
+/session show the current session id
+/sessions list persisted sessions
+/exit quit
+```
## Architecture
@@ -61,34 +169,33 @@ Every agent runs in an isolated Docker container with strict resource limits, ne
│ HOST │
│ │
│ forge CLI (= the builder LLM) │
-│ ├─ skills internes │
-│ ├─ tools (Docker, Files) │
-│ └─ orchestrates │
+│ ├─ Ink TUI (Mission Control + conversation) │
+│ ├─ AGENT.md parser (Zod-validated frontmatter) │
+│ ├─ FileWrite tool (sandboxed under ~/.agent-forge) │
+│ └─ DockerLaunch tool (spawns one-shot containers) │
└────────────────────┬────────────────────────────────────────┘
- │ docker run
+ │ docker run --rm -i
▼
┌─────────────────────────────────────────────────────────────┐
-│ CONTAINER (per team) │
-│ agent-forge/fullstack:latest │
-│ │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ backend │ │ frontend │ │ qa │ │
-│ │ Process │ │ Process │ │ Process │ │
-│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
-│ └─── claude-presence MCP ───┘ │
+│ CONTAINER (one per agent run, disposable) │
+│ agent-forge/base:latest │
│ │
-│ /workspace/ shared filesystem │
+│ Node runtime ── reads /agent/AGENT.md as system prompt │
+│ └─ pipes the user prompt through stdin │
+│ └─ streams the LLM answer to stdout │
└─────────────────────────────────────────────────────────────┘
```
+Long-running agents (`docker exec`) and multi-agent teams (one container, many processes coordinating via [`claude-presence`](https://github.com/garniergeorges/claude-presence)) land in P5 and P7.
+
## Tech stack
-- **TypeScript** + **Bun** runtime
+- **TypeScript** + **Bun** runtime + **Bun workspaces**
- **Ink** (React for terminals) for the TUI
-- `@anthropic-ai/sdk` — LLM provider
-- `@modelcontextprotocol/sdk` — MCP integration
-- `dockerode` — Docker control
-- `zod` — schema validation
+- **Vercel AI SDK** (`ai`, `@ai-sdk/openai`) — provider-agnostic LLM calls
+- `zod` — `AGENT.md` frontmatter validation
+- `docker` CLI via `child_process.spawn` (Bun + dockerode hangs on attach)
+- `biome` for lint/format
- Apache 2.0 license
## Repository structure
@@ -96,50 +203,23 @@ Every agent runs in an isolated Docker container with strict resource limits, ne
```
agent-forge/
├── packages/
-│ ├── core/ # builder LLM, Docker, tool interface, types
-│ ├── cli/ # the `forge` binary
-│ ├── runtime/ # runs inside the container
-│ └── tools-core/ # native tools (Bash, Read, Edit, ...)
-├── docker/ # Dockerfiles (base, fullstack)
-├── examples/ # sample teams and agents
-├── docs/ # architecture docs
-├── scripts/ # build/CI helpers
-├── demo-sprites/ # interactive mockup (already runnable)
+│ ├── core/ # builder LLM, AGENT.md schema, provider config
+│ ├── cli/ # the `forge` binary (Ink REPL + Mission Control)
+│ ├── runtime/ # bundle that runs inside each agent container
+│ └── tools-core/ # FileWrite, DockerLaunch, …
+├── docker/ # Dockerfiles
+├── scripts/ # build helpers (docker, hooks)
+├── demo-sprites/ # interactive mockup (UX reference)
└── assets/ # README images
```
-## Roadmap (POC)
-
-```
-P1 Hello agent in Docker
-P2 Conversational CLI (minimal)
-P3 Builder launches the agent it just designed
-P4 Native tools (Bash, FileRead, FileEdit, FileWrite, Grep, Glob)
-P5 Hardened sandbox + artifact extraction
-P6 Builder skills enriched
-P7 TEAM.md (multi-agent coordination)
-P8 Pixel-art TUI dashboard
-P9 ★ POC validated : Next.js + Laravel + QA demo works end-to-end
-```
-
-After POC :
-
-```
-V1 WebSocket API server
-V2 Auth + state persistence
-V3 Python SDK on PyPI
-V4 Multi-tenant (if needed)
-V5 MCP server adapter
-V6 Release 1.0
-```
-
## Genesis
This project's architecture was informed by a public technical analysis of an existing reference coding-agent. The analysis (~6 400 lines, 13 documents) extracted patterns worth keeping and pitfalls to avoid. **No code was copied** — only architectural patterns inspired the design.
## Contributing
-Project is in active design phase. Feedback and ideas welcome via [issues](https://github.com/garniergeorges/agent-forge/issues). Code contributions will open after the P1 milestone lands.
+Project is in active POC phase. Feedback and ideas welcome via [issues](https://github.com/garniergeorges/agent-forge/issues). Code contributions will open after the P9 milestone (POC validated).
## License
diff --git a/assets/agent-forge.gif b/assets/agent-forge.gif
index 8cfffd6..a65b7d5 100644
Binary files a/assets/agent-forge.gif and b/assets/agent-forge.gif differ
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..3504b9d
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,560 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "@agent-forge/monorepo",
+ "devDependencies": {
+ "@biomejs/biome": "^1.9.0",
+ "@types/bun": "latest",
+ "typescript": "^5.6.0",
+ },
+ },
+ "packages/cli": {
+ "name": "@agent-forge/cli",
+ "version": "0.0.0",
+ "bin": {
+ "forge": "./dist/cli.mjs",
+ },
+ "dependencies": {
+ "@agent-forge/core": "workspace:*",
+ "@agent-forge/tools-core": "workspace:*",
+ "chalk": "^5.3.0",
+ "commander": "^12.1.0",
+ "ink": "^5.0.0",
+ "ink-text-input": "^6.0.0",
+ "react": "^18.3.0",
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "ink-testing-library": "^4.0.0",
+ },
+ },
+ "packages/core": {
+ "name": "@agent-forge/core",
+ "version": "0.0.0",
+ "dependencies": {
+ "@ai-sdk/openai": "^1.0.0",
+ "@modelcontextprotocol/sdk": "^1.0.0",
+ "ai": "^4.0.0",
+ "dockerode": "^4.0.0",
+ "yaml": "^2.6.0",
+ "zod": "^3.23.0",
+ },
+ "devDependencies": {
+ "@types/dockerode": "^3.3.0",
+ },
+ },
+ "packages/runtime": {
+ "name": "@agent-forge/runtime",
+ "version": "0.0.0",
+ "bin": {
+ "forge-runtime": "./dist/runtime.mjs",
+ },
+ "dependencies": {
+ "@agent-forge/core": "workspace:*",
+ "@agent-forge/tools-core": "workspace:*",
+ "@ai-sdk/openai": "^1.0.0",
+ "@modelcontextprotocol/sdk": "^1.0.0",
+ "ai": "^4.0.0",
+ "yaml": "^2.6.0",
+ },
+ },
+ "packages/tools-core": {
+ "name": "@agent-forge/tools-core",
+ "version": "0.0.0",
+ "dependencies": {
+ "@agent-forge/core": "workspace:*",
+ "zod": "^3.23.0",
+ },
+ },
+ },
+ "trustedDependencies": [
+ "@biomejs/biome",
+ ],
+ "packages": {
+ "@agent-forge/cli": ["@agent-forge/cli@workspace:packages/cli"],
+
+ "@agent-forge/core": ["@agent-forge/core@workspace:packages/core"],
+
+ "@agent-forge/runtime": ["@agent-forge/runtime@workspace:packages/runtime"],
+
+ "@agent-forge/tools-core": ["@agent-forge/tools-core@workspace:packages/tools-core"],
+
+ "@ai-sdk/openai": ["@ai-sdk/openai@1.3.24", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q=="],
+
+ "@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
+
+ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
+
+ "@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
+
+ "@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
+
+ "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="],
+
+ "@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
+
+ "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
+
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
+
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
+
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
+
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
+
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
+
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
+
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
+
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
+
+ "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
+
+ "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
+
+ "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
+
+ "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
+
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
+
+ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
+
+ "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
+
+ "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
+
+ "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
+
+ "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
+
+ "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
+
+ "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
+
+ "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
+
+ "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
+
+ "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
+
+ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
+
+ "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
+
+ "@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
+
+ "@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
+
+ "@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="],
+
+ "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
+
+ "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
+
+ "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
+
+ "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
+
+ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+
+ "ai": ["ai@4.3.19", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q=="],
+
+ "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
+
+ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
+
+ "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
+
+ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
+ "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
+ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
+
+ "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
+
+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
+ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
+
+ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
+
+ "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
+
+ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
+
+ "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
+
+ "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
+
+ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
+
+ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+
+ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+
+ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+
+ "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
+
+ "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
+
+ "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="],
+
+ "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
+
+ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
+
+ "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="],
+
+ "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+ "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="],
+
+ "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
+
+ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
+
+ "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="],
+
+ "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
+
+ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
+
+ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
+
+ "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+
+ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
+
+ "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
+
+ "docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="],
+
+ "dockerode": ["dockerode@4.0.12", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw=="],
+
+ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
+ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
+
+ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+
+ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
+
+ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
+
+ "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
+
+ "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+
+ "es-toolkit": ["es-toolkit@1.46.0", "", {}, "sha512-IToJ6ct9OLl5zz6WsC/1vZEwfSZ7Myil+ygl5Tf30Xjn9AEkzNB4kqp2G7VUJKF1DtTx/ra5M5KLlXvzOg51BA=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
+
+ "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
+
+ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
+
+ "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
+
+ "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
+
+ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
+
+ "express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="],
+
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
+
+ "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
+
+ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
+
+ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
+
+ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
+
+ "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
+
+ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
+ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
+ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
+
+ "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="],
+
+ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
+
+ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+
+ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
+ "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
+
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
+ "ink": ["ink@5.2.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.29.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg=="],
+
+ "ink-testing-library": ["ink-testing-library@4.0.0", "", { "peerDependencies": { "@types/react": ">=18.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q=="],
+
+ "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="],
+
+ "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
+
+ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
+
+ "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
+
+ "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="],
+
+ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
+
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
+
+ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
+ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
+
+ "jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
+
+ "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
+
+ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
+
+ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+
+ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
+ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
+
+ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
+
+ "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
+ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+
+ "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
+
+ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "nan": ["nan@2.26.2", "", {}, "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw=="],
+
+ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+
+ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
+
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
+ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+
+ "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
+
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
+ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
+
+ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
+
+ "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
+
+ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
+
+ "protobufjs": ["protobufjs@7.5.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg=="],
+
+ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
+
+ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
+
+ "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
+
+ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
+
+ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
+
+ "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
+
+ "react-reconciler": ["react-reconciler@0.29.2", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="],
+
+ "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+
+ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
+
+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
+
+ "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
+
+ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
+
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
+ "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+
+ "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
+
+ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
+
+ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
+
+ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
+
+ "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
+
+ "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
+
+ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+
+ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
+
+ "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
+
+ "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
+
+ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
+
+ "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
+
+ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+
+ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+
+ "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
+ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
+
+ "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
+
+ "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
+
+ "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
+
+ "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
+
+ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
+
+ "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
+
+ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
+
+ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
+
+ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
+
+ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
+
+ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+
+ "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
+
+ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
+
+ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
+
+ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
+
+ "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
+
+ "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
+
+ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+
+ "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
+
+ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
+
+ "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
+
+ "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="],
+
+ "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+
+ "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
+
+ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+ "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+ "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
+ "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+ "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
+ "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+ "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
+ "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+ "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ }
+}
diff --git a/docker/base.Dockerfile b/docker/base.Dockerfile
index 594cec3..a0e0c6f 100644
--- a/docker/base.Dockerfile
+++ b/docker/base.Dockerfile
@@ -23,7 +23,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
&& rm -rf /var/lib/apt/lists/*
# ─── Non-root user ───────────────────────────────────────────────
-RUN useradd -m -s /bin/bash agent
+RUN useradd -m -s /bin/bash agent \
+ && mkdir -p /workspace \
+ && chown agent:agent /workspace
USER agent
WORKDIR /workspace
diff --git a/package.json b/package.json
index 09d5ee2..1ed794c 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"format": "biome format --write .",
"typecheck": "bun run --filter '*' typecheck",
"mockup": "node demo-sprites/forge-mockup-v3.mjs",
+ "forge": "bun run packages/cli/src/index.tsx",
+ "poc:p1": "bun run packages/cli/src/poc-p1.ts",
"hooks:install": "bash scripts/install-hooks.sh",
"prepare": "bash scripts/install-hooks.sh"
},
@@ -40,5 +42,8 @@
"anthropic",
"mcp",
"orchestration"
+ ],
+ "trustedDependencies": [
+ "@biomejs/biome"
]
}
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 0ca623e..50d0d5f 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -1,21 +1,79 @@
# @agent-forge/cli
-The `forge` binary — conversational CLI builder.
+Binaire `forge` — CLI conversationnelle.
-## What it does
+## Ce que ça fait
-Hosts the **builder LLM** in a React/Ink REPL. The user describes what they want to build, the builder generates AGENT.md / TEAM.md files and launches Docker containers.
+Héberge le **builder LLM** dans un REPL Ink. L'utilisateur décrit ce qu'il veut, le builder génère des fichiers `AGENT.md` (P3) puis `TEAM.md` (P7) et lance les containers Docker correspondants.
-## Status
+## État
-**Phase POC.** Skeleton only. First milestone (P1) is "Hello agent in Docker".
+**Phase POC, P3 livré.** Couvre :
-## Usage (future)
+- REPL Ink bilingue EN/FR (sélecteur de langue au premier lancement)
+- Splash + preflight checks (Docker dispo, image base, runtime bundle)
+- Mission Control (zone haute) — affiche les actions du builder (write, run) avec coloration syntaxique YAML
+- Conversation (zone basse) — uniquement le langage naturel, transcripts persistés en JSONL
+- Permission dialog (Y / N / D) avant toute écriture ou lancement
+- Slash commands : `/help`, `/clear`, `/reset`, `/lang`, `/provider`, `/model`, `/session`, `/sessions`, `/exit`
+- Provider-agnostic via Vercel AI SDK (Mistral, OpenAI, MLX local…)
+- Sessions persistées dans `~/.agent-forge/sessions//transcript.jsonl`
+
+## Lancement
```bash
-forge # start the conversational REPL
-forge run # launch a saved agent
-forge teams # list teams
-forge logs # view logs
-forge kill # abort
+bun run forge # depuis la racine du monorepo
+```
+
+## Slash commands
+
+```
+/help affiche toutes les commandes
+/clear vide la vue (le contexte LLM est conservé)
+/reset vide la vue ET le contexte LLM
+/lang en|fr change la langue de l'interface
+/provider mlx | openai | anthropic | mistral
+/model change de modèle sur le provider actif
+/session affiche l'id de la session courante
+/sessions liste les sessions persistées
+/exit quitte
+```
+
+## Raccourcis clavier
+
```
+[⏎] envoyer
+[PgUp/PgDn] scroll dans le transcript
+[Ctrl+E] retour au live
+[Y/N/D] approuver / refuser / aperçu (dialog de permission)
+```
+
+## Structure
+
+```
+src/
+├── index.tsx entrée Ink
+├── App.tsx layout deux zones (Mission Control xor Splash, puis Welcome)
+├── components/
+│ ├── MissionControl.tsx zone haute, cards d'actions
+│ ├── ProviderLogo.tsx logo pixel art du provider actif
+│ ├── Welcome.tsx zone basse (header + transcript + prompt + footer)
+│ ├── ChatViewport.tsx transcript scrollable
+│ ├── ConfirmAction.tsx dialog de permission Y/N/D
+│ ├── Splash.tsx écran de boot
+│ └── syntax.ts highlighter YAML / plain
+├── hooks/
+│ ├── useChat.ts state machine (messages, actions, streaming)
+│ └── useChatContext.tsx React context wrapper
+├── actions/ types Action (write, run)
+├── builder-actions.ts parser des blocs forge:write / forge:run
+├── commands.ts slash commands
+├── config/ .env, presets providers, langue
+├── i18n/ EN/FR strings
+├── session/ persistence JSONL
+└── poc-p1.ts ancien script P1 (round-trip Docker minimal)
+```
+
+## Suite
+
+P4 — exposer six tools natifs (Bash, FileRead, FileEdit, FileWrite, Grep, Glob) au runtime, pour que les agents puissent agir sur leur propre `/workspace`.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 223b2b0..5bbaf1e 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -16,12 +16,14 @@
"dependencies": {
"@agent-forge/core": "workspace:*",
"@agent-forge/tools-core": "workspace:*",
- "ink": "^5.0.0",
- "react": "^18.3.0",
+ "chalk": "^5.3.0",
"commander": "^12.1.0",
- "chalk": "^5.3.0"
+ "ink": "^5.0.0",
+ "ink-text-input": "^6.0.0",
+ "react": "^18.3.0"
},
"devDependencies": {
- "@types/react": "^18.3.0"
+ "@types/react": "^18.3.0",
+ "ink-testing-library": "^4.0.0"
}
}
diff --git a/packages/cli/src/actions/types.ts b/packages/cli/src/actions/types.ts
new file mode 100644
index 0000000..237ee06
--- /dev/null
+++ b/packages/cli/src/actions/types.ts
@@ -0,0 +1,43 @@
+// Action = anything the builder asks the system to do (write a file,
+// launch an agent, …). Lives in MissionControl, NEVER in the chat
+// transcript. The chat transcript only has prose.
+
+export type ActionStatus =
+ | 'proposed' // builder emitted it ; awaiting user approval
+ | 'approved' // user approved ; about to run
+ | 'running' // currently executing (e.g. agent streaming)
+ | 'done' // finished successfully
+ | 'failed' // finished with error
+ | 'declined' // user declined
+
+export type WriteAction = {
+ id: string
+ kind: 'write'
+ status: ActionStatus
+ path: string
+ content: string
+ createdAt: string
+ finishedAt?: string
+ result?: { absolutePath: string } | { error: string }
+}
+
+export type RunAction = {
+ id: string
+ kind: 'run'
+ status: ActionStatus
+ agent: string
+ prompt: string
+ createdAt: string
+ finishedAt?: string
+ output: string // streamed agent stdout, accumulated
+ exitCode?: number
+ error?: string
+}
+
+export type Action = WriteAction | RunAction
+
+let counter = 0
+export function nextActionId(): string {
+ counter += 1
+ return `a${counter.toString()}`
+}
diff --git a/packages/cli/src/builder-actions.ts b/packages/cli/src/builder-actions.ts
new file mode 100644
index 0000000..8858c2a
--- /dev/null
+++ b/packages/cli/src/builder-actions.ts
@@ -0,0 +1,206 @@
+// Parser + executor for the text-structured action protocol the builder
+// emits (see packages/core/src/builder/system-prompt.ts).
+//
+// Two block types are recognized :
+//
+// ```forge:write
+// path:
+// ---
+//
+// ```
+//
+// ```forge:run
+// agent:
+// ---
+//
+// ```
+//
+// The closing fence is optional (small models sometimes forget the trailing
+// ```). When present, content stops there ; otherwise it extends to the
+// end of the message.
+
+import { parseAgentMd } from '@agent-forge/core/types'
+import { executeFileWrite } from '@agent-forge/tools-core'
+
+const FENCE_OPEN = /```forge:(write|run)\s*\n/g
+// Pattern used to strip whole forge:* blocks (open + body + optional close)
+// from the assistant text so the chat transcript stays prose-only.
+const FENCE_BLOCK = /```forge:(?:write|run)\s*\n[\s\S]*?(?:\n```|$)/g
+
+/** Remove every forge:write / forge:run block from a builder reply.
+ * Used to keep the chat transcript free of action code — actions live in
+ * the mission-control panel above. */
+export function stripActionBlocks(text: string): string {
+ return text.replace(FENCE_BLOCK, '').replace(/\n{3,}/g, '\n\n').trim()
+}
+
+export type ParsedWriteAction = {
+ kind: 'write'
+ path: string
+ content: string
+ raw: string
+}
+
+export type ParsedRunAction = {
+ kind: 'run'
+ agent: string
+ prompt: string
+ raw: string
+}
+
+export type ParsedAction = ParsedWriteAction | ParsedRunAction
+
+export type ActionParseResult =
+ | { ok: true; action: ParsedAction }
+ | { ok: false; error: string; raw: string }
+
+function splitHeaderBody(inner: string): { header: string; body: string } | null {
+ // Expected : `: \n---\n`
+ const lines = inner.split('\n')
+ if (lines.length < 3) return null
+ const headerLine = lines[0] ?? ''
+ const sep = lines[1] ?? ''
+ if (sep.trim() !== '---') return null
+ return { header: headerLine, body: lines.slice(2).join('\n') }
+}
+
+function parseWrite(inner: string, raw: string): ActionParseResult {
+ const split = splitHeaderBody(inner)
+ if (!split || !split.header.startsWith('path:')) {
+ return {
+ ok: false,
+ error: 'malformed forge:write block (expected `path: ...` then `---` then content)',
+ raw,
+ }
+ }
+ return {
+ ok: true,
+ action: {
+ kind: 'write',
+ path: split.header.slice('path:'.length).trim(),
+ content: split.body,
+ raw,
+ },
+ }
+}
+
+function parseRun(inner: string, raw: string): ActionParseResult {
+ const split = splitHeaderBody(inner)
+ if (!split || !split.header.startsWith('agent:')) {
+ return {
+ ok: false,
+ error: 'malformed forge:run block (expected `agent: ` then `---` then prompt)',
+ raw,
+ }
+ }
+ const agent = split.header.slice('agent:'.length).trim()
+ if (!/^[a-z][a-z0-9-]*$/.test(agent)) {
+ return {
+ ok: false,
+ error: `forge:run agent name must be kebab-case (got "${agent}")`,
+ raw,
+ }
+ }
+ const prompt = split.body.trim()
+ if (prompt.length === 0) {
+ return { ok: false, error: 'forge:run prompt is empty', raw }
+ }
+ return { ok: true, action: { kind: 'run', agent, prompt, raw } }
+}
+
+export function findActionBlocks(text: string): ActionParseResult[] {
+ const out: ActionParseResult[] = []
+ const matches = [...text.matchAll(FENCE_OPEN)]
+ for (let i = 0; i < matches.length; i++) {
+ const m = matches[i]
+ if (!m) continue
+ const kind = m[1] as 'write' | 'run'
+ const start = (m.index ?? 0) + m[0].length
+ const closingIdx = text.indexOf('\n```', start)
+ const end = closingIdx >= 0 ? closingIdx : text.length
+ const inner = text.slice(start, end).replace(/\s+$/, '')
+ const raw = text.slice(m.index ?? 0, end + (closingIdx >= 0 ? 4 : 0))
+ out.push(kind === 'write' ? parseWrite(inner, raw) : parseRun(inner, raw))
+ }
+ return out
+}
+
+export type WriteActionExecution = {
+ kind: 'write'
+ path: string
+ result:
+ | { ok: true; absolutePath: string }
+ | { ok: false; error: string }
+}
+
+export type RunActionExecution = {
+ kind: 'run'
+ agent: string
+ // The agent execution itself is asynchronous and streamed — handled by
+ // useChat directly via launchAgent(). This struct is only used for sync
+ // pre-flight (e.g. AGENT.md missing).
+ result: { ok: false; error: string } | { ok: true }
+}
+
+export type ActionExecution = WriteActionExecution | RunActionExecution
+
+function normalizeAgentMd(content: string): string {
+ // Small models often confuse the protocol separator (`---` between path
+ // and content) with the YAML frontmatter opener and forget to write a
+ // leading `---`. If the content looks like raw frontmatter (starts with a
+ // recognized key), prepend `---` so it parses cleanly.
+ const trimmed = content.replace(/^\s+/, '')
+ if (trimmed.startsWith('---')) return content
+ if (/^(name|description|model|sandbox|maxTurns)\s*:/m.test(trimmed)) {
+ return `---\n${content.replace(/^\s+/, '')}`
+ }
+ return content
+}
+
+const AGENT_PATH_RE = /^(agents\/[a-z][a-z0-9-]*)\/[^/]+$/
+
+function normalizeWritePath(path: string): string {
+ const match = path.match(AGENT_PATH_RE)
+ if (match && match[1]) {
+ return `${match[1]}/AGENT.md`
+ }
+ return path
+}
+
+function looksLikeAgent(path: string): boolean {
+ return path.startsWith('agents/')
+}
+
+/**
+ * Synchronously prepare and (for write) execute a parsed action.
+ * For run actions, only validates pre-conditions ; the actual launch is
+ * driven by useChat via launchAgent() so output can be streamed.
+ */
+export function executeAction(
+ action: ParsedAction,
+ options: { overwrite?: boolean } = {},
+): ActionExecution {
+ if (action.kind === 'run') {
+ return { kind: 'run', agent: action.agent, result: { ok: true } }
+ }
+
+ const path = normalizeWritePath(action.path)
+ let content = action.content
+
+ if (looksLikeAgent(path)) {
+ content = normalizeAgentMd(content)
+ try {
+ parseAgentMd(content)
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err)
+ return { kind: 'write', path, result: { ok: false, error: msg } }
+ }
+ }
+
+ const result = executeFileWrite({
+ path,
+ content,
+ overwrite: options.overwrite,
+ })
+ return { kind: 'write', path, result }
+}
diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts
new file mode 100644
index 0000000..fb03232
--- /dev/null
+++ b/packages/cli/src/commands.ts
@@ -0,0 +1,187 @@
+// Slash command parser and runtime. Returns one or more system messages to
+// display in the transcript, and may trigger side effects (clear, exit,
+// language change, provider/model switch).
+
+import {
+ getCurrentBaseURL,
+ getCurrentModelName,
+ setProviderOverride,
+} from '@agent-forge/core/builder'
+import {
+ type ForgeConfig,
+ type Lang,
+ PROVIDER_PRESETS,
+ type ProviderPreset,
+ loadConfig,
+ saveConfig,
+} from './config/store.ts'
+import { type StringKey, translate } from './i18n/strings.ts'
+import { getCurrentSession, listSessions } from './session/store.ts'
+
+export type CommandContext = {
+ lang: Lang
+ setLang: (lang: Lang) => void
+ clearChat: () => void
+ resetChat: () => void
+ exit: () => void
+}
+
+export type CommandOutput = {
+ // Lines to append to the transcript as system messages. Already translated.
+ lines: string[]
+}
+
+const PROVIDER_VALUES: ProviderPreset[] = ['mlx', 'openai', 'anthropic', 'mistral']
+
+function t(key: StringKey, lang: Lang): string {
+ return translate(key, lang)
+}
+
+function helpLines(lang: Lang): string[] {
+ return [
+ t('cmdHelpHeader', lang),
+ ` ${t('cmdHelpExit', lang)}`,
+ ` ${t('cmdHelpClear', lang)}`,
+ ` /reset ${
+ lang === 'fr' ? 'vide la vue ET le contexte LLM' : 'wipe view AND LLM context'
+ }`,
+ ` ${t('cmdHelpHelp', lang)}`,
+ ` ${t('cmdHelpLang', lang)}`,
+ ` ${t('cmdHelpModel', lang)}`,
+ ` ${t('cmdHelpProvider', lang)}`,
+ ` /session ${
+ lang === 'fr' ? 'affiche l’id de la session courante' : 'show the current session id'
+ }`,
+ ` /sessions ${
+ lang === 'fr' ? 'liste les sessions persistées' : 'list persisted sessions'
+ }`,
+ ]
+}
+
+function applyProviderPreset(name: ProviderPreset): void {
+ const preset = PROVIDER_PRESETS[name]
+ setProviderOverride({
+ baseURL: preset.baseURL,
+ model: preset.defaultModel,
+ })
+ const cfg = loadConfig()
+ const next: ForgeConfig = { ...cfg, provider: name, model: preset.defaultModel }
+ saveConfig(next)
+}
+
+function applyModel(model: string): void {
+ setProviderOverride({ model })
+ const cfg = loadConfig()
+ saveConfig({ ...cfg, model })
+}
+
+export function isCommand(input: string): boolean {
+ return input.trimStart().startsWith('/')
+}
+
+export function runCommand(
+ input: string,
+ ctx: CommandContext,
+): CommandOutput {
+ const trimmed = input.trim()
+ const [head, ...rest] = trimmed.split(/\s+/)
+ const arg = rest.join(' ').trim()
+ const lang = ctx.lang
+
+ switch (head) {
+ case '/help':
+ return { lines: helpLines(lang) }
+
+ case '/exit':
+ ctx.exit()
+ return { lines: [] }
+
+ case '/clear':
+ ctx.clearChat()
+ return {
+ lines: [
+ lang === 'fr'
+ ? 'vue effacée (le contexte LLM est conservé · /reset pour tout vider)'
+ : 'view cleared (LLM context kept · /reset to wipe everything)',
+ ],
+ }
+
+ case '/reset':
+ ctx.resetChat()
+ return {
+ lines: [
+ lang === 'fr'
+ ? 'session réinitialisée (vue + contexte vidés)'
+ : 'session reset (view + context wiped)',
+ ],
+ }
+
+ case '/lang': {
+ if (!arg) {
+ return { lines: [`${t('cmdLangCurrent', lang)} : ${ctx.lang}`] }
+ }
+ if (arg !== 'en' && arg !== 'fr') {
+ return { lines: [t('cmdLangInvalid', lang)] }
+ }
+ ctx.setLang(arg)
+ return { lines: [`${t('cmdLangChanged', arg)} (${arg})`] }
+ }
+
+ case '/model': {
+ if (!arg) {
+ return { lines: [`${t('cmdModelCurrent', lang)} : ${getCurrentModelName()}`] }
+ }
+ applyModel(arg)
+ return { lines: [`${t('cmdModelChanged', lang)} ${arg}`] }
+ }
+
+ case '/provider': {
+ if (!arg) {
+ const url = getCurrentBaseURL()
+ return { lines: [`${t('cmdProviderCurrent', lang)} : ${url}`] }
+ }
+ if (!(PROVIDER_VALUES as string[]).includes(arg)) {
+ return { lines: [t('cmdProviderInvalid', lang)] }
+ }
+ const preset = PROVIDER_PRESETS[arg as ProviderPreset]
+ const lines: string[] = []
+ if (preset.needsKey && !process.env.FORGE_API_KEY) {
+ lines.push(t('cmdProviderNeedsKey', lang))
+ }
+ applyProviderPreset(arg as ProviderPreset)
+ lines.push(`${t('cmdProviderChanged', lang)} → ${arg} (${preset.defaultModel})`)
+ return { lines }
+ }
+
+ case '/session': {
+ const s = getCurrentSession()
+ return {
+ lines: [
+ `${lang === 'fr' ? 'session' : 'session'} : ${s.id}`,
+ ` ${s.transcriptPath}`,
+ ],
+ }
+ }
+
+ case '/sessions': {
+ const records = listSessions()
+ if (records.length === 0) {
+ return { lines: [lang === 'fr' ? '(aucune session)' : '(no sessions)'] }
+ }
+ const lines = [
+ lang === 'fr'
+ ? `${records.length.toString()} session(s) trouvée(s) :`
+ : `${records.length.toString()} session(s) found :`,
+ ]
+ for (const r of records.slice(0, 10)) {
+ lines.push(
+ ` ${r.id.slice(0, 30)}${r.id.length > 30 ? '…' : ''} ${r.turns.toString()} turns`,
+ )
+ }
+ return { lines }
+ }
+
+ default:
+ return { lines: [t('cmdUnknown', lang)] }
+ }
+}
diff --git a/packages/cli/src/components/App.tsx b/packages/cli/src/components/App.tsx
new file mode 100644
index 0000000..4687356
--- /dev/null
+++ b/packages/cli/src/components/App.tsx
@@ -0,0 +1,65 @@
+// Top-level layout : two zones, fixed sizes.
+//
+// ┌──────────────┐ ← terminal top (FIXED)
+// │ Top zone │ Splash (boot) OR MissionControl (when actions exist)
+// ├──────────────┤
+// │ empty │ filler — shrinks/disappears when bottom grows
+// ├──────────────┤
+// │ Welcome │ header + transcript + (confirm dialog OR prompt) + footer
+// └──────────────┘ ← terminal bottom (FIXED)
+//
+// PgUp / PgDn / Ctrl+E scroll the chat transcript inside Welcome.
+
+import { Box, useInput, useStdin } from 'ink'
+import React from 'react'
+import { useChatContext } from '../hooks/useChatContext.tsx'
+import { useLanguage } from '../i18n/LanguageContext.tsx'
+import { MissionControl } from './MissionControl.tsx'
+import { ProviderLogo } from './ProviderLogo.tsx'
+import { Splash } from './Splash.tsx'
+import { Welcome } from './Welcome.tsx'
+
+export function App(): React.JSX.Element {
+ const { lang } = useLanguage()
+ const { isRawModeSupported } = useStdin()
+ const { scrollUp, scrollDown, scrollToBottom, pending, state } = useChatContext()
+ const rows = process.stdout.rows ?? 30
+ const cols = process.stdout.columns ?? 80
+ const hasPending = pending !== null
+ const hasActions = state.actions.length > 0
+
+ useInput(
+ (_, key) => {
+ if (key.pageUp) scrollUp()
+ else if (key.pageDown) scrollDown()
+ else if (key.ctrl && _ === 'e') scrollToBottom()
+ },
+ { isActive: isRawModeSupported && lang !== null },
+ )
+
+ return (
+
+
+ {hasActions ? : }
+
+ {/* Spacer pushes Welcome to the bottom AND parks the provider logo
+ at the bottom-right of the top zone (just above the Welcome
+ header). */}
+
+
+
+ {lang ? (
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/packages/cli/src/components/ChatViewport.tsx b/packages/cli/src/components/ChatViewport.tsx
new file mode 100644
index 0000000..e7ad765
--- /dev/null
+++ b/packages/cli/src/components/ChatViewport.tsx
@@ -0,0 +1,130 @@
+// Scrollable chat viewport. Rendered at a fixed height.
+//
+// Approach : flatten the whole transcript into a list of visual lines, then
+// take a window of those lines according to scrollOffset. This way scrolling
+// is line-by-line and never skips parts of a message.
+
+import { Box, Text } from 'ink'
+import React from 'react'
+import type { ChatTurn } from '../hooks/useChat.ts'
+import { C } from '../theme/colors.ts'
+
+const PREFIX_WIDTH = 3 // " ❯ " or " ▸ "
+const CONTINUATION = ' ' // 3 spaces, aligns with prefix
+
+type VisualLine = {
+ key: string
+ prefix: string // displayed prefix (only on first line of a turn)
+ prefixColor: string
+ text: string
+ textColor: string
+ isContinuation: boolean
+}
+
+function wrap(content: string, usable: number): string[] {
+ const out: string[] = []
+ for (const raw of content.split('\n')) {
+ if (raw.length === 0) {
+ out.push('')
+ continue
+ }
+ for (let i = 0; i < raw.length; i += usable) {
+ out.push(raw.slice(i, i + usable))
+ }
+ }
+ return out
+}
+
+function turnToLines(turn: ChatTurn, columns: number): VisualLine[] {
+ const usable = Math.max(20, columns - PREFIX_WIDTH - 2)
+ let prefix: string
+ let prefixColor: string
+ let textColor: string
+ if (turn.role === 'user') {
+ prefix = ' ❯ '
+ prefixColor = C.grey
+ textColor = C.greyLight
+ } else if (turn.role === 'assistant') {
+ prefix = ' ▸ '
+ prefixColor = C.orange
+ textColor = C.white
+ } else {
+ prefix = ' · '
+ prefixColor = C.grey
+ textColor = C.grey
+ }
+ const wrapped = wrap(turn.content, usable)
+ return wrapped.map((line, i) => ({
+ key: `${turn.id}-${i.toString()}`,
+ prefix: i === 0 ? prefix : CONTINUATION,
+ prefixColor,
+ text: line,
+ textColor,
+ isContinuation: i > 0,
+ }))
+}
+
+function blankLine(key: string): VisualLine {
+ return {
+ key,
+ prefix: ' ',
+ prefixColor: C.grey,
+ text: '',
+ textColor: C.grey,
+ isContinuation: false,
+ }
+}
+
+export function ChatViewport({
+ messages,
+ streaming,
+ error,
+ height,
+ scrollOffset,
+}: {
+ messages: ChatTurn[]
+ streaming: ChatTurn | null
+ error: string | null
+ height: number
+ scrollOffset: number
+}): React.JSX.Element {
+ const columns = process.stdout.columns ?? 80
+ const items: ChatTurn[] = streaming ? [...messages, streaming] : messages
+
+ // Flatten : turn → lines, with a blank separator between turns.
+ const allLines: VisualLine[] = []
+ items.forEach((it, idx) => {
+ if (idx > 0) allLines.push(blankLine(`gap-${it.id}`))
+ allLines.push(...turnToLines(it, columns))
+ })
+
+ const errorLines = error ? 1 : 0
+ const scrolled = scrollOffset > 0
+ const indicatorLines = scrolled ? 1 : 0
+ const availableLines = Math.max(1, height - errorLines - indicatorLines)
+
+ // Window : take `availableLines` ending at (totalLines - scrollOffset).
+ const total = allLines.length
+ const end = Math.max(availableLines, total - scrollOffset)
+ const start = Math.max(0, end - availableLines)
+ const visible = allLines.slice(start, end)
+
+ return (
+
+ {visible.map((l) => (
+
+
+ {l.prefix}
+
+ {l.text}
+
+ ))}
+ {error ? {` ✗ ${error}`} : null}
+ {scrolled ? (
+
+ {` … scrolled up · PgDn to scroll down · Ctrl+E to return live`}
+
+ ) : null}
+
+ )
+}
diff --git a/packages/cli/src/components/ConfirmAction.tsx b/packages/cli/src/components/ConfirmAction.tsx
new file mode 100644
index 0000000..5efef0b
--- /dev/null
+++ b/packages/cli/src/components/ConfirmAction.tsx
@@ -0,0 +1,223 @@
+// System-level permission dialog. A modal Q/A widget rendered above the
+// prompt whenever the builder requests a destructive action (file write,
+// container launch, …). Suspends text input until the user picks an option.
+//
+// Style : framed (double orange border), distinct from the chat flow, with
+// an explicit question, a metadata block, a preview, and three "button-
+// like" choices.
+
+import { Box, Text, useInput, useStdin } from 'ink'
+import React, { useState } from 'react'
+import type { Action } from '../actions/types.ts'
+import { useLanguage } from '../i18n/LanguageContext.tsx'
+import { C } from '../theme/colors.ts'
+
+const PREVIEW_LINES = 6
+
+type Strings = {
+ title: string
+ questionWrite: string
+ questionRun: string
+ typeLabel: string
+ pathLabel: string
+ agentLabel: string
+ promptLabel: string
+ sizeLabel: string
+ approve: string
+ decline: string
+ expand: string
+ collapse: string
+ actionWrite: string
+ actionRun: string
+}
+
+const STRINGS: Record<'en' | 'fr', Strings> = {
+ en: {
+ title: 'PERMISSION REQUIRED',
+ questionWrite: 'Allow Agent Forge to write this file?',
+ questionRun: 'Allow Agent Forge to launch this agent?',
+ typeLabel: 'action',
+ pathLabel: 'target',
+ agentLabel: 'agent',
+ promptLabel: 'prompt',
+ sizeLabel: 'size',
+ approve: 'Approve',
+ decline: 'Decline',
+ expand: 'Show full content',
+ collapse: 'Collapse preview',
+ actionWrite: 'create file',
+ actionRun: 'launch agent',
+ },
+ fr: {
+ title: 'AUTORISATION REQUISE',
+ questionWrite: 'Autoriser Agent Forge à écrire ce fichier ?',
+ questionRun: 'Autoriser Agent Forge à lancer cet agent ?',
+ typeLabel: 'action',
+ pathLabel: 'cible',
+ agentLabel: 'agent',
+ promptLabel: 'prompt',
+ sizeLabel: 'taille',
+ approve: 'Autoriser',
+ decline: 'Refuser',
+ expand: 'Afficher le contenu complet',
+ collapse: "Réduire l’aperçu",
+ actionWrite: 'créer un fichier',
+ actionRun: 'lancer un agent',
+ },
+}
+
+function Button({
+ hotkey,
+ label,
+ color,
+}: {
+ hotkey: string
+ label: string
+ color: string
+}): React.JSX.Element {
+ return (
+
+
+ {hotkey}
+
+ {` · ${label}`}
+
+ )
+}
+
+export function ConfirmAction({
+ action,
+ onApprove,
+ onDecline,
+}: {
+ action: Action
+ onApprove: () => void
+ onDecline: () => void
+}): React.JSX.Element {
+ const { lang } = useLanguage()
+ const s = STRINGS[lang ?? 'en']
+ const { isRawModeSupported } = useStdin()
+ const [expanded, setExpanded] = useState(false)
+
+ useInput(
+ (input, key) => {
+ if (input === 'y' || input === 'Y' || key.return) onApprove()
+ else if (input === 'n' || input === 'N' || key.escape) onDecline()
+ else if (input === 'd' || input === 'D') setExpanded((e) => !e)
+ },
+ { isActive: isRawModeSupported },
+ )
+
+ const isWrite = action.kind === 'write'
+ const previewSource = isWrite ? action.content : action.prompt
+ const lines = previewSource.split('\n')
+ const total = lines.length
+ const shown = expanded ? lines : lines.slice(0, PREVIEW_LINES)
+ const hidden = total - shown.length
+ const sizeKb = (previewSource.length / 1024).toFixed(1)
+
+ const labelKeys = isWrite
+ ? [s.typeLabel, s.pathLabel, s.sizeLabel]
+ : [s.typeLabel, s.agentLabel, s.promptLabel]
+ const labelWidth = Math.max(...labelKeys.map((l) => l.length))
+ const pad = (label: string): string => label.padEnd(labelWidth, ' ')
+
+ return (
+
+ {/* Title */}
+
+
+ {`▲ ${s.title}`}
+
+
+
+ {isWrite ? s.questionWrite : s.questionRun}
+
+
+ {/* Metadata */}
+
+
+
+ {` ${pad(s.typeLabel)} `}
+
+
+ {isWrite ? s.actionWrite : s.actionRun}
+
+
+ {isWrite ? (
+ <>
+
+
+ {` ${pad(s.pathLabel)} `}
+
+ {action.path}
+
+
+
+ {` ${pad(s.sizeLabel)} `}
+
+ {`${total.toString()} lines · ${sizeKb} KB`}
+
+ >
+ ) : (
+ <>
+
+
+ {` ${pad(s.agentLabel)} `}
+
+ {action.agent}
+
+
+
+ {` ${pad(s.promptLabel)} `}
+
+ {`${total.toString()} lines · ${sizeKb} KB`}
+
+ >
+ )}
+
+
+ {/* Preview */}
+
+
+ {' ─── preview ───────────────────────────────'}
+
+
+ {shown.map((line, i) => (
+
+
+ {`${(i + 1).toString().padStart(3, ' ')} `}
+
+ {line.length > 0 ? line : ' '}
+
+ ))}
+ {hidden > 0 ? (
+
+ {` … ${hidden.toString()} more line${hidden === 1 ? '' : 's'} hidden`}
+
+ ) : null}
+
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+ )
+}
diff --git a/packages/cli/src/components/Footer.tsx b/packages/cli/src/components/Footer.tsx
new file mode 100644
index 0000000..3a07fc9
--- /dev/null
+++ b/packages/cli/src/components/Footer.tsx
@@ -0,0 +1,43 @@
+// Bottom bar: keyboard hints on the left, screen info on the right.
+// Mirrors drawFooter() from demo-sprites/forge-mockup-v3.mjs.
+
+import { Box, Text } from 'ink'
+import React from 'react'
+import { C } from '../theme/colors.ts'
+
+export type Hint = { key: string; label: string }
+
+export function Footer({
+ hints,
+ info,
+}: {
+ hints: Hint[]
+ info?: string
+}): React.JSX.Element {
+ return (
+
+
+ {'─'.repeat(process.stdout.columns ?? 80)}
+
+
+
+ {hints.map((h, i) => (
+
+ {i > 0 ? {' '} : null}
+
+ {h.key}
+
+
+ {h.label}
+
+ ))}
+
+ {info ? (
+
+ {info}
+
+ ) : null}
+
+
+ )
+}
diff --git a/packages/cli/src/components/Header.tsx b/packages/cli/src/components/Header.tsx
new file mode 100644
index 0000000..8d693e3
--- /dev/null
+++ b/packages/cli/src/components/Header.tsx
@@ -0,0 +1,42 @@
+// Top bar: "▌▌ AGENT FORGE ▐▐" left, optional center label, optional right info.
+// Mirrors drawHeader() from demo-sprites/forge-mockup-v3.mjs.
+
+import { Box, Text } from 'ink'
+import React from 'react'
+import { C } from '../theme/colors.ts'
+
+const VERSION = '0.0.0'
+
+export function Header({
+ label,
+ info,
+}: {
+ label?: string
+ info?: string
+}): React.JSX.Element {
+ return (
+
+
+
+
+ {' ▌▌ AGENT FORGE ▐▐ '}
+
+
+ v{VERSION}
+
+ {label ? (
+ {` ${label}`}
+ ) : null}
+
+ {info ? (
+
+ {info}
+
+ ) : null}
+
+
+ {'─'.repeat(process.stdout.columns ?? 80)}
+
+
+ )
+}
diff --git a/packages/cli/src/components/LanguagePicker.tsx b/packages/cli/src/components/LanguagePicker.tsx
new file mode 100644
index 0000000..6be7294
--- /dev/null
+++ b/packages/cli/src/components/LanguagePicker.tsx
@@ -0,0 +1,78 @@
+// Inline language picker block — designed to be embedded inside Splash, NOT
+// rendered as its own full screen. Bilingual (EN + FR) since we do not yet
+// know which language the user wants. Selection is persisted by the parent.
+
+import { Box, Text, useInput, useStdin } from 'ink'
+import React, { useState } from 'react'
+import { type Lang } from '../config/store.ts'
+import { useT } from '../i18n/LanguageContext.tsx'
+import { C } from '../theme/colors.ts'
+
+const OPTIONS: ReadonlyArray<{ lang: Lang; label: string }> = [
+ { lang: 'en', label: 'English' },
+ { lang: 'fr', label: 'Français' },
+]
+
+export function LanguagePicker({
+ onPick,
+}: {
+ onPick: (lang: Lang) => void
+}): React.JSX.Element {
+ const t = useT()
+ const { isRawModeSupported } = useStdin()
+ const [index, setIndex] = useState(0)
+
+ useInput(
+ (input, key) => {
+ if (key.leftArrow) {
+ setIndex((i) => (i - 1 + OPTIONS.length) % OPTIONS.length)
+ } else if (key.rightArrow) {
+ setIndex((i) => (i + 1) % OPTIONS.length)
+ } else if (input === 'e' || input === 'E') {
+ setIndex(0)
+ } else if (input === 'f' || input === 'F') {
+ setIndex(1)
+ } else if (key.return) {
+ const picked = OPTIONS[index]
+ if (picked) onPick(picked.lang)
+ }
+ },
+ { isActive: isRawModeSupported },
+ )
+
+ return (
+
+
+
+ {t('langPickerTitleEN')}
+
+
+ {t('langPickerTitleFR')}
+
+
+
+
+ {OPTIONS.map((opt, i) => {
+ const selected = i === index
+ return (
+
+
+ {selected ? '▸ ' : ' '}
+
+
+ {opt.label}
+
+
+ )
+ })}
+
+
+
+
+ [←→] {t('langPickerHintNavigate')} [E/F] shortcut [⏎]{' '}
+ {t('langPickerHintSelect')}
+
+
+
+ )
+}
diff --git a/packages/cli/src/components/MissionControl.tsx b/packages/cli/src/components/MissionControl.tsx
new file mode 100644
index 0000000..9edc30f
--- /dev/null
+++ b/packages/cli/src/components/MissionControl.tsx
@@ -0,0 +1,204 @@
+// MissionControl — fills the top zone whenever there is at least one
+// builder action (write or run). Replaces the splash screen for the rest
+// of the session.
+//
+// Each action gets a card with :
+// - a status badge (proposed / running / done / failed)
+// - the target (file path or agent name)
+// - a syntax-highlighted preview of the content (YAML for AGENT.md,
+// plain for prompts) or the streaming agent output
+
+import { Box, Text } from 'ink'
+import React from 'react'
+import type { Action, ActionStatus, RunAction, WriteAction } from '../actions/types.ts'
+import { C } from '../theme/colors.ts'
+import {
+ type HighlightedLine,
+ type Segment,
+ highlightPlain,
+ highlightYamlText,
+} from './syntax.ts'
+
+const STATUS_LABEL: Record = {
+ proposed: 'PROPOSED',
+ approved: 'APPROVED',
+ running: 'RUNNING',
+ done: 'DONE',
+ failed: 'FAILED',
+ declined: 'DECLINED',
+}
+
+const STATUS_COLOR: Record = {
+ proposed: C.orange,
+ approved: C.orangeBright,
+ running: C.yellow,
+ done: C.green,
+ failed: C.red,
+ declined: C.grey,
+}
+
+function HighlightedBlock({
+ lines,
+ maxLines = 12,
+}: {
+ lines: HighlightedLine[]
+ maxLines?: number
+}): React.JSX.Element {
+ const shown = lines.slice(0, maxLines)
+ const hidden = lines.length - shown.length
+ return (
+
+ {shown.map((segments, i) => (
+
+
+ {`${(i + 1).toString().padStart(3, ' ')} `}
+
+ {segments.map((seg: Segment, j: number) => (
+
+ {seg.text}
+
+ ))}
+
+ ))}
+ {hidden > 0 ? (
+
+ {` … ${hidden.toString()} more line${hidden === 1 ? '' : 's'} hidden`}
+
+ ) : null}
+
+ )
+}
+
+function StatusBadge({ status }: { status: ActionStatus }): React.JSX.Element {
+ return (
+
+
+ {`[${STATUS_LABEL[status]}]`}
+
+
+ )
+}
+
+function borderColorFor(status: ActionStatus): string {
+ if (status === 'done') return C.green
+ if (status === 'failed') return C.red
+ if (status === 'declined') return C.grey
+ // proposed / approved / running
+ return C.orange
+}
+
+function CardFrame({
+ status,
+ children,
+}: {
+ status: ActionStatus
+ children: React.ReactNode
+}): React.JSX.Element {
+ return (
+
+ {children}
+
+ )
+}
+
+function WriteCard({ action }: { action: WriteAction }): React.JSX.Element {
+ const lines = highlightYamlText(action.content)
+ return (
+
+
+
+ {' write '}
+ {action.path}
+
+
+
+
+ {action.status === 'done' && action.result && 'absolutePath' in action.result ? (
+
+ {` ✓ written ${action.result.absolutePath}`}
+
+ ) : null}
+ {action.status === 'failed' && action.result && 'error' in action.result ? (
+
+ {` ✗ ${action.result.error}`}
+
+ ) : null}
+
+ )
+}
+
+function RunCard({ action }: { action: RunAction }): React.JSX.Element {
+ const promptLines = highlightPlain(action.prompt)
+ const outputLines = action.output.length > 0 ? highlightPlain(action.output) : []
+ return (
+
+
+
+ {' run '}
+ {action.agent}
+
+
+ {'prompt'}
+
+
+ {outputLines.length > 0 ? (
+ <>
+
+ {'output'}
+
+
+ >
+ ) : null}
+ {action.status === 'failed' && action.error ? (
+
+ {` ✗ ${action.error}`}
+
+ ) : null}
+
+ )
+}
+
+export function MissionControl({
+ actions,
+}: {
+ actions: Action[]
+}): React.JSX.Element {
+ const cols = process.stdout.columns ?? 80
+ return (
+
+
+
+ {' ▌▌ MISSION CONTROL ▐▐ '}
+
+
+ {` ${actions.length.toString()} action${actions.length === 1 ? '' : 's'}`}
+
+
+ {actions.map((a) =>
+ a.kind === 'write' ? (
+
+ ) : (
+
+ ),
+ )}
+
+ )
+}
diff --git a/packages/cli/src/components/ProviderLogo.tsx b/packages/cli/src/components/ProviderLogo.tsx
new file mode 100644
index 0000000..46404e0
--- /dev/null
+++ b/packages/cli/src/components/ProviderLogo.tsx
@@ -0,0 +1,111 @@
+// Tiny pixel-art logo for the active LLM provider.
+//
+// Uses half-blocks (▀) to pack two pixel-rows per terminal line: the
+// glyph's top half takes the foreground color, the bottom half takes the
+// background color. A 5-row sprite renders in 3 terminal lines (last
+// half-row is just empty bottom). One terminal cell per logical pixel
+// (single ▀) — the sprite reads narrower so it doesn't look stretched.
+
+import { Box, Text } from 'ink'
+import React from 'react'
+import { getCurrentBaseURL } from '@agent-forge/core/builder'
+import { C } from '../theme/colors.ts'
+
+type Pixel = string | null
+type Sprite = Pixel[][]
+
+const Y1 = '#ffd800'
+const Y2 = '#ffaf00'
+const O1 = '#ff8205'
+const R1 = '#fa500f'
+const R2 = '#e10500'
+
+// 7 columns × 5 rows. Direct transcription of the official Mistral logo
+// SVG (Wikimedia 2025).
+//
+// col: 0 1 2 3 4 5 6
+// row 0: . Y . . . Y .
+// row 1: . Y2 Y2 . Y2 Y2 .
+// row 2: . O O O O O .
+// row 3: . R1 . R1 . R1 .
+// row 4: R2 R2 R2 . R2 R2 R2
+const MISTRAL: Sprite = [
+ [null, Y1, null, null, null, Y1, null],
+ [null, Y2, Y2, null, Y2, Y2, null],
+ [null, O1, O1, O1, O1, O1, null],
+ [null, R1, null, R1, null, R1, null],
+ [R2, R2, R2, null, R2, R2, R2],
+]
+
+function HalfRow({ top, bot }: { top: Pixel[]; bot: Pixel[] }): React.JSX.Element {
+ // Each cell here represents TWO stacked logical pixels. Three cases:
+ // - both transparent → render two spaces (terminal background shows)
+ // - only top filled → ▀▀ in top color (bottom half = transparent)
+ // - only bot filled → ▄▄ in bot color (top half = transparent)
+ // - both filled → ▀▀ with foreground=top and backgroundColor=bot
+ return (
+
+ {top.map((tColor, i) => {
+ const bColor = bot[i] ?? null
+ if (tColor && bColor) {
+ return (
+
+ ▀
+
+ )
+ }
+ if (tColor) {
+ return (
+
+ ▀
+
+ )
+ }
+ if (bColor) {
+ return (
+
+ ▄
+
+ )
+ }
+ return
+ })}
+
+ )
+}
+
+function detectProvider(): 'mistral' | 'openai' | 'anthropic' | 'mlx' | 'unknown' {
+ const url = getCurrentBaseURL().toLowerCase()
+ if (url.includes('mistral.ai')) return 'mistral'
+ if (url.includes('openai.com')) return 'openai'
+ if (url.includes('anthropic.com')) return 'anthropic'
+ if (url.includes('127.0.0.1') || url.includes('localhost')) return 'mlx'
+ return 'unknown'
+}
+
+export function ProviderLogo(): React.JSX.Element {
+ const provider = detectProvider()
+ if (provider === 'mistral') {
+ // Pair rows: (0,1), (2,3), (4,empty). 5 pixel-rows → 3 terminal lines.
+ const empty: Pixel[] = MISTRAL[0]?.map(() => null) ?? []
+ const pairs: Array<[Pixel[], Pixel[]]> = [
+ [MISTRAL[0] ?? empty, MISTRAL[1] ?? empty],
+ [MISTRAL[2] ?? empty, MISTRAL[3] ?? empty],
+ [MISTRAL[4] ?? empty, empty],
+ ]
+ return (
+
+ {pairs.map(([top, bot], i) => (
+
+ ))}
+
+ )
+ }
+ return (
+
+
+ {provider}
+
+
+ )
+}
diff --git a/packages/cli/src/components/Splash.tsx b/packages/cli/src/components/Splash.tsx
new file mode 100644
index 0000000..529246d
--- /dev/null
+++ b/packages/cli/src/components/Splash.tsx
@@ -0,0 +1,80 @@
+// Boot splash : centered ASCII logo, tagline, animated preflight checks,
+// and (on first run) an inline language picker below the checks.
+//
+// Stays mounted as a session header — App.tsx renders Welcome BELOW it.
+// Will be cleared in P3+ when the build phase replaces this view.
+//
+// Mirrors screenSplash() from demo-sprites/forge-mockup-v3.mjs.
+
+import { Box, Text } from 'ink'
+import React from 'react'
+import { type Lang } from '../config/store.ts'
+import { usePreflight } from '../hooks/usePreflight.ts'
+import { useLanguage } from '../i18n/LanguageContext.tsx'
+import { C } from '../theme/colors.ts'
+import { LOGO_AGENT, LOGO_FORGE } from '../theme/logo.ts'
+import { LanguagePicker } from './LanguagePicker.tsx'
+
+const VERSION = '0.0.0'
+
+export function Splash(): React.JSX.Element {
+ const { checks, allDone } = usePreflight()
+ const { lang, setLang } = useLanguage()
+
+ const handlePick = (picked: Lang): void => {
+ setLang(picked)
+ }
+
+ return (
+
+
+ {LOGO_AGENT.map((line, i) => (
+
+ {line}
+
+ ))}
+ {LOGO_FORGE.map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+
+
+ Forge, run, and orchestrate sandboxed LLM agents
+
+
+ v{VERSION} · by @garniergeorges · Apache 2.0
+
+
+
+
+ {checks.map((c) => {
+ const symbol =
+ c.status === 'ok' ? '✓' : c.status === 'fail' ? '✗' : '·'
+ const symbolColor =
+ c.status === 'ok' ? C.green : c.status === 'fail' ? C.red : C.grey
+ const trail =
+ c.status === 'pending' ? '' : c.status === 'running' ? '...' : ''
+ return (
+
+ {symbol}
+
+ {' '}
+ {c.label}
+ {trail}
+
+
+ )
+ })}
+
+
+ {allDone && lang === null ? (
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/packages/cli/src/components/Welcome.tsx b/packages/cli/src/components/Welcome.tsx
new file mode 100644
index 0000000..e1dcee4
--- /dev/null
+++ b/packages/cli/src/components/Welcome.tsx
@@ -0,0 +1,163 @@
+// Bottom-pinned interactive zone (the "welcome" block).
+//
+// Contains, top to bottom :
+// - Header bar (▌▌ AGENT FORGE ▐▐ ...)
+// - Either the welcome content (question + suggestions) when the chat is
+// empty, or the scrollable transcript once the user has sent a message.
+// - Either a permission dialog (when the builder requested an action),
+// or the prompt input + footer.
+//
+// The whole block stays glued to the bottom of the terminal. The splash
+// above stays put. The chat content area inside this block has a fixed
+// max height : older turns are clipped (visually) but kept in the LLM
+// context.
+
+import { Box, Text, useApp, useStdin } from 'ink'
+import TextInput from 'ink-text-input'
+import React, { useState } from 'react'
+import { getCurrentModelName } from '@agent-forge/core/builder'
+import { isCommand, runCommand } from '../commands.ts'
+import { useChatContext } from '../hooks/useChatContext.tsx'
+import { useLanguage, useT } from '../i18n/LanguageContext.tsx'
+import { C } from '../theme/colors.ts'
+import { ChatViewport } from './ChatViewport.tsx'
+import { ConfirmAction } from './ConfirmAction.tsx'
+import { Footer } from './Footer.tsx'
+import { Header } from './Header.tsx'
+import { WelcomeContent } from './WelcomeContent.tsx'
+
+const CHAT_MAX_HEIGHT = 18
+
+function shortModel(name: string): string {
+ const slash = name.lastIndexOf('/')
+ const base = slash >= 0 ? name.slice(slash + 1) : name
+ return base.length > 32 ? `${base.slice(0, 30)}…` : base
+}
+
+export function Welcome(): React.JSX.Element {
+ const t = useT()
+ const { lang, setLang } = useLanguage()
+ const { exit } = useApp()
+ const { isRawModeSupported } = useStdin()
+ const [input, setInput] = useState('')
+ const {
+ state,
+ send,
+ addSystemMessage,
+ clear,
+ reset,
+ busy,
+ scrollOffset,
+ pending,
+ approvePending,
+ declinePending,
+ } = useChatContext()
+
+ const hasMessages = state.messages.length > 0 || state.streaming !== null
+ const hasPending = pending !== null
+
+ const handleSubmit = (value: string): void => {
+ const trimmed = value.trim()
+ if (!trimmed || busy) return
+ setInput('')
+
+ if (isCommand(trimmed)) {
+ addSystemMessage(trimmed)
+ const result = runCommand(trimmed, {
+ lang: lang ?? 'en',
+ setLang,
+ clearChat: clear,
+ resetChat: reset,
+ exit,
+ })
+ for (const line of result.lines) {
+ addSystemMessage(line)
+ }
+ return
+ }
+
+ void send(trimmed)
+ }
+
+ const cols = process.stdout.columns ?? 80
+
+ return (
+
+
+
+ {hasPending ? null : hasMessages ? (
+
+ ) : (
+
+ )}
+
+ {hasPending ? (
+
+ ) : (
+
+
+ {' '}
+ {'─'.repeat(Math.max(0, (process.stdout.columns ?? 80) - 2))}
+
+
+ {' ❯ '}
+ {isRawModeSupported ? (
+
+ ) : (
+
+ {t('welcomeRawModeDisabled')}
+
+ )}
+
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/packages/cli/src/components/WelcomeContent.tsx b/packages/cli/src/components/WelcomeContent.tsx
new file mode 100644
index 0000000..fa0cb4e
--- /dev/null
+++ b/packages/cli/src/components/WelcomeContent.tsx
@@ -0,0 +1,36 @@
+// Empty-state content for the chat zone : question + suggestions.
+// Shown only before the user sends their first message. Sits inside the
+// bottom Welcome block (NOT centered in the middle of the screen).
+
+import { Box, Text } from 'ink'
+import React from 'react'
+import { useT } from '../i18n/LanguageContext.tsx'
+import { C } from '../theme/colors.ts'
+
+export function WelcomeContent(): React.JSX.Element {
+ const t = useT()
+ return (
+
+
+ {t('welcomeTitle')}
+
+
+ {t('welcomeSubtitle')}
+
+
+
+ {t('welcomeSuggestion1')}
+
+
+ {t('welcomeSuggestion2')}
+
+
+ {t('welcomeSuggestion3')}
+
+
+ {t('welcomeSuggestion4')}
+
+
+
+ )
+}
diff --git a/packages/cli/src/components/syntax.ts b/packages/cli/src/components/syntax.ts
new file mode 100644
index 0000000..d846f3d
--- /dev/null
+++ b/packages/cli/src/components/syntax.ts
@@ -0,0 +1,84 @@
+// Tiny, line-oriented syntax helpers for the MissionControl preview.
+// Returns segments {text, color, dim?} that components can render with Ink.
+// We deliberately avoid a real parser : agents emit small YAML / plain text
+// blocks, a handful of regexes is enough.
+
+import { C } from '../theme/colors.ts'
+
+export type Segment = { text: string; color?: string; dim?: boolean; bold?: boolean }
+
+export type HighlightedLine = Segment[]
+
+const YAML_KEY_RE = /^(\s*)([A-Za-z_][\w-]*)(\s*:)(\s*)(.*)$/
+const YAML_LIST_RE = /^(\s*)(-)(\s+)(.*)$/
+const YAML_SEPARATOR_RE = /^---\s*$/
+const YAML_COMMENT_RE = /^(\s*)(#.*)$/
+
+function valueSegment(value: string): Segment {
+ // Numbers
+ if (/^-?\d+(\.\d+)?$/.test(value)) {
+ return { text: value, color: C.greyLight }
+ }
+ // Quoted string
+ if (/^["'].*["']$/.test(value)) {
+ return { text: value, color: C.greyLight }
+ }
+ // Booleans / null
+ if (/^(true|false|null|yes|no)$/i.test(value)) {
+ return { text: value, color: C.orangeBright }
+ }
+ // Bare value
+ return { text: value, color: C.white }
+}
+
+export function highlightYamlLine(line: string): HighlightedLine {
+ if (line.length === 0) return [{ text: ' ' }]
+ if (YAML_SEPARATOR_RE.test(line)) {
+ return [{ text: line, color: C.grey, dim: true }]
+ }
+ const comment = line.match(YAML_COMMENT_RE)
+ if (comment) {
+ return [
+ { text: comment[1] ?? '' },
+ { text: comment[2] ?? '', color: C.grey, dim: true },
+ ]
+ }
+ const list = line.match(YAML_LIST_RE)
+ if (list) {
+ return [
+ { text: list[1] ?? '' },
+ { text: list[2] ?? '', color: C.orange, bold: true },
+ { text: list[3] ?? '' },
+ { text: list[4] ?? '', color: C.white },
+ ]
+ }
+ const kv = line.match(YAML_KEY_RE)
+ if (kv) {
+ const [, indent, key, colon, space, value] = kv
+ const segs: HighlightedLine = [
+ { text: indent ?? '' },
+ { text: key ?? '', color: C.orange, bold: true },
+ { text: colon ?? '', color: C.grey },
+ { text: space ?? '' },
+ ]
+ if (value && value.length > 0) {
+ segs.push(valueSegment(value))
+ }
+ return segs
+ }
+ // Markdown header inside body
+ if (/^#\s/.test(line)) {
+ return [{ text: line, color: C.orangeBright, bold: true }]
+ }
+ return [{ text: line, color: C.greyLight }]
+}
+
+export function highlightYamlText(text: string): HighlightedLine[] {
+ return text.split('\n').map(highlightYamlLine)
+}
+
+export function highlightPlain(text: string): HighlightedLine[] {
+ return text
+ .split('\n')
+ .map((l) => [{ text: l.length > 0 ? l : ' ', color: C.greyLight }])
+}
diff --git a/packages/cli/src/config/store.ts b/packages/cli/src/config/store.ts
new file mode 100644
index 0000000..d0b8dee
--- /dev/null
+++ b/packages/cli/src/config/store.ts
@@ -0,0 +1,65 @@
+// Persistent user config stored at ~/.agent-forge/config.json.
+// Created lazily on first write, parsed defensively on read.
+
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+
+export type Lang = 'en' | 'fr'
+
+export type ProviderPreset = 'mlx' | 'openai' | 'anthropic' | 'mistral'
+
+export type ForgeConfig = {
+ lang?: Lang
+ model?: string
+ provider?: ProviderPreset
+}
+
+const CONFIG_DIR = join(homedir(), '.agent-forge')
+const CONFIG_PATH = join(CONFIG_DIR, 'config.json')
+
+export function loadConfig(): ForgeConfig {
+ if (!existsSync(CONFIG_PATH)) return {}
+ try {
+ const raw = readFileSync(CONFIG_PATH, 'utf8')
+ const parsed = JSON.parse(raw) as unknown
+ if (typeof parsed !== 'object' || parsed === null) return {}
+ return parsed as ForgeConfig
+ } catch {
+ // Corrupt config — start fresh rather than crashing.
+ return {}
+ }
+}
+
+export function saveConfig(config: ForgeConfig): void {
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true })
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
+}
+
+// Provider presets : default base URL + sensible default model. Used by
+// /provider switching. The user can still override anything via env vars.
+export const PROVIDER_PRESETS: Record<
+ ProviderPreset,
+ { baseURL: string; defaultModel: string; needsKey: boolean }
+> = {
+ mlx: {
+ baseURL: 'http://127.0.0.1:8080/v1',
+ defaultModel: 'mlx-community/Qwen2.5-7B-Instruct-4bit',
+ needsKey: false,
+ },
+ openai: {
+ baseURL: 'https://api.openai.com/v1',
+ defaultModel: 'gpt-4o-mini',
+ needsKey: true,
+ },
+ anthropic: {
+ baseURL: 'https://api.anthropic.com/v1',
+ defaultModel: 'claude-sonnet-4-6',
+ needsKey: true,
+ },
+ mistral: {
+ baseURL: 'https://api.mistral.ai/v1',
+ defaultModel: 'mistral-small-latest',
+ needsKey: true,
+ },
+}
diff --git a/packages/cli/src/hooks/useChat.ts b/packages/cli/src/hooks/useChat.ts
new file mode 100644
index 0000000..199272f
--- /dev/null
+++ b/packages/cli/src/hooks/useChat.ts
@@ -0,0 +1,370 @@
+// Conversation state and streaming wiring for the CLI.
+//
+// Two parallel surfaces are kept here :
+// - `messages` : prose only (user, assistant, slash command output).
+// Renders in the bottom Welcome block.
+// - `actions` : structured actions the builder requested (write/run) with
+// their lifecycle (proposed → approved → running → done|failed).
+// Renders in the top MissionControl panel.
+//
+// Builder code blocks (```forge:*) are extracted into actions and STRIPPED
+// from the assistant's textual reply before that reply lands in `messages`.
+
+import { type ChatMessage, streamBuilder } from '@agent-forge/core/builder'
+import { launchAgent } from '@agent-forge/tools-core'
+import { useCallback, useRef, useState } from 'react'
+import {
+ type Action,
+ type RunAction,
+ type WriteAction,
+ nextActionId,
+} from '../actions/types.ts'
+import {
+ type ParsedAction,
+ executeAction,
+ findActionBlocks,
+ stripActionBlocks,
+} from '../builder-actions.ts'
+import type { Lang } from '../config/store.ts'
+import { getCurrentSession } from '../session/store.ts'
+
+export type TurnRole = 'user' | 'assistant' | 'system'
+
+export type ChatTurn = {
+ id: string
+ role: TurnRole
+ content: string
+}
+
+export type ChatState = {
+ messages: ChatTurn[]
+ streaming: ChatTurn | null
+ error: string | null
+ actions: Action[]
+}
+
+const SCROLL_STEP = 4
+
+let counter = 0
+const nextId = (): string => {
+ counter += 1
+ return `m${counter.toString()}`
+}
+
+function persist(turn: ChatTurn): void {
+ try {
+ getCurrentSession().appendTurn(turn)
+ } catch {
+ // ignore
+ }
+}
+
+function nowIso(): string {
+ return new Date().toISOString()
+}
+
+function actionFromParsed(parsed: ParsedAction): Action {
+ if (parsed.kind === 'write') {
+ return {
+ id: nextActionId(),
+ kind: 'write',
+ status: 'proposed',
+ path: parsed.path,
+ content: parsed.content,
+ createdAt: nowIso(),
+ }
+ }
+ return {
+ id: nextActionId(),
+ kind: 'run',
+ status: 'proposed',
+ agent: parsed.agent,
+ prompt: parsed.prompt,
+ createdAt: nowIso(),
+ output: '',
+ }
+}
+
+function parsedFromAction(action: Action): ParsedAction {
+ if (action.kind === 'write') {
+ return {
+ kind: 'write',
+ path: action.path,
+ content: action.content,
+ raw: '',
+ }
+ }
+ return {
+ kind: 'run',
+ agent: action.agent,
+ prompt: action.prompt,
+ raw: '',
+ }
+}
+
+export function useChat(lang: Lang): {
+ state: ChatState
+ send: (prompt: string) => Promise
+ addSystemMessage: (text: string) => void
+ clear: () => void
+ reset: () => void
+ busy: boolean
+ scrollOffset: number
+ scrollUp: () => void
+ scrollDown: () => void
+ scrollToBottom: () => void
+ pending: Action | null
+ approvePending: () => void
+ declinePending: () => void
+} {
+ const [state, setState] = useState({
+ messages: [],
+ streaming: null,
+ error: null,
+ actions: [],
+ })
+ const [busy, setBusy] = useState(false)
+ const [scrollOffset, setScrollOffset] = useState(0)
+ // Buffer des messages cachés mais toujours envoyés au LLM dans `send`.
+ // `/clear` y déplace les messages visibles (vue vide, contexte préservé) ;
+ // `/reset` le purge. Stocké en ref pour ne pas redéclencher de rendu.
+ const hiddenHistoryRef = useRef([])
+
+ const scrollUp = useCallback(() => setScrollOffset((o) => o + SCROLL_STEP), [])
+ const scrollDown = useCallback(
+ () => setScrollOffset((o) => Math.max(0, o - SCROLL_STEP)),
+ [],
+ )
+ const scrollToBottom = useCallback(() => setScrollOffset(0), [])
+
+ const addSystemMessage = useCallback((text: string) => {
+ const sysTurn: ChatTurn = { id: nextId(), role: 'system', content: text }
+ setScrollOffset(0)
+ persist(sysTurn)
+ setState((prev) => ({ ...prev, messages: [...prev.messages, sysTurn] }))
+ }, [])
+
+ // /clear : vide uniquement la vue (transcript + actions). Les messages
+ // visibles sont déplacés dans hiddenHistoryRef pour rester dans le contexte
+ // LLM aux prochains tours.
+ const clear = useCallback(() => {
+ setScrollOffset(0)
+ setState((prev) => {
+ hiddenHistoryRef.current = [...hiddenHistoryRef.current, ...prev.messages]
+ return { messages: [], streaming: null, error: null, actions: [] }
+ })
+ }, [])
+
+ // /reset : vide vue ET contexte LLM. Comme un redémarrage de session.
+ const reset = useCallback(() => {
+ setScrollOffset(0)
+ hiddenHistoryRef.current = []
+ setState({ messages: [], streaming: null, error: null, actions: [] })
+ }, [])
+
+ const updateAction = useCallback(
+ (id: string, patch: Partial): void => {
+ setState((prev) => ({
+ ...prev,
+ actions: prev.actions.map((a) =>
+ a.id === id ? ({ ...a, ...patch } as Action) : a,
+ ),
+ }))
+ },
+ [],
+ )
+
+ const runAgentAction = useCallback(
+ async (action: RunAction): Promise => {
+ updateAction(action.id, { status: 'running' })
+ const handle = launchAgent({ agent: action.agent, prompt: action.prompt })
+ let acc = ''
+ let finalCode = -1
+ let stderrOut = ''
+ try {
+ for await (const evt of handle.events) {
+ if (evt.type === 'chunk') {
+ acc += evt.text
+ updateAction(action.id, { output: acc })
+ } else if (evt.type === 'stderr') {
+ stderrOut += evt.text
+ } else if (evt.type === 'done') {
+ finalCode = evt.exitCode
+ } else if (evt.type === 'error') {
+ updateAction(action.id, {
+ status: 'failed',
+ error: evt.error,
+ finishedAt: nowIso(),
+ })
+ return
+ }
+ }
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err)
+ updateAction(action.id, {
+ status: 'failed',
+ error: msg,
+ finishedAt: nowIso(),
+ })
+ return
+ }
+ updateAction(action.id, {
+ status: finalCode === 0 ? 'done' : 'failed',
+ output: acc.trim(),
+ exitCode: finalCode,
+ error:
+ finalCode === 0
+ ? undefined
+ : `exit ${finalCode.toString()}${stderrOut ? ` : ${stderrOut.split('\n').pop() ?? ''}` : ''}`,
+ finishedAt: nowIso(),
+ })
+ },
+ [updateAction],
+ )
+
+ const headPending = (state.actions.find((a) => a.status === 'proposed') ??
+ null) as Action | null
+
+ const approvePending = useCallback(() => {
+ const head = state.actions.find((a) => a.status === 'proposed')
+ if (!head) return
+ if (head.kind === 'write') {
+ const parsed = parsedFromAction(head)
+ const exec = executeAction(parsed, { overwrite: true })
+ if (exec.kind === 'write' && exec.result.ok) {
+ updateAction(head.id, {
+ status: 'done',
+ result: { absolutePath: exec.result.absolutePath },
+ finishedAt: nowIso(),
+ })
+ } else {
+ updateAction(head.id, {
+ status: 'failed',
+ result:
+ exec.kind === 'write' && !exec.result.ok
+ ? { error: exec.result.error }
+ : { error: 'unknown error' },
+ finishedAt: nowIso(),
+ })
+ }
+ } else {
+ updateAction(head.id, { status: 'approved' })
+ void runAgentAction(head as RunAction)
+ }
+ }, [state.actions, runAgentAction, updateAction])
+
+ const declinePending = useCallback(() => {
+ const head = state.actions.find((a) => a.status === 'proposed')
+ if (!head) return
+ updateAction(head.id, { status: 'declined', finishedAt: nowIso() })
+ }, [state.actions, updateAction])
+
+ const send = useCallback(
+ async (prompt: string): Promise => {
+ const userTurn: ChatTurn = { id: nextId(), role: 'user', content: prompt }
+ const assistantTurn: ChatTurn = {
+ id: nextId(),
+ role: 'assistant',
+ content: '',
+ }
+
+ setScrollOffset(0)
+ persist(userTurn)
+ setState((prev) => ({
+ ...prev,
+ messages: [...prev.messages, userTurn],
+ streaming: assistantTurn,
+ error: null,
+ }))
+ setBusy(true)
+
+ try {
+ const history: ChatMessage[] = [
+ ...hiddenHistoryRef.current
+ .filter((m) => m.role !== 'system')
+ .map(({ role, content }) => ({
+ role: role as 'user' | 'assistant',
+ content,
+ })),
+ ...state.messages
+ .filter((m) => m.role !== 'system')
+ .map(({ role, content }) => ({
+ role: role as 'user' | 'assistant',
+ content,
+ })),
+ { role: 'user', content: prompt },
+ ]
+
+ let acc = ''
+ for await (const chunk of streamBuilder({ messages: history, lang })) {
+ acc += chunk
+ setState((prev) =>
+ prev.streaming
+ ? { ...prev, streaming: { ...prev.streaming, content: acc } }
+ : prev,
+ )
+ }
+
+ // Extract any forge:* blocks BEFORE persisting the assistant text.
+ const blocks = findActionBlocks(acc)
+ const parseErrors: ChatTurn[] = []
+ const newActions: Action[] = []
+ for (const block of blocks) {
+ if (!block.ok) {
+ parseErrors.push({
+ id: nextId(),
+ role: 'system',
+ content: `✗ action skipped : ${block.error}`,
+ })
+ } else {
+ newActions.push(actionFromParsed(block.action))
+ }
+ }
+ const proseOnly = stripActionBlocks(acc)
+ const finalAssistant: ChatTurn = {
+ ...assistantTurn,
+ content: proseOnly,
+ }
+ persist(finalAssistant)
+ for (const e of parseErrors) persist(e)
+ setState((prev) => ({
+ ...prev,
+ messages: [
+ ...prev.messages,
+ ...(proseOnly.length > 0 ? [finalAssistant] : []),
+ ...parseErrors,
+ ],
+ streaming: null,
+ error: null,
+ actions: [...prev.actions, ...newActions],
+ }))
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err)
+ setState((prev) => ({
+ ...prev,
+ streaming: null,
+ error: msg,
+ }))
+ } finally {
+ setBusy(false)
+ }
+ },
+ [state.messages, lang],
+ )
+
+ return {
+ state,
+ send,
+ addSystemMessage,
+ clear,
+ reset,
+ busy,
+ scrollOffset,
+ scrollUp,
+ scrollDown,
+ scrollToBottom,
+ pending: headPending,
+ approvePending,
+ declinePending,
+ }
+}
diff --git a/packages/cli/src/hooks/useChatContext.tsx b/packages/cli/src/hooks/useChatContext.tsx
new file mode 100644
index 0000000..4803abe
--- /dev/null
+++ b/packages/cli/src/hooks/useChatContext.tsx
@@ -0,0 +1,26 @@
+// React context wrapping useChat so App, ChatViewport and PromptBar share
+// the same chat state without prop drilling.
+
+import React, { createContext, useContext } from 'react'
+import { useLanguage } from '../i18n/LanguageContext.tsx'
+import { useChat } from './useChat.ts'
+
+type ChatContextValue = ReturnType
+
+const ChatContext = createContext(null)
+
+export function ChatProvider({
+ children,
+}: {
+ children: React.ReactNode
+}): React.JSX.Element {
+ const { lang } = useLanguage()
+ const value = useChat(lang ?? 'en')
+ return {children}
+}
+
+export function useChatContext(): ChatContextValue {
+ const ctx = useContext(ChatContext)
+ if (!ctx) throw new Error('useChatContext must be used within ChatProvider')
+ return ctx
+}
diff --git a/packages/cli/src/hooks/usePreflight.ts b/packages/cli/src/hooks/usePreflight.ts
new file mode 100644
index 0000000..f9d8266
--- /dev/null
+++ b/packages/cli/src/hooks/usePreflight.ts
@@ -0,0 +1,94 @@
+// Splash-screen preflight checks. Each check resolves to ok / fail with a label.
+// Mirrors the visual rhythm of the mockup ("· checking docker daemon...").
+
+import { spawnSync } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { useEffect, useState } from 'react'
+
+export type CheckStatus = 'pending' | 'running' | 'ok' | 'fail'
+
+export type Check = {
+ id: string
+ label: string
+ status: CheckStatus
+ detail?: string
+}
+
+const RUNTIME_BUNDLE = join(
+ new URL('../../../runtime/dist', import.meta.url).pathname,
+ 'runtime.mjs',
+)
+const FORGE_BASE_URL =
+ process.env.FORGE_BASE_URL ?? 'http://127.0.0.1:8080/v1'
+
+async function checkDocker(): Promise {
+ return spawnSync('docker', ['info'], { stdio: 'ignore' }).status === 0
+}
+
+async function checkLLM(): Promise {
+ try {
+ const url = new URL('models', FORGE_BASE_URL.endsWith('/') ? FORGE_BASE_URL : `${FORGE_BASE_URL}/`)
+ const apiKey = process.env.FORGE_API_KEY
+ const headers: Record = {}
+ // Cloud endpoints (Mistral, OpenAI, Anthropic, …) require auth even
+ // on /models. Local endpoints (MLX server) accept anything or no header.
+ if (apiKey && apiKey !== 'not-needed') {
+ headers.Authorization = `Bearer ${apiKey}`
+ }
+ const res = await fetch(url, {
+ headers,
+ signal: AbortSignal.timeout(2500),
+ })
+ return res.ok
+ } catch {
+ return false
+ }
+}
+
+async function checkRuntime(): Promise {
+ return existsSync(RUNTIME_BUNDLE)
+}
+
+const DEFINITIONS: ReadonlyArray<{ id: string; label: string; run: () => Promise }> = [
+ { id: 'docker', label: 'checking docker daemon', run: checkDocker },
+ { id: 'llm', label: `verifying llm endpoint (${new URL(FORGE_BASE_URL).host})`, run: checkLLM },
+ { id: 'runtime', label: 'loading agent runtime bundle', run: checkRuntime },
+]
+
+const TICK_MS = 280
+
+export function usePreflight(): { checks: Check[]; allDone: boolean; allOk: boolean } {
+ const [checks, setChecks] = useState(() =>
+ DEFINITIONS.map((d) => ({ id: d.id, label: d.label, status: 'pending' })),
+ )
+
+ useEffect(() => {
+ let cancelled = false
+ const run = async (): Promise => {
+ for (let i = 0; i < DEFINITIONS.length; i++) {
+ if (cancelled) return
+ setChecks((prev) =>
+ prev.map((c, idx) => (idx === i ? { ...c, status: 'running' } : c)),
+ )
+ const def = DEFINITIONS[i]
+ if (!def) continue
+ const ok = await def.run().catch(() => false)
+ if (cancelled) return
+ // Pace the visual rhythm so the splash does not flash.
+ await new Promise((r) => setTimeout(r, TICK_MS))
+ setChecks((prev) =>
+ prev.map((c, idx) => (idx === i ? { ...c, status: ok ? 'ok' : 'fail' } : c)),
+ )
+ }
+ }
+ void run()
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const allDone = checks.every((c) => c.status === 'ok' || c.status === 'fail')
+ const allOk = checks.every((c) => c.status === 'ok')
+ return { checks, allDone, allOk }
+}
diff --git a/packages/cli/src/i18n/LanguageContext.tsx b/packages/cli/src/i18n/LanguageContext.tsx
new file mode 100644
index 0000000..f2160d6
--- /dev/null
+++ b/packages/cli/src/i18n/LanguageContext.tsx
@@ -0,0 +1,53 @@
+// React context that exposes the current language and a setter.
+// All UI components read from here via useT().
+
+import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
+import { type Lang, loadConfig, saveConfig } from '../config/store.ts'
+import { type StringKey, translate } from './strings.ts'
+
+type LanguageContextValue = {
+ lang: Lang | null // null until the user has picked one (first run)
+ setLang: (lang: Lang) => void
+ t: (key: StringKey) => string
+}
+
+const LanguageContext = createContext(null)
+
+export function LanguageProvider({
+ children,
+}: {
+ children: React.ReactNode
+}): React.JSX.Element {
+ const [lang, setLangState] = useState(() => {
+ const cfg = loadConfig()
+ return cfg.lang ?? null
+ })
+
+ const setLang = useCallback((next: Lang) => {
+ setLangState(next)
+ const cfg = loadConfig()
+ saveConfig({ ...cfg, lang: next })
+ }, [])
+
+ const t = useCallback(
+ (key: StringKey): string => translate(key, lang ?? 'en'),
+ [lang],
+ )
+
+ const value = useMemo(
+ () => ({ lang, setLang, t }),
+ [lang, setLang, t],
+ )
+
+ return {children}
+}
+
+export function useLanguage(): LanguageContextValue {
+ const ctx = useContext(LanguageContext)
+ if (!ctx) throw new Error('useLanguage must be used within LanguageProvider')
+ return ctx
+}
+
+export function useT(): (key: StringKey) => string {
+ return useLanguage().t
+}
diff --git a/packages/cli/src/i18n/strings.ts b/packages/cli/src/i18n/strings.ts
new file mode 100644
index 0000000..0f983f0
--- /dev/null
+++ b/packages/cli/src/i18n/strings.ts
@@ -0,0 +1,229 @@
+// All UI strings live here. Add a key in both EN and FR at the same time.
+// Keep keys descriptive (welcomeTitle, not w1) so we can grep for usage.
+
+import type { Lang } from '../config/store.ts'
+
+export type StringKey =
+ | 'splashTagline'
+ | 'splashCheckDocker'
+ | 'splashCheckLLM'
+ | 'splashCheckRuntime'
+ | 'langPickerTitleEN'
+ | 'langPickerTitleFR'
+ | 'langPickerHintNavigate'
+ | 'langPickerHintSelect'
+ | 'langPickerHintExit'
+ | 'langPickerScreenInfo'
+ | 'welcomeHeaderLabel'
+ | 'welcomeHeaderInfo'
+ | 'welcomeTitle'
+ | 'welcomeSubtitle'
+ | 'welcomeSuggestion1'
+ | 'welcomeSuggestion2'
+ | 'welcomeSuggestion3'
+ | 'welcomeSuggestion4'
+ | 'welcomeInputPlaceholder'
+ | 'welcomeHintSend'
+ | 'welcomeHintCommands'
+ | 'welcomeHintExit'
+ | 'welcomeScreenInfo'
+ | 'welcomeRawModeDisabled'
+ | 'cmdHelpHeader'
+ | 'cmdHelpExit'
+ | 'cmdHelpClear'
+ | 'cmdHelpHelp'
+ | 'cmdHelpLang'
+ | 'cmdHelpModel'
+ | 'cmdHelpProvider'
+ | 'cmdLangChanged'
+ | 'cmdLangCurrent'
+ | 'cmdLangInvalid'
+ | 'cmdModelChanged'
+ | 'cmdModelCurrent'
+ | 'cmdModelMissing'
+ | 'cmdProviderChanged'
+ | 'cmdProviderCurrent'
+ | 'cmdProviderInvalid'
+ | 'cmdProviderNeedsKey'
+ | 'cmdUnknown'
+ | 'cmdCleared'
+
+const TABLE: Record> = {
+ splashTagline: {
+ en: 'Forge, run, and orchestrate sandboxed LLM agents',
+ fr: "Forgez, lancez et orchestrez des agents LLM en sandbox",
+ },
+ splashCheckDocker: {
+ en: 'checking docker daemon',
+ fr: 'vérification du daemon docker',
+ },
+ splashCheckLLM: {
+ en: 'verifying llm endpoint',
+ fr: "vérification de l'endpoint llm",
+ },
+ splashCheckRuntime: {
+ en: 'loading agent runtime bundle',
+ fr: 'chargement du bundle runtime',
+ },
+ langPickerTitleEN: {
+ en: 'Choose your language',
+ fr: 'Choose your language',
+ },
+ langPickerTitleFR: {
+ en: 'Choisissez votre langue',
+ fr: 'Choisissez votre langue',
+ },
+ langPickerHintNavigate: {
+ en: 'navigate',
+ fr: 'navigate',
+ },
+ langPickerHintSelect: {
+ en: 'select',
+ fr: 'select',
+ },
+ langPickerHintExit: {
+ en: 'exit',
+ fr: 'exit',
+ },
+ langPickerScreenInfo: {
+ en: 'first run · language',
+ fr: 'first run · language',
+ },
+ welcomeHeaderLabel: {
+ en: 'welcome · new session',
+ fr: 'accueil · nouvelle session',
+ },
+ welcomeHeaderInfo: {
+ en: 'session: new',
+ fr: 'session : nouvelle',
+ },
+ welcomeTitle: {
+ en: 'What do you want to build today?',
+ fr: "Que voulez-vous construire aujourd'hui ?",
+ },
+ welcomeSubtitle: {
+ en: "Describe your project — I'll design and run a team of agents.",
+ fr: "Décrivez votre projet — je conçois et lance une équipe d'agents.",
+ },
+ welcomeSuggestion1: {
+ en: '▸ Build a Next.js + Laravel app with shadcn/ui and Sanctum auth',
+ fr: '▸ Construire une app Next.js + Laravel avec shadcn/ui et Sanctum',
+ },
+ welcomeSuggestion2: {
+ en: '▸ Audit this repository for security vulnerabilities',
+ fr: '▸ Auditer ce dépôt à la recherche de vulnérabilités',
+ },
+ welcomeSuggestion3: {
+ en: '▸ Migrate the codebase from JavaScript to TypeScript',
+ fr: '▸ Migrer le codebase de JavaScript vers TypeScript',
+ },
+ welcomeSuggestion4: {
+ en: '▸ Generate a weekly intelligence digest from RSS feeds',
+ fr: "▸ Générer un digest hebdomadaire d'intelligence depuis des flux RSS",
+ },
+ welcomeInputPlaceholder: {
+ en: 'Describe what you want to build...',
+ fr: 'Décrivez ce que vous voulez construire...',
+ },
+ welcomeHintSend: {
+ en: 'send',
+ fr: 'envoyer',
+ },
+ welcomeHintCommands: {
+ en: 'commands',
+ fr: 'commandes',
+ },
+ welcomeHintExit: {
+ en: 'exit',
+ fr: 'quitter',
+ },
+ welcomeScreenInfo: {
+ en: 'screen 1/1 · welcome',
+ fr: 'écran 1/1 · accueil',
+ },
+ welcomeRawModeDisabled: {
+ en: '(input disabled : terminal does not support raw mode)',
+ fr: "(saisie désactivée : le terminal ne supporte pas le mode raw)",
+ },
+ cmdHelpHeader: {
+ en: 'Available commands :',
+ fr: 'Commandes disponibles :',
+ },
+ cmdHelpExit: {
+ en: '/exit close the session',
+ fr: '/exit ferme la session',
+ },
+ cmdHelpClear: {
+ en: '/clear clear the conversation',
+ fr: '/clear vide la conversation',
+ },
+ cmdHelpHelp: {
+ en: '/help show this list',
+ fr: '/help affiche cette liste',
+ },
+ cmdHelpLang: {
+ en: '/lang [en|fr] show or change the UI language',
+ fr: '/lang [en|fr] affiche ou change la langue',
+ },
+ cmdHelpModel: {
+ en: '/model [] show or change the LLM model',
+ fr: '/model [] affiche ou change le modèle LLM',
+ },
+ cmdHelpProvider: {
+ en: '/provider [mlx|openai|anthropic|mistral] show or switch the provider',
+ fr: '/provider [mlx|openai|anthropic|mistral] affiche ou change le provider',
+ },
+ cmdLangChanged: {
+ en: 'Language changed.',
+ fr: 'Langue changée.',
+ },
+ cmdLangCurrent: {
+ en: 'Current language',
+ fr: 'Langue courante',
+ },
+ cmdLangInvalid: {
+ en: 'Unknown language. Use : en, fr.',
+ fr: 'Langue inconnue. Utilisez : en, fr.',
+ },
+ cmdModelChanged: {
+ en: 'Model changed for this session.',
+ fr: 'Modèle changé pour cette session.',
+ },
+ cmdModelCurrent: {
+ en: 'Current model',
+ fr: 'Modèle courant',
+ },
+ cmdModelMissing: {
+ en: 'Usage : /model ',
+ fr: 'Usage : /model ',
+ },
+ cmdProviderChanged: {
+ en: 'Provider switched.',
+ fr: 'Provider changé.',
+ },
+ cmdProviderCurrent: {
+ en: 'Current provider',
+ fr: 'Provider courant',
+ },
+ cmdProviderInvalid: {
+ en: 'Unknown provider. Use : mlx, openai, anthropic, mistral.',
+ fr: 'Provider inconnu. Utilisez : mlx, openai, anthropic, mistral.',
+ },
+ cmdProviderNeedsKey: {
+ en: 'This provider needs FORGE_API_KEY to be set in your environment.',
+ fr: 'Ce provider nécessite la variable FORGE_API_KEY dans votre environnement.',
+ },
+ cmdUnknown: {
+ en: 'Unknown command. Type /help for the list.',
+ fr: 'Commande inconnue. Tapez /help pour la liste.',
+ },
+ cmdCleared: {
+ en: 'Conversation cleared.',
+ fr: 'Conversation vidée.',
+ },
+}
+
+export function translate(key: StringKey, lang: Lang): string {
+ const entry = TABLE[key]
+ return entry[lang]
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
deleted file mode 100644
index 5f641c4..0000000
--- a/packages/cli/src/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-// @agent-forge/cli — entry point
-//
-// The conversational CLI builder. This is the `forge` binary.
-//
-// Phase POC : not implemented yet.
-// First milestone (P1) : "Hello agent in Docker".
-// See ../../../CLAUDE.md for context.
-
-export {}
diff --git a/packages/cli/src/index.tsx b/packages/cli/src/index.tsx
new file mode 100644
index 0000000..1b2f967
--- /dev/null
+++ b/packages/cli/src/index.tsx
@@ -0,0 +1,18 @@
+// @agent-forge/cli — entry point
+//
+// Conversational REPL for Agent Forge. The user dialogues with the builder
+// LLM, which will eventually design and orchestrate other agents.
+
+import { render } from 'ink'
+import React from 'react'
+import { App } from './components/App.tsx'
+import { ChatProvider } from './hooks/useChatContext.tsx'
+import { LanguageProvider } from './i18n/LanguageContext.tsx'
+
+render(
+
+
+
+
+ ,
+)
diff --git a/packages/cli/src/poc-p1.ts b/packages/cli/src/poc-p1.ts
new file mode 100644
index 0000000..de6b246
--- /dev/null
+++ b/packages/cli/src/poc-p1.ts
@@ -0,0 +1,146 @@
+// @agent-forge/cli — P1 proof of concept
+//
+// Smallest end-to-end demo of the architecture :
+// host orchestrator → docker run base image → mount runtime via volume →
+// pipe prompt to stdin → capture stdout → container removed (--rm).
+//
+// Requires :
+// - Docker daemon running
+// - Image agent-forge/base:latest built (see scripts/docker/build-base.sh)
+// - packages/runtime/dist/runtime.mjs built (cd packages/runtime && bun run build)
+// - An OpenAI-compatible LLM endpoint reachable from the container
+// (defaults to MLX at host.docker.internal:8080, see P1.2)
+//
+// Implementation note : we shell out to the docker CLI rather than using the
+// Docker Engine API (e.g. dockerode) because dockerode's `attach` upgrade
+// hangs under Bun. The CLI is plenty for P1 — we will switch to the API later
+// when we need finer-grained control (resource limits, healthchecks, …).
+
+import { spawn, spawnSync } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+
+const PROMPT = 'Write a haiku about Docker'
+const IMAGE = 'agent-forge/base:latest'
+const RUNTIME_DIST = new URL('../../runtime/dist', import.meta.url).pathname
+const RUNTIME_BUNDLE = join(RUNTIME_DIST, 'runtime.mjs')
+const CONTAINER_NAME = `agent-forge-poc-${process.pid}`
+const TIMEOUT_MS = Number(process.env.FORGE_POC_TIMEOUT_MS ?? '60000')
+const RUNTIME_BASE_URL = process.env.FORGE_BASE_URL ?? 'http://host.docker.internal:8080/v1'
+
+function fail(message: string): never {
+ process.stderr.write(`✗ ${message}\n`)
+ process.exit(1)
+}
+
+function preflight(): void {
+ // 1. Docker daemon reachable
+ const docker = spawnSync('docker', ['info'], { stdio: 'ignore' })
+ if (docker.error || docker.status !== 0) {
+ fail(
+ 'Docker daemon is not reachable.\n' +
+ ' Start Docker Desktop (or `colima start`) and try again.',
+ )
+ }
+
+ // 2. Base image present locally (we cannot `docker pull` because the image
+ // is not published to a registry yet — it is built locally).
+ const image = spawnSync('docker', ['image', 'inspect', IMAGE], { stdio: 'ignore' })
+ if (image.status !== 0) {
+ fail(
+ `Image ${IMAGE} is not built locally.\n` +
+ ' Run: bash scripts/docker/build-base.sh',
+ )
+ }
+
+ // 3. Runtime bundle built
+ if (!existsSync(RUNTIME_BUNDLE)) {
+ fail(
+ `Runtime bundle missing: ${RUNTIME_BUNDLE}\n` +
+ ' Run: cd packages/runtime && bun run build',
+ )
+ }
+}
+
+function forceRemoveContainer(): void {
+ // Best-effort sync cleanup. Used in signal handlers and on timeout.
+ spawnSync('docker', ['rm', '-f', CONTAINER_NAME], { stdio: 'ignore' })
+}
+
+async function main(): Promise {
+ preflight()
+
+ const args = [
+ 'run',
+ '--rm',
+ '-i',
+ '--name',
+ CONTAINER_NAME,
+ '-v',
+ `${RUNTIME_DIST}:/runtime:ro`,
+ '-e',
+ `FORGE_BASE_URL=${RUNTIME_BASE_URL}`,
+ IMAGE,
+ 'node',
+ '/runtime/runtime.mjs',
+ ]
+
+ const child = spawn('docker', args, {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ })
+
+ // Cleanup on Ctrl+C / SIGTERM. Also kill the child docker client.
+ const onSignal = (signal: NodeJS.Signals) => {
+ forceRemoveContainer()
+ child.kill('SIGTERM')
+ process.stderr.write(`\n✗ interrupted by ${signal}\n`)
+ process.exit(130) // 128 + SIGINT (2)
+ }
+ process.on('SIGINT', onSignal)
+ process.on('SIGTERM', onSignal)
+
+ // Hard timeout : kill the container if the runtime takes too long.
+ const timeout = setTimeout(() => {
+ forceRemoveContainer()
+ child.kill('SIGTERM')
+ process.stderr.write(`✗ runtime timeout after ${TIMEOUT_MS / 1000}s\n`)
+ process.exit(1)
+ }, TIMEOUT_MS)
+
+ try {
+ const stdoutChunks: Buffer[] = []
+ const stderrChunks: Buffer[] = []
+ child.stdout.on('data', (c: Buffer) => stdoutChunks.push(c))
+ child.stderr.on('data', (c: Buffer) => stderrChunks.push(c))
+
+ child.stdin.write(PROMPT)
+ child.stdin.end()
+
+ const exitCode: number = await new Promise((res, rej) => {
+ child.on('error', rej)
+ child.on('close', (code) => res(code ?? 1))
+ })
+
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8').trim()
+ const stderr = Buffer.concat(stderrChunks).toString('utf8').trim()
+
+ if (stderr) {
+ process.stderr.write(`${stderr}\n`)
+ }
+ if (stdout) {
+ process.stdout.write(`${stdout}\n`)
+ }
+ if (exitCode !== 0) {
+ process.exit(exitCode)
+ }
+ } finally {
+ clearTimeout(timeout)
+ forceRemoveContainer()
+ }
+}
+
+main().catch((err) => {
+ const msg = err instanceof Error ? err.message : String(err)
+ console.error(`✗ poc-p1 error: ${msg}`)
+ process.exit(1)
+})
diff --git a/packages/cli/src/session/store.ts b/packages/cli/src/session/store.ts
new file mode 100644
index 0000000..686ba80
--- /dev/null
+++ b/packages/cli/src/session/store.ts
@@ -0,0 +1,138 @@
+// Session persistence : every turn (user, assistant, agent, system) is
+// appended to ~/.agent-forge/sessions//transcript.jsonl as a single
+// JSON line. Replayable and grep-friendly.
+//
+// We do NOT persist the streaming partial state — only finalized turns,
+// once their content is committed.
+
+import {
+ appendFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ statSync,
+} from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import type { ChatTurn } from '../hooks/useChat.ts'
+
+const SESSIONS_DIR = join(homedir(), '.agent-forge', 'sessions')
+
+export type SessionRecord = {
+ id: string
+ startedAt: string // ISO
+ turns: number
+ lastTurnAt?: string
+}
+
+export type PersistedTurn = ChatTurn & { ts: string }
+
+function newSessionId(): string {
+ const ts = new Date().toISOString().replace(/[:.]/g, '-')
+ const rand = Math.random().toString(36).slice(2, 8)
+ return `${ts}-${rand}`
+}
+
+export class Session {
+ readonly id: string
+ readonly dir: string
+ readonly transcriptPath: string
+
+ private constructor(id: string) {
+ this.id = id
+ this.dir = join(SESSIONS_DIR, id)
+ this.transcriptPath = join(this.dir, 'transcript.jsonl')
+ }
+
+ static start(): Session {
+ const session = new Session(newSessionId())
+ mkdirSync(session.dir, { recursive: true })
+ return session
+ }
+
+ static resume(id: string): Session {
+ const session = new Session(id)
+ if (!existsSync(session.transcriptPath)) {
+ throw new Error(`session not found : ${id}`)
+ }
+ return session
+ }
+
+ appendTurn(turn: ChatTurn): void {
+ const persisted: PersistedTurn = { ...turn, ts: new Date().toISOString() }
+ appendFileSync(
+ this.transcriptPath,
+ `${JSON.stringify(persisted)}\n`,
+ 'utf8',
+ )
+ }
+
+ loadTurns(): ChatTurn[] {
+ if (!existsSync(this.transcriptPath)) return []
+ const raw = readFileSync(this.transcriptPath, 'utf8')
+ const out: ChatTurn[] = []
+ for (const line of raw.split('\n')) {
+ if (line.trim().length === 0) continue
+ try {
+ const parsed = JSON.parse(line) as PersistedTurn
+ // Strip the ts before handing back to the chat state.
+ const { ts: _ts, ...turn } = parsed
+ void _ts
+ out.push(turn)
+ } catch {
+ // Skip corrupted lines silently — better than crashing the whole resume.
+ }
+ }
+ return out
+ }
+}
+
+// Module-level singleton : the current session for this CLI process. Lazily
+// instantiated on first access so tests and tools can stay quiet.
+let CURRENT: Session | null = null
+
+export function getCurrentSession(): Session {
+ if (!CURRENT) CURRENT = Session.start()
+ return CURRENT
+}
+
+export function setCurrentSession(s: Session): void {
+ CURRENT = s
+}
+
+export function listSessions(): SessionRecord[] {
+ if (!existsSync(SESSIONS_DIR)) return []
+ const entries = readdirSync(SESSIONS_DIR)
+ const records: SessionRecord[] = []
+ for (const name of entries) {
+ const dir = join(SESSIONS_DIR, name)
+ const transcriptPath = join(dir, 'transcript.jsonl')
+ if (!existsSync(transcriptPath)) continue
+ let turns = 0
+ let lastTurnAt: string | undefined
+ try {
+ const lines = readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean)
+ turns = lines.length
+ const last = lines[lines.length - 1]
+ if (last) {
+ const parsed = JSON.parse(last) as PersistedTurn
+ lastTurnAt = parsed.ts
+ }
+ } catch {
+ // ignore
+ }
+ let startedAt = name
+ try {
+ startedAt = statSync(dir).birthtime.toISOString()
+ } catch {
+ // fall back to the id
+ }
+ records.push({ id: name, startedAt, turns, lastTurnAt })
+ }
+ // Most recent first.
+ records.sort((a, b) =>
+ (b.lastTurnAt ?? b.startedAt).localeCompare(a.lastTurnAt ?? a.startedAt),
+ )
+ return records
+}
diff --git a/packages/cli/src/theme/colors.ts b/packages/cli/src/theme/colors.ts
new file mode 100644
index 0000000..7688d63
--- /dev/null
+++ b/packages/cli/src/theme/colors.ts
@@ -0,0 +1,33 @@
+// Agent Forge — palette
+// Ported from demo-sprites/forge-mockup-v3.mjs (visual identity validated).
+// Background is NEVER forced — we always respect the user's terminal background.
+
+export type RGB = readonly [number, number, number]
+
+export const ORANGE: RGB = [255, 140, 30]
+export const ORANGE_DIM: RGB = [160, 80, 20]
+export const ORANGE_BRIGHT: RGB = [255, 200, 80]
+export const YELLOW: RGB = [240, 220, 80]
+export const RED: RGB = [240, 80, 80]
+export const GREEN: RGB = [80, 200, 120]
+export const BLUE: RGB = [100, 160, 240]
+export const GREY: RGB = [120, 120, 130]
+export const GREY_LIGHT: RGB = [180, 180, 190]
+export const WHITE: RGB = [240, 240, 245]
+
+// Ink expects color as a string. Hex is the most portable form.
+const toHex = ([r, g, b]: RGB): string =>
+ `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
+
+export const C = {
+ orange: toHex(ORANGE),
+ orangeDim: toHex(ORANGE_DIM),
+ orangeBright: toHex(ORANGE_BRIGHT),
+ yellow: toHex(YELLOW),
+ red: toHex(RED),
+ green: toHex(GREEN),
+ blue: toHex(BLUE),
+ grey: toHex(GREY),
+ greyLight: toHex(GREY_LIGHT),
+ white: toHex(WHITE),
+}
diff --git a/packages/cli/src/theme/logo.ts b/packages/cli/src/theme/logo.ts
new file mode 100644
index 0000000..b4b62a3
--- /dev/null
+++ b/packages/cli/src/theme/logo.ts
@@ -0,0 +1,21 @@
+// Agent Forge — splash screen ASCII logo.
+// Two stacked words: "AGENT" in orange-bright, "FORGE" in orange.
+// Ported from demo-sprites/forge-mockup-v3.mjs.
+
+export const LOGO_AGENT: readonly string[] = [
+ ' █████╗ ██████╗ ███████╗███╗ ██╗████████╗',
+ ' ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝',
+ ' ███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ',
+ ' ██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ',
+ ' ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ',
+ ' ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ',
+]
+
+export const LOGO_FORGE: readonly string[] = [
+ ' ███████╗ ██████╗ ██████╗ ██████╗ ███████╗',
+ ' ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝',
+ ' █████╗ ██║ ██║██████╔╝██║ ███╗█████╗ ',
+ ' ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝ ',
+ ' ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗',
+ ' ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝',
+]
diff --git a/packages/cli/tests/app.test.tsx b/packages/cli/tests/app.test.tsx
new file mode 100644
index 0000000..71ab8dc
--- /dev/null
+++ b/packages/cli/tests/app.test.tsx
@@ -0,0 +1,29 @@
+// Smoke test for the Ink app : render , assert the splash logo
+// is visible. We do NOT exercise the streaming chat (would require mocking
+// the LLM SDK and an interactive TextInput) — just the boot rendering.
+
+import { describe, expect, test } from 'bun:test'
+import { render } from 'ink-testing-library'
+import React from 'react'
+import { App } from '../src/components/App.tsx'
+import { ChatProvider } from '../src/hooks/useChatContext.tsx'
+import { LanguageProvider } from '../src/i18n/LanguageContext.tsx'
+
+describe('', () => {
+ test('boots without crashing and shows the AGENT FORGE logo', () => {
+ const { lastFrame, unmount } = render(
+
+
+
+
+ ,
+ )
+ const frame = lastFrame() ?? ''
+ // The splash ASCII logo is built from box-drawing characters, but the
+ // word "AGENT FORGE" is also referenced in the header bar of the
+ // welcome block, which is enough to assert the app rendered.
+ expect(frame.length).toBeGreaterThan(0)
+ expect(frame).toMatch(/Forge|AGENT|forge/i)
+ unmount()
+ })
+})
diff --git a/packages/cli/tests/builder-actions.test.ts b/packages/cli/tests/builder-actions.test.ts
new file mode 100644
index 0000000..25c58d1
--- /dev/null
+++ b/packages/cli/tests/builder-actions.test.ts
@@ -0,0 +1,214 @@
+// Parser tests for the text-structured action protocol.
+
+import { describe, expect, test } from 'bun:test'
+import { existsSync, rmSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach } from 'bun:test'
+import { executeAction, findActionBlocks } from '../src/builder-actions.ts'
+
+const TEST_AGENT = `agent-test-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`
+const TEST_DIR = join(homedir(), '.agent-forge', 'agents', TEST_AGENT)
+
+afterEach(() => {
+ if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true, force: true })
+})
+
+describe('findActionBlocks (write)', () => {
+ test('parses a single valid write block', () => {
+ const md = `Sure, here is the agent :
+
+\`\`\`forge:write
+path: agents/haiku-writer/AGENT.md
+---
+---
+name: haiku-writer
+description: writes haiku
+sandbox:
+ image: agent-forge/base:latest
+ timeout: 60s
+maxTurns: 1
+---
+
+You are a poet.
+\`\`\`
+
+Confirm to proceed.`
+ const blocks = findActionBlocks(md)
+ expect(blocks.length).toBe(1)
+ const first = blocks[0]
+ expect(first?.ok).toBe(true)
+ if (first?.ok && first.action.kind === 'write') {
+ expect(first.action.path).toBe('agents/haiku-writer/AGENT.md')
+ expect(first.action.content).toContain('name: haiku-writer')
+ expect(first.action.content).toContain('You are a poet.')
+ }
+ })
+
+ test('reports a malformed write block (missing path/---)', () => {
+ const md = "\`\`\`forge:write\nno path here\n\`\`\`"
+ const blocks = findActionBlocks(md)
+ expect(blocks.length).toBe(1)
+ expect(blocks[0]?.ok).toBe(false)
+ })
+
+ test('returns empty when no block', () => {
+ expect(findActionBlocks('hello world').length).toBe(0)
+ })
+
+ test('parses multiple write blocks', () => {
+ const md = `\`\`\`forge:write
+path: a.md
+---
+A
+\`\`\`
+
+and
+
+\`\`\`forge:write
+path: b.md
+---
+B
+\`\`\``
+ const blocks = findActionBlocks(md)
+ expect(blocks.length).toBe(2)
+ expect(blocks.every((b) => b.ok)).toBe(true)
+ })
+})
+
+describe('findActionBlocks (run)', () => {
+ test('parses a single valid run block', () => {
+ const md = `Lancement :
+
+\`\`\`forge:run
+agent: haiku-writer
+---
+écris un haiku sur Docker
+\`\`\``
+ const blocks = findActionBlocks(md)
+ expect(blocks.length).toBe(1)
+ const first = blocks[0]
+ expect(first?.ok).toBe(true)
+ if (first?.ok && first.action.kind === 'run') {
+ expect(first.action.agent).toBe('haiku-writer')
+ expect(first.action.prompt).toBe('écris un haiku sur Docker')
+ }
+ })
+
+ test('rejects run with non-kebab-case agent name', () => {
+ const md = `\`\`\`forge:run
+agent: HaikuWriter
+---
+hello
+\`\`\``
+ const blocks = findActionBlocks(md)
+ expect(blocks[0]?.ok).toBe(false)
+ })
+
+ test('rejects run with empty prompt', () => {
+ const md = `\`\`\`forge:run
+agent: haiku-writer
+---
+
+\`\`\``
+ const blocks = findActionBlocks(md)
+ expect(blocks[0]?.ok).toBe(false)
+ })
+
+ test('parses mixed write + run in the same message', () => {
+ const md = `\`\`\`forge:write
+path: agents/foo/AGENT.md
+---
+content
+\`\`\`
+
+\`\`\`forge:run
+agent: foo
+---
+prompt
+\`\`\``
+ const blocks = findActionBlocks(md)
+ expect(blocks.length).toBe(2)
+ expect(blocks[0]?.ok).toBe(true)
+ expect(blocks[1]?.ok).toBe(true)
+ if (blocks[0]?.ok) expect(blocks[0].action.kind).toBe('write')
+ if (blocks[1]?.ok) expect(blocks[1].action.kind).toBe('run')
+ })
+})
+
+describe('executeAction (path coercion + agent validation)', () => {
+ const validFrontmatter = `---
+name: ${TEST_AGENT}
+description: A test agent.
+sandbox:
+ image: agent-forge/base:latest
+ timeout: 60s
+maxTurns: 1
+---
+
+# test
+
+You are a test agent.`
+
+ test('coerces agents//.md to AGENT.md', () => {
+ const exec = executeAction({
+ kind: 'write',
+ path: `agents/${TEST_AGENT}/${TEST_AGENT}.md`,
+ content: validFrontmatter,
+ raw: '',
+ })
+ expect(exec.kind).toBe('write')
+ if (exec.kind === 'write') {
+ expect(exec.path).toBe(`agents/${TEST_AGENT}/AGENT.md`)
+ expect(exec.result.ok).toBe(true)
+ if (exec.result.ok) {
+ expect(exec.result.absolutePath).toMatch(/AGENT\.md$/)
+ }
+ }
+ })
+
+ test('rejects an invalid AGENT.md (missing required fields)', () => {
+ const exec = executeAction({
+ kind: 'write',
+ path: `agents/${TEST_AGENT}/AGENT.md`,
+ content: '# just a title',
+ raw: '',
+ })
+ expect(exec.kind).toBe('write')
+ if (exec.kind === 'write') expect(exec.result.ok).toBe(false)
+ })
+
+ test('normalizes a missing leading --- in frontmatter', () => {
+ const noOpener = `name: ${TEST_AGENT}
+description: A test.
+sandbox:
+ image: agent-forge/base:latest
+ timeout: 60s
+maxTurns: 1
+---
+
+body`
+ const exec = executeAction({
+ kind: 'write',
+ path: `agents/${TEST_AGENT}/AGENT.md`,
+ content: noOpener,
+ raw: '',
+ })
+ expect(exec.kind).toBe('write')
+ if (exec.kind === 'write') expect(exec.result.ok).toBe(true)
+ })
+
+ test('run action passes through pre-flight (actual launch is async)', () => {
+ const exec = executeAction({
+ kind: 'run',
+ agent: 'haiku-writer',
+ prompt: 'hello',
+ raw: '',
+ })
+ expect(exec.kind).toBe('run')
+ if (exec.kind === 'run') {
+ expect(exec.agent).toBe('haiku-writer')
+ expect(exec.result.ok).toBe(true)
+ }
+ })
+})
diff --git a/packages/cli/tests/commands.test.ts b/packages/cli/tests/commands.test.ts
new file mode 100644
index 0000000..f3a6948
--- /dev/null
+++ b/packages/cli/tests/commands.test.ts
@@ -0,0 +1,65 @@
+// Smoke tests for the slash command parser.
+// Pure logic — no Ink, no LLM, fast.
+
+import { describe, expect, mock, test } from 'bun:test'
+import { isCommand, runCommand } from '../src/commands.ts'
+
+function makeCtx(overrides: Partial[1]> = {}) {
+ return {
+ lang: 'en' as const,
+ setLang: mock(() => {}),
+ clearChat: mock(() => {}),
+ exit: mock(() => {}),
+ ...overrides,
+ }
+}
+
+describe('isCommand', () => {
+ test('detects leading slash', () => {
+ expect(isCommand('/help')).toBe(true)
+ expect(isCommand(' /help')).toBe(true)
+ })
+ test('rejects non-commands', () => {
+ expect(isCommand('hello')).toBe(false)
+ expect(isCommand('')).toBe(false)
+ })
+})
+
+describe('runCommand', () => {
+ test('/help returns the help block', () => {
+ const out = runCommand('/help', makeCtx())
+ expect(out.lines.length).toBeGreaterThan(3)
+ expect(out.lines[0]).toContain('Available commands')
+ })
+
+ test('/clear triggers clearChat', () => {
+ const ctx = makeCtx()
+ runCommand('/clear', ctx)
+ expect(ctx.clearChat).toHaveBeenCalledTimes(1)
+ })
+
+ test('/exit triggers exit', () => {
+ const ctx = makeCtx()
+ runCommand('/exit', ctx)
+ expect(ctx.exit).toHaveBeenCalledTimes(1)
+ })
+
+ test('/lang fr changes language', () => {
+ const ctx = makeCtx()
+ const out = runCommand('/lang fr', ctx)
+ expect(ctx.setLang).toHaveBeenCalledWith('fr')
+ expect(out.lines[0]).toBeDefined()
+ })
+
+ test('/lang invalid is rejected', () => {
+ const ctx = makeCtx()
+ const out = runCommand('/lang klingon', ctx)
+ expect(ctx.setLang).not.toHaveBeenCalled()
+ expect(out.lines[0]).toContain('Unknown language')
+ })
+
+ test('unknown command produces an error line', () => {
+ const out = runCommand('/totallymadeup', makeCtx())
+ expect(out.lines[0]).toContain('Unknown command')
+ })
+})
diff --git a/packages/cli/tests/store.test.ts b/packages/cli/tests/store.test.ts
new file mode 100644
index 0000000..6521b16
--- /dev/null
+++ b/packages/cli/tests/store.test.ts
@@ -0,0 +1,45 @@
+// Smoke test for the config store : round-trip a config through disk.
+// We back up and restore the user's real config to avoid clobbering it.
+
+import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
+import { existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import { loadConfig, saveConfig } from '../src/config/store.ts'
+
+const REAL_PATH = join(homedir(), '.agent-forge', 'config.json')
+const BACKUP_PATH = `${REAL_PATH}.bak-test-${Date.now().toString()}`
+let hadConfig = false
+
+beforeAll(() => {
+ hadConfig = existsSync(REAL_PATH)
+ if (hadConfig) renameSync(REAL_PATH, BACKUP_PATH)
+})
+
+afterAll(() => {
+ if (existsSync(REAL_PATH)) unlinkSync(REAL_PATH)
+ if (hadConfig) renameSync(BACKUP_PATH, REAL_PATH)
+})
+
+describe('config store', () => {
+ test('returns empty object when no file exists', () => {
+ if (existsSync(REAL_PATH)) unlinkSync(REAL_PATH)
+ expect(loadConfig()).toEqual({})
+ })
+
+ test('saves and reloads a config', () => {
+ saveConfig({ lang: 'fr', model: 'foo/bar' })
+ expect(loadConfig()).toEqual({ lang: 'fr', model: 'foo/bar' })
+ // Sanity check : the file actually exists.
+ expect(existsSync(REAL_PATH)).toBe(true)
+ expect(JSON.parse(readFileSync(REAL_PATH, 'utf8'))).toEqual({
+ lang: 'fr',
+ model: 'foo/bar',
+ })
+ })
+
+ test('returns empty object on corrupt JSON', () => {
+ require('node:fs').writeFileSync(REAL_PATH, '{not valid', 'utf8')
+ expect(loadConfig()).toEqual({})
+ })
+})
diff --git a/packages/core/README.md b/packages/core/README.md
index 33c50ab..3668fc8 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -1,22 +1,23 @@
# @agent-forge/core
-Core primitives for Agent Forge.
+Primitives de base d'Agent Forge.
-## What's inside
+## Contenu (état P3)
-- **`builder/`** — the conversational LLM agent that designs other agents
-- **`docker/`** — Docker sandbox management (create, start, stop, mount, network)
-- **`tools/`** — Tool interface (`Tool`)
-- **`types/`** — shared TypeScript types
+- **`builder/`** — l'agent LLM conversationnel qui conçoit les autres agents
+ - `provider.ts` — résout `FORGE_BASE_URL` / `FORGE_API_KEY` / `FORGE_MODEL`, supporte les overrides à chaud (`/provider`, `/model`)
+ - `system-prompt.ts` — prompt système bilingue EN/FR avec ACTION PROTOCOL et RUN PROTOCOL (fenced blocks `forge:write` et `forge:run`)
+ - `stream.ts` — `streamBuilder({ messages, lang })` via Vercel AI SDK
+- **`types/agent-md.ts`** — `parseAgentMd(text)` : sépare frontmatter / body, valide via Zod (name kebab-case, description non vide, sandbox.image, sandbox.timeout, maxTurns)
-## Status
+## À venir
-**Phase POC. Not implemented yet.** See `../../CLAUDE.md` and `../../SESSION-RECAP.md`.
+- **`docker/`** — abstraction sandbox (P5 : agents persistants via `docker exec`, pas seulement `run --rm`)
+- **`tools/`** — interface `Tool` partagée (P4)
## Dependencies
-- `@anthropic-ai/sdk` — LLM provider
-- `@modelcontextprotocol/sdk` — MCP integration
-- `dockerode` — Docker control from Node
-- `zod` — schema validation
-- `yaml` — parsing AGENT.md / TEAM.md frontmatter
+- `ai`, `@ai-sdk/openai` — Vercel AI SDK pour les appels LLM provider-agnostic
+- `zod` — validation du frontmatter `AGENT.md`
+- `@modelcontextprotocol/sdk` — intégration MCP (P6+)
+- `yaml` — parsing du frontmatter
diff --git a/packages/core/package.json b/packages/core/package.json
index d9b351a..84419fe 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -19,11 +19,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@anthropic-ai/sdk": "^0.32.0",
+ "@ai-sdk/openai": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.0.0",
+ "ai": "^4.0.0",
"dockerode": "^4.0.0",
- "zod": "^3.23.0",
- "yaml": "^2.6.0"
+ "yaml": "^2.6.0",
+ "zod": "^3.23.0"
},
"devDependencies": {
"@types/dockerode": "^3.3.0"
diff --git a/packages/core/src/builder/index.ts b/packages/core/src/builder/index.ts
new file mode 100644
index 0000000..696cea8
--- /dev/null
+++ b/packages/core/src/builder/index.ts
@@ -0,0 +1,11 @@
+export {
+ FORGE_MODEL,
+ clearProviderOverride,
+ getCurrentBaseURL,
+ getCurrentModelName,
+ getBuilderModel,
+ setProviderOverride,
+ type ProviderConfig,
+} from './provider.ts'
+export { type ChatMessage, type ChatRole, streamBuilder } from './stream.ts'
+export { type BuilderLang, getBuilderSystemPrompt } from './system-prompt.ts'
diff --git a/packages/core/src/builder/provider.ts b/packages/core/src/builder/provider.ts
new file mode 100644
index 0000000..7a2ccb0
--- /dev/null
+++ b/packages/core/src/builder/provider.ts
@@ -0,0 +1,53 @@
+// LLM provider factory — wraps Vercel AI SDK + an OpenAI-compatible
+// endpoint. Defaults match the runtime (P1.2) so the builder talks to
+// the same MLX server by default. Config is read live each time so the
+// CLI can hot-swap provider/model without a restart.
+
+import { createOpenAI } from '@ai-sdk/openai'
+import type { LanguageModelV1 } from 'ai'
+
+export type ProviderConfig = {
+ baseURL: string
+ apiKey: string
+ model: string
+}
+
+let override: Partial = {}
+
+export function setProviderOverride(patch: Partial): void {
+ override = { ...override, ...patch }
+}
+
+export function clearProviderOverride(): void {
+ override = {}
+}
+
+function readConfig(): ProviderConfig {
+ return {
+ baseURL:
+ override.baseURL ??
+ process.env.FORGE_BASE_URL ??
+ 'https://api.mistral.ai/v1',
+ apiKey: override.apiKey ?? process.env.FORGE_API_KEY ?? 'not-needed',
+ model:
+ override.model ??
+ process.env.FORGE_MODEL ??
+ 'mistral-small-latest',
+ }
+}
+
+export function getCurrentModelName(): string {
+ return readConfig().model
+}
+
+export function getCurrentBaseURL(): string {
+ return readConfig().baseURL
+}
+
+export function getBuilderModel(): LanguageModelV1 {
+ const cfg = readConfig()
+ return createOpenAI({ baseURL: cfg.baseURL, apiKey: cfg.apiKey })(cfg.model)
+}
+
+// Backwards-compatibility export (used by the CLI Welcome header).
+export const FORGE_MODEL = getCurrentModelName()
diff --git a/packages/core/src/builder/stream.ts b/packages/core/src/builder/stream.ts
new file mode 100644
index 0000000..1722f7c
--- /dev/null
+++ b/packages/core/src/builder/stream.ts
@@ -0,0 +1,42 @@
+// streamBuilder — call the builder LLM with the full message history and
+// stream back text chunks as they arrive. Reads the provider config live
+// so /provider and /model switches take effect on the next call.
+//
+// Note : we do NOT use Vercel AI SDK native tool-use because mlx_lm.server
+// (our default local backend) does not implement OpenAI tool_calls. Instead
+// the CLI parses fenced action blocks the builder emits in plain text. See
+// packages/cli/src/builder-actions.ts.
+
+import { streamText } from 'ai'
+import { getBuilderModel } from './provider.ts'
+import { type BuilderLang, getBuilderSystemPrompt } from './system-prompt.ts'
+
+export type ChatRole = 'user' | 'assistant'
+
+export type ChatMessage = {
+ role: ChatRole
+ content: string
+}
+
+export type StreamBuilderArgs = {
+ messages: ChatMessage[]
+ lang: BuilderLang
+}
+
+export async function* streamBuilder({
+ messages,
+ lang,
+}: StreamBuilderArgs): AsyncGenerator {
+ const result = streamText({
+ model: getBuilderModel(),
+ system: getBuilderSystemPrompt(lang),
+ messages,
+ // 512 leaves room for a full forge:write block (~300 tokens) plus a
+ // short intro sentence. Override via FORGE_MAX_TOKENS if needed.
+ maxTokens: Number(process.env.FORGE_MAX_TOKENS ?? '512'),
+ })
+
+ for await (const chunk of result.textStream) {
+ yield chunk
+ }
+}
diff --git a/packages/core/src/builder/system-prompt.ts b/packages/core/src/builder/system-prompt.ts
new file mode 100644
index 0000000..81ab986
--- /dev/null
+++ b/packages/core/src/builder/system-prompt.ts
@@ -0,0 +1,145 @@
+// Builder system prompt — defines the LLM's identity as the Agent Forge
+// builder. Bilingual (EN / FR) so the user dialogues in their chosen
+// language. Kept short on purpose : skill files (P6) will enrich it later.
+//
+// Action protocol (text-structured, since our local backend does not yet
+// support OpenAI tool_calls) : the builder emits a fenced ```forge:write
+// block whose body starts with `path:` then `---` then the file content.
+// The CLI parses these blocks, executes them after user confirmation, and
+// echoes the result back as a system message in the next turn.
+
+export type BuilderLang = 'en' | 'fr'
+
+const ACTION_BLOCK_EN = `
+ACTION PROTOCOL :
+
+You CANNOT call functions directly. To create a file, you emit a fenced block formatted EXACTLY like this :
+
+\`\`\`forge:write
+path: agents/haiku-writer/AGENT.md
+---
+---
+name: haiku-writer
+description: Writes a haiku in 5-7-5 about the user's topic.
+sandbox:
+ image: agent-forge/base:latest
+ timeout: 60s
+maxTurns: 1
+---
+
+# haiku-writer
+
+You are a haiku poet. Answer with exactly three lines, syllables 5-7-5.
+\`\`\`
+
+ABSOLUTE rules — failing any of these IS A BUG :
+- The path MUST be exactly \`agents//AGENT.md\`. The filename MUST be the literal string \`AGENT.md\`. Never invent variants like \`haiku-writer.md\` or \`HAIKU-WRITER.md\`.
+- The file content MUST start with a YAML frontmatter block : a line \`---\`, then the YAML keys (name, description, sandbox, maxTurns), then a closing \`---\`, then the body. Look at the example above carefully — there are TWO \`---\` after the \`path:\` line : the first one separates the path from the content, the second one OPENS the frontmatter.
+- The block opens with three backticks + \`forge:write\` and CLOSES with three backticks on their own line.
+- Replace placeholders with real values. Do not keep angle brackets.
+- Always propose the block first and ask the user to confirm with "yes" / "go" / "ok" before re-emitting it.
+- After confirmation, re-emit the same block verbatim. The runtime will execute it and reply with a system line confirming the path written.
+- Path must be RELATIVE to ~/.agent-forge/. Files cannot be overwritten — pick a unique name.
+- Only one write block per turn.
+
+To LAUNCH an agent (after its AGENT.md exists), emit a forge:run block. You decide the prompt — the user does NOT talk to the agent directly, only to you. Format :
+
+\`\`\`forge:run
+agent: haiku-writer
+---
+write a haiku about Docker
+\`\`\`
+
+Run rules :
+- The agent name must reference an AGENT.md you (or someone) wrote earlier.
+- The prompt is the message YOU formulate based on the user's intent.
+- You may emit MULTIPLE forge:run blocks in the same turn — they run in parallel containers, results streamed back into the conversation.
+- Each forge:run requires the user's confirmation in a system dialog. After approval the agent's reply appears in the transcript prefixed with \` ◆ :\`.
+- Use forge:run when the user wants to test or use an existing agent, not when they just want it created.
+`
+
+const ACTION_BLOCK_FR = `
+PROTOCOLE D'ACTION :
+
+Tu ne peux PAS appeler des fonctions directement. Pour créer un fichier, tu produis un bloc encadré EXACTEMENT comme ceci :
+
+\`\`\`forge:write
+path: agents/haiku-writer/AGENT.md
+---
+---
+name: haiku-writer
+description: Écrit un haïku en 5-7-5 sur le sujet de l'utilisateur.
+sandbox:
+ image: agent-forge/base:latest
+ timeout: 60s
+maxTurns: 1
+---
+
+# haiku-writer
+
+Tu es un poète haïku. Réponds par exactement trois lignes, syllabes 5-7-5.
+\`\`\`
+
+Règles ABSOLUES — toute violation EST UN BUG :
+- Le chemin DOIT être exactement \`agents//AGENT.md\`. Le nom de fichier DOIT être la chaîne littérale \`AGENT.md\`. N'invente jamais de variante comme \`haiku-writer.md\` ou \`HAIKU-WRITER.md\`.
+- Le contenu du fichier DOIT commencer par un bloc YAML frontmatter : une ligne \`---\`, puis les clés YAML (name, description, sandbox, maxTurns), puis un \`---\` de fermeture, puis le corps. Regarde bien l'exemple ci-dessus — il y a DEUX \`---\` après la ligne \`path:\` : le premier sépare le path du contenu, le second OUVRE le frontmatter.
+- Le bloc s'ouvre par trois backticks + \`forge:write\` et se FERME par trois backticks sur leur propre ligne.
+- Remplace les placeholders par des vraies valeurs. Ne laisse pas les chevrons.
+- Propose toujours le bloc d'abord et demande la confirmation (« oui » / « ok » / « go ») avant de le ré-émettre.
+- Une fois confirmé, ré-émets le même bloc à l'identique. Le runtime l'exécutera et répondra par une ligne système confirmant le chemin écrit.
+- Le chemin doit être RELATIF à ~/.agent-forge/. Les fichiers ne peuvent pas être écrasés — choisis un nom unique.
+- Un seul bloc write par tour.
+
+Pour LANCER un agent (après que son AGENT.md ait été créé), émets un bloc forge:run. C'est TOI qui formules le prompt — l'utilisateur NE parle PAS directement à l'agent, seulement à toi. Format :
+
+\`\`\`forge:run
+agent: haiku-writer
+---
+écris un haïku sur Docker
+\`\`\`
+
+Règles run :
+- Le nom de l'agent doit référencer un AGENT.md créé précédemment.
+- Le prompt est le message que TU formules à partir de l'intention de l'utilisateur.
+- Tu peux émettre PLUSIEURS blocs forge:run dans le même tour — ils tourneront en containers parallèles, les résultats sont streamés dans la conversation.
+- Chaque forge:run demande confirmation à l'utilisateur via un dialog système. Après accord, la réponse de l'agent apparaît dans le transcript préfixée par \` ◆ :\`.
+- Utilise forge:run quand l'utilisateur veut tester ou utiliser un agent existant, pas seulement pour le créer.
+`
+
+const EN = `You are the Agent Forge builder.
+
+Your job: help the user describe a software task in plain language, then design and (when confirmed) launch a small LLM agent that handles it. Each agent runs inside a sandboxed Docker container.
+
+DEFAULT BEHAVIOUR — BE DECISIVE :
+- The moment the user describes ANY agent ("create an agent that …", "I want an agent for …", "build me a …"), IMMEDIATELY propose the AGENT.md as a forge:write block. Do NOT ask clarifying questions first.
+- If the user did not give a name, invent a kebab-case name yourself based on the task (e.g. "haiku-writer", "code-reviewer", "rss-summarizer").
+- If the user did not specify a sandbox, default to image: agent-forge/base:latest, timeout: 60s, maxTurns: 1.
+- Only ask a clarifying question if the user's message is genuinely ambiguous about WHAT the agent should DO. Vague requests like "create an agent that writes haiku" are NOT ambiguous — propose immediately.
+- Keep prose minimal. Two short sentences max before the block.
+
+After you proposed the block, wait for the user's confirmation ("yes" / "go" / "ok") and re-emit the same block verbatim.
+
+${ACTION_BLOCK_EN}
+
+Always answer in English.`
+
+const FR = `Tu es le builder Agent Forge.
+
+Ton rôle : aider l'utilisateur à décrire une tâche logicielle en langage naturel, puis concevoir et (quand il a confirmé) lancer un petit agent LLM qui s'en occupe. Chaque agent tourne dans un container Docker isolé.
+
+COMPORTEMENT PAR DÉFAUT — SOIS DÉCISIF :
+- Dès que l'utilisateur décrit UN agent (« crée un agent qui … », « je veux un agent pour … », « construis-moi un … »), propose IMMÉDIATEMENT l'AGENT.md sous forme de bloc forge:write. Ne pose PAS de question de clarification d'abord.
+- Si l'utilisateur n'a pas donné de nom, invente un nom en kebab-case basé sur la tâche (ex : « haiku-writer », « code-reviewer », « rss-summarizer »).
+- Si l'utilisateur n'a pas précisé de sandbox, utilise par défaut image: agent-forge/base:latest, timeout: 60s, maxTurns: 1.
+- Ne pose une question de clarification QUE si la demande est réellement ambiguë sur CE QUE l'agent doit FAIRE. Une demande vague comme « crée un agent qui écrit des haikus » n'est PAS ambiguë — propose immédiatement.
+- Garde la prose minimale. Deux phrases courtes maximum avant le bloc.
+
+Après avoir proposé le bloc, attends la confirmation de l'utilisateur (« oui » / « ok » / « go ») et ré-émets le même bloc à l'identique.
+
+${ACTION_BLOCK_FR}
+
+Réponds toujours en français.`
+
+export function getBuilderSystemPrompt(lang: BuilderLang): string {
+ return lang === 'fr' ? FR : EN
+}
diff --git a/packages/core/src/types/agent-md.ts b/packages/core/src/types/agent-md.ts
new file mode 100644
index 0000000..73c405f
--- /dev/null
+++ b/packages/core/src/types/agent-md.ts
@@ -0,0 +1,83 @@
+// AGENT.md — the central artifact describing one agent.
+//
+// Format : Markdown with YAML frontmatter at the top, optional body below.
+// Example :
+//
+// ---
+// name: haiku-writer
+// description: Writes a single haiku about the user's topic.
+// model: mlx-community/Llama-3.2-3B-Instruct-4bit
+// sandbox:
+// image: agent-forge/base:latest
+// timeout: 60s
+// maxTurns: 1
+// ---
+//
+// # haiku-writer
+//
+// You are a poet. You answer with exactly three lines following the
+// 5-7-5 syllable pattern of a haiku.
+//
+// In P3 the schema is intentionally minimal (single-agent, no tools,
+// no MCP, no skills). Those will be re-introduced in later milestones.
+
+import { parse as parseYaml } from 'yaml'
+import { z } from 'zod'
+
+const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/
+
+export const AgentSandboxSchema = z.object({
+ image: z.string().min(1),
+ timeout: z
+ .string()
+ .regex(/^\d+[smh]$/, 'timeout must look like 30s, 5m, 1h')
+ .optional(),
+})
+
+export const AgentMdSchema = z.object({
+ name: z
+ .string()
+ .min(1)
+ .regex(/^[a-z][a-z0-9-]*$/, 'name must be kebab-case (lowercase, digits, hyphens)'),
+ description: z.string().min(1),
+ model: z.string().min(1).optional(),
+ sandbox: AgentSandboxSchema,
+ maxTurns: z.number().int().positive().default(1),
+})
+
+export type AgentMd = z.infer
+
+export type ParsedAgentMd = {
+ meta: AgentMd
+ body: string
+}
+
+export class AgentMdError extends Error {
+ constructor(message: string, public readonly cause?: unknown) {
+ super(message)
+ this.name = 'AgentMdError'
+ }
+}
+
+export function parseAgentMd(text: string): ParsedAgentMd {
+ const match = text.match(FRONTMATTER_RE)
+ if (!match) {
+ throw new AgentMdError(
+ 'AGENT.md must start with a YAML frontmatter block delimited by ---',
+ )
+ }
+ const [, yamlText, body] = match
+ let parsedYaml: unknown
+ try {
+ parsedYaml = parseYaml(yamlText ?? '')
+ } catch (err) {
+ throw new AgentMdError('Invalid YAML in frontmatter', err)
+ }
+ const result = AgentMdSchema.safeParse(parsedYaml)
+ if (!result.success) {
+ const first = result.error.issues[0]
+ const path = first?.path.join('.') ?? ''
+ throw new AgentMdError(`Invalid AGENT.md : ${path} — ${first?.message ?? 'unknown error'}`)
+ }
+ return { meta: result.data, body: (body ?? '').trim() }
+}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
new file mode 100644
index 0000000..b74f28b
--- /dev/null
+++ b/packages/core/src/types/index.ts
@@ -0,0 +1,8 @@
+export {
+ AgentMdError,
+ AgentMdSchema,
+ AgentSandboxSchema,
+ parseAgentMd,
+ type AgentMd,
+ type ParsedAgentMd,
+} from './agent-md.ts'
diff --git a/packages/core/tests/agent-md.test.ts b/packages/core/tests/agent-md.test.ts
new file mode 100644
index 0000000..0421f53
--- /dev/null
+++ b/packages/core/tests/agent-md.test.ts
@@ -0,0 +1,58 @@
+// Schema and parser tests for AGENT.md.
+
+import { describe, expect, test } from 'bun:test'
+import { AgentMdError, parseAgentMd } from '../src/types/agent-md.ts'
+
+const valid = `---
+name: haiku-writer
+description: Writes a single haiku about the user's topic.
+model: mlx-community/Llama-3.2-3B-Instruct-4bit
+sandbox:
+ image: agent-forge/base:latest
+ timeout: 60s
+maxTurns: 1
+---
+
+# haiku-writer
+
+You are a poet.
+`
+
+describe('parseAgentMd', () => {
+ test('parses a valid AGENT.md', () => {
+ const result = parseAgentMd(valid)
+ expect(result.meta.name).toBe('haiku-writer')
+ expect(result.meta.sandbox.image).toBe('agent-forge/base:latest')
+ expect(result.meta.maxTurns).toBe(1)
+ expect(result.body).toContain('You are a poet.')
+ })
+
+ test('rejects content without frontmatter', () => {
+ expect(() => parseAgentMd('# just a title\n')).toThrow(AgentMdError)
+ })
+
+ test('rejects invalid YAML', () => {
+ expect(() => parseAgentMd('---\nname: : :\n---\n')).toThrow(AgentMdError)
+ })
+
+ test('rejects an invalid name (not kebab-case)', () => {
+ const bad = valid.replace('name: haiku-writer', 'name: HaikuWriter')
+ expect(() => parseAgentMd(bad)).toThrow(/name.*kebab-case/)
+ })
+
+ test('rejects a missing required field', () => {
+ const bad = valid.replace(/sandbox:[\s\S]+?maxTurns: 1/, 'maxTurns: 1')
+ expect(() => parseAgentMd(bad)).toThrow(/sandbox/)
+ })
+
+ test('rejects a malformed timeout', () => {
+ const bad = valid.replace('timeout: 60s', 'timeout: forever')
+ expect(() => parseAgentMd(bad)).toThrow(/timeout/)
+ })
+
+ test('defaults maxTurns when omitted', () => {
+ const noTurns = valid.replace(/\nmaxTurns: 1/, '')
+ const result = parseAgentMd(noTurns)
+ expect(result.meta.maxTurns).toBe(1)
+ })
+})
diff --git a/packages/runtime/README.md b/packages/runtime/README.md
index 360ad03..c852792 100644
--- a/packages/runtime/README.md
+++ b/packages/runtime/README.md
@@ -1,15 +1,39 @@
# @agent-forge/runtime
-The process that runs **inside** Docker containers when an Agent Forge agent boots.
+Le process qui tourne **à l'intérieur** des containers Docker lancés par Agent Forge.
-## What it does
+## Ce que ça fait (état P3)
-1. Reads its `AGENT.md` from `/agent-config/`
-2. Connects to its MCP servers (always including `claude-presence`)
-3. Boots the query loop with the agent's tools, skills, model
-4. Streams events to the host via stdio
-5. Exits cleanly on completion
+1. Lit le fichier `/agent/AGENT.md` monté en lecture seule dans le container
+2. Sépare le frontmatter (validé Zod côté host) du corps Markdown
+3. Utilise le corps comme **system prompt** de l'agent
+4. Récupère le prompt utilisateur via stdin
+5. Streame la réponse du LLM (`streamText` du Vercel AI SDK) sur stdout, chunk par chunk
+6. Sort avec le code 0 quand le LLM a fini
-## Status
+Le container est lancé avec `docker run --rm -i`, donc il est détruit dès la sortie.
-**Phase POC. Not implemented yet.**
+## Variables d'environnement
+
+Héritées du host par le `DockerLaunch` tool :
+
+```
+FORGE_BASE_URL endpoint OpenAI-compatible
+FORGE_API_KEY clé (peut être vide pour MLX local)
+FORGE_MODEL nom du modèle
+```
+
+## Build
+
+```bash
+bun run --cwd packages/runtime build
+```
+
+Produit `dist/runtime.mjs`. **Cible Node, pas Bun** — les containers tournent une image Node Alpine, ils ne savent pas exécuter `__require` injecté par `bun build --target bun`.
+
+## À venir
+
+- **P4** — exposer six tools natifs (Bash, FileRead, FileEdit, FileWrite, Grep, Glob) à l'agent depuis l'intérieur du container
+- **P5** — agents persistants via `docker exec` (au lieu de `docker run --rm` jetable)
+- **P5** — extraction d'artefacts du `/workspace` du container vers le host
+- **P6** — `claude-presence` MCP pour la coordination entre agents d'une même team
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
index 8e58405..c2e75a8 100644
--- a/packages/runtime/package.json
+++ b/packages/runtime/package.json
@@ -8,15 +8,16 @@
"forge-runtime": "./dist/runtime.mjs"
},
"scripts": {
- "build": "bun build ./src/index.ts --outdir ./dist --target bun",
+ "build": "bun build ./src/index.ts --outfile ./dist/runtime.mjs --target node --format esm",
"test": "bun test",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@agent-forge/core": "workspace:*",
"@agent-forge/tools-core": "workspace:*",
- "@anthropic-ai/sdk": "^0.32.0",
+ "@ai-sdk/openai": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.0.0",
+ "ai": "^4.0.0",
"yaml": "^2.6.0"
}
}
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index cf0d594..19d2732 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -1,8 +1,99 @@
// @agent-forge/runtime — entry point
//
-// Runs inside the Docker container. Reads its AGENT.md, boots the query loop,
-// connects to claude-presence MCP, streams events to the host via stdio.
+// Runs INSIDE a Docker container. Two modes :
//
-// Phase POC : not implemented yet.
+// 1. Standalone (P1) : reads a prompt from stdin, calls an OpenAI-
+// compatible LLM endpoint, streams the answer to stdout. No agent
+// configuration required.
+//
+// 2. Agent mode (P3.4) : if an AGENT.md is mounted at /agent/AGENT.md,
+// its frontmatter overrides the model and its body becomes the
+// system prompt. The prompt from stdin is the user message.
+//
+// The output is STREAMED token by token to stdout so the host can render
+// progress live in the TUI.
+
+import { readFileSync } from 'node:fs'
+import { createOpenAI } from '@ai-sdk/openai'
+import { parseAgentMd } from '@agent-forge/core/types'
+import { streamText } from 'ai'
+
+const AGENT_MD_PATH = '/agent/AGENT.md'
+
+const BASE_URL = process.env.FORGE_BASE_URL ?? 'http://127.0.0.1:8080/v1'
+const API_KEY = process.env.FORGE_API_KEY ?? 'not-needed'
+const ENV_MODEL =
+ process.env.FORGE_MODEL ?? 'mlx-community/Qwen2.5-7B-Instruct-4bit'
+const MAX_TOKENS = Number(process.env.FORGE_MAX_TOKENS ?? '1024')
+
+type AgentConfig = {
+ model: string
+ systemPrompt?: string
+ agentName?: string
+}
+
+function loadAgentConfig(): AgentConfig {
+ // Default config when no AGENT.md is mounted (standalone P1 mode).
+ let config: AgentConfig = { model: ENV_MODEL }
+ try {
+ const raw = readFileSync(AGENT_MD_PATH, 'utf8')
+ const parsed = parseAgentMd(raw)
+ config = {
+ model: parsed.meta.model ?? ENV_MODEL,
+ systemPrompt: parsed.body.length > 0 ? parsed.body : undefined,
+ agentName: parsed.meta.name,
+ }
+ } catch (err) {
+ // ENOENT means standalone mode, that is fine. Anything else is fatal :
+ // a malformed AGENT.md would otherwise silently fall back to the
+ // default model + no system prompt, which is misleading.
+ const code = (err as NodeJS.ErrnoException).code
+ if (code !== 'ENOENT') {
+ console.error(
+ `✗ runtime error: failed to load ${AGENT_MD_PATH} : ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ )
+ process.exit(1)
+ }
+ }
+ return config
+}
+
+async function readStdin(): Promise {
+ const chunks: Uint8Array[] = []
+ for await (const chunk of process.stdin) {
+ chunks.push(chunk as Uint8Array)
+ }
+ return Buffer.concat(chunks).toString('utf8').trim()
+}
+
+async function main(): Promise {
+ const config = loadAgentConfig()
+ const prompt = await readStdin()
+ if (!prompt) {
+ console.error('✗ no prompt received on stdin')
+ process.exit(1)
+ }
+
+ const provider = createOpenAI({ baseURL: BASE_URL, apiKey: API_KEY })
+
+ const result = streamText({
+ model: provider(config.model),
+ system: config.systemPrompt,
+ prompt,
+ maxTokens: MAX_TOKENS,
+ })
+
+ for await (const chunk of result.textStream) {
+ process.stdout.write(chunk)
+ }
+ // Trailing newline so the host can detect the end of the stream cleanly.
+ process.stdout.write('\n')
+}
-export {}
+main().catch((err) => {
+ const msg = err instanceof Error ? err.message : String(err)
+ console.error(`✗ runtime error: ${msg}`)
+ process.exit(1)
+})
diff --git a/packages/tools-core/README.md b/packages/tools-core/README.md
index 96198e6..e55ec9b 100644
--- a/packages/tools-core/README.md
+++ b/packages/tools-core/README.md
@@ -1,20 +1,33 @@
# @agent-forge/tools-core
-Native tools shared between builder (on host) and runtime (in container).
+Tools natifs partagés entre le builder (côté host) et le runtime (dans le container).
-## Tool interface
+## État P3
-All tools implement `Tool` (inspired by OpenClaude analysis, see `../../../analyse/06-tools-system.md`).
+Deux tools livrés et utilisés dans le parcours `forge` :
-## Tools planned for P4
+- **`FileWrite`** — écrit sous `~/.agent-forge/agents//` avec sandbox de chemin (refuse tout `..`, refuse les écrasements sauf `overwrite: true` quand l'utilisateur a confirmé dans le dialog de permission). Schéma Zod sur l'input.
+- **`DockerLaunch`** — `launchAgent({ agent, prompt })` : retourne un handle `{ containerName, events: AsyncGenerator, abort }`. Spawn `docker run --rm -i`, monte `AGENT.md` + le bundle runtime, hérite des env vars provider, force le cleanup en `try/finally`.
-- **`Bash`** — shell execution
-- **`FileRead`** — read with offset/limit
-- **`FileEdit`** — patch via `old_string`/`new_string`
-- **`FileWrite`** — overwrite
-- **`Grep`** — ripgrep search
+## Tools prévus pour P4
+
+Depuis l'intérieur du container, accessibles à l'agent :
+
+- **`Bash`** — exécution shell, restreinte au `/workspace`
+- **`FileRead`** — lecture avec offset/limit
+- **`FileEdit`** — patch par `old_string` / `new_string`
+- **`FileWrite`** — version "in-container" (différente de la version builder host)
+- **`Grep`** — recherche ripgrep
- **`Glob`** — pattern matching
-## Status
+## Interface tool
+
+```ts
+type Tool = {
+ name: string
+ schema: ZodSchema
+ run(input: Input, ctx: ToolContext): AsyncGenerator