From 16e8af389565ab77c37044e709730cf80c7c148b Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 15 Apr 2026 19:22:11 +0200 Subject: [PATCH 01/26] ci: run CodeQL on develop branch as well (#4) The develop ruleset requires CodeQL results before merge, but the workflow only triggered on main. PRs targeting develop were deadlocked. Add develop to push and pull_request triggers, mirroring the client repo's setup. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0461125..06cffcc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,9 +9,9 @@ name: CodeQL on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] schedule: - cron: '0 6 * * 1' workflow_dispatch: From ea6d3092fac272fa4de21d9a956ee6b3ac878cb9 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 15 Apr 2026 19:26:31 +0200 Subject: [PATCH 02/26] docs(prd): split PRD into per-repo focused docs (#2) * docs(prd): split monolithic PRD into per-repo PRDs Rewrite core/PRD.md to focus solely on @focusmcp/core (lib TS): 3 piliers, manifest, SDK, validator, CLI, marketplace client, brick loader. Reflects current architecture (core in WebView, Tauri sole HTTP gateway). Companion PRDs added in client/ and marketplace/ repos. Co-Authored-By: Claude Opus 4.6 (1M context) * docs(prd): address Copilot review feedback - Replace cross-repo relative links with absolute GitHub URLs - Clarify monorepo layout: package `packages/core`, not `core/` - Manifest naming: parser only enforces kebab-case; the `focus-` prefix is a marketplace convention - Validator section: list only checks actually implemented; defer namespace/dependency/bypass checks to P1 - Stack and Decisions tables: replace "Zod / JSON Schema" with "custom validator (parseManifest)"; JSON Schema is used only for tools[].inputSchema Co-Authored-By: Claude Opus 4.6 (1M context) * docs(prd): add SPDX headers for REUSE compliance Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- PRD.md | 725 ++++++++++++++++----------------------------------------- 1 file changed, 198 insertions(+), 527 deletions(-) diff --git a/PRD.md b/PRD.md index fce65ca..92b3ebe 100644 --- a/PRD.md +++ b/PRD.md @@ -1,100 +1,66 @@ -# FocusMCP — Product Requirements Document + -## Vision +# @focusmcp/core — Product Requirements Document + +> Périmètre de ce document : la **bibliothèque TypeScript** `@focusmcp/core` (package `packages/core` du monorepo). +> Pour l'app desktop : voir le repo [`focus-mcp/client`](https://github.com/focus-mcp/client). Pour le catalogue de briques : voir le repo [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace). + +## Vision (rappel) **FocusMCP** — Focaliser les agents AI sur l'essentiel. FocusMCP est un **écosystème intelligent de briques MCP** qui communiquent entre elles, travaillent ensemble, et sont chargées à la demande. Les briques optimisent la compréhension du code, filtrent les données et distillent les résultats pour **minimiser les tokens et le contexte** envoyés à l'agent AI. -FocusMCP est une **coquille vide** — un orchestrateur sans aucune brique incluse. Il fournit l'infrastructure (Registry, EventBus, Router, UI) et un **marketplace officiel par défaut** pour découvrir et installer des briques. Toute la valeur est dans l'écosystème de briques, pas dans FocusMCP lui-même. +Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. -Comme **Node.js + npm**, **VS Code + marketplace**, ou **Docker + Docker Hub** : FocusMCP est le runtime, les briques sont les packages. - -> **Sans FocusMCP** : l'AI lit 50 fichiers bruts → 200k tokens consommés -> **Avec FocusMCP** : les briques indexent, analysent, filtrent → l'AI reçoit 2k tokens de résultat pertinent +> **Sans FocusMCP** : l'AI lit 50 fichiers bruts → 200k tokens +> **Avec FocusMCP** : les briques indexent, filtrent, distillent → 2k tokens pertinents --- -## Problème +## Rôle de `@focusmcp/core` dans l'écosystème -Aujourd'hui, les serveurs MCP sont : -- **Isolés** : chaque serveur fonctionne seul, sans connaissance des autres -- **Redondants** : chaque serveur réimplémente les mêmes bases (lecture de fichiers, cache, parsing...) -- **Lourds à configurer** : chaque client AI doit référencer chaque serveur manuellement -- **Non composables** : impossible de chaîner les capacités de plusieurs serveurs +`@focusmcp/core` est la **bibliothèque TypeScript** qui implémente toute la logique MCP : -## Solution +- **Importée par l'app desktop** (`client/`, Tauri) directement dans la WebView — pas de sidecar Node.js +- **Aucun transport HTTP** : Tauri (Rust) est le **seul gardien HTTP** (Streamable HTTP MCP côté client) +- **Browser-compatible** : pas de `node:async_hooks`, pas de Pino, primitives compatibles WebView +- **Sans dépendance OS directe** : tout accès filesystem/réseau passe par des fournisseurs injectés (le client Tauri fournit les implémentations sandboxed) -Une **application desktop** (comme WAMP/MAMP) qui orchestre un écosystème de MCP modulaires (**briques**) : -- **App desktop** (Tauri) : tray icon, auto-start, tourne en fond, UI native -- Chaque brique a **une responsabilité unique** -- Les briques **déclarent leurs dépendances** et peuvent utiliser d'autres briques -- FocusMCP **résout les dépendances**, route les appels et expose un endpoint unifié -- Tauri **sandbox le JavaScript** des briques MCP (couche de sécurité système) -- Un **marketplace officiel** par défaut pour découvrir et installer des briques +``` +┌────────────────────────────────────┐ +│ Tauri (Rust) — gateway HTTP MCP │ +│ • Streamable HTTP /mcp │ +│ • Sandbox système (FS, réseau) │ +└──────────────┬─────────────────────┘ + │ Tauri commands (IPC) +┌──────────────▼─────────────────────┐ +│ WebView — UI Svelte │ +│ └─ @focusmcp/core (this lib) │ +│ Registry + EventBus + Router │ +│ + briques (modules TS) │ +└────────────────────────────────────┘ +``` --- -## Architecture - -### Principes +## Packages du monorepo -| Principe | Description | +| Package | Rôle | |---|---| -| **Atomique** | Une brique = **un seul domaine spécialisé**, comme un plugin VS Code (1 plugin Prettier, 1 plugin ESLint, 1 plugin Twig...). Le nom déclare le domaine (`focus-doctrine`, `focus-twig`, `focus-sf-router`). Pas de brique fourre-tout. | -| **Micro-service** | Chaque MCP = une brique indépendante, découplée, remplaçable | -| **Composabilité** | Les briques s'empilent : une brique peut utiliser d'autres briques | -| **Point d'entrée unique** | Le client AI ne connecte qu'un seul endpoint HTTP | -| **Déclaratif** | Chaque brique déclare ses tools, ses dépendances et sa config via un manifeste | -| **Hot-swap** | Ajouter/retirer une brique sans redémarrer le système | -| **Event-driven** | Les briques communiquent via un EventBus (pub/sub), jamais directement | - -### Application Desktop (Tauri) - -Le FocusMCP est une **application desktop** construite avec Tauri, inspirée de WAMP/MAMP. L'utilisateur double-clique pour lancer, l'app tourne en fond avec un tray icon dans la barre des tâches. +| `@focusmcp/core` | Registry, EventBus, Router, Manifest, Bootstrap, Observability | +| `@focusmcp/sdk` | Helper `defineBrick` pour auteurs de briques | +| `@focusmcp/validator` | Test runner conformance (manifeste, namespace, dépendances, garde-fous) | +| `@focusmcp/cli` | CLI `focus` — gestion des briques installées | -``` -┌──────────────────────────────────────────────────────────┐ -│ MCP CENTER — App Desktop (Tauri) │ -│ │ -│ Tauri (Rust) — Coquille desktop + Sandbox │ -│ • Tray icon (barre des tâches) │ -│ • Auto-start au démarrage du système │ -│ • Sandbox : contrôle l'accès filesystem/réseau du JS │ -│ • Fenêtre native avec WebView │ -│ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ WebView — UI │ │ -│ │ Dashboard, Marketplace, Logs, Config, Graphe │ │ -│ └──────────────────────┬─────────────────────────────┘ │ -│ │ Tauri Commands (IPC) │ -│ ┌──────────────────────▼─────────────────────────────┐ │ -│ │ Node.js (sidecar) — Le MCP Server │ │ -│ │ │ │ -│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ -│ │ │ Registry │ │ EventBus │ │ MCP Router │ │ │ -│ │ │ │ │ + gardes │ │ (HTTP endpoint) │ │ │ -│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │ -│ │ │ │ -│ │ 🧱 Indexer 🧱 Cache 🧱 PHP 🧱 Symfony │ │ -│ │ (module) (module) (module) (module) │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ │ -│ Tauri Sandbox ─────────┤ contrôle les accès │ -│ ▼ │ -│ Host (filesystem, réseau, OS) │ -└──────────────────────────────────────────────────────────┘ -``` - -**Tauri (Rust)** gère : tray icon, auto-start, fenêtre, sandbox, lancement du sidecar Node.js -**Node.js (sidecar)** gère : tout le MCP (core, briques, HTTP endpoint) -**WebView** affiche : l'UI (même code qu'une web app classique) - -### Les 3 piliers du MCP Core +--- -Le core MCP tourne dans le sidecar Node.js. Les briques sont des **modules TypeScript** chargés dans le même process. +## Les 3 piliers -#### 1. McpRegistry — L'annuaire +### 1. McpRegistry — L'annuaire Le registre central connaît toutes les briques, leurs manifestes, leurs dépendances et leur état. @@ -108,188 +74,80 @@ registry.getTools() // liste tous les tools exposés par toutes les b ``` Responsabilités : -- Stocker les manifestes (`mcp-brick.json`) de chaque brique +- Stocker les manifestes (`mcp-brick.json`) - Résoudre le **graphe de dépendances** (ordre de démarrage, détection de cycles) - Suivre l'**état** de chaque brique (running, stopped, error, starting) - Valider la **compatibilité** entre versions de briques -#### 2. EventBus — Le système nerveux +### 2. EventBus — Le système nerveux Les briques ne s'appellent **jamais directement entre elles**. Toute communication passe par l'EventBus. -Deux modes de communication : - -**Événements (fire & forget)** — notification sans attente de réponse : +**Événements (fire & forget)** : ```typescript -// MCP Indexer publie quand l'indexation est terminée eventBus.emit("files:indexed", { path: "src/", files: [...] }) - -// MCP PHP écoute et réagit -eventBus.on("files:indexed", (data) => { /* met à jour son cache PHP */ }) +eventBus.on("files:indexed", (data) => { /* ... */ }) ``` -**Requêtes (request/response)** — appel synchrone avec réponse attendue : +**Requêtes (request/response)** : ```typescript -// MCP PHP demande à Indexer de chercher des fichiers const files = await eventBus.request("indexer:search", { pattern: "*.php" }) - -// MCP Symfony demande à PHP d'analyser un fichier -const ast = await eventBus.request("php:analyze", { file: "src/Controller/UserController.php" }) ``` -Avantages de l'EventBus : -- **Découplage total** : les briques ne se connaissent pas, elles connaissent des événements -- **Monitoring gratuit** : FocusMCP intercepte et logue tous les événements -- **Cache au niveau du bus** : même requête = résultat en cache, sans toucher la brique -- **Extensibilité** : ajouter une brique qui réagit à un événement existant sans modifier les autres -- **Résilience** : si une brique est down, l'EventBus peut retourner une erreur propre +**Avantages** : découplage total, monitoring gratuit (tout passe par le bus), cache au niveau du bus, extensibilité, résilience. -#### Garde-fous (EventBus) - -L'EventBus intègre des protections centralisées. Les briques n'ont rien à implémenter — les garde-fous sont appliqués automatiquement par le bus : +#### Garde-fous (intégrés au bus) | Garde-fou | Protection | Comportement | |---|---|---| -| **Max call depth** | Boucles infinies (A → B → A → B...) | Bloque l'appel au-delà de N niveaux de profondeur | -| **Timeout** | Brique qui ne répond plus | Coupe l'appel après N secondes, retourne une erreur | -| **Rate limit** | Brique qui spam le bus | Limite le nombre d'appels/seconde par brique | -| **Permissions (manifeste)** | Appels non autorisés | Une brique ne peut appeler que ses **dépendances déclarées** dans `mcp-brick.json` | -| **Payload size** | Données trop volumineuses | Rejette les payloads au-delà d'une taille max | -| **Circuit breaker** | Brique instable | Si une brique échoue X fois consécutives → désactivée temporairement | - -**Permissions via le manifeste** — le `dependencies` du manifeste sert de whitelist : +| **Max call depth** | Boucles infinies (A → B → A...) | Bloque au-delà de N niveaux | +| **Timeout** | Brique qui ne répond plus | Coupe l'appel après N secondes | +| **Rate limit** | Brique qui spam le bus | Limite appels/sec par source | +| **Permissions** | Appels non autorisés | Whitelist via `dependencies` du manifeste | +| **Payload size** | Données trop volumineuses | Rejette au-delà d'une taille max | +| **Circuit breaker** | Brique instable | Désactivation temporaire après X échecs | + +**Permissions via le manifeste** : ``` PHP déclare dependencies: ["indexer", "cache"] - -PHP → request("indexer:search") ✅ autorisé (dans ses dépendances) -PHP → request("symfony:something") ❌ bloqué (pas dans ses dépendances) +PHP → request("indexer:search") ✅ autorisé +PHP → request("symfony:something") ❌ bloqué ``` -**Monitoring** — tout ce qui passe par le bus est observable : -- Chaque appel est loggé (source, cible, arguments, durée, résultat/erreur) -- Traçabilité complète d'une requête de bout en bout (trace ID) -- Métriques agrégées par brique (nombre d'appels, temps moyen, taux d'erreur) -- Appels bloqués par les garde-fous visibles dans l'UI -- Tout est affichable en **temps réel** dans le dashboard - -#### 3. MCP Router — La gateway +### 3. McpRouter — La gateway -Le point d'entrée pour les clients AI. Reçoit les appels MCP (tools/list, tools/call) et les dispatch. +Reçoit les appels MCP (`tools/list`, `tools/call`) du transport (Tauri HTTP) et les dispatche. ```typescript -// Client AI appelle un tool router.handle("symfony_find_controllers", { entity: "User" }) - // 1. Consulte le Registry : "qui gère symfony_find_controllers ?" - // → Brique "symfony" - // 2. Dispatch via l'EventBus : request("symfony:find_controllers", { entity: "User" }) - // 3. Retourne le résultat au client AI + // 1. Registry : "qui gère ce tool ?" → brique "symfony" + // 2. EventBus : request("symfony:find_controllers", ...) + // 3. Retourne le résultat ``` Responsabilités : -- Exposer l'**endpoint Streamable HTTP** conforme à la spec MCP -- **Agréger les tools** de toutes les briques actives (tools/list) +- **Agréger les tools** de toutes les briques actives (`tools/list`) - **Router** chaque appel tool vers la bonne brique via l'EventBus -- Gérer les **timeouts** et **erreurs** proprement +- Gérer **timeouts** et **erreurs** proprement +- **Aucun transport HTTP propre** : exposé via une API JS consommée par le client Tauri -### Communication inter-briques — Flux complet +### Flux complet ``` -AI appelle "symfony_find_controllers({ entity: 'User' })" - │ - ▼ -Router → Registry : "qui gère symfony_find_controllers ?" - │ → Brique "symfony" - ▼ -Router → EventBus : request("symfony:find_controllers", { entity: "User" }) - │ - ▼ -Symfony → EventBus : request("php:analyze", { path: "src/Controller/" }) - │ - ▼ -PHP → EventBus : request("indexer:search", { pattern: "*.php", path: "src/Controller/" }) - │ - ▼ -Indexer → Cache hit ? → retourne les fichiers indexés - │ - ▼ -PHP ← parse les fichiers, extrait les classes et leurs dépendances - │ - ▼ -Symfony ← filtre les AbstractController qui utilisent l'entité User - │ - ▼ -Router ← résultat final → Client AI +AI → Tauri HTTP /mcp → IPC → Router → Registry (lookup) + ↓ + EventBus → Brique cible + ↓ + (peut chaîner d'autres briques via le bus) + ↓ + ← résultat ← ``` -Les briques ne se connectent **jamais directement entre elles**. FocusMCP (via l'EventBus) est toujours l'intermédiaire. Cela permet : -- Le **monitoring centralisé** de tous les appels -- Le **remplacement** d'une brique sans impacter les autres -- Le **cache intelligent** des résultats intermédiaires -- La **traçabilité** complète de chaque requête (du client AI jusqu'à la brique finale) - ---- - -## Patterns d'optimisation des tokens - -Quatre patterns transverses, applicables par toutes les briques, qui matérialisent la promesse "200k → 2k tokens". - -### 1. Output filtering (distillation) - -Chaque brique retourne **uniquement le résultat pertinent**, jamais la donnée brute intermédiaire. - -``` -❌ Mauvais : retourner le contenu de 50 fichiers PHP (200k tokens) -✅ Bon : retourner la liste des 3 controllers qui matchent (200 tokens) -``` - -### 2. Think in code (sandbox d'exécution) - -Au lieu de demander à l'agent de lire 50 fichiers pour compter des fonctions, l'agent **écrit un script** dans une sandbox JS qui ne `console.log()` que le résultat final. - -``` -❌ AI : "lis tous les fichiers, compte les fonctions" → 200k tokens -✅ AI : sandbox.run("globby + count") → 1 ligne : "324 fonctions" -``` - -Implémenté par la brique **focus-sandbox** (V8 isolé, accès filesystem via Tauri). - -### 3. Session memory (persistance entre compactions) - -Toutes les actions (édits de fichiers, opérations git, tâches, erreurs) sont **trackées dans SQLite + FTS5**. Quand la conversation est compactée, l'agent retrouve le contexte via une recherche BM25 ciblée plutôt qu'en re-lisant tout. - -``` -Compaction → AI a perdu le contexte - → memory.search("ce que j'ai modifié dans UserController") - → 5 entrées pertinentes (200 tokens) au lieu de re-scanner le repo -``` - -Implémenté par la brique **focus-memory** (SQLite + FTS5/BM25). - -### 4. Indexation + cache intelligent - -Les briques lourdes (indexation, parsing AST, embeddings) **mémorisent leurs résultats**. Les briques en aval (PHP, Symfony, SQL) consomment l'index sans jamais re-toucher le disque. - -Implémenté par la brique **focus-indexer** (FTS5 partagé avec memory). - -### 5. Reasoning externalisé (sequential thinking) - -L'agent **externalise sa chaîne de raisonnement** dans une brique dédiée : pensées numérotées, révisions, branches alternatives. Au lieu de re-jouer toute la chaîne dans le contexte, l'agent récupère sélectivement les pensées pertinentes. - -``` -❌ AI re-écrit toute sa réflexion à chaque étape (contexte qui gonfle) -✅ AI : thinking.add({ thought, branch, revisesThought }) → ne garde que la conclusion - AI : thinking.recall("decision sur X") → 3 thoughts pertinents -``` - -Implémenté par la brique **focus-thinking** (chaînes de raisonnement persistées via `focus-memory`). - -**Combo puissant** : `focus-thinking` + `focus-memory` = chaînes de raisonnement persistées entre sessions, retrouvables via FTS5/BM25. - --- ## Manifeste de brique -Chaque brique se déclare via un fichier `mcp-brick.json` : +Format `mcp-brick.json` (parsé par `parseManifest`) : ```json { @@ -298,125 +156,89 @@ Chaque brique se déclare via un fichier `mcp-brick.json` : "description": "Compréhension avancée du langage PHP", "dependencies": ["indexer", "cache"], "tools": [ - { - "name": "php_analyze", - "description": "Analyse un fichier PHP et retourne sa structure" - }, - { - "name": "php_find_usages", - "description": "Trouve toutes les utilisations d'un symbole PHP" - } + { "name": "php_analyze", "description": "Analyse un fichier PHP" }, + { "name": "php_find_usages", "description": "Trouve les utilisations d'un symbole" } ], "config": { - "phpVersion": { - "type": "string", - "default": "8.3", - "description": "Version PHP cible" - } + "phpVersion": { "type": "string", "default": "8.3", "description": "Version PHP cible" } } } ``` +Validation stricte (par `parseManifest`) : nom en kebab-case (ex: `php`, `indexer`, `sf-router`), version semver, tools (nom + JSON Schema d'entrée), dépendances déclarées, config typée. La convention `focus-` est appliquée au niveau du marketplace officiel (cf. `marketplace/PRD.md`), pas par le parser. + --- -## Exemple concret : Stack Symfony +## SDK — `@focusmcp/sdk` -### Briques impliquées +Helper `defineBrick` pour les auteurs de briques : +```typescript +import { defineBrick } from '@focusmcp/sdk' + +export default defineBrick({ + manifest: { /* mcp-brick.json inline ou import */ }, + setup({ eventBus, logger }) { + eventBus.on('files:indexed', (data) => { /* ... */ }) + return { + 'php:analyze': async ({ file }) => { /* ... */ }, + } + }, +}) ``` -MCP Symfony - ├── MCP PHP - │ ├── MCP Indexer - │ └── MCP Cache - └── MCP SQL - └── MCP Cache -``` -### Scénario : l'AI demande "Trouve tous les controllers Symfony qui utilisent l'entité User" +--- + +## Validator — `@focusmcp/validator` -1. **MCP Symfony** reçoit l'appel via FocusMCP -2. **MCP Symfony** demande à **MCP PHP** d'analyser les fichiers dans `src/Controller/` -3. **MCP PHP** demande à **MCP Indexer** la liste des fichiers PHP dans ce dossier -4. **MCP Indexer** utilise **MCP Cache** pour retourner le résultat indexé (pas de re-scan disque) -5. **MCP PHP** parse chaque fichier, comprend les `use` statements, les type hints -6. **MCP Symfony** filtre ceux qui étendent `AbstractController` et référencent `User` -7. Le résultat remonte au Center → au client AI +Test runner qui valide qu'une brique respecte le contrat FocusMCP. Checks actuellement implémentés : +- Manifeste valide (`INVALID_MANIFEST` via `parseManifest`) +- Démarrage propre (`START_FAILED` si `start()` lève) +- Handlers/tools enregistrés sur le bus (`MISSING_HANDLER`) +- Tools appelables dans le runtime de validation (`TOOL_CALL_FAILED`) +- Pas de leaks après `stop` (`HANDLER_LEAK`, `STOP_FAILED`) -**Bénéfice token** : seul le résultat final (liste des controllers) est envoyé à l'AI, pas le contenu de tous les fichiers. +Lancé en CI sur chaque brique du marketplace officiel et utilisable par les développeurs tiers. Des validations supplémentaires (conventions de namespace `brique:action`, correspondance dépendances↔appels effectifs, détection de bypass des garde-fous) sont planifiées en P1. --- -## Fonctionnalités +## CLI — `@focusmcp/cli` -### P0 — MVP +Commandes (inspirées npm/yarn) opérant sur `~/.focus/center.json` + `~/.focus/center.lock` : -FocusMCP est une coquille vide livré avec un marketplace officiel. L'utilisateur installe les briques dont il a besoin. - -**Core** -- [ ] **Center core** : McpRegistry + EventBus + MCP Router -- [ ] **EventBus** : pub/sub + request/response + garde-fous (timeout, max depth, permissions) -- [ ] **Endpoint HTTP** : Streamable HTTP pour les clients AI -- [ ] **Manifeste** : format `mcp-brick.json` pour déclarer une brique -- [ ] **Marketplace officiel** : catalogue par défaut intégré au Center - - [ ] Découverte de briques (browse/search) - - [ ] Installation / désinstallation de briques - - [ ] Source : GitHub (`owner/repo`) -- [ ] **UI Web** : dashboard - - [ ] Liste des briques installées + statut - - [ ] Onglet Discover (marketplace) - - [ ] Démarrage / arrêt des briques -- [ ] **CLI** : `focus start`, `focus add `, `focus remove ` - -**Briques officielles MVP** (livrées via le marketplace officiel) -- [ ] **focus-indexer** : indexation filesystem + recherche FTS5/BM25 (base partagée pour les autres briques) -- [ ] **focus-memory** : persistance de session SQLite + FTS5 (édits, git ops, tâches, erreurs) — survit aux compactions -- [ ] **focus-sandbox** : exécution JS éphémère ("think in code") — l'agent écrit un script, ne récupère que le résultat -- [ ] **focus-thinking** : raisonnement externalisé (thoughts, révisions, branches) avec persistance via `focus-memory` - -**Qualité & conformité** -- [ ] **Conformité spec MCP (externe)** : suite de tests qui vérifie que l'endpoint Streamable HTTP du Router respecte la spec MCP officielle. Oracle = serveur de référence [`Everything`](https://github.com/modelcontextprotocol/servers/tree/main/src/everything) → garantit la compatibilité avec tous les clients AI (Claude, Cursor, Codex…) -- [ ] **focus-validator (interne)** : test runner qui valide qu'une brique respecte le contrat FocusMCP — manifeste valide, tools déclarés conformes au schéma, namespace `brique:action` respecté, dépendances déclarées, garde-fous EventBus respectés. Lancé en CI sur chaque brique du marketplace officiel et utilisable par les développeurs tiers - -### P1 — Enrichissement - -- [ ] **UI** : visualisation du graphe de dépendances entre briques -- [ ] **UI** : logs en temps réel des appels inter-briques (monitoring EventBus) -- [ ] **UI** : configuration de chaque brique -- [ ] **UI** : métriques par brique (appels, durée, erreurs, garde-fous déclenchés) -- [ ] **Hot-reload** : ajout/suppression de briques sans redémarrage -- [ ] **Health checks** : monitoring de l'état de chaque brique -- [ ] **Catalogues tiers** : ajouter des marketplaces externes (GitHub, GitLab, URL, local) -- [ ] **Auto-update** : mise à jour automatique des catalogues et briques -- [ ] **Hook-based routing** : adaptateurs client (Claude Code, Cursor, Codex, Gemini CLI…) qui interceptent et redirigent les tool calls vers FocusMCP - -### P2 — Écosystème - -- [ ] **SDK** : template + outils pour créer une nouvelle brique facilement -- [ ] **Permissions** : contrôle de quels tools sont exposés au client AI -- [ ] **Scopes** : installation globale, par projet, ou locale -- [ ] **focus-worktree** : isolation git worktree pour exécutions parallèles (inspiré claude-octopus) -- [ ] **focus-reactor** : écoute événements externes (CI, PR, webhooks) et déclenche des briques (inspiré claude-octopus) -- [ ] **Documentation** : guide pour les développeurs de briques +```bash +focus add # installe une brique (+ ses dépendances) +focus remove # supprime une brique +focus update [brick] # met à jour +focus list # liste les briques installées +focus search # cherche dans le marketplace +focus info # détails d'une brique ---- +focus status # état de chaque brique (running/stopped/error) +focus logs [brick] # logs EventBus -## Marketplace & Gestion des briques (inspiré npm/yarn) +focus catalog add # ajoute un marketplace tiers (P1) +focus catalog list +focus catalog remove + +focus config get / set +``` -### Philosophie +Note : `focus start/stop` lance l'app desktop (Tauri) — implémenté côté `client/`, exposé via la CLI pour confort. -FocusMCP ne contient **aucune brique**. Il est livré avec un **marketplace officiel** par défaut qui permet de découvrir et installer des briques. L'utilisateur est responsable de ce qu'il installe (comme npm). +--- -> **Granularité maximale, à la VS Code** — l'écosystème FocusMCP suit le principe d'atomicité : préférer 10 briques spécialisées (focus-doctrine, focus-twig, focus-sf-router…) à 1 brique monolithique (focus-symfony). Comme les plugins VS Code : un plugin par capability, l'utilisateur compose son setup. Le nom de chaque brique déclare sans ambiguïté son domaine. Le marketplace officiel **refuse les briques fourre-tout** (« reject the kitchen sink »). +## Marketplace client (résolveur + installer) -**Convention de nommage** : `focus-` ou `focus--` (ex: `focus-php`, `focus-doctrine`, `focus-sf-router`, `focus-react-query`). +Module du core qui résout/télécharge/installe les briques publiées dans le marketplace. -### Mapping npm/yarn → FocusMCP +### Mapping npm → FocusMCP ``` npm/yarn FocusMCP ───────── ────────── package.json center.json (briques installées + config) -package-lock.json / yarn.lock center.lock (versions exactes verrouillées) +package-lock.json center.lock (versions exactes verrouillées) node_modules/ bricks/ (code des briques téléchargées) .npmrc .centerrc (config globale, auth, registries) npm registry marketplace officiel @@ -431,26 +253,10 @@ npm registry marketplace officiel ├── center.lock # versions résolues + hash intégrité └── bricks/ # code des briques téléchargées ├── indexer/ - │ ├── mcp-brick.json - │ └── index.ts └── php/ - ├── mcp-brick.json - └── index.ts -``` - -### `.centerrc` — Config globale (comme `.npmrc`) - -```json -{ - "port": 3000, - "auth": { "enabled": false, "token": null }, - "catalogs": [ - { "name": "official", "source": "focus/marketplace" } - ] -} ``` -### `center.json` — Briques installées (comme `package.json`) +### Format `center.json` ```json { @@ -461,7 +267,7 @@ npm registry marketplace officiel } ``` -### `center.lock` — Versions verrouillées (comme `yarn.lock`) +### Format `center.lock` ```json { @@ -469,173 +275,76 @@ npm registry marketplace officiel "version": "1.0.3", "resolved": "focus/brick-indexer#v1.0.3", "integrity": "sha256-abc123..." - }, - "php": { - "version": "1.2.0", - "resolved": "focus/brick-php#v1.2.0", - "integrity": "sha256-def456...", - "dependencies": { "indexer": "^1.0.0" } } } ``` -### Architecture du marketplace +### Responsabilités du marketplace client -``` -┌──────────────────────────────────────────┐ -│ MCP CENTER │ -│ │ -│ ┌─────────────────────────────────────┐ │ -│ │ Marketplace Manager │ │ -│ │ │ │ -│ │ ┌───────────┐ ┌───────────────┐ │ │ -│ │ │ Officiel │ │ Tiers (P1) │ │ │ -│ │ │ (défaut) │ │ owner/repo │ │ │ -│ │ │ │ │ URL │ │ │ -│ │ │ │ │ local │ │ │ -│ │ └───────────┘ └───────────────┘ │ │ -│ └─────────────────────────────────────┘ │ -└──────────────────────────────────────────┘ -``` +- Résoudre `@` contre un ou plusieurs catalogues (`catalog.json`) +- Télécharger depuis GitHub (`owner/repo#tag`) +- Vérifier intégrité (sha256) +- Écrire `bricks//` + mettre à jour `center.lock` +- Construire le graphe de dépendances et installer en cascade -### Format du catalogue +### Brick loader -Chaque marketplace expose un fichier `catalog.json` : - -```json -{ - "name": "focus-official", - "description": "Marketplace officiel FocusMCP", - "bricks": [ - { - "name": "indexer", - "version": "1.0.0", - "description": "Indexation de fichiers avec cache", - "source": "focus/brick-indexer", - "dependencies": [], - "tags": ["core", "filesystem"] - }, - { - "name": "php", - "version": "1.0.0", - "description": "Compréhension avancée du langage PHP", - "source": "focus/brick-php", - "dependencies": ["indexer"], - "tags": ["language", "php"] - } - ] -} -``` - -### CLI — Commandes (inspirées npm/yarn) - -```bash -focus start # démarre FocusMCP (Tauri + sidecar) -focus stop # arrête FocusMCP - -focus add php # installe une brique (+ ses dépendances) -focus remove php # supprime une brique -focus update # met à jour toutes les briques -focus update php # met à jour une brique spécifique -focus list # liste les briques installées -focus search indexer # cherche dans le marketplace -focus info php # détails d'une brique - -focus status # état de chaque brique (running/stopped/error) -focus logs # affiche les logs EventBus -focus logs php # logs filtrés pour une brique - -focus catalog add org/repo # ajoute un marketplace tiers (P1) -focus catalog list # liste les catalogues configurés -focus catalog remove org/repo # supprime un catalogue - -focus config set auth.enabled true # configure via CLI -focus config set port 4000 # change le port -focus config get # affiche la config -``` +Au démarrage, le loader lit `center.json` + `center.lock`, charge dynamiquement chaque brique depuis `bricks//`, parse son manifeste, et l'enregistre dans le `Registry`. Démarrage dans l'ordre topologique du graphe de dépendances. --- -## Sécurité — 3 couches - -``` -┌─────────────────────────────────────────┐ -│ Couche 1 — EventBus (garde-fous) │ Node.js -│ Timeout, rate limit, max call depth │ -│ Permissions inter-briques (manifeste) │ -│ Circuit breaker, payload size │ -├─────────────────────────────────────────┤ -│ Couche 2 — Tauri (sandbox système) │ Rust -│ Contrôle accès filesystem │ -│ Contrôle accès réseau │ -│ Scope par brique (dossiers autorisés) │ -│ Confirmation utilisateur si hors scope │ -├─────────────────────────────────────────┤ -│ Couche 3 — UI (contrôle humain) │ -│ Configurer les permissions par brique │ -│ Voir les accès bloqués / autorisés │ -│ Activer / désactiver les garde-fous │ -└─────────────────────────────────────────┘ -``` +## Observability -- **Couche 1 (EventBus)** : protège les briques **entre elles** (logique applicative) -- **Couche 2 (Tauri)** : protège le **système** contre les briques (sandbox OS) -- **Couche 3 (UI)** : l'**utilisateur** configure et supervise le tout - -Les briques JS n'accèdent **jamais au système directement**. Tout passe par Tauri (Rust) qui filtre et autorise. +- `createLogger` / `rootLogger` : logger structuré browser-compatible (remplace Pino) +- `getTracer` / `trace` : trace ID propagé dans les requêtes EventBus (remplace `node:async_hooks`) +- Tout appel bus est observable : source, cible, args, durée, résultat/erreur, garde-fous déclenchés +- Exposé au client (Tauri) pour affichage dans l'UI temps réel --- -## Modes de fonctionnement - -### Mode Desktop (par défaut) - -App Tauri avec fenêtre native. Tray icon, auto-start, sandbox Rust, UI dans le WebView. - -``` -Utilisateur → App Desktop (Tauri + WebView) → UI native -Client AI → http://localhost:3000/mcp (endpoint MCP) -``` - -### Mode Serveur - -Tauri tourne en **headless** (sans fenêtre), mais le sandbox Rust reste actif. L'UI est exposée en web sur un port configurable pour accès à distance. - -``` -Tauri (headless, sandbox actif) - └── Node.js (sidecar, sandboxé) - ├── http://server:3000 → UI web (navigateur distant) - └── http://server:3000/mcp → endpoint MCP (clients AI) -``` +## Roadmap -### Mode CLI only +### P0 — MVP -Tauri tourne en **headless** (sans fenêtre, sans UI web). Le sandbox Rust reste actif. Gestion uniquement via le terminal. +- [x] McpRegistry + résolution dépendances +- [x] EventBus + garde-fous (timeout, max depth, rate limit, permissions, payload size, circuit breaker) +- [x] McpRouter (sans HTTP propre — exposé via API JS au client Tauri) +- [x] Manifest parser strict +- [x] SDK `defineBrick` +- [x] Validator (test runner conformance) +- [x] Bootstrap helper (`createFocusMcp`) +- [x] Observability browser-compatible (logger, tracing) +- [ ] **CLI** : `focus add/remove/list/search/info/status/logs` +- [ ] **Marketplace client** : résolveur + downloader + intégrité +- [ ] **Brick loader** : chargement dynamique depuis `bricks/` +- [ ] **MCP spec conformance** : suite de tests vs serveur de référence [`Everything`](https://github.com/modelcontextprotocol/servers/tree/main/src/everything) + +### P1 + +- [ ] **Hot-reload** : ajout/suppression de briques sans redémarrer +- [ ] **Health checks** programmés par brique +- [ ] **Catalogues tiers** dans le résolveur (URL, local, GitHub org) +- [ ] **Auto-update** des catalogues et briques + +### P2 + +- [ ] **Permissions tools** : contrôle de quels tools sont exposés au client AI +- [ ] **Scopes** : installation globale, par projet, ou locale +- [ ] **Documentation** auteurs de briques (guide complet) -```bash -focus start # démarre Tauri + sidecar Node.js -focus add php # installe une brique -focus list # liste les briques -focus status # état de chaque brique -focus logs # affiche les logs EventBus -focus remove php # supprime une brique -``` +--- -``` -Tauri (headless, sandbox actif) - └── Node.js (sidecar, sandboxé) - └── http://localhost:3000/mcp → endpoint MCP (clients AI) -``` +## Patterns d'optimisation des tokens (référence) -### Résumé des modes +Les patterns transverses applicables par toutes les briques. Implémentés dans des **briques officielles** publiées sur le marketplace (voir le repo [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace)) : -| Mode | UI | Sandbox Tauri | Tray icon | Cible | -|---|---|---|---|---| -| **Desktop** | WebView natif | Oui | Oui | Utilisateurs desktop | -| **Serveur** | Web (navigateur distant) | Oui | Non | VPS, CI, équipes | -| **CLI only** | Aucune (terminal) | Oui | Non | Devs, scripts, automation | +- **Output filtering** — chaque brique retourne le résultat distillé, jamais la donnée brute +- **Think in code** — sandbox JS éphémère (brique `focus-sandbox`) +- **Session memory** — SQLite + FTS5/BM25 (brique `focus-memory`) +- **Indexation + cache** — index FTS5 partagé (brique `focus-indexer`) +- **Reasoning externalisé** — chaînes de pensées persistées (brique `focus-thinking`) -> **Tauri tourne toujours** — c'est lui qui lance et contrôle le sidecar Node.js. Le sandbox Rust est actif dans les 3 modes. Seule l'interface change : WebView natif, web exposé, ou terminal. Le core Node.js (Registry + EventBus + Router + briques) est identique dans les 3 modes. +`@focusmcp/core` ne contient aucune brique — il fournit l'infrastructure qui les rend possibles. --- @@ -643,71 +352,33 @@ Tauri (headless, sandbox actif) | Composant | Technologie | Rôle | |---|---|---| -| App desktop | **Tauri** (Rust) | Coquille desktop, sandbox, tray icon, auto-start | -| MCP core | **Node.js / TypeScript** | Registry, EventBus, Router, briques | -| UI | **Web** (React/Svelte + Tailwind) | Dashboard (WebView Tauri ou navigateur en mode serveur) | -| Communication AI | **Streamable HTTP** (spec MCP 2025-03-26) | Endpoint pour les clients AI | -| Briques | **TypeScript** | Modules chargés dans le core Node.js | -| Manifeste | **JSON** (`mcp-brick.json`) | Déclaration d'une brique | -| Catalogue | **JSON** (`catalog.json`) | Liste des briques d'un marketplace | -| Persistance briques | **SQLite + FTS5** (BM25) | Mémoire de session, indexation, recherche full-text (briques `focus-memory`, `focus-indexer`) | -| Sandbox JS éphémère | **isolated-vm** ou **vm2** | Exécution "think in code" (brique `focus-sandbox`) | +| Lib | **TypeScript strict** | Code source | +| Build | **tsup** | Bundling (ESM + types) | +| Tests | **Vitest** | Unit + intégration | +| Lint/Format | **Biome** | Style et qualité | +| Manifeste | **JSON** + validateur custom (`parseManifest`) | Validation stricte ; JSON Schema utilisé uniquement pour `tools[].inputSchema` | +| Logger | Browser-compatible (custom) | Pas de Pino (incompatible WebView) | +| Tracing | Browser-compatible (custom) | Pas de `node:async_hooks` | --- -## Ce qui existe vs. ce qu'on fait - -| Critère | MetaMCP / MCPHub | Context Mode | Claude Octopus | FocusMCP | -|---|---|---|---|---| -| Architecture | MCP côte à côte | 1 serveur MCP avec 6 outils sandbox | Orchestration multi-LLM (8 providers) | Écosystème de briques composables | -| Communication | Proxy passif | Hooks + sandbox | Phases (discover/define/develop/deliver) | Router + EventBus inter-briques | -| Extensibilité | Brancher d'autres MCP | Outils figés | Personas + commandes | Marketplace de briques | -| Optimisation tokens | Non | Sandbox + persistance SQLite | Compression pipeline (~7k/session) | Cache + indexation + sandbox + memory | -| Persistance | Non | SQLite/FTS5 intégré | Worktrees git | Brique `focus-memory` (SQLite/FTS5) | -| Philosophie | Agrégateur | Boîte à outils figée | Adversarial multi-LLM | Écosystème composable de spécialistes | - ---- - -## Décisions prises +## Décisions clés | Décision | Choix | Raison | |---|---|---| -| **App desktop** | Tauri (Rust) | Léger, sandbox système, tray icon, auto-start, API native | -| **MCP core** | Node.js / TypeScript | Écosystème MCP existant, briques en TS, un seul langage pour le core + briques | -| **Transport externe** (AI → Center) | Streamable HTTP (spec MCP 2025-03-26) | Standard MCP, compatible tous les clients | -| **Transport inter-briques** | EventBus interne (in-process) | Un seul process, zéro overhead | -| **Architecture interne** | 3 piliers : McpRegistry + EventBus + MCP Router | Séparation des responsabilités claire | -| **Communication** | Event-driven : pub/sub + request/response | Découplage total entre briques | -| **Isolation briques** | In-process (modules TS) | Un seul MCP, simple, performant | -| **Sandbox** | Tauri (Rust) contrôle les accès système du JS | Les briques n'accèdent jamais au système directement | -| **Sécurité** | 3 couches : EventBus (logique) + Tauri (système) + UI (humain) | Défense en profondeur | -| **Authentification** | Optionnelle, configurable via UI/CLI | Désactivée par défaut (local). Activable avec token/clé API pour le mode serveur | -| **Monitoring** | Via l'EventBus | Tout passe par le bus → tout est observable gratuitement | -| **Modes** | Desktop / Serveur / CLI only | Tauri toujours actif (sandbox), seule l'UI change | -| **Marketplace** | Officiel par défaut, tiers en P1 | Coquille vide + catalogue, comme npm | -| **Persistence** | Fichier JSON | Simple, lisible, pas de dépendance DB | -| **Namespace événements** | `brick:action` (ex: `indexer:search`) | Cohérent avec le nom de brique dans le manifeste | -| **Framework UI** | Svelte + Tailwind | Léger, parfait pour Tauri, moins de boilerplate | -| **Structure** | 2 repos : `focus` (l'app) + `focus-marketplace` (toutes les briques officielles) | Séparation claire app vs écosystème. Les briques officielles sont dans un seul monorepo | - -## Questions ouvertes - -Aucune — toutes les décisions sont prises. +| **Transport HTTP** | Délégué à Tauri | Un seul gardien HTTP, sandbox Rust | +| **Runtime** | WebView (browser-compatible) | Pas de sidecar Node.js, IPC direct | +| **Communication briques** | EventBus in-process | Découplage + monitoring centralisé | +| **Sécurité bus** | Whitelist via `dependencies` du manifeste | Permissions déclaratives, pas de config séparée | +| **Manifeste** | JSON + validateur custom (`parseManifest`) | Lisible, validable, versionnable ; JSON Schema réservé à `tools[].inputSchema` | +| **Lock file** | `center.lock` (sha256) | Reproductibilité + intégrité | +| **Briques** | Modules TS chargés dynamiquement | Hot-reload possible, simple | --- ## Inspirations -FocusMCP s'inspire d'idées éprouvées dans plusieurs projets et écosystèmes. Chaque référence est ici pour rendre à César ce qui est à César et pour documenter d'où viennent nos patterns. - -| Source | Ce qu'on a piqué | Pourquoi | -|---|---|---| -| **[Context Mode](https://github.com/mksglu/context-mode)** | Pattern "think in code" (brique `focus-sandbox`), persistance session SQLite + FTS5/BM25 (brique `focus-memory`), hook-based routing client (P1) | Approche éprouvée pour réduire les outputs MCP de 98% et survivre aux compactions de contexte | -| **[Claude Octopus](https://github.com/nyldn/claude-octopus)** | Isolated git worktrees pour parallélisation (brique `focus-worktree` P2), reaction engine pour événements externes CI/PR (brique `focus-reactor` P2), circuit breakers | Patterns d'orchestration et de résilience multi-agents | -| **[Claude Code Plugins](https://code.claude.com/docs/fr/discover-plugins)** | Modèle marketplace officiel + tiers, format de catalogue, mécanisme d'installation/découverte | Référence canonique pour l'expérience d'installation/découverte de plugins | -| **[modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers)** — notamment [`sequentialthinking`](https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking) | Concept de raisonnement externalisé (thoughts numérotés, révisions, branches) — réimplémenté en native dans la brique `focus-thinking` | Pattern éprouvé d'optimisation de contexte par externalisation du reasoning | -| **npm / yarn** | `.centerrc`, `center.json`, `center.lock`, CLI (`add`, `remove`, `update`, `search`...), graphe de dépendances, intégrité (sha256) | Modèle mature de gestion de packages, graphes de dépendances, lock files | -| **WAMP / MAMP** | App desktop avec tray icon, auto-start, services en fond | UX desktop familière pour gérer un orchestrateur local | -| **VS Code marketplace** | Coquille vide + écosystème d'extensions, qualité tier (officiel/communauté) | Modèle d'éditeur extensible où la valeur est dans l'écosystème | -| **Docker / Docker Hub** | Runtime + registry public, séparation runtime / catalogue | Architecture éprouvée runtime ↔ marketplace | -| **MetaMCP / MCPHub** | Concept d'agrégation MCP (qu'on dépasse avec la composabilité) | Point de comparaison pour positionner FocusMCP au-delà du simple agrégateur | +- **Context Mode** — pattern "think in code", persistance SQLite + FTS5 +- **Claude Octopus** — circuit breakers, isolation worktrees (P2) +- **modelcontextprotocol/servers** — référence pour conformance (`Everything`), pattern sequentialthinking +- **npm / yarn** — `.centerrc`, `center.json`, `center.lock`, CLI, graphe de dépendances, intégrité sha256 From 84adba57ff9b0bff018e5f72b71899fee4d2cf72 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 15 Apr 2026 19:26:34 +0200 Subject: [PATCH 03/26] feat(core): add brick loader with abstract source (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): add brick loader with abstract source `loadBricks({ source })` reads installed bricks via an abstract `BrickSource` (list/readManifest/loadModule), validates manifests with `parseManifest`, ensures the loaded module exports a Brick whose manifest matches the source declaration, and collects per-brick failures without aborting the load. Browser-compatible: no direct FS access — the source is injected by the host (Tauri commands for desktop, in-memory for tests). 11 tests, 100% line / 94.4% branch coverage. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): tighten brick-loader validation per review - Parse the module-provided brick.manifest with `parseManifest` and enforce strict equality against the source manifest (canonical JSON comparison). Catches divergence in deps/tools, not just name. - Split default-export check into two messages: missing default vs default-not-an-object (clearer diagnostics for `default: 42` etc.). - Move the manifest-shape check out of the Brick contract assertion so malformed `brick.manifest` produces an INVALID_MANIFEST error rather than a misleading "does not implement Brick contract". 3 new tests (divergence, malformed module manifest, default-not-object). 14 tests total, 100% line / 96.3% branch coverage on the loader. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/core/src/index.ts | 7 + packages/core/src/loader/brick-loader.test.ts | 198 ++++++++++++++++++ packages/core/src/loader/brick-loader.ts | 101 +++++++++ 3 files changed, 306 insertions(+) create mode 100644 packages/core/src/loader/brick-loader.test.ts create mode 100644 packages/core/src/loader/brick-loader.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 980e76e..869d8ac 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,13 @@ export { type EventBusOptions, InProcessEventBus, } from './event-bus/event-bus.ts'; +export { + type BrickLoaderOptions, + type BrickLoadFailure, + type BrickLoadResult, + type BrickSource, + loadBricks, +} from './loader/brick-loader.ts'; export { ManifestError, type ManifestErrorCode, diff --git a/packages/core/src/loader/brick-loader.test.ts b/packages/core/src/loader/brick-loader.test.ts new file mode 100644 index 0000000..0560b68 --- /dev/null +++ b/packages/core/src/loader/brick-loader.test.ts @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { describe, expect, it, vi } from 'vitest'; +import type { Brick, BrickManifest } from '../types/index.ts'; +import { type BrickSource, loadBricks } from './brick-loader.ts'; + +function makeManifest(name: string, deps: readonly string[] = []): BrickManifest { + return { + name, + version: '1.0.0', + description: `${name} brick`, + dependencies: deps, + tools: [], + }; +} + +function makeBrick(manifest: BrickManifest): Brick { + return { + manifest, + start() { + /* noop */ + }, + stop() { + /* noop */ + }, + }; +} + +function makeSource( + bricks: ReadonlyArray<{ + name: string; + manifest?: unknown; + module?: unknown; + throws?: 'manifest' | 'module'; + }>, +): BrickSource { + return { + list: vi.fn(async () => bricks.map((b) => b.name)), + readManifest: vi.fn(async (name) => { + const entry = bricks.find((b) => b.name === name); + if (!entry) throw new Error(`unknown brick: ${name}`); + if (entry.throws === 'manifest') throw new Error(`manifest read failed: ${name}`); + return 'manifest' in entry ? entry.manifest : makeManifest(name); + }), + loadModule: vi.fn(async (name) => { + const entry = bricks.find((b) => b.name === name); + if (!entry) throw new Error(`unknown brick: ${name}`); + if (entry.throws === 'module') throw new Error(`module load failed: ${name}`); + return 'module' in entry ? entry.module : { default: makeBrick(makeManifest(name)) }; + }), + }; +} + +describe('loadBricks', () => { + it('returns empty result when no bricks are listed', async () => { + const source = makeSource([]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toEqual([]); + }); + + it('loads a single brick successfully', async () => { + const source = makeSource([{ name: 'indexer' }]); + const result = await loadBricks({ source }); + expect(result.bricks).toHaveLength(1); + expect(result.bricks[0]?.manifest.name).toBe('indexer'); + expect(result.failures).toEqual([]); + }); + + it('loads multiple bricks preserving input order', async () => { + const source = makeSource([{ name: 'indexer' }, { name: 'cache' }, { name: 'php' }]); + const result = await loadBricks({ source }); + expect(result.bricks.map((b) => b.manifest.name)).toEqual(['indexer', 'cache', 'php']); + expect(result.failures).toEqual([]); + }); + + it('records a failure when manifest read throws', async () => { + const source = makeSource([{ name: 'indexer' }, { name: 'broken', throws: 'manifest' }]); + const result = await loadBricks({ source }); + expect(result.bricks.map((b) => b.manifest.name)).toEqual(['indexer']); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + expect(result.failures[0]?.error).toBeInstanceOf(Error); + }); + + it('records a failure when manifest is invalid (parse error)', async () => { + const source = makeSource([ + { name: 'broken', manifest: { name: 'broken' /* missing fields */ } }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + }); + + it('records a failure when module load throws', async () => { + const source = makeSource([{ name: 'indexer' }, { name: 'broken', throws: 'module' }]); + const result = await loadBricks({ source }); + expect(result.bricks.map((b) => b.manifest.name)).toEqual(['indexer']); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + }); + + it('records a failure when manifest name mismatches module brick name', async () => { + const source = makeSource([ + { + name: 'indexer', + manifest: makeManifest('indexer'), + module: { default: makeBrick(makeManifest('cache')) }, + }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('indexer'); + expect(result.failures[0]?.error.message).toMatch(/mismatch/i); + }); + + it('records a failure when module manifest diverges from source manifest', async () => { + const source = makeSource([ + { + name: 'indexer', + manifest: makeManifest('indexer', ['cache']), + module: { default: makeBrick(makeManifest('indexer', [])) }, + }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.error.message).toMatch(/mismatch/i); + }); + + it('records a failure when module brick has malformed manifest', async () => { + const source = makeSource([ + { + name: 'broken', + manifest: makeManifest('broken'), + module: { + default: { manifest: 'not-an-object', start() {}, stop() {} }, + }, + }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + }); + + it('records a failure when module has no default export', async () => { + const source = makeSource([{ name: 'broken', module: { other: 1 } }]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + expect(result.failures[0]?.error.message).toMatch(/has no default export/i); + }); + + it('records a failure when default export is not an object (e.g. number)', async () => { + const source = makeSource([{ name: 'broken', module: { default: 42 } }]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.error.message).toMatch(/default export is not an object/i); + }); + + it('records a failure when module is not an object (null)', async () => { + const source = makeSource([{ name: 'broken', module: null }]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.error.message).toMatch(/not an object/i); + }); + + it('records a failure when default export does not implement Brick contract', async () => { + const source = makeSource([ + { + name: 'broken', + module: { default: { manifest: makeManifest('broken') /* no start/stop */ } }, + }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.error.message).toMatch(/brick contract/i); + }); + + it('continues loading subsequent bricks after a failure', async () => { + const source = makeSource([ + { name: 'a' }, + { name: 'broken', throws: 'manifest' }, + { name: 'b' }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks.map((b) => b.manifest.name)).toEqual(['a', 'b']); + expect(result.failures.map((f) => f.name)).toEqual(['broken']); + }); +}); diff --git a/packages/core/src/loader/brick-loader.ts b/packages/core/src/loader/brick-loader.ts new file mode 100644 index 0000000..3cd9002 --- /dev/null +++ b/packages/core/src/loader/brick-loader.ts @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { parseManifest } from '../manifest/manifest.ts'; +import type { Brick } from '../types/index.ts'; + +/** + * Source abstraite des briques installées. Implémentations possibles : + * - filesystem (côté Tauri, via commands FS sandboxed) + * - in-memory / virtual FS (tests, dev) + * - HTTP (récupération directe depuis le marketplace) + * + * Le loader est browser-compatible — c'est l'implémentation de la source + * qui décide comment accéder au disque/réseau. + */ +export interface BrickSource { + /** Liste des briques à charger (typiquement issue de center.json). */ + list(): Promise; + /** Manifeste brut (objet JSON), prêt pour `parseManifest`. */ + readManifest(name: string): Promise; + /** Module ESM de la brique. Doit exposer un `default` conforme à `Brick`. */ + loadModule(name: string): Promise; +} + +export interface BrickLoaderOptions { + readonly source: BrickSource; +} + +export interface BrickLoadFailure { + readonly name: string; + readonly error: Error; +} + +export interface BrickLoadResult { + readonly bricks: readonly Brick[]; + readonly failures: readonly BrickLoadFailure[]; +} + +/** + * Charge toutes les briques listées par la source. Les échecs n'interrompent + * pas le chargement : ils sont collectés dans `failures` et le reste continue. + */ +export async function loadBricks(options: BrickLoaderOptions): Promise { + const { source } = options; + const names = await source.list(); + + const bricks: Brick[] = []; + const failures: BrickLoadFailure[] = []; + + for (const name of names) { + try { + const sourceManifest = parseManifest(await source.readManifest(name)); + const brick = extractBrick(await source.loadModule(name)); + const moduleManifest = parseManifest(brick.manifest); + + if (canonicalize(sourceManifest) !== canonicalize(moduleManifest)) { + throw new Error(`manifest mismatch between source and module for "${sourceManifest.name}"`); + } + + bricks.push(brick); + } catch (cause) { + failures.push({ name, error: toError(cause) }); + } + } + + return { bricks, failures }; +} + +function extractBrick(module: unknown): Brick { + if (!module || typeof module !== 'object') { + throw new Error('module is not an object'); + } + if (!('default' in module)) { + throw new Error('module has no default export'); + } + const candidate = (module as { default: unknown }).default; + if (!candidate || typeof candidate !== 'object') { + throw new Error('module default export is not an object'); + } + const brick = candidate as Partial; + if (typeof brick.start !== 'function' || typeof brick.stop !== 'function') { + throw new Error('default export does not implement the Brick contract'); + } + return brick as Brick; +} + +/** Canonicalize a JSON-shaped value (sort object keys) for stable comparison. */ +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]`; + } + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(',')}}`; +} + +function toError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)); +} From 338366671c0d915c947a512ed8cef61e3b549758 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Thu, 16 Apr 2026 22:30:25 +0200 Subject: [PATCH 04/26] feat(core): marketplace resolver (parse + find + semver + updates) (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): add marketplace resolver (parse + find + semver + updates) Pure, browser-compatible module that consumes a catalog.json as published by the FocusMCP marketplace. Does no I/O — the host injects raw JSON and this module validates, normalizes and queries it. Public API: - parseCatalog(raw): Catalog — structural validation aligned with the published JSON Schema (kebab-case names, semver versions, typed source variants). - findBrick(catalog, name): CatalogBrick | undefined - compareSemver(a, b): -1 | 0 | 1 — minimal inline implementation (core + optional pre-release; no build metadata, no range matching yet — added at need). - listUpdates(installed, catalog): UpdateInfo[] 20 unit tests (parseCatalog, findBrick, compareSemver incl. pre-release ordering per semver §11, listUpdates). Full suite: 127 passing. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): address Copilot review on marketplace resolver - Thread a full `loc` path into requireString/optionalString and the array variants so validation errors now produce "bricks[3].owner.email must be a string" instead of just "email must be a string". Much easier to diagnose. - Tighten SEMVER regex: reject numeric pre-release identifiers with leading zeros (per semver 2.0 §9). - Extend SEMVER regex to accept optional build metadata ("+..."), matching the manifest parser and semver 2.0 §10. Build metadata is captured but discarded for precedence comparisons, as required. - parseTool: use conditional spread for `inputSchema` instead of emitting `inputSchema: undefined` when the field is missing. Consistent with how other optionals are handled in this file and compatible with `exactOptionalPropertyTypes`. - Add 2 tests: build metadata is ignored when comparing; compareSemver rejects pre-release identifiers with leading zeros. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/core/src/index.ts | 13 + .../core/src/marketplace/resolver.test.ts | 170 +++++++++ packages/core/src/marketplace/resolver.ts | 342 ++++++++++++++++++ 3 files changed, 525 insertions(+) create mode 100644 packages/core/src/marketplace/resolver.test.ts create mode 100644 packages/core/src/marketplace/resolver.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 869d8ac..9e8578d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,19 @@ export { type ManifestErrorCode, parseManifest, } from './manifest/manifest.ts'; +export { + type Catalog, + type CatalogBrick, + type CatalogBrickSource, + type CatalogOwner, + type CatalogTool, + compareSemver, + findBrick, + type InstalledBrick, + listUpdates, + parseCatalog, + type UpdateInfo, +} from './marketplace/resolver.ts'; export { createLogger, rootLogger } from './observability/logger.ts'; export { getTracer, trace } from './observability/tracing.ts'; export { permissionProviderFromRegistry } from './registry/permission-provider.ts'; diff --git a/packages/core/src/marketplace/resolver.test.ts b/packages/core/src/marketplace/resolver.test.ts new file mode 100644 index 0000000..26687f4 --- /dev/null +++ b/packages/core/src/marketplace/resolver.test.ts @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from 'vitest'; +import { + type Catalog, + type CatalogBrick, + compareSemver, + findBrick, + type InstalledBrick, + listUpdates, + parseCatalog, +} from './resolver.ts'; + +function validCatalog(bricks: CatalogBrick[] = []): Catalog { + return { + $schema: 'https://marketplace.focusmcp.dev/schemas/catalog/v1.json', + name: 'FocusMCP Marketplace', + description: 'Official catalog', + owner: { name: 'FocusMCP contributors' }, + updated: '2026-04-16T00:00:00.000Z', + bricks, + }; +} + +function validBrick(overrides: Partial = {}): CatalogBrick { + return { + name: 'echo', + version: '1.0.0', + description: 'Hello-world brick', + dependencies: [], + tools: [{ name: 'echo_say', description: 'Echo' }], + source: { type: 'local', path: 'bricks/echo' }, + ...overrides, + }; +} + +describe('parseCatalog', () => { + it('parses a well-formed catalog', () => { + const catalog = parseCatalog(validCatalog([validBrick()])); + expect(catalog.name).toBe('FocusMCP Marketplace'); + expect(catalog.bricks).toHaveLength(1); + expect(catalog.bricks[0]?.name).toBe('echo'); + }); + + it('rejects non-objects', () => { + expect(() => parseCatalog(null)).toThrow(/catalog/i); + expect(() => parseCatalog('not a catalog')).toThrow(/catalog/i); + expect(() => parseCatalog(42)).toThrow(/catalog/i); + }); + + it('rejects a catalog missing required top-level fields', () => { + expect(() => parseCatalog({ bricks: [] })).toThrow(/name/i); + expect(() => parseCatalog({ name: 'X', bricks: [] })).toThrow(/owner/i); + }); + + it('rejects a catalog where bricks is not an array', () => { + expect(() => + parseCatalog({ + ...validCatalog(), + bricks: 'nope', + }), + ).toThrow(/bricks/i); + }); + + it('rejects a brick with an invalid semver version', () => { + expect(() => parseCatalog(validCatalog([validBrick({ version: 'not-semver' })]))).toThrow( + /version/i, + ); + }); + + it('rejects a brick with an invalid kebab-case name', () => { + expect(() => parseCatalog(validCatalog([validBrick({ name: 'BadName' })]))).toThrow(/name/i); + }); + + it('rejects a brick with an invalid source type', () => { + const bad = { ...validBrick(), source: { type: 'invalid' } } as unknown as CatalogBrick; + expect(() => parseCatalog(validCatalog([bad]))).toThrow(/source/i); + }); +}); + +describe('findBrick', () => { + it('returns the brick matching the name', () => { + const catalog = validCatalog([validBrick(), validBrick({ name: 'indexer' })]); + expect(findBrick(catalog, 'indexer')?.name).toBe('indexer'); + }); + + it('returns undefined when the brick is not in the catalog', () => { + const catalog = validCatalog([validBrick()]); + expect(findBrick(catalog, 'missing')).toBeUndefined(); + }); +}); + +describe('compareSemver', () => { + it('returns 0 for equal versions', () => { + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + }); + + it('returns -1 when a is older than b', () => { + expect(compareSemver('1.2.3', '1.2.4')).toBe(-1); + expect(compareSemver('1.2.3', '1.3.0')).toBe(-1); + expect(compareSemver('1.2.3', '2.0.0')).toBe(-1); + }); + + it('returns 1 when a is newer than b', () => { + expect(compareSemver('1.2.4', '1.2.3')).toBe(1); + expect(compareSemver('2.0.0', '1.99.99')).toBe(1); + }); + + it('treats pre-release as older than the same major.minor.patch', () => { + expect(compareSemver('1.0.0-alpha', '1.0.0')).toBe(-1); + expect(compareSemver('1.0.0', '1.0.0-alpha')).toBe(1); + }); + + it('compares pre-release identifiers lexically', () => { + expect(compareSemver('1.0.0-alpha', '1.0.0-beta')).toBe(-1); + expect(compareSemver('1.0.0-beta', '1.0.0-alpha')).toBe(1); + expect(compareSemver('1.0.0-alpha.1', '1.0.0-alpha.2')).toBe(-1); + }); + + it('ignores build metadata when comparing versions', () => { + expect(compareSemver('1.0.0+build.1', '1.0.0+build.2')).toBe(0); + expect(compareSemver('1.0.0+build.1', '1.0.0')).toBe(0); + expect(compareSemver('1.0.0-alpha+x', '1.0.0-alpha+y')).toBe(0); + }); + + it('throws on malformed input', () => { + expect(() => compareSemver('not-semver', '1.0.0')).toThrow(/semver/i); + expect(() => compareSemver('1.0', '1.0.0')).toThrow(/semver/i); + expect(() => compareSemver('1.0.0-alpha.01', '1.0.0-alpha.1')).toThrow(/semver/i); + expect(() => compareSemver('1.0.0-01', '1.0.0-1')).toThrow(/semver/i); + }); +}); + +describe('listUpdates', () => { + it('returns empty when every installed brick is at the catalog version', () => { + const catalog = validCatalog([validBrick({ version: '1.0.0' })]); + const installed: InstalledBrick[] = [{ name: 'echo', version: '1.0.0' }]; + expect(listUpdates(installed, catalog)).toEqual([]); + }); + + it('reports an available update when the catalog has a newer version', () => { + const catalog = validCatalog([validBrick({ version: '1.3.0' })]); + const installed: InstalledBrick[] = [{ name: 'echo', version: '1.0.0' }]; + expect(listUpdates(installed, catalog)).toEqual([ + { name: 'echo', installed: '1.0.0', available: '1.3.0' }, + ]); + }); + + it('ignores bricks installed locally but missing from the catalog', () => { + const catalog = validCatalog([validBrick()]); + const installed: InstalledBrick[] = [{ name: 'ghost', version: '0.1.0' }]; + expect(listUpdates(installed, catalog)).toEqual([]); + }); + + it('ignores catalog bricks that are not installed', () => { + const catalog = validCatalog([ + validBrick({ version: '1.0.0' }), + validBrick({ name: 'indexer', version: '2.0.0' }), + ]); + const installed: InstalledBrick[] = [{ name: 'echo', version: '1.0.0' }]; + expect(listUpdates(installed, catalog)).toEqual([]); + }); + + it('never reports a downgrade when the installed version is newer', () => { + const catalog = validCatalog([validBrick({ version: '1.0.0' })]); + const installed: InstalledBrick[] = [{ name: 'echo', version: '1.5.0' }]; + expect(listUpdates(installed, catalog)).toEqual([]); + }); +}); diff --git a/packages/core/src/marketplace/resolver.ts b/packages/core/src/marketplace/resolver.ts new file mode 100644 index 0000000..86b572d --- /dev/null +++ b/packages/core/src/marketplace/resolver.ts @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * Marketplace resolver — pure, browser-compatible. + * + * Parses and queries a `catalog.json` as published by the FocusMCP + * marketplace. Does no I/O: the host injects raw JSON (fetched from + * `https://marketplace.focusmcp.dev/catalog.json` or a tiers mirror) + * and this module validates, normalizes and queries it. + * + * Aligned with the published JSON Schema `schemas/catalog/v1.json`. + */ + +export interface CatalogOwner { + readonly name: string; + readonly url?: string; + readonly email?: string; +} + +export interface CatalogTool { + readonly name: string; + readonly description: string; + readonly inputSchema?: unknown; +} + +export type CatalogBrickSource = + | { readonly type: 'local'; readonly path: string } + | { readonly type: 'url'; readonly url: string; readonly sha?: string } + | { + readonly type: 'git-subdir'; + readonly url: string; + readonly path: string; + readonly ref: string; + readonly sha?: string; + }; + +export interface CatalogBrick { + readonly name: string; + readonly version: string; + readonly description: string; + readonly tags?: readonly string[]; + readonly dependencies: readonly string[]; + readonly tools: readonly CatalogTool[]; + readonly source: CatalogBrickSource; + readonly tarballUrl?: string; + readonly integrity?: string; + readonly publishedAt?: string; + readonly license?: string; + readonly homepage?: string; + readonly publisher?: string; +} + +export interface Catalog { + readonly $schema?: string; + readonly name: string; + readonly description?: string; + readonly owner: CatalogOwner; + readonly updated: string; + readonly bricks: readonly CatalogBrick[]; +} + +export interface InstalledBrick { + readonly name: string; + readonly version: string; +} + +export interface UpdateInfo { + readonly name: string; + readonly installed: string; + readonly available: string; +} + +// ---------- Constants ---------- + +const KEBAB_NAME = /^[a-z][a-z0-9-]*$/; +// SemVer 2.0 — strict pre-release (no leading-zero numeric identifiers) + optional build metadata. +// Groups: 1=major, 2=minor, 3=patch, 4=pre-release (without the leading dash). +const SEMVER = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +// ---------- parseCatalog ---------- + +export function parseCatalog(raw: unknown): Catalog { + const obj = requireObject(raw, 'catalog'); + const name = requireString(obj, 'name', 'catalog'); + const owner = requireObject(obj['owner'], 'catalog.owner'); + const ownerName = requireString(owner, 'name', 'catalog.owner'); + const updated = requireString(obj, 'updated', 'catalog'); + const bricksRaw = obj['bricks']; + if (!Array.isArray(bricksRaw)) { + throw new Error('catalog.bricks must be an array'); + } + const bricks = bricksRaw.map((b, i) => parseBrick(b, i)); + + const schema = optionalString(obj, '$schema', 'catalog'); + const description = optionalString(obj, 'description', 'catalog'); + const ownerUrl = optionalString(owner, 'url', 'catalog.owner'); + const ownerEmail = optionalString(owner, 'email', 'catalog.owner'); + + return { + name, + owner: { + name: ownerName, + ...(ownerUrl !== undefined ? { url: ownerUrl } : {}), + ...(ownerEmail !== undefined ? { email: ownerEmail } : {}), + }, + updated, + bricks, + ...(schema !== undefined ? { $schema: schema } : {}), + ...(description !== undefined ? { description } : {}), + }; +} + +function parseBrick(raw: unknown, index: number): CatalogBrick { + const loc = `bricks[${index}]`; + const obj = requireObject(raw, loc); + const name = requireString(obj, 'name', loc); + if (!KEBAB_NAME.test(name)) { + throw new Error(`${loc}.name "${name}" is not kebab-case`); + } + const version = requireString(obj, 'version', loc); + if (!SEMVER.test(version)) { + throw new Error(`${loc}.version "${version}" is not a valid semver`); + } + const description = requireString(obj, 'description', loc); + const dependencies = requireStringArray(obj, 'dependencies', loc); + const tools = requireArray(obj, 'tools', loc).map((t, ti) => parseTool(t, loc, ti)); + const source = parseSource(obj['source'], loc); + const tags = optionalStringArray(obj, 'tags', loc); + const tarballUrl = optionalString(obj, 'tarballUrl', loc); + const integrity = optionalString(obj, 'integrity', loc); + const publishedAt = optionalString(obj, 'publishedAt', loc); + const license = optionalString(obj, 'license', loc); + const homepage = optionalString(obj, 'homepage', loc); + const publisher = optionalString(obj, 'publisher', loc); + + return { + name, + version, + description, + dependencies, + tools, + source, + ...(tags !== undefined ? { tags } : {}), + ...(tarballUrl !== undefined ? { tarballUrl } : {}), + ...(integrity !== undefined ? { integrity } : {}), + ...(publishedAt !== undefined ? { publishedAt } : {}), + ...(license !== undefined ? { license } : {}), + ...(homepage !== undefined ? { homepage } : {}), + ...(publisher !== undefined ? { publisher } : {}), + }; +} + +function parseTool(raw: unknown, parentLoc: string, toolIndex: number): CatalogTool { + const loc = `${parentLoc}.tools[${toolIndex}]`; + const obj = requireObject(raw, loc); + const inputSchema = obj['inputSchema']; + return { + name: requireString(obj, 'name', loc), + description: requireString(obj, 'description', loc), + ...(inputSchema !== undefined ? { inputSchema } : {}), + }; +} + +function parseSource(raw: unknown, parentLoc: string): CatalogBrickSource { + const loc = `${parentLoc}.source`; + const obj = requireObject(raw, loc); + const type = obj['type']; + if (type === 'local') { + return { type: 'local', path: requireString(obj, 'path', loc) }; + } + if (type === 'url') { + const sha = optionalString(obj, 'sha', loc); + return { + type: 'url', + url: requireString(obj, 'url', loc), + ...(sha !== undefined ? { sha } : {}), + }; + } + if (type === 'git-subdir') { + const sha = optionalString(obj, 'sha', loc); + return { + type: 'git-subdir', + url: requireString(obj, 'url', loc), + path: requireString(obj, 'path', loc), + ref: requireString(obj, 'ref', loc), + ...(sha !== undefined ? { sha } : {}), + }; + } + throw new Error( + `${loc}.type must be "local", "url" or "git-subdir", got ${JSON.stringify(type)}`, + ); +} + +// ---------- findBrick ---------- + +export function findBrick(catalog: Catalog, name: string): CatalogBrick | undefined { + return catalog.bricks.find((b) => b.name === name); +} + +// ---------- compareSemver ---------- + +/** -1 if a < b, 0 if equal, 1 if a > b. Throws on malformed input. Build metadata is ignored per SemVer 2.0 §10. */ +export function compareSemver(a: string, b: string): -1 | 0 | 1 { + const pa = parseSemver(a); + const pb = parseSemver(b); + + for (let i = 0; i < 3; i++) { + const av = pa.core[i] as number; + const bv = pb.core[i] as number; + if (av !== bv) return av < bv ? -1 : 1; + } + + // Core equal — compare pre-release per semver §11. + if (pa.pre === undefined && pb.pre === undefined) return 0; + if (pa.pre === undefined) return 1; // non-pre > pre + if (pb.pre === undefined) return -1; + return comparePreRelease(pa.pre, pb.pre); +} + +function parseSemver(version: string): { + readonly core: readonly [number, number, number]; + readonly pre: string | undefined; +} { + const match = SEMVER.exec(version); + if (!match) throw new Error(`"${version}" is not a valid semver`); + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])] as const, + pre: match[4], + }; +} + +function comparePreRelease(a: string, b: string): -1 | 0 | 1 { + const as = a.split('.'); + const bs = b.split('.'); + const maxLen = Math.max(as.length, bs.length); + for (let i = 0; i < maxLen; i++) { + const cmp = comparePreReleaseId(as[i], bs[i]); + if (cmp !== 0) return cmp; + } + return 0; +} + +function comparePreReleaseId(a: string | undefined, b: string | undefined): -1 | 0 | 1 { + // Per semver §11.4.4: shorter set of identifiers is lower. + if (a === undefined) return -1; + if (b === undefined) return 1; + const an = /^\d+$/.test(a); + const bn = /^\d+$/.test(b); + if (an && bn) { + const ai = Number(a); + const bi = Number(b); + if (ai === bi) return 0; + return ai < bi ? -1 : 1; + } + if (an) return -1; // numeric < non-numeric + if (bn) return 1; + if (a === b) return 0; + return a < b ? -1 : 1; +} + +// ---------- listUpdates ---------- + +export function listUpdates( + installed: readonly InstalledBrick[], + catalog: Catalog, +): readonly UpdateInfo[] { + const updates: UpdateInfo[] = []; + for (const inst of installed) { + const entry = findBrick(catalog, inst.name); + if (!entry) continue; + if (compareSemver(entry.version, inst.version) === 1) { + updates.push({ name: inst.name, installed: inst.version, available: entry.version }); + } + } + return updates; +} + +// ---------- helpers ---------- + +function requireObject(raw: unknown, loc: string): Record { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`${loc} must be an object`); + } + return raw as Record; +} + +function requireString(obj: Record, key: string, parentLoc: string): string { + const value = obj[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${parentLoc}.${key} must be a non-empty string`); + } + return value; +} + +function optionalString( + obj: Record, + key: string, + parentLoc: string, +): string | undefined { + const value = obj[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string') { + throw new Error(`${parentLoc}.${key} must be a string when provided`); + } + return value; +} + +function requireArray( + obj: Record, + key: string, + parentLoc: string, +): readonly unknown[] { + const value = obj[key]; + if (!Array.isArray(value)) throw new Error(`${parentLoc}.${key} must be an array`); + return value; +} + +function requireStringArray( + obj: Record, + key: string, + parentLoc: string, +): readonly string[] { + const arr = requireArray(obj, key, parentLoc); + for (const item of arr) { + if (typeof item !== 'string') { + throw new Error(`${parentLoc}.${key} must contain only strings`); + } + } + return arr as readonly string[]; +} + +function optionalStringArray( + obj: Record, + key: string, + parentLoc: string, +): readonly string[] | undefined { + const value = obj[key]; + if (value === undefined) return undefined; + return requireStringArray(obj, key, parentLoc); +} From bc51101c025fdfa0a03d3847dba19c39ea96abe7 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Fri, 17 Apr 2026 15:12:30 +0200 Subject: [PATCH 05/26] style: migrate indent from 2 to 4 spaces (#6) * style: migrate indent from 2 to 4 spaces (Biome config) Standardize indentation to 4 spaces across all projects. Biome formatter config updated accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update .editorconfig indent_size to 4 Aligns with Biome formatter config to prevent editor/formatter conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: align biome schema to 2.4.11 and format new develop files Update $schema version to match installed Biome CLI. Reformat brick-loader and marketplace resolver (merged from develop). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- .changeset/config.json | 26 +- .editorconfig | 2 +- .github/renovate.json | 70 +-- biome.json | 176 +++--- config/commitlint.config.js | 48 +- config/jscpd.json | 30 +- config/knip.json | 50 +- config/lint-staged.config.js | 4 +- config/playwright.config.ts | 32 +- config/stryker.config.json | 58 +- config/tsconfig.base.json | 50 +- config/tsup.preset.ts | 28 +- config/vitest.config.ts | 88 +-- package.json | 184 +++--- packages/cli/package.json | 54 +- packages/cli/tsconfig.json | 6 +- packages/cli/tsup.config.ts | 6 +- packages/core/package.json | 72 +-- .../src/bootstrap/create-focus-mcp.test.ts | 276 ++++----- .../core/src/bootstrap/create-focus-mcp.ts | 160 ++--- packages/core/src/event-bus/event-bus.test.ts | 548 +++++++++--------- packages/core/src/event-bus/event-bus.ts | 486 ++++++++-------- packages/core/src/index.ts | 50 +- packages/core/src/loader/brick-loader.test.ts | 358 ++++++------ packages/core/src/loader/brick-loader.ts | 106 ++-- packages/core/src/manifest/manifest.test.ts | 296 +++++----- packages/core/src/manifest/manifest.ts | 472 +++++++-------- .../core/src/marketplace/resolver.test.ts | 294 +++++----- packages/core/src/marketplace/resolver.ts | 470 +++++++-------- .../core/src/observability/async-storage.ts | 24 +- packages/core/src/observability/logger.ts | 102 ++-- packages/core/src/observability/tracing.ts | 2 +- .../src/registry/permission-provider.test.ts | 60 +- .../core/src/registry/permission-provider.ts | 4 +- packages/core/src/registry/registry.test.ts | 360 ++++++------ packages/core/src/registry/registry.ts | 178 +++--- packages/core/src/router/router.test.ts | 242 ++++---- packages/core/src/router/router.ts | 54 +- packages/core/src/types/brick.ts | 38 +- packages/core/src/types/event-bus.ts | 136 ++--- packages/core/src/types/index.ts | 26 +- packages/core/src/types/manifest.ts | 36 +- packages/core/src/types/registry.ts | 68 +-- packages/core/src/types/router.ts | 24 +- packages/core/src/types/tool.ts | 40 +- packages/core/tsconfig.json | 6 +- packages/sdk/package.json | 68 +-- packages/sdk/src/define-brick.test.ts | 284 ++++----- packages/sdk/src/define-brick.ts | 140 ++--- packages/sdk/src/index.ts | 10 +- packages/sdk/tsconfig.json | 18 +- packages/validator/package.json | 68 +-- packages/validator/src/index.ts | 10 +- packages/validator/src/validate-brick.test.ts | 210 +++---- packages/validator/src/validate-brick.ts | 248 ++++---- packages/validator/tsconfig.json | 18 +- tsconfig.json | 8 +- 57 files changed, 3534 insertions(+), 3448 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 46146a5..89be74a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,15 +1,15 @@ { - "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [["@focusmcp/core", "@focusmcp/sdk"]], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": ["@focusmcp/ui", "@focusmcp/tauri-app"], - "privatePackages": { - "version": false, - "tag": false - } + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [["@focusmcp/core", "@focusmcp/sdk"]], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["@focusmcp/ui", "@focusmcp/tauri-app"], + "privatePackages": { + "version": false, + "tag": false + } } diff --git a/.editorconfig b/.editorconfig index 00f2c60..bfe4868 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,7 +6,7 @@ end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space -indent_size = 2 +indent_size = 4 [*.md] trim_trailing_whitespace = false diff --git a/.github/renovate.json b/.github/renovate.json index fe2af53..afc94c8 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,39 +1,39 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended", - ":semanticCommits", - ":semanticCommitTypeAll(chore)", - "group:monorepos", - "group:recommended", - "schedule:weekly" - ], - "labels": ["dependencies"], - "rangeStrategy": "bump", - "lockFileMaintenance": { - "enabled": true, - "schedule": ["before 5am on monday"] - }, - "vulnerabilityAlerts": { - "labels": ["security"], - "schedule": ["at any time"] - }, - "packageRules": [ - { - "matchUpdateTypes": ["patch", "minor"], - "matchCurrentVersion": "!/^0/", - "automerge": true, - "automergeType": "branch" + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":semanticCommits", + ":semanticCommitTypeAll(chore)", + "group:monorepos", + "group:recommended", + "schedule:weekly" + ], + "labels": ["dependencies"], + "rangeStrategy": "bump", + "lockFileMaintenance": { + "enabled": true, + "schedule": ["before 5am on monday"] }, - { - "matchPackageNames": ["typescript", "@biomejs/biome", "vitest"], - "automerge": false + "vulnerabilityAlerts": { + "labels": ["security"], + "schedule": ["at any time"] }, - { - "matchDepTypes": ["devDependencies"], - "automerge": true - } - ], - "prHourlyLimit": 4, - "prConcurrentLimit": 10 + "packageRules": [ + { + "matchUpdateTypes": ["patch", "minor"], + "matchCurrentVersion": "!/^0/", + "automerge": true, + "automergeType": "branch" + }, + { + "matchPackageNames": ["typescript", "@biomejs/biome", "vitest"], + "automerge": false + }, + { + "matchDepTypes": ["devDependencies"], + "automerge": true + } + ], + "prHourlyLimit": 4, + "prConcurrentLimit": 10 } diff --git a/biome.json b/biome.json index a63a369..42f2560 100644 --- a/biome.json +++ b/biome.json @@ -1,93 +1,93 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "ignoreUnknown": true, - "includes": [ - "**", - "!**/node_modules", - "!**/dist", - "!**/build", - "!**/coverage", - "!**/.stryker-tmp", - "!**/sbom.json", - "!**/pnpm-lock.yaml", - "!**/target" - ] - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100, - "lineEnding": "lf" - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "complexity": { - "noExcessiveCognitiveComplexity": { - "level": "error", - "options": { "maxAllowedComplexity": 15 } - }, - "useLiteralKeys": "off" - }, - "correctness": { - "noUnusedVariables": "error", - "noUnusedImports": "error", - "useExhaustiveDependencies": "error" - }, - "style": { - "useImportType": "error", - "useExportType": "error", - "useNodejsImportProtocol": "error", - "noNonNullAssertion": "error" - }, - "suspicious": { - "noConsole": "error", - "noExplicitAny": "error" - }, - "nursery": { - "noFloatingPromises": "error" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "trailingCommas": "all", - "semicolons": "always", - "arrowParentheses": "always" - } - }, - "json": { + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": [ + "**", + "!**/node_modules", + "!**/dist", + "!**/build", + "!**/coverage", + "!**/.stryker-tmp", + "!**/sbom.json", + "!**/pnpm-lock.yaml", + "!**/target" + ] + }, "formatter": { - "indentWidth": 2, - "trailingCommas": "none" - } - }, - "assist": { - "actions": { - "source": { - "organizeImports": "on" - } - } - }, - "overrides": [ - { - "includes": ["packages/core/src/observability/logger.ts"], - "linter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 100, + "lineEnding": "lf" + }, + "linter": { + "enabled": true, "rules": { - "suspicious": { - "noConsole": "off" - } + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 15 } + }, + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useExhaustiveDependencies": "error" + }, + "style": { + "useImportType": "error", + "useExportType": "error", + "useNodejsImportProtocol": "error", + "noNonNullAssertion": "error" + }, + "suspicious": { + "noConsole": "error", + "noExplicitAny": "error" + }, + "nursery": { + "noFloatingPromises": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always" + } + }, + "json": { + "formatter": { + "indentWidth": 4, + "trailingCommas": "none" } - } - } - ] + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": ["packages/core/src/observability/logger.ts"], + "linter": { + "rules": { + "suspicious": { + "noConsole": "off" + } + } + } + } + ] } diff --git a/config/commitlint.config.js b/config/commitlint.config.js index 6f3140a..f0f098b 100644 --- a/config/commitlint.config.js +++ b/config/commitlint.config.js @@ -3,28 +3,28 @@ /** @type {import('@commitlint/types').UserConfig} */ export default { - extends: ['@commitlint/config-conventional'], - rules: { - 'type-enum': [ - 2, - 'always', - [ - 'feat', - 'fix', - 'docs', - 'style', - 'refactor', - 'perf', - 'test', - 'build', - 'ci', - 'chore', - 'revert', - ], - ], - 'subject-case': [2, 'never', ['upper-case', 'pascal-case', 'start-case']], - 'header-max-length': [2, 'always', 100], - 'body-leading-blank': [2, 'always'], - 'footer-leading-blank': [2, 'always'], - }, + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [ + 2, + 'always', + [ + 'feat', + 'fix', + 'docs', + 'style', + 'refactor', + 'perf', + 'test', + 'build', + 'ci', + 'chore', + 'revert', + ], + ], + 'subject-case': [2, 'never', ['upper-case', 'pascal-case', 'start-case']], + 'header-max-length': [2, 'always', 100], + 'body-leading-blank': [2, 'always'], + 'footer-leading-blank': [2, 'always'], + }, }; diff --git a/config/jscpd.json b/config/jscpd.json index fb00a0e..262b6c5 100644 --- a/config/jscpd.json +++ b/config/jscpd.json @@ -1,17 +1,17 @@ { - "threshold": 1.0, - "minTokens": 70, - "minLines": 8, - "ignore": [ - "**/node_modules/**", - "**/dist/**", - "**/coverage/**", - "**/.stryker-tmp/**", - "**/*.test.ts", - "**/*.spec.ts" - ], - "format": ["typescript", "javascript"], - "reporters": ["console", "html"], - "output": "../reports/jscpd", - "absolute": true + "threshold": 1.0, + "minTokens": 70, + "minLines": 8, + "ignore": [ + "**/node_modules/**", + "**/dist/**", + "**/coverage/**", + "**/.stryker-tmp/**", + "**/*.test.ts", + "**/*.spec.ts" + ], + "format": ["typescript", "javascript"], + "reporters": ["console", "html"], + "output": "../reports/jscpd", + "absolute": true } diff --git a/config/knip.json b/config/knip.json index 6d1a0af..fa25cec 100644 --- a/config/knip.json +++ b/config/knip.json @@ -1,28 +1,28 @@ { - "$schema": "https://unpkg.com/knip@5/schema.json", - "ignoreExportsUsedInFile": true, - "workspaces": { - ".": { - "entry": [ - "config/vitest.config.ts", - "config/playwright.config.ts", - "config/commitlint.config.js", - "config/lint-staged.config.js", - "config/stryker.config.json" - ], - "project": ["**/*.{ts,tsx,js,mjs}"] + "$schema": "https://unpkg.com/knip@5/schema.json", + "ignoreExportsUsedInFile": true, + "workspaces": { + ".": { + "entry": [ + "config/vitest.config.ts", + "config/playwright.config.ts", + "config/commitlint.config.js", + "config/lint-staged.config.js", + "config/stryker.config.json" + ], + "project": ["**/*.{ts,tsx,js,mjs}"] + }, + "packages/core": {}, + "packages/sdk": {}, + "packages/cli": { + "entry": ["src/index.ts", "src/bin/*.ts"] + }, + "packages/validator": {} }, - "packages/core": {}, - "packages/sdk": {}, - "packages/cli": { - "entry": ["src/index.ts", "src/bin/*.ts"] - }, - "packages/validator": {} - }, - "ignoreDependencies": [ - "@commitlint/config-conventional", - "@stryker-mutator/vitest-runner", - "fast-check" - ], - "ignoreBinaries": ["playwright", "reuse"] + "ignoreDependencies": [ + "@commitlint/config-conventional", + "@stryker-mutator/vitest-runner", + "fast-check" + ], + "ignoreBinaries": ["playwright", "reuse"] } diff --git a/config/lint-staged.config.js b/config/lint-staged.config.js index c9b2479..6750a33 100644 --- a/config/lint-staged.config.js +++ b/config/lint-staged.config.js @@ -3,6 +3,6 @@ /** @type {import('lint-staged').Configuration} */ export default { - '*.{ts,tsx,js,jsx,json,md}': ['biome check --write --no-errors-on-unmatched'], - '*.{ts,tsx}': () => 'pnpm typecheck', + '*.{ts,tsx,js,jsx,json,md}': ['biome check --write --no-errors-on-unmatched'], + '*.{ts,tsx}': () => 'pnpm typecheck', }; diff --git a/config/playwright.config.ts b/config/playwright.config.ts index 1ce4aec..7415fb1 100644 --- a/config/playwright.config.ts +++ b/config/playwright.config.ts @@ -4,21 +4,21 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: '../tests/e2e', - fullyParallel: true, - forbidOnly: !!process.env['CI'], - retries: process.env['CI'] ? 2 : 0, - workers: process.env['CI'] ? 1 : undefined, - reporter: [['html'], ['list']], - use: { - baseURL: 'http://localhost:3000', - trace: 'on-first-retry', - screenshot: 'only-on-failure', - }, - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + testDir: '../tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env['CI'], + retries: process.env['CI'] ? 2 : 0, + workers: process.env['CI'] ? 1 : undefined, + reporter: [['html'], ['list']], + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', }, - ], + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], }); diff --git a/config/stryker.config.json b/config/stryker.config.json index 03d079f..c48fe7d 100644 --- a/config/stryker.config.json +++ b/config/stryker.config.json @@ -1,31 +1,31 @@ { - "$schema": "../node_modules/@stryker-mutator/core/schema/stryker-schema.json", - "_comment": "Mutation testing — lancé en nightly, pas en CI standard", - "packageManager": "pnpm", - "testRunner": "vitest", - "vitest": { - "configFile": "config/vitest.config.ts" - }, - "reporters": ["clear-text", "progress", "html", "json"], - "htmlReporter": { - "fileName": "reports/mutation/index.html" - }, - "coverageAnalysis": "perTest", - "mutate": [ - "packages/*/src/**/*.ts", - "!packages/*/src/**/*.test.ts", - "!packages/*/src/**/*.spec.ts", - "!packages/*/src/**/*.d.ts", - "!packages/*/src/**/index.ts", - "!packages/*/src/**/types/**" - ], - "thresholds": { - "high": 85, - "low": 70, - "break": 60 - }, - "concurrency": 4, - "timeoutMS": 60000, - "tempDirName": ".stryker-tmp", - "incremental": true + "$schema": "../node_modules/@stryker-mutator/core/schema/stryker-schema.json", + "_comment": "Mutation testing — lancé en nightly, pas en CI standard", + "packageManager": "pnpm", + "testRunner": "vitest", + "vitest": { + "configFile": "config/vitest.config.ts" + }, + "reporters": ["clear-text", "progress", "html", "json"], + "htmlReporter": { + "fileName": "reports/mutation/index.html" + }, + "coverageAnalysis": "perTest", + "mutate": [ + "packages/*/src/**/*.ts", + "!packages/*/src/**/*.test.ts", + "!packages/*/src/**/*.spec.ts", + "!packages/*/src/**/*.d.ts", + "!packages/*/src/**/index.ts", + "!packages/*/src/**/types/**" + ], + "thresholds": { + "high": 85, + "low": 70, + "break": 60 + }, + "concurrency": 4, + "timeoutMS": 60000, + "tempDirName": ".stryker-tmp", + "incremental": true } diff --git a/config/tsconfig.base.json b/config/tsconfig.base.json index 708338b..791eb8d 100644 --- a/config/tsconfig.base.json +++ b/config/tsconfig.base.json @@ -1,30 +1,30 @@ { - "$schema": "https://json.schemastore.org/tsconfig.json", - "compilerOptions": { - "target": "ES2023", - "module": "ESNext", - "moduleResolution": "Bundler", - "lib": ["ES2023"], - "types": ["node"], + "$schema": "https://json.schemastore.org/tsconfig.json", + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2023"], + "types": ["node"], - "strict": true, - "noUncheckedIndexedAccess": true, - "exactOptionalPropertyTypes": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "noPropertyAccessFromIndexSignature": true, - "useUnknownInCatchVariables": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "useUnknownInCatchVariables": true, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "allowImportingTsExtensions": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, - "noEmit": true, - "skipLibCheck": true - } + "noEmit": true, + "skipLibCheck": true + } } diff --git a/config/tsup.preset.ts b/config/tsup.preset.ts index 24a535a..70fe6ed 100644 --- a/config/tsup.preset.ts +++ b/config/tsup.preset.ts @@ -8,18 +8,18 @@ import type { Options } from 'tsup'; * Build ESM-only, types, sourcemaps, target Node 22. */ export function focusTsupPreset(overrides: Partial = {}): Options { - return { - entry: ['src/index.ts'], - format: ['esm'], - target: 'node22', - platform: 'node', - dts: true, - sourcemap: true, - clean: true, - splitting: false, - treeshake: true, - minify: false, - outDir: 'dist', - ...overrides, - }; + return { + entry: ['src/index.ts'], + format: ['esm'], + target: 'node22', + platform: 'node', + dts: true, + sourcemap: true, + clean: true, + splitting: false, + treeshake: true, + minify: false, + outDir: 'dist', + ...overrides, + }; } diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 2580a5a..277efe6 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -8,51 +8,51 @@ import { defineConfig } from 'vitest/config'; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export default defineConfig({ - resolve: { - alias: { - '@focusmcp/core': resolve(projectRoot, 'packages/core/src/index.ts'), - '@focusmcp/sdk': resolve(projectRoot, 'packages/sdk/src/index.ts'), - '@focusmcp/validator': resolve(projectRoot, 'packages/validator/src/index.ts'), - }, - }, - test: { - globals: false, - environment: 'node', - root: projectRoot, - include: ['packages/**/*.{test,spec}.ts', 'packages/**/__tests__/**/*.ts'], - exclude: ['**/node_modules/**', '**/dist/**', '**/.stryker-tmp/**'], - reporters: ['default'], - coverage: { - provider: 'v8', - reporter: ['text', 'html', 'lcov', 'json-summary'], - reportsDirectory: resolve(projectRoot, 'coverage'), - include: ['packages/*/src/**/*.ts'], - exclude: [ - '**/*.test.ts', - '**/*.spec.ts', - '**/*.d.ts', - '**/index.ts', - '**/types/**', - '**/__tests__/**', - ], - thresholds: { - lines: 80, - functions: 80, - branches: 80, - statements: 80, - 'packages/core/src/event-bus/**': { - lines: 95, - functions: 95, - branches: 90, - statements: 95, + resolve: { + alias: { + '@focusmcp/core': resolve(projectRoot, 'packages/core/src/index.ts'), + '@focusmcp/sdk': resolve(projectRoot, 'packages/sdk/src/index.ts'), + '@focusmcp/validator': resolve(projectRoot, 'packages/validator/src/index.ts'), }, - 'packages/core/src/registry/**': { - lines: 95, - functions: 95, - branches: 95, - statements: 95, + }, + test: { + globals: false, + environment: 'node', + root: projectRoot, + include: ['packages/**/*.{test,spec}.ts', 'packages/**/__tests__/**/*.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/.stryker-tmp/**'], + reporters: ['default'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov', 'json-summary'], + reportsDirectory: resolve(projectRoot, 'coverage'), + include: ['packages/*/src/**/*.ts'], + exclude: [ + '**/*.test.ts', + '**/*.spec.ts', + '**/*.d.ts', + '**/index.ts', + '**/types/**', + '**/__tests__/**', + ], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + 'packages/core/src/event-bus/**': { + lines: 95, + functions: 95, + branches: 90, + statements: 95, + }, + 'packages/core/src/registry/**': { + lines: 95, + functions: 95, + branches: 95, + statements: 95, + }, + }, }, - }, }, - }, }); diff --git a/package.json b/package.json index e80deec..7ffaca7 100644 --- a/package.json +++ b/package.json @@ -1,97 +1,97 @@ { - "name": "@focusmcp/root", - "version": "0.0.0", - "private": true, - "description": "FocusMCP — Focaliser les agents AI sur l'essentiel", - "license": "MIT", - "type": "module", - "engines": { - "node": ">=22.0.0", - "pnpm": ">=10.0.0" - }, - "packageManager": "pnpm@10.32.1", - "repository": { - "type": "git", - "url": "git+https://github.com/focus-mcp/core.git" - }, - "homepage": "https://focusmcp.dev", - "bugs": { - "url": "https://github.com/focus-mcp/core/issues" - }, - "scripts": { - "build": "pnpm -r --filter \"./packages/**\" build", - "lint": "biome check .", - "lint:fix": "biome check --write .", - "format": "biome format --write .", - "typecheck": "pnpm -r typecheck", - "test": "vitest run --config config/vitest.config.ts", - "test:watch": "vitest --config config/vitest.config.ts", - "test:coverage": "vitest run --coverage --config config/vitest.config.ts", - "test:mutation": "stryker run config/stryker.config.json", - "test:e2e": "playwright test --config config/playwright.config.ts", - "knip": "knip --config config/knip.json", - "size": "size-limit", - "jscpd": "jscpd --config config/jscpd.json .", - "license-check": "license-checker --production --onlyAllow \"MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;Unlicense;CC0-1.0\"", - "audit": "pnpm audit --prod", - "reuse": "reuse lint", - "sbom": "cdxgen -o sbom.json -t js --no-install-deps", - "changeset": "changeset", - "version": "changeset version", - "release": "changeset publish", - "prepare": "husky" - }, - "size-limit": [ - { - "name": "@focusmcp/core (gzip)", - "path": "packages/core/dist/index.js", - "limit": "30 KB", - "gzip": true + "name": "@focusmcp/root", + "version": "0.0.0", + "private": true, + "description": "FocusMCP — Focaliser les agents AI sur l'essentiel", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.0.0" }, - { - "name": "@focusmcp/sdk (gzip)", - "path": "packages/sdk/dist/index.js", - "limit": "10 KB", - "gzip": true + "packageManager": "pnpm@10.32.1", + "repository": { + "type": "git", + "url": "git+https://github.com/focus-mcp/core.git" }, - { - "name": "@focusmcp/cli (gzip)", - "path": "packages/cli/dist/index.js", - "limit": "50 KB", - "gzip": true - } - ], - "devDependencies": { - "@biomejs/biome": "^2.2.0", - "@changesets/cli": "^2.27.0", - "@commitlint/cli": "^19.6.0", - "@commitlint/config-conventional": "^19.6.0", - "@commitlint/types": "^19.8.1", - "@cyclonedx/cdxgen": "^11.0.0", - "@playwright/test": "^1.59.1", - "@size-limit/preset-small-lib": "^12.1.0", - "@stryker-mutator/core": "^9.0.0", - "@stryker-mutator/vitest-runner": "^9.0.0", - "@types/node": "^22.10.0", - "@vitest/coverage-v8": "^3.2.0", - "fast-check": "^3.23.0", - "husky": "^9.1.0", - "jscpd": "^4.0.5", - "knip": "^5.40.0", - "license-checker": "^25.0.1", - "lint-staged": "^15.2.0", - "size-limit": "^11.1.0", - "tsup": "^8.3.0", - "typescript": "^5.7.0", - "vitest": "^3.2.0" - }, - "pnpm": { - "overrides": { - "tar": "^7.5.13", - "glob": "^10.4.5", - "minimatch": "^9.0.5", - "jws": "^4.0.1", - "sequelize": "^6.37.8" + "homepage": "https://focusmcp.dev", + "bugs": { + "url": "https://github.com/focus-mcp/core/issues" + }, + "scripts": { + "build": "pnpm -r --filter \"./packages/**\" build", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "pnpm -r typecheck", + "test": "vitest run --config config/vitest.config.ts", + "test:watch": "vitest --config config/vitest.config.ts", + "test:coverage": "vitest run --coverage --config config/vitest.config.ts", + "test:mutation": "stryker run config/stryker.config.json", + "test:e2e": "playwright test --config config/playwright.config.ts", + "knip": "knip --config config/knip.json", + "size": "size-limit", + "jscpd": "jscpd --config config/jscpd.json .", + "license-check": "license-checker --production --onlyAllow \"MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;Unlicense;CC0-1.0\"", + "audit": "pnpm audit --prod", + "reuse": "reuse lint", + "sbom": "cdxgen -o sbom.json -t js --no-install-deps", + "changeset": "changeset", + "version": "changeset version", + "release": "changeset publish", + "prepare": "husky" + }, + "size-limit": [ + { + "name": "@focusmcp/core (gzip)", + "path": "packages/core/dist/index.js", + "limit": "30 KB", + "gzip": true + }, + { + "name": "@focusmcp/sdk (gzip)", + "path": "packages/sdk/dist/index.js", + "limit": "10 KB", + "gzip": true + }, + { + "name": "@focusmcp/cli (gzip)", + "path": "packages/cli/dist/index.js", + "limit": "50 KB", + "gzip": true + } + ], + "devDependencies": { + "@biomejs/biome": "^2.2.0", + "@changesets/cli": "^2.27.0", + "@commitlint/cli": "^19.6.0", + "@commitlint/config-conventional": "^19.6.0", + "@commitlint/types": "^19.8.1", + "@cyclonedx/cdxgen": "^11.0.0", + "@playwright/test": "^1.59.1", + "@size-limit/preset-small-lib": "^12.1.0", + "@stryker-mutator/core": "^9.0.0", + "@stryker-mutator/vitest-runner": "^9.0.0", + "@types/node": "^22.10.0", + "@vitest/coverage-v8": "^3.2.0", + "fast-check": "^3.23.0", + "husky": "^9.1.0", + "jscpd": "^4.0.5", + "knip": "^5.40.0", + "license-checker": "^25.0.1", + "lint-staged": "^15.2.0", + "size-limit": "^11.1.0", + "tsup": "^8.3.0", + "typescript": "^5.7.0", + "vitest": "^3.2.0" + }, + "pnpm": { + "overrides": { + "tar": "^7.5.13", + "glob": "^10.4.5", + "minimatch": "^9.0.5", + "jws": "^4.0.1", + "sequelize": "^6.37.8" + } } - } } diff --git a/packages/cli/package.json b/packages/cli/package.json index a96f40e..0bb27b2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,29 +1,29 @@ { - "name": "@focusmcp/cli", - "version": "0.0.0", - "description": "FocusMCP CLI — focus start, add, remove, update...", - "license": "MIT", - "type": "module", - "bin": { - "focus": "./dist/bin/focus.js" - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "typescript": "^5.7.0", - "vitest": "^3.2.0" - }, - "publishConfig": { - "access": "public", - "provenance": true - } + "name": "@focusmcp/cli", + "version": "0.0.0", + "description": "FocusMCP CLI — focus start, add, remove, update...", + "license": "MIT", + "type": "module", + "bin": { + "focus": "./dist/bin/focus.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + "vitest": "^3.2.0" + }, + "publishConfig": { + "access": "public", + "provenance": true + } } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 021387c..9c9369a 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../config/tsconfig.base.json", - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] + "extends": "../../config/tsconfig.base.json", + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] } diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index f4c1fee..9776776 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'tsup'; import { focusTsupPreset } from '../../config/tsup.preset.ts'; export default defineConfig( - focusTsupPreset({ - entry: ['src/index.ts', 'src/bin/focus.ts'], - }), + focusTsupPreset({ + entry: ['src/index.ts', 'src/bin/focus.ts'], + }), ); diff --git a/packages/core/package.json b/packages/core/package.json index fa904ce..7da5855 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,39 +1,39 @@ { - "name": "@focusmcp/core", - "version": "0.0.0", - "description": "FocusMCP core — Registry, EventBus, Router, manifest parser (browser+node compatible)", - "license": "MIT", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "name": "@focusmcp/core", + "version": "0.0.0", + "description": "FocusMCP core — Registry, EventBus, Router, manifest parser (browser+node compatible)", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "fast-check": "^3.23.0", + "typescript": "^5.7.0", + "vitest": "^3.2.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" - }, - "dependencies": { - "@opentelemetry/api": "^1.9.0" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "fast-check": "^3.23.0", - "typescript": "^5.7.0", - "vitest": "^3.2.0" - }, - "publishConfig": { - "access": "public", - "provenance": true - } } diff --git a/packages/core/src/bootstrap/create-focus-mcp.test.ts b/packages/core/src/bootstrap/create-focus-mcp.test.ts index 1c2735d..d34600e 100644 --- a/packages/core/src/bootstrap/create-focus-mcp.test.ts +++ b/packages/core/src/bootstrap/create-focus-mcp.test.ts @@ -6,159 +6,167 @@ import type { Brick, Unsubscribe } from '../types/index.ts'; import { createFocusMcp } from './create-focus-mcp.ts'; function brick( - name: string, - deps: readonly string[], - toolName: string, - handler: (payload: unknown) => unknown, + name: string, + deps: readonly string[], + toolName: string, + handler: (payload: unknown) => unknown, ): Brick { - let unsubs: Unsubscribe[] = []; - return { - manifest: { - name, - version: '1.0.0', - description: name, - dependencies: deps, - tools: [{ name: toolName, description: 'x', inputSchema: { type: 'object' } }], - }, - start(ctx): void { - unsubs.push(ctx.bus.handle(`${name}:${toolName}`, handler)); - }, - stop(): void { - for (const u of unsubs) u(); - unsubs = []; - }, - }; + let unsubs: Unsubscribe[] = []; + return { + manifest: { + name, + version: '1.0.0', + description: name, + dependencies: deps, + tools: [{ name: toolName, description: 'x', inputSchema: { type: 'object' } }], + }, + start(ctx): void { + unsubs.push(ctx.bus.handle(`${name}:${toolName}`, handler)); + }, + stop(): void { + for (const u of unsubs) u(); + unsubs = []; + }, + }; } describe('createFocusMcp — assembly', () => { - it('expose registry, bus, router immédiatement', () => { - const app = createFocusMcp(); - expect(app.registry).toBeDefined(); - expect(app.bus).toBeDefined(); - expect(app.router).toBeDefined(); - }); - - it('enregistre les briques passées dans options.bricks', () => { - const app = createFocusMcp({ - bricks: [brick('indexer', [], 'indexer_search', () => 'ok')], + it('expose registry, bus, router immédiatement', () => { + const app = createFocusMcp(); + expect(app.registry).toBeDefined(); + expect(app.bus).toBeDefined(); + expect(app.router).toBeDefined(); + }); + + it('enregistre les briques passées dans options.bricks', () => { + const app = createFocusMcp({ + bricks: [brick('indexer', [], 'indexer_search', () => 'ok')], + }); + expect(app.registry.getBrick('indexer')).toBeDefined(); }); - expect(app.registry.getBrick('indexer')).toBeDefined(); - }); }); describe('createFocusMcp — lifecycle', () => { - it('start() passe les briques en running', async () => { - const app = createFocusMcp({ - bricks: [brick('maths', [], 'maths_add', () => ({ ok: true }))], - }); - await app.start(); - expect(app.registry.getStatus('maths')).toBe('running'); - await app.stop(); - }); - - it('start() démarre les briques dans l’ordre des dépendances', async () => { - const starts: string[] = []; - const recording = (name: string, deps: readonly string[]): Brick => ({ - manifest: { name, version: '1.0.0', description: name, dependencies: deps, tools: [] }, - start(): void { - starts.push(name); - }, - stop(): void {}, + it('start() passe les briques en running', async () => { + const app = createFocusMcp({ + bricks: [brick('maths', [], 'maths_add', () => ({ ok: true }))], + }); + await app.start(); + expect(app.registry.getStatus('maths')).toBe('running'); + await app.stop(); }); - const app = createFocusMcp({ - bricks: [ - recording('php', ['indexer']), - recording('indexer', []), - recording('symfony', ['php']), - ], + it('start() démarre les briques dans l’ordre des dépendances', async () => { + const starts: string[] = []; + const recording = (name: string, deps: readonly string[]): Brick => ({ + manifest: { name, version: '1.0.0', description: name, dependencies: deps, tools: [] }, + start(): void { + starts.push(name); + }, + stop(): void {}, + }); + + const app = createFocusMcp({ + bricks: [ + recording('php', ['indexer']), + recording('indexer', []), + recording('symfony', ['php']), + ], + }); + + await app.start(); + expect(starts).toEqual(['indexer', 'php', 'symfony']); + await app.stop(); }); - await app.start(); - expect(starts).toEqual(['indexer', 'php', 'symfony']); - await app.stop(); - }); - - it('expose les tools des briques running via le Router', async () => { - const app = createFocusMcp({ - bricks: [ - brick('maths', [], 'maths_add', () => ({ content: [{ type: 'text', text: '42' }] })), - ], - }); - await app.start(); + it('expose les tools des briques running via le Router', async () => { + const app = createFocusMcp({ + bricks: [ + brick('maths', [], 'maths_add', () => ({ + content: [{ type: 'text', text: '42' }], + })), + ], + }); + await app.start(); - const names = app.router.listTools().map((t) => t.name); - expect(names).toEqual(['maths_add']); + const names = app.router.listTools().map((t) => t.name); + expect(names).toEqual(['maths_add']); - const result = await app.router.callTool('maths_add', {}); - expect(result.content[0]).toEqual({ type: 'text', text: '42' }); + const result = await app.router.callTool('maths_add', {}); + expect(result.content[0]).toEqual({ type: 'text', text: '42' }); - await app.stop(); - }); + await app.stop(); + }); - it('stop() passe les briques en stopped', async () => { - const app = createFocusMcp({ - bricks: [brick('maths', [], 'maths_add', () => 'ok')], + it('stop() passe les briques en stopped', async () => { + const app = createFocusMcp({ + bricks: [brick('maths', [], 'maths_add', () => 'ok')], + }); + await app.start(); + await app.stop(); + expect(app.registry.getStatus('maths')).toBe('stopped'); }); - await app.start(); - await app.stop(); - expect(app.registry.getStatus('maths')).toBe('stopped'); - }); }); describe('createFocusMcp — permissions', () => { - it('applique les permissions manifeste : appel hors deps → PERMISSION_DENIED', async () => { - let unsubs: Unsubscribe[] = []; - const indexer: Brick = { - manifest: { - name: 'indexer', - version: '1.0.0', - description: 'indexer', - dependencies: [], - tools: [{ name: 'indexer_search', description: 'x', inputSchema: { type: 'object' } }], - }, - start(ctx): void { - unsubs.push(ctx.bus.handle('indexer:indexer_search', () => ({ files: [] }))); - }, - stop(): void { - for (const u of unsubs) u(); - unsubs = []; - }, - }; - - // php déclare UNIQUEMENT 'indexer' comme dépendance - let phpUnsubs: Unsubscribe[] = []; - const php: Brick = { - manifest: { - name: 'php', - version: '1.0.0', - description: 'php', - dependencies: ['indexer'], - tools: [ - { name: 'php_ok', description: 'allowed', inputSchema: { type: 'object' } }, - { name: 'php_ko', description: 'denied', inputSchema: { type: 'object' } }, - ], - }, - start(ctx): void { - phpUnsubs.push( - ctx.bus.handle('php:php_ok', () => ctx.bus.request('indexer:indexer_search', {})), - ); - phpUnsubs.push(ctx.bus.handle('php:php_ko', () => ctx.bus.request('cache:cache_get', {}))); - }, - stop(): void { - for (const u of phpUnsubs) u(); - phpUnsubs = []; - }, - }; - - const app = createFocusMcp({ bricks: [indexer, php] }); - await app.start(); - - await expect(app.bus.request('php:php_ok', {})).resolves.toEqual({ files: [] }); - await expect(app.bus.request('php:php_ko', {})).rejects.toMatchObject({ - code: 'PERMISSION_DENIED', + it('applique les permissions manifeste : appel hors deps → PERMISSION_DENIED', async () => { + let unsubs: Unsubscribe[] = []; + const indexer: Brick = { + manifest: { + name: 'indexer', + version: '1.0.0', + description: 'indexer', + dependencies: [], + tools: [ + { name: 'indexer_search', description: 'x', inputSchema: { type: 'object' } }, + ], + }, + start(ctx): void { + unsubs.push(ctx.bus.handle('indexer:indexer_search', () => ({ files: [] }))); + }, + stop(): void { + for (const u of unsubs) u(); + unsubs = []; + }, + }; + + // php déclare UNIQUEMENT 'indexer' comme dépendance + let phpUnsubs: Unsubscribe[] = []; + const php: Brick = { + manifest: { + name: 'php', + version: '1.0.0', + description: 'php', + dependencies: ['indexer'], + tools: [ + { name: 'php_ok', description: 'allowed', inputSchema: { type: 'object' } }, + { name: 'php_ko', description: 'denied', inputSchema: { type: 'object' } }, + ], + }, + start(ctx): void { + phpUnsubs.push( + ctx.bus.handle('php:php_ok', () => + ctx.bus.request('indexer:indexer_search', {}), + ), + ); + phpUnsubs.push( + ctx.bus.handle('php:php_ko', () => ctx.bus.request('cache:cache_get', {})), + ); + }, + stop(): void { + for (const u of phpUnsubs) u(); + phpUnsubs = []; + }, + }; + + const app = createFocusMcp({ bricks: [indexer, php] }); + await app.start(); + + await expect(app.bus.request('php:php_ok', {})).resolves.toEqual({ files: [] }); + await expect(app.bus.request('php:php_ko', {})).rejects.toMatchObject({ + code: 'PERMISSION_DENIED', + }); + + await app.stop(); }); - - await app.stop(); - }); }); diff --git a/packages/core/src/bootstrap/create-focus-mcp.ts b/packages/core/src/bootstrap/create-focus-mcp.ts index fdac6df..86c35d4 100644 --- a/packages/core/src/bootstrap/create-focus-mcp.ts +++ b/packages/core/src/bootstrap/create-focus-mcp.ts @@ -12,18 +12,18 @@ import type { Registry } from '../types/registry.ts'; import type { Router } from '../types/router.ts'; export interface CreateFocusMcpOptions { - /** Briques à enregistrer au démarrage (l'ordre de démarrage suit les dépendances). */ - readonly bricks?: readonly Brick[]; - /** Garde-fous EventBus. Par défaut : `DEFAULT_GUARDS`. */ - readonly guards?: EventBusGuards; + /** Briques à enregistrer au démarrage (l'ordre de démarrage suit les dépendances). */ + readonly bricks?: readonly Brick[]; + /** Garde-fous EventBus. Par défaut : `DEFAULT_GUARDS`. */ + readonly guards?: EventBusGuards; } export interface FocusMcp { - readonly registry: Registry; - readonly bus: EventBus; - readonly router: Router; - start(): Promise; - stop(): Promise; + readonly registry: Registry; + readonly bus: EventBus; + readonly router: Router; + start(): Promise; + stop(): Promise; } /** @@ -37,93 +37,93 @@ export interface FocusMcp { * `stop()` les arrête dans l'ordre inverse. */ export function createFocusMcp(options: CreateFocusMcpOptions = {}): FocusMcp { - const registry = new InMemoryRegistry(); - for (const brick of options.bricks ?? []) { - registry.register(brick); - } + const registry = new InMemoryRegistry(); + for (const brick of options.bricks ?? []) { + registry.register(brick); + } - const permissionProvider = permissionProviderFromRegistry(registry); - const bus = options.guards - ? new InProcessEventBus(options.guards, { permissionProvider }) - : new InProcessEventBus(undefined, { permissionProvider }); + const permissionProvider = permissionProviderFromRegistry(registry); + const bus = options.guards + ? new InProcessEventBus(options.guards, { permissionProvider }) + : new InProcessEventBus(undefined, { permissionProvider }); - const router = new McpRouter({ registry, bus }); + const router = new McpRouter({ registry, bus }); - let startedBricks: Brick[] = []; - let started = false; + let startedBricks: Brick[] = []; + let started = false; - return { - registry, - bus, - router, + return { + registry, + bus, + router, - async start(): Promise { - if (started) throw new Error('FocusMcp already started'); - started = true; + async start(): Promise { + if (started) throw new Error('FocusMcp already started'); + started = true; - startedBricks = []; - const order = resolveStartOrder(registry); - for (const brick of order) { - const ctx = buildCtx(bus, brick.manifest.name); - registry.setStatus(brick.manifest.name, 'starting'); - try { - await brick.start(ctx); - registry.setStatus(brick.manifest.name, 'running'); - startedBricks.push(brick); - } catch (err) { - registry.setStatus(brick.manifest.name, 'error'); - await rollbackStartedBricks(startedBricks, registry); - started = false; - throw err; - } - } - }, + startedBricks = []; + const order = resolveStartOrder(registry); + for (const brick of order) { + const ctx = buildCtx(bus, brick.manifest.name); + registry.setStatus(brick.manifest.name, 'starting'); + try { + await brick.start(ctx); + registry.setStatus(brick.manifest.name, 'running'); + startedBricks.push(brick); + } catch (err) { + registry.setStatus(brick.manifest.name, 'error'); + await rollbackStartedBricks(startedBricks, registry); + started = false; + throw err; + } + } + }, - async stop(): Promise { - if (!started) return; - for (const brick of [...startedBricks].reverse()) { - try { - await brick.stop(); - } catch (err) { - createLogger('bootstrap').error('brick stop failed', { - brick: brick.manifest.name, - err: err instanceof Error ? err.message : String(err), - }); - } - registry.setStatus(brick.manifest.name, 'stopped'); - } - startedBricks = []; - started = false; - }, - }; + async stop(): Promise { + if (!started) return; + for (const brick of [...startedBricks].reverse()) { + try { + await brick.stop(); + } catch (err) { + createLogger('bootstrap').error('brick stop failed', { + brick: brick.manifest.name, + err: err instanceof Error ? err.message : String(err), + }); + } + registry.setStatus(brick.manifest.name, 'stopped'); + } + startedBricks = []; + started = false; + }, + }; } function resolveStartOrder(registry: Registry): readonly Brick[] { - const order: Brick[] = []; - const seen = new Set(); - for (const brick of registry.getBricks()) { - for (const dep of registry.resolve(brick.manifest.name)) { - if (seen.has(dep.manifest.name)) continue; - seen.add(dep.manifest.name); - order.push(dep); + const order: Brick[] = []; + const seen = new Set(); + for (const brick of registry.getBricks()) { + for (const dep of registry.resolve(brick.manifest.name)) { + if (seen.has(dep.manifest.name)) continue; + seen.add(dep.manifest.name); + order.push(dep); + } } - } - return order; + return order; } async function rollbackStartedBricks(startedBricks: Brick[], registry: Registry): Promise { - for (const brick of [...startedBricks].reverse()) { - try { - await brick.stop(); - } catch { - /* ignore rollback errors */ + for (const brick of [...startedBricks].reverse()) { + try { + await brick.stop(); + } catch { + /* ignore rollback errors */ + } + registry.setStatus(brick.manifest.name, 'stopped'); } - registry.setStatus(brick.manifest.name, 'stopped'); - } - startedBricks.length = 0; + startedBricks.length = 0; } function buildCtx(bus: EventBus, brickName: string): BrickContext { - const logger: BrickLogger = createLogger('brick', { brick: brickName }); - return { bus, config: {}, logger }; + const logger: BrickLogger = createLogger('brick', { brick: brickName }); + return { bus, config: {}, logger }; } diff --git a/packages/core/src/event-bus/event-bus.test.ts b/packages/core/src/event-bus/event-bus.test.ts index face479..552af42 100644 --- a/packages/core/src/event-bus/event-bus.test.ts +++ b/packages/core/src/event-bus/event-bus.test.ts @@ -6,347 +6,347 @@ import { EventBusError, type EventBusGuards } from '../types/event-bus.ts'; import { DEFAULT_GUARDS, InProcessEventBus } from './event-bus.ts'; const baseGuards = (overrides: Partial = {}): EventBusGuards => ({ - maxDepth: 16, - defaultTimeoutMs: 1000, - maxPayloadBytes: 1024 * 1024, - rateLimit: { callsPerSecond: 1000, burstSize: 2000 }, - circuitBreaker: { failureThreshold: 100, cooldownMs: 1000 }, - ...overrides, + maxDepth: 16, + defaultTimeoutMs: 1000, + maxPayloadBytes: 1024 * 1024, + rateLimit: { callsPerSecond: 1000, burstSize: 2000 }, + circuitBreaker: { failureThreshold: 100, cooldownMs: 1000 }, + ...overrides, }); describe('InProcessEventBus — pub/sub', () => { - it('appelle tous les handlers abonnés à un événement', () => { - const bus = new InProcessEventBus(); - const handler1 = vi.fn(); - const handler2 = vi.fn(); - - bus.on('files:indexed', handler1); - bus.on('files:indexed', handler2); - bus.emit('files:indexed', { count: 42 }); - - expect(handler1).toHaveBeenCalledOnce(); - expect(handler2).toHaveBeenCalledOnce(); - expect(handler1).toHaveBeenCalledWith({ count: 42 }, expect.any(Object)); - }); - - it("n'appelle pas les handlers d'autres événements", () => { - const bus = new InProcessEventBus(); - const handler = vi.fn(); - - bus.on('files:indexed', handler); - bus.emit('php:analyzed', { file: 'x.php' }); - - expect(handler).not.toHaveBeenCalled(); - }); - - it('permet de se désabonner via le retour de on()', () => { - const bus = new InProcessEventBus(); - const handler = vi.fn(); - - const unsubscribe = bus.on('test', handler); - unsubscribe(); - bus.emit('test', null); - - expect(handler).not.toHaveBeenCalled(); - }); - - it('passe un EventMeta avec source, traceId, depth, emittedAt', () => { - const bus = new InProcessEventBus(); - const handler = vi.fn(); - - bus.on('test', handler); - bus.emit('test', null); - - expect(handler).toHaveBeenCalledWith( - null, - expect.objectContaining({ - source: expect.any(String), - traceId: expect.any(String), - depth: expect.any(Number), - emittedAt: expect.any(Number), - }), - ); - }); - - it('swallow les erreurs synchrones des handlers (fire-and-forget)', () => { - const bus = new InProcessEventBus(); - const throwing = vi.fn(() => { - throw new Error('boom'); + it('appelle tous les handlers abonnés à un événement', () => { + const bus = new InProcessEventBus(); + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + bus.on('files:indexed', handler1); + bus.on('files:indexed', handler2); + bus.emit('files:indexed', { count: 42 }); + + expect(handler1).toHaveBeenCalledOnce(); + expect(handler2).toHaveBeenCalledOnce(); + expect(handler1).toHaveBeenCalledWith({ count: 42 }, expect.any(Object)); }); - const ok = vi.fn(); - bus.on('test', throwing); - bus.on('test', ok); + it("n'appelle pas les handlers d'autres événements", () => { + const bus = new InProcessEventBus(); + const handler = vi.fn(); - expect(() => bus.emit('test', null)).not.toThrow(); - expect(throwing).toHaveBeenCalledOnce(); - expect(ok).toHaveBeenCalledOnce(); - }); + bus.on('files:indexed', handler); + bus.emit('php:analyzed', { file: 'x.php' }); - it('swallow les erreurs async des handlers', async () => { - const bus = new InProcessEventBus(); - const asyncFail = vi.fn(async () => { - await Promise.resolve(); - throw new Error('async boom'); + expect(handler).not.toHaveBeenCalled(); }); - bus.on('test', asyncFail); - expect(() => bus.emit('test', null)).not.toThrow(); + it('permet de se désabonner via le retour de on()', () => { + const bus = new InProcessEventBus(); + const handler = vi.fn(); - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(asyncFail).toHaveBeenCalledOnce(); - }); -}); + const unsubscribe = bus.on('test', handler); + unsubscribe(); + bus.emit('test', null); -describe('InProcessEventBus — request/response', () => { - it("résout avec la valeur retournée par le handler enregistré sur 'brick:action'", async () => { - const bus = new InProcessEventBus(); - bus.handle('indexer:search', async (payload: { pattern: string }) => ({ - files: [`match-${payload.pattern}`], - })); + expect(handler).not.toHaveBeenCalled(); + }); - const result = await bus.request('indexer:search', { pattern: '*.ts' }); + it('passe un EventMeta avec source, traceId, depth, emittedAt', () => { + const bus = new InProcessEventBus(); + const handler = vi.fn(); + + bus.on('test', handler); + bus.emit('test', null); + + expect(handler).toHaveBeenCalledWith( + null, + expect.objectContaining({ + source: expect.any(String), + traceId: expect.any(String), + depth: expect.any(Number), + emittedAt: expect.any(Number), + }), + ); + }); - expect(result).toEqual({ files: ['match-*.ts'] }); - }); + it('swallow les erreurs synchrones des handlers (fire-and-forget)', () => { + const bus = new InProcessEventBus(); + const throwing = vi.fn(() => { + throw new Error('boom'); + }); + const ok = vi.fn(); - it("rejette avec NO_HANDLER si aucune brique n'est enregistrée pour la cible", async () => { - const bus = new InProcessEventBus(); + bus.on('test', throwing); + bus.on('test', ok); - await expect(bus.request('unknown:target', {})).rejects.toMatchObject({ - name: 'EventBusError', - code: 'NO_HANDLER', + expect(() => bus.emit('test', null)).not.toThrow(); + expect(throwing).toHaveBeenCalledOnce(); + expect(ok).toHaveBeenCalledOnce(); }); - }); - it('refuse un second handler pour la même cible (HANDLER_ALREADY_REGISTERED)', () => { - const bus = new InProcessEventBus(); - bus.handle('php:analyze', () => 'first'); + it('swallow les erreurs async des handlers', async () => { + const bus = new InProcessEventBus(); + const asyncFail = vi.fn(async () => { + await Promise.resolve(); + throw new Error('async boom'); + }); - expect(() => bus.handle('php:analyze', () => 'second')).toThrow(EventBusError); - }); + bus.on('test', asyncFail); + expect(() => bus.emit('test', null)).not.toThrow(); - it('propage le traceId fourni dans options vers le handler', async () => { - const bus = new InProcessEventBus(); - let capturedTraceId: string | undefined; - bus.handle('echo', (_, meta) => { - capturedTraceId = meta.traceId; - return 'ok'; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(asyncFail).toHaveBeenCalledOnce(); }); +}); - await bus.request('echo', null, { traceId: 'trace-123' }); +describe('InProcessEventBus — request/response', () => { + it("résout avec la valeur retournée par le handler enregistré sur 'brick:action'", async () => { + const bus = new InProcessEventBus(); + bus.handle('indexer:search', async (payload: { pattern: string }) => ({ + files: [`match-${payload.pattern}`], + })); - expect(capturedTraceId).toBe('trace-123'); - }); + const result = await bus.request('indexer:search', { pattern: '*.ts' }); - it('propage le traceId parent dans les requêtes imbriquées', async () => { - const bus = new InProcessEventBus(); - const captured: string[] = []; - bus.handle('parent', async (_, meta) => { - captured.push(meta.traceId); - return bus.request('child', null); + expect(result).toEqual({ files: ['match-*.ts'] }); }); - bus.handle('child', (_, meta) => { - captured.push(meta.traceId); - return 'done'; + + it("rejette avec NO_HANDLER si aucune brique n'est enregistrée pour la cible", async () => { + const bus = new InProcessEventBus(); + + await expect(bus.request('unknown:target', {})).rejects.toMatchObject({ + name: 'EventBusError', + code: 'NO_HANDLER', + }); + }); + + it('refuse un second handler pour la même cible (HANDLER_ALREADY_REGISTERED)', () => { + const bus = new InProcessEventBus(); + bus.handle('php:analyze', () => 'first'); + + expect(() => bus.handle('php:analyze', () => 'second')).toThrow(EventBusError); }); - await bus.request('parent', null, { traceId: 'shared-trace' }); + it('propage le traceId fourni dans options vers le handler', async () => { + const bus = new InProcessEventBus(); + let capturedTraceId: string | undefined; + bus.handle('echo', (_, meta) => { + capturedTraceId = meta.traceId; + return 'ok'; + }); - expect(captured).toEqual(['shared-trace', 'shared-trace']); - }); + await bus.request('echo', null, { traceId: 'trace-123' }); - it('respecte options.timeoutMs (override du default)', async () => { - const bus = new InProcessEventBus(); - bus.handle('slow', () => new Promise((resolve) => setTimeout(resolve, 200))); + expect(capturedTraceId).toBe('trace-123'); + }); - await expect(bus.request('slow', null, { timeoutMs: 30 })).rejects.toMatchObject({ - name: 'EventBusError', - code: 'TIMEOUT', + it('propage le traceId parent dans les requêtes imbriquées', async () => { + const bus = new InProcessEventBus(); + const captured: string[] = []; + bus.handle('parent', async (_, meta) => { + captured.push(meta.traceId); + return bus.request('child', null); + }); + bus.handle('child', (_, meta) => { + captured.push(meta.traceId); + return 'done'; + }); + + await bus.request('parent', null, { traceId: 'shared-trace' }); + + expect(captured).toEqual(['shared-trace', 'shared-trace']); + }); + + it('respecte options.timeoutMs (override du default)', async () => { + const bus = new InProcessEventBus(); + bus.handle('slow', () => new Promise((resolve) => setTimeout(resolve, 200))); + + await expect(bus.request('slow', null, { timeoutMs: 30 })).rejects.toMatchObject({ + name: 'EventBusError', + code: 'TIMEOUT', + }); }); - }); }); describe('InProcessEventBus — garde-fous', () => { - it('TIMEOUT : rejette si le handler ne répond pas dans le délai', async () => { - const bus = new InProcessEventBus(baseGuards({ defaultTimeoutMs: 50 })); - bus.handle('slow:op', () => new Promise((resolve) => setTimeout(resolve, 200))); - - await expect(bus.request('slow:op', null)).rejects.toMatchObject({ - name: 'EventBusError', - code: 'TIMEOUT', + it('TIMEOUT : rejette si le handler ne répond pas dans le délai', async () => { + const bus = new InProcessEventBus(baseGuards({ defaultTimeoutMs: 50 })); + bus.handle('slow:op', () => new Promise((resolve) => setTimeout(resolve, 200))); + + await expect(bus.request('slow:op', null)).rejects.toMatchObject({ + name: 'EventBusError', + code: 'TIMEOUT', + }); }); - }); - it('MAX_DEPTH_EXCEEDED : bloque les boucles infinies inter-briques', async () => { - const bus = new InProcessEventBus(baseGuards({ maxDepth: 3 })); + it('MAX_DEPTH_EXCEEDED : bloque les boucles infinies inter-briques', async () => { + const bus = new InProcessEventBus(baseGuards({ maxDepth: 3 })); - bus.handle('a:call', () => bus.request('b:call', null)); - bus.handle('b:call', () => bus.request('a:call', null)); + bus.handle('a:call', () => bus.request('b:call', null)); + bus.handle('b:call', () => bus.request('a:call', null)); - await expect(bus.request('a:call', null)).rejects.toMatchObject({ - name: 'EventBusError', - code: 'MAX_DEPTH_EXCEEDED', + await expect(bus.request('a:call', null)).rejects.toMatchObject({ + name: 'EventBusError', + code: 'MAX_DEPTH_EXCEEDED', + }); }); - }); - it('PAYLOAD_TOO_LARGE : rejette les payloads dépassant maxPayloadBytes', async () => { - const bus = new InProcessEventBus(baseGuards({ maxPayloadBytes: 100 })); - bus.handle('echo', (payload) => payload); + it('PAYLOAD_TOO_LARGE : rejette les payloads dépassant maxPayloadBytes', async () => { + const bus = new InProcessEventBus(baseGuards({ maxPayloadBytes: 100 })); + bus.handle('echo', (payload) => payload); - const tooBig = 'x'.repeat(1000); + const tooBig = 'x'.repeat(1000); - await expect(bus.request('echo', tooBig)).rejects.toMatchObject({ - name: 'EventBusError', - code: 'PAYLOAD_TOO_LARGE', + await expect(bus.request('echo', tooBig)).rejects.toMatchObject({ + name: 'EventBusError', + code: 'PAYLOAD_TOO_LARGE', + }); }); - }); }); describe('InProcessEventBus — rate limit', () => { - it('RATE_LIMIT_EXCEEDED : rejette au-delà du burst size par source', async () => { - const bus = new InProcessEventBus( - baseGuards({ rateLimit: { callsPerSecond: 1, burstSize: 2 } }), - ); - bus.handle('target:call', () => 'ok'); - - await expect(bus.request('target:call', null)).resolves.toBe('ok'); - await expect(bus.request('target:call', null)).resolves.toBe('ok'); - await expect(bus.request('target:call', null)).rejects.toMatchObject({ - name: 'EventBusError', - code: 'RATE_LIMIT_EXCEEDED', + it('RATE_LIMIT_EXCEEDED : rejette au-delà du burst size par source', async () => { + const bus = new InProcessEventBus( + baseGuards({ rateLimit: { callsPerSecond: 1, burstSize: 2 } }), + ); + bus.handle('target:call', () => 'ok'); + + await expect(bus.request('target:call', null)).resolves.toBe('ok'); + await expect(bus.request('target:call', null)).resolves.toBe('ok'); + await expect(bus.request('target:call', null)).rejects.toMatchObject({ + name: 'EventBusError', + code: 'RATE_LIMIT_EXCEEDED', + }); }); - }); - it('refill les tokens proportionnellement au temps écoulé', async () => { - const bus = new InProcessEventBus( - baseGuards({ rateLimit: { callsPerSecond: 200, burstSize: 1 } }), - ); - bus.handle('target:call', () => 'ok'); - - await bus.request('target:call', null); - await expect(bus.request('target:call', null)).rejects.toMatchObject({ - code: 'RATE_LIMIT_EXCEEDED', + it('refill les tokens proportionnellement au temps écoulé', async () => { + const bus = new InProcessEventBus( + baseGuards({ rateLimit: { callsPerSecond: 200, burstSize: 1 } }), + ); + bus.handle('target:call', () => 'ok'); + + await bus.request('target:call', null); + await expect(bus.request('target:call', null)).rejects.toMatchObject({ + code: 'RATE_LIMIT_EXCEEDED', + }); + await new Promise((r) => setTimeout(r, 20)); + await expect(bus.request('target:call', null)).resolves.toBe('ok'); }); - await new Promise((r) => setTimeout(r, 20)); - await expect(bus.request('target:call', null)).resolves.toBe('ok'); - }); - - it('sépare les buckets par source (appelant)', async () => { - const bus = new InProcessEventBus( - baseGuards({ rateLimit: { callsPerSecond: 1, burstSize: 1 } }), - ); - bus.handle('leaf:x', () => 'ok'); - bus.handle('a:x', () => bus.request('leaf:x', null)); - bus.handle('b:x', () => bus.request('leaf:x', null)); - - // burst=1 per source. 'router' spends its token on a:x then exhausts - // it when calling b:x → RATE_LIMIT_EXCEEDED. a:x → leaf:x uses a's - // bucket (not router's), so nested calls keep working. That's the - // property under test: buckets are keyed by source, not target. - await expect(bus.request('a:x', null)).resolves.toBe('ok'); - await expect(bus.request('b:x', null)).rejects.toMatchObject({ - code: 'RATE_LIMIT_EXCEEDED', + + it('sépare les buckets par source (appelant)', async () => { + const bus = new InProcessEventBus( + baseGuards({ rateLimit: { callsPerSecond: 1, burstSize: 1 } }), + ); + bus.handle('leaf:x', () => 'ok'); + bus.handle('a:x', () => bus.request('leaf:x', null)); + bus.handle('b:x', () => bus.request('leaf:x', null)); + + // burst=1 per source. 'router' spends its token on a:x then exhausts + // it when calling b:x → RATE_LIMIT_EXCEEDED. a:x → leaf:x uses a's + // bucket (not router's), so nested calls keep working. That's the + // property under test: buckets are keyed by source, not target. + await expect(bus.request('a:x', null)).resolves.toBe('ok'); + await expect(bus.request('b:x', null)).rejects.toMatchObject({ + code: 'RATE_LIMIT_EXCEEDED', + }); }); - }); }); describe('InProcessEventBus — circuit breaker', () => { - it('CIRCUIT_OPEN : ouvre après failureThreshold échecs consécutifs', async () => { - const bus = new InProcessEventBus( - baseGuards({ circuitBreaker: { failureThreshold: 2, cooldownMs: 1000 } }), - ); - bus.handle('flaky:call', () => { - throw new Error('boom'); - }); - - await expect(bus.request('flaky:call', null)).rejects.toThrow('boom'); - await expect(bus.request('flaky:call', null)).rejects.toThrow('boom'); - await expect(bus.request('flaky:call', null)).rejects.toMatchObject({ - name: 'EventBusError', - code: 'CIRCUIT_OPEN', - }); - }); - - it('referme le circuit après cooldown si l’appel half-open réussit', async () => { - let shouldFail = true; - const bus = new InProcessEventBus( - baseGuards({ circuitBreaker: { failureThreshold: 2, cooldownMs: 50 } }), - ); - bus.handle('flaky:call', () => { - if (shouldFail) throw new Error('boom'); - return 'ok'; + it('CIRCUIT_OPEN : ouvre après failureThreshold échecs consécutifs', async () => { + const bus = new InProcessEventBus( + baseGuards({ circuitBreaker: { failureThreshold: 2, cooldownMs: 1000 } }), + ); + bus.handle('flaky:call', () => { + throw new Error('boom'); + }); + + await expect(bus.request('flaky:call', null)).rejects.toThrow('boom'); + await expect(bus.request('flaky:call', null)).rejects.toThrow('boom'); + await expect(bus.request('flaky:call', null)).rejects.toMatchObject({ + name: 'EventBusError', + code: 'CIRCUIT_OPEN', + }); }); - await expect(bus.request('flaky:call', null)).rejects.toThrow('boom'); - await expect(bus.request('flaky:call', null)).rejects.toThrow('boom'); - await expect(bus.request('flaky:call', null)).rejects.toMatchObject({ - code: 'CIRCUIT_OPEN', + it('referme le circuit après cooldown si l’appel half-open réussit', async () => { + let shouldFail = true; + const bus = new InProcessEventBus( + baseGuards({ circuitBreaker: { failureThreshold: 2, cooldownMs: 50 } }), + ); + bus.handle('flaky:call', () => { + if (shouldFail) throw new Error('boom'); + return 'ok'; + }); + + await expect(bus.request('flaky:call', null)).rejects.toThrow('boom'); + await expect(bus.request('flaky:call', null)).rejects.toThrow('boom'); + await expect(bus.request('flaky:call', null)).rejects.toMatchObject({ + code: 'CIRCUIT_OPEN', + }); + + shouldFail = false; + await new Promise((r) => setTimeout(r, 60)); + await expect(bus.request('flaky:call', null)).resolves.toBe('ok'); + await expect(bus.request('flaky:call', null)).resolves.toBe('ok'); }); - shouldFail = false; - await new Promise((r) => setTimeout(r, 60)); - await expect(bus.request('flaky:call', null)).resolves.toBe('ok'); - await expect(bus.request('flaky:call', null)).resolves.toBe('ok'); - }); - - it('un succès réinitialise le compteur d’échecs (pas d’ouverture sur échecs non consécutifs)', async () => { - let call = 0; - const bus = new InProcessEventBus( - baseGuards({ circuitBreaker: { failureThreshold: 2, cooldownMs: 1000 } }), - ); - bus.handle('intermittent:call', () => { - call += 1; - if (call % 2 === 1) throw new Error('boom'); - return 'ok'; + it('un succès réinitialise le compteur d’échecs (pas d’ouverture sur échecs non consécutifs)', async () => { + let call = 0; + const bus = new InProcessEventBus( + baseGuards({ circuitBreaker: { failureThreshold: 2, cooldownMs: 1000 } }), + ); + bus.handle('intermittent:call', () => { + call += 1; + if (call % 2 === 1) throw new Error('boom'); + return 'ok'; + }); + + await expect(bus.request('intermittent:call', null)).rejects.toThrow('boom'); + await expect(bus.request('intermittent:call', null)).resolves.toBe('ok'); + await expect(bus.request('intermittent:call', null)).rejects.toThrow('boom'); + await expect(bus.request('intermittent:call', null)).resolves.toBe('ok'); + await expect(bus.request('intermittent:call', null)).rejects.toThrow('boom'); }); - - await expect(bus.request('intermittent:call', null)).rejects.toThrow('boom'); - await expect(bus.request('intermittent:call', null)).resolves.toBe('ok'); - await expect(bus.request('intermittent:call', null)).rejects.toThrow('boom'); - await expect(bus.request('intermittent:call', null)).resolves.toBe('ok'); - await expect(bus.request('intermittent:call', null)).rejects.toThrow('boom'); - }); }); describe('InProcessEventBus — permissions', () => { - it('PERMISSION_DENIED : une brique ne peut pas appeler hors de ses dépendances déclarées', async () => { - const bus = new InProcessEventBus(DEFAULT_GUARDS, { - permissionProvider: (source) => (source === 'php' ? ['indexer'] : []), + it('PERMISSION_DENIED : une brique ne peut pas appeler hors de ses dépendances déclarées', async () => { + const bus = new InProcessEventBus(DEFAULT_GUARDS, { + permissionProvider: (source) => (source === 'php' ? ['indexer'] : []), + }); + bus.handle('symfony:find', () => 'ok'); + bus.handle('php:analyze', () => bus.request('symfony:find', null)); + + await expect(bus.request('php:analyze', null)).rejects.toMatchObject({ + name: 'EventBusError', + code: 'PERMISSION_DENIED', + }); }); - bus.handle('symfony:find', () => 'ok'); - bus.handle('php:analyze', () => bus.request('symfony:find', null)); - await expect(bus.request('php:analyze', null)).rejects.toMatchObject({ - name: 'EventBusError', - code: 'PERMISSION_DENIED', - }); - }); + it('autorise un appel vers une brique listée dans les dépendances', async () => { + const bus = new InProcessEventBus(DEFAULT_GUARDS, { + permissionProvider: (source) => (source === 'php' ? ['indexer'] : []), + }); + bus.handle('indexer:search', () => ({ files: ['a.php'] })); + bus.handle('php:analyze', () => bus.request('indexer:search', null)); - it('autorise un appel vers une brique listée dans les dépendances', async () => { - const bus = new InProcessEventBus(DEFAULT_GUARDS, { - permissionProvider: (source) => (source === 'php' ? ['indexer'] : []), + await expect(bus.request('php:analyze', null)).resolves.toEqual({ files: ['a.php'] }); }); - bus.handle('indexer:search', () => ({ files: ['a.php'] })); - bus.handle('php:analyze', () => bus.request('indexer:search', null)); - await expect(bus.request('php:analyze', null)).resolves.toEqual({ files: ['a.php'] }); - }); + it('n’applique pas les permissions aux appels entrants du router', async () => { + const bus = new InProcessEventBus(DEFAULT_GUARDS, { + permissionProvider: () => [], + }); + bus.handle('anything:x', () => 'ok'); - it('n’applique pas les permissions aux appels entrants du router', async () => { - const bus = new InProcessEventBus(DEFAULT_GUARDS, { - permissionProvider: () => [], + await expect(bus.request('anything:x', null)).resolves.toBe('ok'); }); - bus.handle('anything:x', () => 'ok'); - - await expect(bus.request('anything:x', null)).resolves.toBe('ok'); - }); - it('sans permissionProvider : aucune restriction (compat par défaut)', async () => { - const bus = new InProcessEventBus(); - bus.handle('a:x', () => bus.request('b:x', null)); - bus.handle('b:x', () => 'ok'); + it('sans permissionProvider : aucune restriction (compat par défaut)', async () => { + const bus = new InProcessEventBus(); + bus.handle('a:x', () => bus.request('b:x', null)); + bus.handle('b:x', () => 'ok'); - await expect(bus.request('a:x', null)).resolves.toBe('ok'); - }); + await expect(bus.request('a:x', null)).resolves.toBe('ok'); + }); }); diff --git a/packages/core/src/event-bus/event-bus.ts b/packages/core/src/event-bus/event-bus.ts index b98a453..7fabdd0 100644 --- a/packages/core/src/event-bus/event-bus.ts +++ b/packages/core/src/event-bus/event-bus.ts @@ -4,307 +4,309 @@ import { AsyncLocalStorage } from '../observability/async-storage.ts'; import { createLogger } from '../observability/logger.ts'; import { - type EventBus, - EventBusError, - type EventBusGuards, - type EventHandler, - type EventMeta, - type RequestHandler, - type RequestOptions, - type Unsubscribe, + type EventBus, + EventBusError, + type EventBusGuards, + type EventHandler, + type EventMeta, + type RequestHandler, + type RequestOptions, + type Unsubscribe, } from '../types/event-bus.ts'; function randomUUID(): string { - return globalThis.crypto.randomUUID(); + return globalThis.crypto.randomUUID(); } interface CallContext { - readonly source: string; - readonly traceId: string; - readonly depth: number; + readonly source: string; + readonly traceId: string; + readonly depth: number; } const callStorage = new AsyncLocalStorage(); const logger = createLogger('event-bus'); function toRecord(err: unknown): Record { - if (err instanceof Error) { - return { name: err.name, message: err.message, stack: err.stack }; - } - if (typeof err === 'object' && err !== null) return err as Record; - return { value: String(err) }; + if (err instanceof Error) { + return { name: err.name, message: err.message, stack: err.stack }; + } + if (typeof err === 'object' && err !== null) return err as Record; + return { value: String(err) }; } export const DEFAULT_GUARDS: EventBusGuards = { - maxDepth: 16, - defaultTimeoutMs: 30_000, - maxPayloadBytes: 5 * 1024 * 1024, - rateLimit: { callsPerSecond: 100, burstSize: 200 }, - circuitBreaker: { failureThreshold: 5, cooldownMs: 30_000 }, + maxDepth: 16, + defaultTimeoutMs: 30_000, + maxPayloadBytes: 5 * 1024 * 1024, + rateLimit: { callsPerSecond: 100, burstSize: 200 }, + circuitBreaker: { failureThreshold: 5, cooldownMs: 30_000 }, }; export interface EventBusOptions { - /** - * Retourne la whitelist des briques qu'une source est autorisée à appeler, - * typiquement alimenté par le Registry à partir du manifeste. - * Si omis, aucune vérification de permission n'est appliquée. - */ - readonly permissionProvider?: (source: string) => readonly string[]; + /** + * Retourne la whitelist des briques qu'une source est autorisée à appeler, + * typiquement alimenté par le Registry à partir du manifeste. + * Si omis, aucune vérification de permission n'est appliquée. + */ + readonly permissionProvider?: (source: string) => readonly string[]; } interface TokenBucket { - tokens: number; - lastRefillAt: number; + tokens: number; + lastRefillAt: number; } interface CircuitState { - failures: number; - openedAt: number | null; + failures: number; + openedAt: number | null; } export class InProcessEventBus implements EventBus { - readonly #subscribers = new Map>(); - readonly #handlers = new Map(); - readonly #guards: EventBusGuards; - readonly #permissionProvider?: (source: string) => readonly string[]; - readonly #buckets = new Map(); - readonly #circuits = new Map(); + readonly #subscribers = new Map>(); + readonly #handlers = new Map(); + readonly #guards: EventBusGuards; + readonly #permissionProvider?: (source: string) => readonly string[]; + readonly #buckets = new Map(); + readonly #circuits = new Map(); - constructor(guards: EventBusGuards = DEFAULT_GUARDS, options: EventBusOptions = {}) { - this.#guards = guards; - if (options.permissionProvider) { - this.#permissionProvider = options.permissionProvider; + constructor(guards: EventBusGuards = DEFAULT_GUARDS, options: EventBusOptions = {}) { + this.#guards = guards; + if (options.permissionProvider) { + this.#permissionProvider = options.permissionProvider; + } } - } - emit(event: string, payload: T): void { - const meta = this.#buildMeta(); - const handlers = this.#subscribers.get(event); - if (!handlers) return; - for (const handler of handlers) { - try { - const result = handler(payload, meta); - if (result instanceof Promise) { - result.catch((err: unknown) => { - logger.error('event handler error', { event, err: toRecord(err) }); - }); + emit(event: string, payload: T): void { + const meta = this.#buildMeta(); + const handlers = this.#subscribers.get(event); + if (!handlers) return; + for (const handler of handlers) { + try { + const result = handler(payload, meta); + if (result instanceof Promise) { + result.catch((err: unknown) => { + logger.error('event handler error', { event, err: toRecord(err) }); + }); + } + } catch (err: unknown) { + logger.error('event handler error', { event, err: toRecord(err) }); + } } - } catch (err: unknown) { - logger.error('event handler error', { event, err: toRecord(err) }); - } } - } - on(event: string, handler: EventHandler): Unsubscribe { - let set = this.#subscribers.get(event); - if (!set) { - set = new Set(); - this.#subscribers.set(event, set); + on(event: string, handler: EventHandler): Unsubscribe { + let set = this.#subscribers.get(event); + if (!set) { + set = new Set(); + this.#subscribers.set(event, set); + } + const generic = handler as EventHandler; + set.add(generic); + return (): void => { + set?.delete(generic); + }; } - const generic = handler as EventHandler; - set.add(generic); - return (): void => { - set?.delete(generic); - }; - } - async request( - target: string, - payload: TRequest, - options?: RequestOptions, - ): Promise { - this.#assertPayloadSize(payload); + async request( + target: string, + payload: TRequest, + options?: RequestOptions, + ): Promise { + this.#assertPayloadSize(payload); - const parentCtx = callStorage.getStore(); - const source = parentCtx?.source ?? 'router'; - const targetBrick = target.split(':')[0] ?? 'unknown'; + const parentCtx = callStorage.getStore(); + const source = parentCtx?.source ?? 'router'; + const targetBrick = target.split(':')[0] ?? 'unknown'; - this.#assertPermission(source, targetBrick); - this.#assertRateLimit(source); - this.#assertCircuitClosed(target); + this.#assertPermission(source, targetBrick); + this.#assertRateLimit(source); + this.#assertCircuitClosed(target); - const handler = this.#handlers.get(target); - if (!handler) { - throw new EventBusError(`No handler registered for "${target}"`, 'NO_HANDLER', { target }); - } + const handler = this.#handlers.get(target); + if (!handler) { + throw new EventBusError(`No handler registered for "${target}"`, 'NO_HANDLER', { + target, + }); + } - const currentDepth = parentCtx?.depth ?? 0; - if (currentDepth >= this.#guards.maxDepth) { - throw new EventBusError( - `Max call depth exceeded (${this.#guards.maxDepth}) on "${target}"`, - 'MAX_DEPTH_EXCEEDED', - { target, depth: currentDepth, max: this.#guards.maxDepth }, - ); - } + const currentDepth = parentCtx?.depth ?? 0; + if (currentDepth >= this.#guards.maxDepth) { + throw new EventBusError( + `Max call depth exceeded (${this.#guards.maxDepth}) on "${target}"`, + 'MAX_DEPTH_EXCEEDED', + { target, depth: currentDepth, max: this.#guards.maxDepth }, + ); + } - const nextDepth = currentDepth + 1; - const traceId = options?.traceId ?? parentCtx?.traceId ?? randomUUID(); + const nextDepth = currentDepth + 1; + const traceId = options?.traceId ?? parentCtx?.traceId ?? randomUUID(); - const meta: EventMeta = { - source, - traceId, - depth: nextDepth, - emittedAt: Date.now(), - }; + const meta: EventMeta = { + source, + traceId, + depth: nextDepth, + emittedAt: Date.now(), + }; - const ctx: CallContext = { source: targetBrick, traceId, depth: nextDepth }; - const timeoutMs = options?.timeoutMs ?? this.#guards.defaultTimeoutMs; + const ctx: CallContext = { source: targetBrick, traceId, depth: nextDepth }; + const timeoutMs = options?.timeoutMs ?? this.#guards.defaultTimeoutMs; - try { - const result = await callStorage.run(ctx, () => - this.#runWithTimeout(target, handler, payload, meta, timeoutMs), - ); - this.#recordSuccess(target); - return result; - } catch (err) { - this.#recordFailure(target); - throw err; + try { + const result = await callStorage.run(ctx, () => + this.#runWithTimeout(target, handler, payload, meta, timeoutMs), + ); + this.#recordSuccess(target); + return result; + } catch (err) { + this.#recordFailure(target); + throw err; + } } - } - handle( - target: string, - handler: RequestHandler, - ): Unsubscribe { - if (this.#handlers.has(target)) { - throw new EventBusError( - `Handler already registered for "${target}"`, - 'HANDLER_ALREADY_REGISTERED', - { target }, - ); + handle( + target: string, + handler: RequestHandler, + ): Unsubscribe { + if (this.#handlers.has(target)) { + throw new EventBusError( + `Handler already registered for "${target}"`, + 'HANDLER_ALREADY_REGISTERED', + { target }, + ); + } + this.#handlers.set(target, handler as RequestHandler); + return (): void => { + this.#handlers.delete(target); + }; } - this.#handlers.set(target, handler as RequestHandler); - return (): void => { - this.#handlers.delete(target); - }; - } - #buildMeta(): EventMeta { - const ctx = callStorage.getStore(); - return { - source: ctx?.source ?? 'router', - traceId: ctx?.traceId ?? randomUUID(), - depth: ctx?.depth ?? 0, - emittedAt: Date.now(), - }; - } - - #assertPayloadSize(payload: unknown): void { - const serialized = JSON.stringify(payload ?? null); - const size = Buffer.byteLength(serialized, 'utf8'); - if (size > this.#guards.maxPayloadBytes) { - throw new EventBusError( - `Payload too large: ${size} bytes > ${this.#guards.maxPayloadBytes}`, - 'PAYLOAD_TOO_LARGE', - { size, max: this.#guards.maxPayloadBytes }, - ); + #buildMeta(): EventMeta { + const ctx = callStorage.getStore(); + return { + source: ctx?.source ?? 'router', + traceId: ctx?.traceId ?? randomUUID(), + depth: ctx?.depth ?? 0, + emittedAt: Date.now(), + }; } - } - #assertPermission(source: string, targetBrick: string): void { - if (!this.#permissionProvider) return; - if (source === 'router') return; - const allowed = this.#permissionProvider(source); - if (!allowed.includes(targetBrick)) { - throw new EventBusError( - `"${source}" is not allowed to call "${targetBrick}" (not in declared dependencies)`, - 'PERMISSION_DENIED', - { source, target: targetBrick, allowed }, - ); + #assertPayloadSize(payload: unknown): void { + const serialized = JSON.stringify(payload ?? null); + const size = Buffer.byteLength(serialized, 'utf8'); + if (size > this.#guards.maxPayloadBytes) { + throw new EventBusError( + `Payload too large: ${size} bytes > ${this.#guards.maxPayloadBytes}`, + 'PAYLOAD_TOO_LARGE', + { size, max: this.#guards.maxPayloadBytes }, + ); + } } - } - #assertRateLimit(source: string): void { - const { callsPerSecond, burstSize } = this.#guards.rateLimit; - const now = Date.now(); - let bucket = this.#buckets.get(source); - if (!bucket) { - bucket = { tokens: burstSize, lastRefillAt: now }; - this.#buckets.set(source, bucket); - } else { - const elapsed = now - bucket.lastRefillAt; - if (elapsed > 0) { - const refill = (elapsed * callsPerSecond) / 1000; - bucket.tokens = Math.min(burstSize, bucket.tokens + refill); - bucket.lastRefillAt = now; - } - } - if (bucket.tokens < 1) { - throw new EventBusError( - `Rate limit exceeded for "${source}" (${callsPerSecond}/s, burst ${burstSize})`, - 'RATE_LIMIT_EXCEEDED', - { source, callsPerSecond, burstSize }, - ); + #assertPermission(source: string, targetBrick: string): void { + if (!this.#permissionProvider) return; + if (source === 'router') return; + const allowed = this.#permissionProvider(source); + if (!allowed.includes(targetBrick)) { + throw new EventBusError( + `"${source}" is not allowed to call "${targetBrick}" (not in declared dependencies)`, + 'PERMISSION_DENIED', + { source, target: targetBrick, allowed }, + ); + } } - bucket.tokens -= 1; - } - #assertCircuitClosed(target: string): void { - const circuit = this.#circuits.get(target); - if (!circuit || circuit.openedAt === null) return; - const { cooldownMs } = this.#guards.circuitBreaker; - const elapsed = Date.now() - circuit.openedAt; - if (elapsed < cooldownMs) { - throw new EventBusError( - `Circuit open for "${target}" (cooldown ${cooldownMs}ms)`, - 'CIRCUIT_OPEN', - { target, cooldownMs, remainingMs: cooldownMs - elapsed }, - ); + #assertRateLimit(source: string): void { + const { callsPerSecond, burstSize } = this.#guards.rateLimit; + const now = Date.now(); + let bucket = this.#buckets.get(source); + if (!bucket) { + bucket = { tokens: burstSize, lastRefillAt: now }; + this.#buckets.set(source, bucket); + } else { + const elapsed = now - bucket.lastRefillAt; + if (elapsed > 0) { + const refill = (elapsed * callsPerSecond) / 1000; + bucket.tokens = Math.min(burstSize, bucket.tokens + refill); + bucket.lastRefillAt = now; + } + } + if (bucket.tokens < 1) { + throw new EventBusError( + `Rate limit exceeded for "${source}" (${callsPerSecond}/s, burst ${burstSize})`, + 'RATE_LIMIT_EXCEEDED', + { source, callsPerSecond, burstSize }, + ); + } + bucket.tokens -= 1; } - // Cooldown écoulé : half-open → on laisse passer cet appel. - // Un succès réinitialise le circuit, un échec le ré-ouvre immédiatement. - circuit.openedAt = null; - } - #recordSuccess(target: string): void { - const circuit = this.#circuits.get(target); - if (circuit) { - circuit.failures = 0; - circuit.openedAt = null; + #assertCircuitClosed(target: string): void { + const circuit = this.#circuits.get(target); + if (!circuit || circuit.openedAt === null) return; + const { cooldownMs } = this.#guards.circuitBreaker; + const elapsed = Date.now() - circuit.openedAt; + if (elapsed < cooldownMs) { + throw new EventBusError( + `Circuit open for "${target}" (cooldown ${cooldownMs}ms)`, + 'CIRCUIT_OPEN', + { target, cooldownMs, remainingMs: cooldownMs - elapsed }, + ); + } + // Cooldown écoulé : half-open → on laisse passer cet appel. + // Un succès réinitialise le circuit, un échec le ré-ouvre immédiatement. + circuit.openedAt = null; } - } - #recordFailure(target: string): void { - let circuit = this.#circuits.get(target); - if (!circuit) { - circuit = { failures: 0, openedAt: null }; - this.#circuits.set(target, circuit); + #recordSuccess(target: string): void { + const circuit = this.#circuits.get(target); + if (circuit) { + circuit.failures = 0; + circuit.openedAt = null; + } } - circuit.failures += 1; - const { failureThreshold } = this.#guards.circuitBreaker; - if (circuit.failures >= failureThreshold && circuit.openedAt === null) { - circuit.openedAt = Date.now(); + + #recordFailure(target: string): void { + let circuit = this.#circuits.get(target); + if (!circuit) { + circuit = { failures: 0, openedAt: null }; + this.#circuits.set(target, circuit); + } + circuit.failures += 1; + const { failureThreshold } = this.#guards.circuitBreaker; + if (circuit.failures >= failureThreshold && circuit.openedAt === null) { + circuit.openedAt = Date.now(); + } } - } - async #runWithTimeout( - target: string, - handler: RequestHandler, - payload: unknown, - meta: EventMeta, - timeoutMs: number, - ): Promise { - let timer: NodeJS.Timeout | undefined; - try { - return await Promise.race([ - Promise.resolve(handler(payload, meta)) as Promise, - new Promise((_, reject) => { - timer = setTimeout(() => { - reject( - new EventBusError( - `Request to "${target}" timed out after ${timeoutMs}ms`, - 'TIMEOUT', - { - target, - timeoutMs, - }, - ), - ); - }, timeoutMs); - }), - ]); - } finally { - if (timer !== undefined) clearTimeout(timer); + async #runWithTimeout( + target: string, + handler: RequestHandler, + payload: unknown, + meta: EventMeta, + timeoutMs: number, + ): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + Promise.resolve(handler(payload, meta)) as Promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new EventBusError( + `Request to "${target}" timed out after ${timeoutMs}ms`, + 'TIMEOUT', + { + target, + timeoutMs, + }, + ), + ); + }, timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } } - } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9e8578d..68ebcf9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,39 +2,39 @@ // SPDX-License-Identifier: MIT export { - type CreateFocusMcpOptions, - createFocusMcp, - type FocusMcp, + type CreateFocusMcpOptions, + createFocusMcp, + type FocusMcp, } from './bootstrap/create-focus-mcp.ts'; export { - DEFAULT_GUARDS, - type EventBusOptions, - InProcessEventBus, + DEFAULT_GUARDS, + type EventBusOptions, + InProcessEventBus, } from './event-bus/event-bus.ts'; export { - type BrickLoaderOptions, - type BrickLoadFailure, - type BrickLoadResult, - type BrickSource, - loadBricks, + type BrickLoaderOptions, + type BrickLoadFailure, + type BrickLoadResult, + type BrickSource, + loadBricks, } from './loader/brick-loader.ts'; export { - ManifestError, - type ManifestErrorCode, - parseManifest, + ManifestError, + type ManifestErrorCode, + parseManifest, } from './manifest/manifest.ts'; export { - type Catalog, - type CatalogBrick, - type CatalogBrickSource, - type CatalogOwner, - type CatalogTool, - compareSemver, - findBrick, - type InstalledBrick, - listUpdates, - parseCatalog, - type UpdateInfo, + type Catalog, + type CatalogBrick, + type CatalogBrickSource, + type CatalogOwner, + type CatalogTool, + compareSemver, + findBrick, + type InstalledBrick, + listUpdates, + parseCatalog, + type UpdateInfo, } from './marketplace/resolver.ts'; export { createLogger, rootLogger } from './observability/logger.ts'; export { getTracer, trace } from './observability/tracing.ts'; diff --git a/packages/core/src/loader/brick-loader.test.ts b/packages/core/src/loader/brick-loader.test.ts index 0560b68..c7ebc32 100644 --- a/packages/core/src/loader/brick-loader.test.ts +++ b/packages/core/src/loader/brick-loader.test.ts @@ -6,193 +6,193 @@ import type { Brick, BrickManifest } from '../types/index.ts'; import { type BrickSource, loadBricks } from './brick-loader.ts'; function makeManifest(name: string, deps: readonly string[] = []): BrickManifest { - return { - name, - version: '1.0.0', - description: `${name} brick`, - dependencies: deps, - tools: [], - }; + return { + name, + version: '1.0.0', + description: `${name} brick`, + dependencies: deps, + tools: [], + }; } function makeBrick(manifest: BrickManifest): Brick { - return { - manifest, - start() { - /* noop */ - }, - stop() { - /* noop */ - }, - }; + return { + manifest, + start() { + /* noop */ + }, + stop() { + /* noop */ + }, + }; } function makeSource( - bricks: ReadonlyArray<{ - name: string; - manifest?: unknown; - module?: unknown; - throws?: 'manifest' | 'module'; - }>, + bricks: ReadonlyArray<{ + name: string; + manifest?: unknown; + module?: unknown; + throws?: 'manifest' | 'module'; + }>, ): BrickSource { - return { - list: vi.fn(async () => bricks.map((b) => b.name)), - readManifest: vi.fn(async (name) => { - const entry = bricks.find((b) => b.name === name); - if (!entry) throw new Error(`unknown brick: ${name}`); - if (entry.throws === 'manifest') throw new Error(`manifest read failed: ${name}`); - return 'manifest' in entry ? entry.manifest : makeManifest(name); - }), - loadModule: vi.fn(async (name) => { - const entry = bricks.find((b) => b.name === name); - if (!entry) throw new Error(`unknown brick: ${name}`); - if (entry.throws === 'module') throw new Error(`module load failed: ${name}`); - return 'module' in entry ? entry.module : { default: makeBrick(makeManifest(name)) }; - }), - }; + return { + list: vi.fn(async () => bricks.map((b) => b.name)), + readManifest: vi.fn(async (name) => { + const entry = bricks.find((b) => b.name === name); + if (!entry) throw new Error(`unknown brick: ${name}`); + if (entry.throws === 'manifest') throw new Error(`manifest read failed: ${name}`); + return 'manifest' in entry ? entry.manifest : makeManifest(name); + }), + loadModule: vi.fn(async (name) => { + const entry = bricks.find((b) => b.name === name); + if (!entry) throw new Error(`unknown brick: ${name}`); + if (entry.throws === 'module') throw new Error(`module load failed: ${name}`); + return 'module' in entry ? entry.module : { default: makeBrick(makeManifest(name)) }; + }), + }; } describe('loadBricks', () => { - it('returns empty result when no bricks are listed', async () => { - const source = makeSource([]); - const result = await loadBricks({ source }); - expect(result.bricks).toEqual([]); - expect(result.failures).toEqual([]); - }); - - it('loads a single brick successfully', async () => { - const source = makeSource([{ name: 'indexer' }]); - const result = await loadBricks({ source }); - expect(result.bricks).toHaveLength(1); - expect(result.bricks[0]?.manifest.name).toBe('indexer'); - expect(result.failures).toEqual([]); - }); - - it('loads multiple bricks preserving input order', async () => { - const source = makeSource([{ name: 'indexer' }, { name: 'cache' }, { name: 'php' }]); - const result = await loadBricks({ source }); - expect(result.bricks.map((b) => b.manifest.name)).toEqual(['indexer', 'cache', 'php']); - expect(result.failures).toEqual([]); - }); - - it('records a failure when manifest read throws', async () => { - const source = makeSource([{ name: 'indexer' }, { name: 'broken', throws: 'manifest' }]); - const result = await loadBricks({ source }); - expect(result.bricks.map((b) => b.manifest.name)).toEqual(['indexer']); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.name).toBe('broken'); - expect(result.failures[0]?.error).toBeInstanceOf(Error); - }); - - it('records a failure when manifest is invalid (parse error)', async () => { - const source = makeSource([ - { name: 'broken', manifest: { name: 'broken' /* missing fields */ } }, - ]); - const result = await loadBricks({ source }); - expect(result.bricks).toEqual([]); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.name).toBe('broken'); - }); - - it('records a failure when module load throws', async () => { - const source = makeSource([{ name: 'indexer' }, { name: 'broken', throws: 'module' }]); - const result = await loadBricks({ source }); - expect(result.bricks.map((b) => b.manifest.name)).toEqual(['indexer']); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.name).toBe('broken'); - }); - - it('records a failure when manifest name mismatches module brick name', async () => { - const source = makeSource([ - { - name: 'indexer', - manifest: makeManifest('indexer'), - module: { default: makeBrick(makeManifest('cache')) }, - }, - ]); - const result = await loadBricks({ source }); - expect(result.bricks).toEqual([]); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.name).toBe('indexer'); - expect(result.failures[0]?.error.message).toMatch(/mismatch/i); - }); - - it('records a failure when module manifest diverges from source manifest', async () => { - const source = makeSource([ - { - name: 'indexer', - manifest: makeManifest('indexer', ['cache']), - module: { default: makeBrick(makeManifest('indexer', [])) }, - }, - ]); - const result = await loadBricks({ source }); - expect(result.bricks).toEqual([]); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.error.message).toMatch(/mismatch/i); - }); - - it('records a failure when module brick has malformed manifest', async () => { - const source = makeSource([ - { - name: 'broken', - manifest: makeManifest('broken'), - module: { - default: { manifest: 'not-an-object', start() {}, stop() {} }, - }, - }, - ]); - const result = await loadBricks({ source }); - expect(result.bricks).toEqual([]); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.name).toBe('broken'); - }); - - it('records a failure when module has no default export', async () => { - const source = makeSource([{ name: 'broken', module: { other: 1 } }]); - const result = await loadBricks({ source }); - expect(result.bricks).toEqual([]); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.name).toBe('broken'); - expect(result.failures[0]?.error.message).toMatch(/has no default export/i); - }); - - it('records a failure when default export is not an object (e.g. number)', async () => { - const source = makeSource([{ name: 'broken', module: { default: 42 } }]); - const result = await loadBricks({ source }); - expect(result.bricks).toEqual([]); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.error.message).toMatch(/default export is not an object/i); - }); - - it('records a failure when module is not an object (null)', async () => { - const source = makeSource([{ name: 'broken', module: null }]); - const result = await loadBricks({ source }); - expect(result.bricks).toEqual([]); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.error.message).toMatch(/not an object/i); - }); - - it('records a failure when default export does not implement Brick contract', async () => { - const source = makeSource([ - { - name: 'broken', - module: { default: { manifest: makeManifest('broken') /* no start/stop */ } }, - }, - ]); - const result = await loadBricks({ source }); - expect(result.bricks).toEqual([]); - expect(result.failures).toHaveLength(1); - expect(result.failures[0]?.error.message).toMatch(/brick contract/i); - }); - - it('continues loading subsequent bricks after a failure', async () => { - const source = makeSource([ - { name: 'a' }, - { name: 'broken', throws: 'manifest' }, - { name: 'b' }, - ]); - const result = await loadBricks({ source }); - expect(result.bricks.map((b) => b.manifest.name)).toEqual(['a', 'b']); - expect(result.failures.map((f) => f.name)).toEqual(['broken']); - }); + it('returns empty result when no bricks are listed', async () => { + const source = makeSource([]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toEqual([]); + }); + + it('loads a single brick successfully', async () => { + const source = makeSource([{ name: 'indexer' }]); + const result = await loadBricks({ source }); + expect(result.bricks).toHaveLength(1); + expect(result.bricks[0]?.manifest.name).toBe('indexer'); + expect(result.failures).toEqual([]); + }); + + it('loads multiple bricks preserving input order', async () => { + const source = makeSource([{ name: 'indexer' }, { name: 'cache' }, { name: 'php' }]); + const result = await loadBricks({ source }); + expect(result.bricks.map((b) => b.manifest.name)).toEqual(['indexer', 'cache', 'php']); + expect(result.failures).toEqual([]); + }); + + it('records a failure when manifest read throws', async () => { + const source = makeSource([{ name: 'indexer' }, { name: 'broken', throws: 'manifest' }]); + const result = await loadBricks({ source }); + expect(result.bricks.map((b) => b.manifest.name)).toEqual(['indexer']); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + expect(result.failures[0]?.error).toBeInstanceOf(Error); + }); + + it('records a failure when manifest is invalid (parse error)', async () => { + const source = makeSource([ + { name: 'broken', manifest: { name: 'broken' /* missing fields */ } }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + }); + + it('records a failure when module load throws', async () => { + const source = makeSource([{ name: 'indexer' }, { name: 'broken', throws: 'module' }]); + const result = await loadBricks({ source }); + expect(result.bricks.map((b) => b.manifest.name)).toEqual(['indexer']); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + }); + + it('records a failure when manifest name mismatches module brick name', async () => { + const source = makeSource([ + { + name: 'indexer', + manifest: makeManifest('indexer'), + module: { default: makeBrick(makeManifest('cache')) }, + }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('indexer'); + expect(result.failures[0]?.error.message).toMatch(/mismatch/i); + }); + + it('records a failure when module manifest diverges from source manifest', async () => { + const source = makeSource([ + { + name: 'indexer', + manifest: makeManifest('indexer', ['cache']), + module: { default: makeBrick(makeManifest('indexer', [])) }, + }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.error.message).toMatch(/mismatch/i); + }); + + it('records a failure when module brick has malformed manifest', async () => { + const source = makeSource([ + { + name: 'broken', + manifest: makeManifest('broken'), + module: { + default: { manifest: 'not-an-object', start() {}, stop() {} }, + }, + }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + }); + + it('records a failure when module has no default export', async () => { + const source = makeSource([{ name: 'broken', module: { other: 1 } }]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.name).toBe('broken'); + expect(result.failures[0]?.error.message).toMatch(/has no default export/i); + }); + + it('records a failure when default export is not an object (e.g. number)', async () => { + const source = makeSource([{ name: 'broken', module: { default: 42 } }]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.error.message).toMatch(/default export is not an object/i); + }); + + it('records a failure when module is not an object (null)', async () => { + const source = makeSource([{ name: 'broken', module: null }]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.error.message).toMatch(/not an object/i); + }); + + it('records a failure when default export does not implement Brick contract', async () => { + const source = makeSource([ + { + name: 'broken', + module: { default: { manifest: makeManifest('broken') /* no start/stop */ } }, + }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.error.message).toMatch(/brick contract/i); + }); + + it('continues loading subsequent bricks after a failure', async () => { + const source = makeSource([ + { name: 'a' }, + { name: 'broken', throws: 'manifest' }, + { name: 'b' }, + ]); + const result = await loadBricks({ source }); + expect(result.bricks.map((b) => b.manifest.name)).toEqual(['a', 'b']); + expect(result.failures.map((f) => f.name)).toEqual(['broken']); + }); }); diff --git a/packages/core/src/loader/brick-loader.ts b/packages/core/src/loader/brick-loader.ts index 3cd9002..18e681a 100644 --- a/packages/core/src/loader/brick-loader.ts +++ b/packages/core/src/loader/brick-loader.ts @@ -14,26 +14,26 @@ import type { Brick } from '../types/index.ts'; * qui décide comment accéder au disque/réseau. */ export interface BrickSource { - /** Liste des briques à charger (typiquement issue de center.json). */ - list(): Promise; - /** Manifeste brut (objet JSON), prêt pour `parseManifest`. */ - readManifest(name: string): Promise; - /** Module ESM de la brique. Doit exposer un `default` conforme à `Brick`. */ - loadModule(name: string): Promise; + /** Liste des briques à charger (typiquement issue de center.json). */ + list(): Promise; + /** Manifeste brut (objet JSON), prêt pour `parseManifest`. */ + readManifest(name: string): Promise; + /** Module ESM de la brique. Doit exposer un `default` conforme à `Brick`. */ + loadModule(name: string): Promise; } export interface BrickLoaderOptions { - readonly source: BrickSource; + readonly source: BrickSource; } export interface BrickLoadFailure { - readonly name: string; - readonly error: Error; + readonly name: string; + readonly error: Error; } export interface BrickLoadResult { - readonly bricks: readonly Brick[]; - readonly failures: readonly BrickLoadFailure[]; + readonly bricks: readonly Brick[]; + readonly failures: readonly BrickLoadFailure[]; } /** @@ -41,61 +41,63 @@ export interface BrickLoadResult { * pas le chargement : ils sont collectés dans `failures` et le reste continue. */ export async function loadBricks(options: BrickLoaderOptions): Promise { - const { source } = options; - const names = await source.list(); + const { source } = options; + const names = await source.list(); - const bricks: Brick[] = []; - const failures: BrickLoadFailure[] = []; + const bricks: Brick[] = []; + const failures: BrickLoadFailure[] = []; - for (const name of names) { - try { - const sourceManifest = parseManifest(await source.readManifest(name)); - const brick = extractBrick(await source.loadModule(name)); - const moduleManifest = parseManifest(brick.manifest); + for (const name of names) { + try { + const sourceManifest = parseManifest(await source.readManifest(name)); + const brick = extractBrick(await source.loadModule(name)); + const moduleManifest = parseManifest(brick.manifest); - if (canonicalize(sourceManifest) !== canonicalize(moduleManifest)) { - throw new Error(`manifest mismatch between source and module for "${sourceManifest.name}"`); - } + if (canonicalize(sourceManifest) !== canonicalize(moduleManifest)) { + throw new Error( + `manifest mismatch between source and module for "${sourceManifest.name}"`, + ); + } - bricks.push(brick); - } catch (cause) { - failures.push({ name, error: toError(cause) }); + bricks.push(brick); + } catch (cause) { + failures.push({ name, error: toError(cause) }); + } } - } - return { bricks, failures }; + return { bricks, failures }; } function extractBrick(module: unknown): Brick { - if (!module || typeof module !== 'object') { - throw new Error('module is not an object'); - } - if (!('default' in module)) { - throw new Error('module has no default export'); - } - const candidate = (module as { default: unknown }).default; - if (!candidate || typeof candidate !== 'object') { - throw new Error('module default export is not an object'); - } - const brick = candidate as Partial; - if (typeof brick.start !== 'function' || typeof brick.stop !== 'function') { - throw new Error('default export does not implement the Brick contract'); - } - return brick as Brick; + if (!module || typeof module !== 'object') { + throw new Error('module is not an object'); + } + if (!('default' in module)) { + throw new Error('module has no default export'); + } + const candidate = (module as { default: unknown }).default; + if (!candidate || typeof candidate !== 'object') { + throw new Error('module default export is not an object'); + } + const brick = candidate as Partial; + if (typeof brick.start !== 'function' || typeof brick.stop !== 'function') { + throw new Error('default export does not implement the Brick contract'); + } + return brick as Brick; } /** Canonicalize a JSON-shaped value (sort object keys) for stable comparison. */ function canonicalize(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value); - if (Array.isArray(value)) { - return `[${value.map(canonicalize).join(',')}]`; - } - const entries = Object.entries(value as Record).sort(([a], [b]) => - a.localeCompare(b), - ); - return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(',')}}`; + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]`; + } + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(',')}}`; } function toError(cause: unknown): Error { - return cause instanceof Error ? cause : new Error(String(cause)); + return cause instanceof Error ? cause : new Error(String(cause)); } diff --git a/packages/core/src/manifest/manifest.test.ts b/packages/core/src/manifest/manifest.test.ts index 38ab7cf..5fa0a58 100644 --- a/packages/core/src/manifest/manifest.test.ts +++ b/packages/core/src/manifest/manifest.test.ts @@ -5,169 +5,171 @@ import { describe, expect, it } from 'vitest'; import { ManifestError, parseManifest } from './manifest.ts'; const validRaw = { - name: 'indexer', - version: '1.0.0', - description: 'Indexation filesystem avec cache', - dependencies: [], - tools: [ - { - name: 'indexer_search', - description: 'Recherche fichiers par pattern', - inputSchema: { - type: 'object', - properties: { pattern: { type: 'string' } }, - required: ['pattern'], - }, - }, - ], + name: 'indexer', + version: '1.0.0', + description: 'Indexation filesystem avec cache', + dependencies: [], + tools: [ + { + name: 'indexer_search', + description: 'Recherche fichiers par pattern', + inputSchema: { + type: 'object', + properties: { pattern: { type: 'string' } }, + required: ['pattern'], + }, + }, + ], }; describe('parseManifest — cas valides', () => { - it('parse un manifeste minimal valide (objet)', () => { - const manifest = parseManifest(validRaw); - expect(manifest.name).toBe('indexer'); - expect(manifest.version).toBe('1.0.0'); - expect(manifest.tools).toHaveLength(1); - }); - - it('accepte une string JSON', () => { - const manifest = parseManifest(JSON.stringify(validRaw)); - expect(manifest.name).toBe('indexer'); - }); - - it('accepte les champs optionnels config et tags', () => { - const manifest = parseManifest({ - ...validRaw, - config: { - phpVersion: { type: 'string', description: 'Version PHP', default: '8.3' }, - }, - tags: ['language', 'filesystem'], - }); - expect(manifest.config?.['phpVersion']?.default).toBe('8.3'); - expect(manifest.tags).toEqual(['language', 'filesystem']); - }); - - it('accepte une version SemVer avec pre-release', () => { - const manifest = parseManifest({ ...validRaw, version: '1.2.3-beta.1' }); - expect(manifest.version).toBe('1.2.3-beta.1'); - }); - - it('accepte un nom multi-segments (focus-sf-router)', () => { - const manifest = parseManifest({ ...validRaw, name: 'focus-sf-router' }); - expect(manifest.name).toBe('focus-sf-router'); - }); + it('parse un manifeste minimal valide (objet)', () => { + const manifest = parseManifest(validRaw); + expect(manifest.name).toBe('indexer'); + expect(manifest.version).toBe('1.0.0'); + expect(manifest.tools).toHaveLength(1); + }); + + it('accepte une string JSON', () => { + const manifest = parseManifest(JSON.stringify(validRaw)); + expect(manifest.name).toBe('indexer'); + }); + + it('accepte les champs optionnels config et tags', () => { + const manifest = parseManifest({ + ...validRaw, + config: { + phpVersion: { type: 'string', description: 'Version PHP', default: '8.3' }, + }, + tags: ['language', 'filesystem'], + }); + expect(manifest.config?.['phpVersion']?.default).toBe('8.3'); + expect(manifest.tags).toEqual(['language', 'filesystem']); + }); + + it('accepte une version SemVer avec pre-release', () => { + const manifest = parseManifest({ ...validRaw, version: '1.2.3-beta.1' }); + expect(manifest.version).toBe('1.2.3-beta.1'); + }); + + it('accepte un nom multi-segments (focus-sf-router)', () => { + const manifest = parseManifest({ ...validRaw, name: 'focus-sf-router' }); + expect(manifest.name).toBe('focus-sf-router'); + }); }); describe('parseManifest — erreurs JSON et forme', () => { - it('INVALID_JSON : string non-JSON', () => { - expect(() => parseManifest('{not json')).toThrow(ManifestError); - try { - parseManifest('{not json'); - } catch (err) { - expect((err as ManifestError).code).toBe('INVALID_JSON'); - } - }); - - it('INVALID_SHAPE : pas un objet', () => { - expect(() => parseManifest(null)).toThrow( - expect.objectContaining({ name: 'ManifestError', code: 'INVALID_SHAPE' }), - ); - expect(() => parseManifest(42)).toThrow(expect.objectContaining({ code: 'INVALID_SHAPE' })); - expect(() => parseManifest([])).toThrow(expect.objectContaining({ code: 'INVALID_SHAPE' })); - }); + it('INVALID_JSON : string non-JSON', () => { + expect(() => parseManifest('{not json')).toThrow(ManifestError); + try { + parseManifest('{not json'); + } catch (err) { + expect((err as ManifestError).code).toBe('INVALID_JSON'); + } + }); + + it('INVALID_SHAPE : pas un objet', () => { + expect(() => parseManifest(null)).toThrow( + expect.objectContaining({ name: 'ManifestError', code: 'INVALID_SHAPE' }), + ); + expect(() => parseManifest(42)).toThrow(expect.objectContaining({ code: 'INVALID_SHAPE' })); + expect(() => parseManifest([])).toThrow(expect.objectContaining({ code: 'INVALID_SHAPE' })); + }); }); describe('parseManifest — validation name', () => { - it('INVALID_NAME : manquant', () => { - const { name: _, ...rest } = validRaw; - expect(() => parseManifest(rest)).toThrow(expect.objectContaining({ code: 'INVALID_NAME' })); - }); - - it('INVALID_NAME : pas kebab-case', () => { - expect(() => parseManifest({ ...validRaw, name: 'Indexer' })).toThrow( - expect.objectContaining({ code: 'INVALID_NAME' }), - ); - expect(() => parseManifest({ ...validRaw, name: 'indexer_v2' })).toThrow( - expect.objectContaining({ code: 'INVALID_NAME' }), - ); - expect(() => parseManifest({ ...validRaw, name: '' })).toThrow( - expect.objectContaining({ code: 'INVALID_NAME' }), - ); - expect(() => parseManifest({ ...validRaw, name: '1indexer' })).toThrow( - expect.objectContaining({ code: 'INVALID_NAME' }), - ); - }); + it('INVALID_NAME : manquant', () => { + const { name: _, ...rest } = validRaw; + expect(() => parseManifest(rest)).toThrow( + expect.objectContaining({ code: 'INVALID_NAME' }), + ); + }); + + it('INVALID_NAME : pas kebab-case', () => { + expect(() => parseManifest({ ...validRaw, name: 'Indexer' })).toThrow( + expect.objectContaining({ code: 'INVALID_NAME' }), + ); + expect(() => parseManifest({ ...validRaw, name: 'indexer_v2' })).toThrow( + expect.objectContaining({ code: 'INVALID_NAME' }), + ); + expect(() => parseManifest({ ...validRaw, name: '' })).toThrow( + expect.objectContaining({ code: 'INVALID_NAME' }), + ); + expect(() => parseManifest({ ...validRaw, name: '1indexer' })).toThrow( + expect.objectContaining({ code: 'INVALID_NAME' }), + ); + }); }); describe('parseManifest — validation version', () => { - it('INVALID_VERSION : pas SemVer', () => { - expect(() => parseManifest({ ...validRaw, version: '1.0' })).toThrow( - expect.objectContaining({ code: 'INVALID_VERSION' }), - ); - expect(() => parseManifest({ ...validRaw, version: 'v1.0.0' })).toThrow( - expect.objectContaining({ code: 'INVALID_VERSION' }), - ); - expect(() => parseManifest({ ...validRaw, version: '' })).toThrow( - expect.objectContaining({ code: 'INVALID_VERSION' }), - ); - }); + it('INVALID_VERSION : pas SemVer', () => { + expect(() => parseManifest({ ...validRaw, version: '1.0' })).toThrow( + expect.objectContaining({ code: 'INVALID_VERSION' }), + ); + expect(() => parseManifest({ ...validRaw, version: 'v1.0.0' })).toThrow( + expect.objectContaining({ code: 'INVALID_VERSION' }), + ); + expect(() => parseManifest({ ...validRaw, version: '' })).toThrow( + expect.objectContaining({ code: 'INVALID_VERSION' }), + ); + }); }); describe('parseManifest — validation tools', () => { - it('INVALID_TOOL : tool sans name', () => { - expect(() => - parseManifest({ - ...validRaw, - tools: [{ description: 'x', inputSchema: { type: 'object' } }], - }), - ).toThrow(expect.objectContaining({ code: 'INVALID_TOOL' })); - }); - - it("INVALID_TOOL : inputSchema dont le type n'est pas 'object'", () => { - expect(() => - parseManifest({ - ...validRaw, - tools: [{ name: 'x', description: 'y', inputSchema: { type: 'string' } }], - }), - ).toThrow(expect.objectContaining({ code: 'INVALID_TOOL' })); - }); - - it('DUPLICATE_TOOL : deux tools avec le même name', () => { - expect(() => - parseManifest({ - ...validRaw, - tools: [ - { name: 'dup', description: 'a', inputSchema: { type: 'object' } }, - { name: 'dup', description: 'b', inputSchema: { type: 'object' } }, - ], - }), - ).toThrow(expect.objectContaining({ code: 'DUPLICATE_TOOL' })); - }); - - it('accepte une liste de tools vide', () => { - const manifest = parseManifest({ ...validRaw, tools: [] }); - expect(manifest.tools).toEqual([]); - }); + it('INVALID_TOOL : tool sans name', () => { + expect(() => + parseManifest({ + ...validRaw, + tools: [{ description: 'x', inputSchema: { type: 'object' } }], + }), + ).toThrow(expect.objectContaining({ code: 'INVALID_TOOL' })); + }); + + it("INVALID_TOOL : inputSchema dont le type n'est pas 'object'", () => { + expect(() => + parseManifest({ + ...validRaw, + tools: [{ name: 'x', description: 'y', inputSchema: { type: 'string' } }], + }), + ).toThrow(expect.objectContaining({ code: 'INVALID_TOOL' })); + }); + + it('DUPLICATE_TOOL : deux tools avec le même name', () => { + expect(() => + parseManifest({ + ...validRaw, + tools: [ + { name: 'dup', description: 'a', inputSchema: { type: 'object' } }, + { name: 'dup', description: 'b', inputSchema: { type: 'object' } }, + ], + }), + ).toThrow(expect.objectContaining({ code: 'DUPLICATE_TOOL' })); + }); + + it('accepte une liste de tools vide', () => { + const manifest = parseManifest({ ...validRaw, tools: [] }); + expect(manifest.tools).toEqual([]); + }); }); describe('parseManifest — validation dependencies', () => { - it('INVALID_DEPENDENCY : dep pas kebab-case', () => { - expect(() => parseManifest({ ...validRaw, dependencies: ['Indexer'] })).toThrow( - expect.objectContaining({ code: 'INVALID_DEPENDENCY' }), - ); - }); - - it('INVALID_DEPENDENCY : dependencies pas un array', () => { - expect(() => parseManifest({ ...validRaw, dependencies: 'indexer' })).toThrow( - expect.objectContaining({ code: 'INVALID_DEPENDENCY' }), - ); - }); - - it('accepte plusieurs dépendances valides', () => { - const manifest = parseManifest({ - ...validRaw, - dependencies: ['indexer', 'cache', 'focus-sf-router'], - }); - expect(manifest.dependencies).toEqual(['indexer', 'cache', 'focus-sf-router']); - }); + it('INVALID_DEPENDENCY : dep pas kebab-case', () => { + expect(() => parseManifest({ ...validRaw, dependencies: ['Indexer'] })).toThrow( + expect.objectContaining({ code: 'INVALID_DEPENDENCY' }), + ); + }); + + it('INVALID_DEPENDENCY : dependencies pas un array', () => { + expect(() => parseManifest({ ...validRaw, dependencies: 'indexer' })).toThrow( + expect.objectContaining({ code: 'INVALID_DEPENDENCY' }), + ); + }); + + it('accepte plusieurs dépendances valides', () => { + const manifest = parseManifest({ + ...validRaw, + dependencies: ['indexer', 'cache', 'focus-sf-router'], + }); + expect(manifest.dependencies).toEqual(['indexer', 'cache', 'focus-sf-router']); + }); }); diff --git a/packages/core/src/manifest/manifest.ts b/packages/core/src/manifest/manifest.ts index 5ba3df8..ad2b7d4 100644 --- a/packages/core/src/manifest/manifest.ts +++ b/packages/core/src/manifest/manifest.ts @@ -5,299 +5,313 @@ import type { BrickManifest, ConfigField } from '../types/manifest.ts'; import type { JsonSchema, ToolDefinition } from '../types/tool.ts'; export type ManifestErrorCode = - | 'INVALID_JSON' - | 'INVALID_SHAPE' - | 'INVALID_NAME' - | 'INVALID_VERSION' - | 'INVALID_DESCRIPTION' - | 'INVALID_TOOL' - | 'DUPLICATE_TOOL' - | 'INVALID_DEPENDENCY' - | 'INVALID_CONFIG' - | 'INVALID_TAGS'; + | 'INVALID_JSON' + | 'INVALID_SHAPE' + | 'INVALID_NAME' + | 'INVALID_VERSION' + | 'INVALID_DESCRIPTION' + | 'INVALID_TOOL' + | 'DUPLICATE_TOOL' + | 'INVALID_DEPENDENCY' + | 'INVALID_CONFIG' + | 'INVALID_TAGS'; export class ManifestError extends Error { - constructor( - message: string, - public readonly code: ManifestErrorCode, - public readonly meta?: Record, - ) { - super(message); - this.name = 'ManifestError'; - } + constructor( + message: string, + public readonly code: ManifestErrorCode, + public readonly meta?: Record, + ) { + super(message); + this.name = 'ManifestError'; + } } const KEBAB_NAME = /^[a-z][a-z0-9-]*$/; // SemVer 2.0 (core) + optional pre-release / build metadata const SEMVER = - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; const CONFIG_TYPES: ReadonlySet = new Set([ - 'string', - 'number', - 'boolean', - 'array', - 'object', + 'string', + 'number', + 'boolean', + 'array', + 'object', ]); const SCHEMA_PROP_TYPES: ReadonlySet = new Set([ - 'string', - 'number', - 'integer', - 'boolean', - 'array', - 'object', + 'string', + 'number', + 'integer', + 'boolean', + 'array', + 'object', ]); export function parseManifest(raw: unknown): BrickManifest { - const obj = coerceToObject(raw); + const obj = coerceToObject(raw); - const name = validateName(obj['name']); - const version = validateVersion(obj['version']); - const description = validateDescription(obj['description']); - const dependencies = validateDependencies(obj['dependencies']); - const tools = validateTools(obj['tools']); + const name = validateName(obj['name']); + const version = validateVersion(obj['version']); + const description = validateDescription(obj['description']); + const dependencies = validateDependencies(obj['dependencies']); + const tools = validateTools(obj['tools']); - const manifest: Mutable = { - name, - version, - description, - dependencies, - tools, - }; + const manifest: Mutable = { + name, + version, + description, + dependencies, + tools, + }; - if (obj['config'] !== undefined) { - manifest.config = validateConfig(obj['config']); - } - if (obj['tags'] !== undefined) { - manifest.tags = validateTags(obj['tags']); - } + if (obj['config'] !== undefined) { + manifest.config = validateConfig(obj['config']); + } + if (obj['tags'] !== undefined) { + manifest.tags = validateTags(obj['tags']); + } - return manifest as BrickManifest; + return manifest as BrickManifest; } type Mutable = { -readonly [K in keyof T]: T[K] }; function coerceToObject(raw: unknown): Record { - if (typeof raw === 'string') { - try { - const parsed: unknown = JSON.parse(raw); - return coerceToObject(parsed); - } catch (err) { - throw new ManifestError('Invalid JSON', 'INVALID_JSON', { - cause: err instanceof Error ? err.message : String(err), - }); + if (typeof raw === 'string') { + try { + const parsed: unknown = JSON.parse(raw); + return coerceToObject(parsed); + } catch (err) { + throw new ManifestError('Invalid JSON', 'INVALID_JSON', { + cause: err instanceof Error ? err.message : String(err), + }); + } } - } - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - throw new ManifestError('Manifest must be a JSON object', 'INVALID_SHAPE', { - type: Array.isArray(raw) ? 'array' : raw === null ? 'null' : typeof raw, - }); - } - return raw as Record; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new ManifestError('Manifest must be a JSON object', 'INVALID_SHAPE', { + type: Array.isArray(raw) ? 'array' : raw === null ? 'null' : typeof raw, + }); + } + return raw as Record; } function validateName(value: unknown): string { - if (typeof value !== 'string' || value.length === 0) { - throw new ManifestError('Manifest.name must be a non-empty string', 'INVALID_NAME'); - } - if (!KEBAB_NAME.test(value)) { - throw new ManifestError( - `Manifest.name "${value}" must be kebab-case starting with a letter (e.g. "focus-indexer")`, - 'INVALID_NAME', - { value }, - ); - } - return value; + if (typeof value !== 'string' || value.length === 0) { + throw new ManifestError('Manifest.name must be a non-empty string', 'INVALID_NAME'); + } + if (!KEBAB_NAME.test(value)) { + throw new ManifestError( + `Manifest.name "${value}" must be kebab-case starting with a letter (e.g. "focus-indexer")`, + 'INVALID_NAME', + { value }, + ); + } + return value; } function validateVersion(value: unknown): string { - if (typeof value !== 'string' || !SEMVER.test(value)) { - throw new ManifestError( - 'Manifest.version must follow SemVer 2.0 (e.g. "1.2.3", "1.0.0-beta.1")', - 'INVALID_VERSION', - { value }, - ); - } - return value; + if (typeof value !== 'string' || !SEMVER.test(value)) { + throw new ManifestError( + 'Manifest.version must follow SemVer 2.0 (e.g. "1.2.3", "1.0.0-beta.1")', + 'INVALID_VERSION', + { value }, + ); + } + return value; } function validateDescription(value: unknown): string { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new ManifestError( - 'Manifest.description must be a non-empty string', - 'INVALID_DESCRIPTION', - ); - } - return value; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new ManifestError( + 'Manifest.description must be a non-empty string', + 'INVALID_DESCRIPTION', + ); + } + return value; } function validateDependencies(value: unknown): readonly string[] { - if (!Array.isArray(value)) { - throw new ManifestError( - 'Manifest.dependencies must be an array of brick names', - 'INVALID_DEPENDENCY', - { value }, - ); - } - for (const dep of value) { - if (typeof dep !== 'string' || !KEBAB_NAME.test(dep)) { - throw new ManifestError( - `Invalid dependency "${String(dep)}": must be a kebab-case brick name`, - 'INVALID_DEPENDENCY', - { dep }, - ); + if (!Array.isArray(value)) { + throw new ManifestError( + 'Manifest.dependencies must be an array of brick names', + 'INVALID_DEPENDENCY', + { value }, + ); + } + for (const dep of value) { + if (typeof dep !== 'string' || !KEBAB_NAME.test(dep)) { + throw new ManifestError( + `Invalid dependency "${String(dep)}": must be a kebab-case brick name`, + 'INVALID_DEPENDENCY', + { dep }, + ); + } } - } - return [...(value as string[])]; + return [...(value as string[])]; } function validateTools(value: unknown): readonly ToolDefinition[] { - if (!Array.isArray(value)) { - throw new ManifestError('Manifest.tools must be an array', 'INVALID_TOOL', { value }); - } - const seen = new Set(); - const tools: ToolDefinition[] = []; - for (const rawTool of value) { - const tool = validateTool(rawTool); - if (seen.has(tool.name)) { - throw new ManifestError(`Duplicate tool name "${tool.name}"`, 'DUPLICATE_TOOL', { - name: tool.name, - }); + if (!Array.isArray(value)) { + throw new ManifestError('Manifest.tools must be an array', 'INVALID_TOOL', { value }); } - seen.add(tool.name); - tools.push(tool); - } - return tools; + const seen = new Set(); + const tools: ToolDefinition[] = []; + for (const rawTool of value) { + const tool = validateTool(rawTool); + if (seen.has(tool.name)) { + throw new ManifestError(`Duplicate tool name "${tool.name}"`, 'DUPLICATE_TOOL', { + name: tool.name, + }); + } + seen.add(tool.name); + tools.push(tool); + } + return tools; } function validateTool(raw: unknown): ToolDefinition { - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - throw new ManifestError('Tool entry must be an object', 'INVALID_TOOL'); - } - const rec = raw as Record; - const name = rec['name']; - const description = rec['description']; - const inputSchema = rec['inputSchema']; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new ManifestError('Tool entry must be an object', 'INVALID_TOOL'); + } + const rec = raw as Record; + const name = rec['name']; + const description = rec['description']; + const inputSchema = rec['inputSchema']; - if (typeof name !== 'string' || name.length === 0) { - throw new ManifestError('Tool.name must be a non-empty string', 'INVALID_TOOL', { tool: rec }); - } - if (typeof description !== 'string' || description.length === 0) { - throw new ManifestError(`Tool "${name}" must have a non-empty description`, 'INVALID_TOOL', { - tool: name, - }); - } - const schema = validateInputSchema(inputSchema, name); + if (typeof name !== 'string' || name.length === 0) { + throw new ManifestError('Tool.name must be a non-empty string', 'INVALID_TOOL', { + tool: rec, + }); + } + if (typeof description !== 'string' || description.length === 0) { + throw new ManifestError( + `Tool "${name}" must have a non-empty description`, + 'INVALID_TOOL', + { + tool: name, + }, + ); + } + const schema = validateInputSchema(inputSchema, name); - return { name, description, inputSchema: schema }; + return { name, description, inputSchema: schema }; } function validateInputSchema(raw: unknown, toolName: string): JsonSchema { - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - throw new ManifestError( - `Tool "${toolName}" inputSchema must be a JSON Schema object`, - 'INVALID_TOOL', - { tool: toolName }, - ); - } - const rec = raw as Record; - if (rec['type'] !== 'object') { - throw new ManifestError( - `Tool "${toolName}" inputSchema.type must be "object"`, - 'INVALID_TOOL', - { tool: toolName, got: rec['type'] }, - ); - } - validateSchemaProperties(rec['properties'], toolName); - return raw as JsonSchema; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new ManifestError( + `Tool "${toolName}" inputSchema must be a JSON Schema object`, + 'INVALID_TOOL', + { tool: toolName }, + ); + } + const rec = raw as Record; + if (rec['type'] !== 'object') { + throw new ManifestError( + `Tool "${toolName}" inputSchema.type must be "object"`, + 'INVALID_TOOL', + { tool: toolName, got: rec['type'] }, + ); + } + validateSchemaProperties(rec['properties'], toolName); + return raw as JsonSchema; } function validateSchemaProperties(raw: unknown, toolName: string): void { - if (raw === undefined) return; - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - throw new ManifestError( - `Tool "${toolName}" inputSchema.properties must be an object`, - 'INVALID_TOOL', - { tool: toolName }, - ); - } - for (const [propName, propDef] of Object.entries(raw as Record)) { - if (propDef === null || typeof propDef !== 'object') { - throw new ManifestError( - `Tool "${toolName}" property "${propName}" must be an object`, - 'INVALID_TOOL', - { tool: toolName, property: propName }, - ); + if (raw === undefined) return; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new ManifestError( + `Tool "${toolName}" inputSchema.properties must be an object`, + 'INVALID_TOOL', + { tool: toolName }, + ); } - const t = (propDef as Record)['type']; - if (typeof t !== 'string' || !SCHEMA_PROP_TYPES.has(t)) { - throw new ManifestError( - `Tool "${toolName}" property "${propName}" has invalid type "${String(t)}"`, - 'INVALID_TOOL', - { tool: toolName, property: propName, type: t }, - ); + for (const [propName, propDef] of Object.entries(raw as Record)) { + if (propDef === null || typeof propDef !== 'object') { + throw new ManifestError( + `Tool "${toolName}" property "${propName}" must be an object`, + 'INVALID_TOOL', + { tool: toolName, property: propName }, + ); + } + const t = (propDef as Record)['type']; + if (typeof t !== 'string' || !SCHEMA_PROP_TYPES.has(t)) { + throw new ManifestError( + `Tool "${toolName}" property "${propName}" has invalid type "${String(t)}"`, + 'INVALID_TOOL', + { tool: toolName, property: propName, type: t }, + ); + } } - } } function validateConfig(raw: unknown): Readonly> { - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - throw new ManifestError('Manifest.config must be an object', 'INVALID_CONFIG'); - } - const result: Record = {}; - for (const [key, def] of Object.entries(raw as Record)) { - result[key] = validateConfigField(key, def); - } - return result; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new ManifestError('Manifest.config must be an object', 'INVALID_CONFIG'); + } + const result: Record = {}; + for (const [key, def] of Object.entries(raw as Record)) { + result[key] = validateConfigField(key, def); + } + return result; } function validateConfigField(key: string, raw: unknown): ConfigField { - if (raw === null || typeof raw !== 'object') { - throw new ManifestError(`Config field "${key}" must be an object`, 'INVALID_CONFIG', { - field: key, - }); - } - const rec = raw as Record; - const type = rec['type']; - const description = rec['description']; - if (typeof type !== 'string' || !CONFIG_TYPES.has(type)) { - throw new ManifestError( - `Config field "${key}" has invalid type "${String(type)}"`, - 'INVALID_CONFIG', - { field: key, type }, - ); - } - if (typeof description !== 'string' || description.length === 0) { - throw new ManifestError( - `Config field "${key}" must have a non-empty description`, - 'INVALID_CONFIG', - { field: key }, - ); - } - const field: Mutable = { type: type as ConfigField['type'], description }; - if (rec['default'] !== undefined) field.default = rec['default']; - if (rec['required'] !== undefined) { - if (typeof rec['required'] !== 'boolean') { - throw new ManifestError(`Config field "${key}".required must be boolean`, 'INVALID_CONFIG', { - field: key, - }); + if (raw === null || typeof raw !== 'object') { + throw new ManifestError(`Config field "${key}" must be an object`, 'INVALID_CONFIG', { + field: key, + }); + } + const rec = raw as Record; + const type = rec['type']; + const description = rec['description']; + if (typeof type !== 'string' || !CONFIG_TYPES.has(type)) { + throw new ManifestError( + `Config field "${key}" has invalid type "${String(type)}"`, + 'INVALID_CONFIG', + { field: key, type }, + ); + } + if (typeof description !== 'string' || description.length === 0) { + throw new ManifestError( + `Config field "${key}" must have a non-empty description`, + 'INVALID_CONFIG', + { field: key }, + ); } - field.required = rec['required']; - } - return field as ConfigField; + const field: Mutable = { type: type as ConfigField['type'], description }; + if (rec['default'] !== undefined) field.default = rec['default']; + if (rec['required'] !== undefined) { + if (typeof rec['required'] !== 'boolean') { + throw new ManifestError( + `Config field "${key}".required must be boolean`, + 'INVALID_CONFIG', + { + field: key, + }, + ); + } + field.required = rec['required']; + } + return field as ConfigField; } function validateTags(raw: unknown): readonly string[] { - if (!Array.isArray(raw)) { - throw new ManifestError('Manifest.tags must be an array of strings', 'INVALID_TAGS'); - } - for (const tag of raw) { - if (typeof tag !== 'string' || tag.length === 0) { - throw new ManifestError('Each manifest tag must be a non-empty string', 'INVALID_TAGS', { - tag, - }); + if (!Array.isArray(raw)) { + throw new ManifestError('Manifest.tags must be an array of strings', 'INVALID_TAGS'); + } + for (const tag of raw) { + if (typeof tag !== 'string' || tag.length === 0) { + throw new ManifestError( + 'Each manifest tag must be a non-empty string', + 'INVALID_TAGS', + { + tag, + }, + ); + } } - } - return [...(raw as string[])]; + return [...(raw as string[])]; } diff --git a/packages/core/src/marketplace/resolver.test.ts b/packages/core/src/marketplace/resolver.test.ts index 26687f4..6dc6cd6 100644 --- a/packages/core/src/marketplace/resolver.test.ts +++ b/packages/core/src/marketplace/resolver.test.ts @@ -3,168 +3,170 @@ import { describe, expect, it } from 'vitest'; import { - type Catalog, - type CatalogBrick, - compareSemver, - findBrick, - type InstalledBrick, - listUpdates, - parseCatalog, + type Catalog, + type CatalogBrick, + compareSemver, + findBrick, + type InstalledBrick, + listUpdates, + parseCatalog, } from './resolver.ts'; function validCatalog(bricks: CatalogBrick[] = []): Catalog { - return { - $schema: 'https://marketplace.focusmcp.dev/schemas/catalog/v1.json', - name: 'FocusMCP Marketplace', - description: 'Official catalog', - owner: { name: 'FocusMCP contributors' }, - updated: '2026-04-16T00:00:00.000Z', - bricks, - }; + return { + $schema: 'https://marketplace.focusmcp.dev/schemas/catalog/v1.json', + name: 'FocusMCP Marketplace', + description: 'Official catalog', + owner: { name: 'FocusMCP contributors' }, + updated: '2026-04-16T00:00:00.000Z', + bricks, + }; } function validBrick(overrides: Partial = {}): CatalogBrick { - return { - name: 'echo', - version: '1.0.0', - description: 'Hello-world brick', - dependencies: [], - tools: [{ name: 'echo_say', description: 'Echo' }], - source: { type: 'local', path: 'bricks/echo' }, - ...overrides, - }; + return { + name: 'echo', + version: '1.0.0', + description: 'Hello-world brick', + dependencies: [], + tools: [{ name: 'echo_say', description: 'Echo' }], + source: { type: 'local', path: 'bricks/echo' }, + ...overrides, + }; } describe('parseCatalog', () => { - it('parses a well-formed catalog', () => { - const catalog = parseCatalog(validCatalog([validBrick()])); - expect(catalog.name).toBe('FocusMCP Marketplace'); - expect(catalog.bricks).toHaveLength(1); - expect(catalog.bricks[0]?.name).toBe('echo'); - }); - - it('rejects non-objects', () => { - expect(() => parseCatalog(null)).toThrow(/catalog/i); - expect(() => parseCatalog('not a catalog')).toThrow(/catalog/i); - expect(() => parseCatalog(42)).toThrow(/catalog/i); - }); - - it('rejects a catalog missing required top-level fields', () => { - expect(() => parseCatalog({ bricks: [] })).toThrow(/name/i); - expect(() => parseCatalog({ name: 'X', bricks: [] })).toThrow(/owner/i); - }); - - it('rejects a catalog where bricks is not an array', () => { - expect(() => - parseCatalog({ - ...validCatalog(), - bricks: 'nope', - }), - ).toThrow(/bricks/i); - }); - - it('rejects a brick with an invalid semver version', () => { - expect(() => parseCatalog(validCatalog([validBrick({ version: 'not-semver' })]))).toThrow( - /version/i, - ); - }); - - it('rejects a brick with an invalid kebab-case name', () => { - expect(() => parseCatalog(validCatalog([validBrick({ name: 'BadName' })]))).toThrow(/name/i); - }); - - it('rejects a brick with an invalid source type', () => { - const bad = { ...validBrick(), source: { type: 'invalid' } } as unknown as CatalogBrick; - expect(() => parseCatalog(validCatalog([bad]))).toThrow(/source/i); - }); + it('parses a well-formed catalog', () => { + const catalog = parseCatalog(validCatalog([validBrick()])); + expect(catalog.name).toBe('FocusMCP Marketplace'); + expect(catalog.bricks).toHaveLength(1); + expect(catalog.bricks[0]?.name).toBe('echo'); + }); + + it('rejects non-objects', () => { + expect(() => parseCatalog(null)).toThrow(/catalog/i); + expect(() => parseCatalog('not a catalog')).toThrow(/catalog/i); + expect(() => parseCatalog(42)).toThrow(/catalog/i); + }); + + it('rejects a catalog missing required top-level fields', () => { + expect(() => parseCatalog({ bricks: [] })).toThrow(/name/i); + expect(() => parseCatalog({ name: 'X', bricks: [] })).toThrow(/owner/i); + }); + + it('rejects a catalog where bricks is not an array', () => { + expect(() => + parseCatalog({ + ...validCatalog(), + bricks: 'nope', + }), + ).toThrow(/bricks/i); + }); + + it('rejects a brick with an invalid semver version', () => { + expect(() => parseCatalog(validCatalog([validBrick({ version: 'not-semver' })]))).toThrow( + /version/i, + ); + }); + + it('rejects a brick with an invalid kebab-case name', () => { + expect(() => parseCatalog(validCatalog([validBrick({ name: 'BadName' })]))).toThrow( + /name/i, + ); + }); + + it('rejects a brick with an invalid source type', () => { + const bad = { ...validBrick(), source: { type: 'invalid' } } as unknown as CatalogBrick; + expect(() => parseCatalog(validCatalog([bad]))).toThrow(/source/i); + }); }); describe('findBrick', () => { - it('returns the brick matching the name', () => { - const catalog = validCatalog([validBrick(), validBrick({ name: 'indexer' })]); - expect(findBrick(catalog, 'indexer')?.name).toBe('indexer'); - }); - - it('returns undefined when the brick is not in the catalog', () => { - const catalog = validCatalog([validBrick()]); - expect(findBrick(catalog, 'missing')).toBeUndefined(); - }); + it('returns the brick matching the name', () => { + const catalog = validCatalog([validBrick(), validBrick({ name: 'indexer' })]); + expect(findBrick(catalog, 'indexer')?.name).toBe('indexer'); + }); + + it('returns undefined when the brick is not in the catalog', () => { + const catalog = validCatalog([validBrick()]); + expect(findBrick(catalog, 'missing')).toBeUndefined(); + }); }); describe('compareSemver', () => { - it('returns 0 for equal versions', () => { - expect(compareSemver('1.2.3', '1.2.3')).toBe(0); - }); - - it('returns -1 when a is older than b', () => { - expect(compareSemver('1.2.3', '1.2.4')).toBe(-1); - expect(compareSemver('1.2.3', '1.3.0')).toBe(-1); - expect(compareSemver('1.2.3', '2.0.0')).toBe(-1); - }); - - it('returns 1 when a is newer than b', () => { - expect(compareSemver('1.2.4', '1.2.3')).toBe(1); - expect(compareSemver('2.0.0', '1.99.99')).toBe(1); - }); - - it('treats pre-release as older than the same major.minor.patch', () => { - expect(compareSemver('1.0.0-alpha', '1.0.0')).toBe(-1); - expect(compareSemver('1.0.0', '1.0.0-alpha')).toBe(1); - }); - - it('compares pre-release identifiers lexically', () => { - expect(compareSemver('1.0.0-alpha', '1.0.0-beta')).toBe(-1); - expect(compareSemver('1.0.0-beta', '1.0.0-alpha')).toBe(1); - expect(compareSemver('1.0.0-alpha.1', '1.0.0-alpha.2')).toBe(-1); - }); - - it('ignores build metadata when comparing versions', () => { - expect(compareSemver('1.0.0+build.1', '1.0.0+build.2')).toBe(0); - expect(compareSemver('1.0.0+build.1', '1.0.0')).toBe(0); - expect(compareSemver('1.0.0-alpha+x', '1.0.0-alpha+y')).toBe(0); - }); - - it('throws on malformed input', () => { - expect(() => compareSemver('not-semver', '1.0.0')).toThrow(/semver/i); - expect(() => compareSemver('1.0', '1.0.0')).toThrow(/semver/i); - expect(() => compareSemver('1.0.0-alpha.01', '1.0.0-alpha.1')).toThrow(/semver/i); - expect(() => compareSemver('1.0.0-01', '1.0.0-1')).toThrow(/semver/i); - }); + it('returns 0 for equal versions', () => { + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + }); + + it('returns -1 when a is older than b', () => { + expect(compareSemver('1.2.3', '1.2.4')).toBe(-1); + expect(compareSemver('1.2.3', '1.3.0')).toBe(-1); + expect(compareSemver('1.2.3', '2.0.0')).toBe(-1); + }); + + it('returns 1 when a is newer than b', () => { + expect(compareSemver('1.2.4', '1.2.3')).toBe(1); + expect(compareSemver('2.0.0', '1.99.99')).toBe(1); + }); + + it('treats pre-release as older than the same major.minor.patch', () => { + expect(compareSemver('1.0.0-alpha', '1.0.0')).toBe(-1); + expect(compareSemver('1.0.0', '1.0.0-alpha')).toBe(1); + }); + + it('compares pre-release identifiers lexically', () => { + expect(compareSemver('1.0.0-alpha', '1.0.0-beta')).toBe(-1); + expect(compareSemver('1.0.0-beta', '1.0.0-alpha')).toBe(1); + expect(compareSemver('1.0.0-alpha.1', '1.0.0-alpha.2')).toBe(-1); + }); + + it('ignores build metadata when comparing versions', () => { + expect(compareSemver('1.0.0+build.1', '1.0.0+build.2')).toBe(0); + expect(compareSemver('1.0.0+build.1', '1.0.0')).toBe(0); + expect(compareSemver('1.0.0-alpha+x', '1.0.0-alpha+y')).toBe(0); + }); + + it('throws on malformed input', () => { + expect(() => compareSemver('not-semver', '1.0.0')).toThrow(/semver/i); + expect(() => compareSemver('1.0', '1.0.0')).toThrow(/semver/i); + expect(() => compareSemver('1.0.0-alpha.01', '1.0.0-alpha.1')).toThrow(/semver/i); + expect(() => compareSemver('1.0.0-01', '1.0.0-1')).toThrow(/semver/i); + }); }); describe('listUpdates', () => { - it('returns empty when every installed brick is at the catalog version', () => { - const catalog = validCatalog([validBrick({ version: '1.0.0' })]); - const installed: InstalledBrick[] = [{ name: 'echo', version: '1.0.0' }]; - expect(listUpdates(installed, catalog)).toEqual([]); - }); - - it('reports an available update when the catalog has a newer version', () => { - const catalog = validCatalog([validBrick({ version: '1.3.0' })]); - const installed: InstalledBrick[] = [{ name: 'echo', version: '1.0.0' }]; - expect(listUpdates(installed, catalog)).toEqual([ - { name: 'echo', installed: '1.0.0', available: '1.3.0' }, - ]); - }); - - it('ignores bricks installed locally but missing from the catalog', () => { - const catalog = validCatalog([validBrick()]); - const installed: InstalledBrick[] = [{ name: 'ghost', version: '0.1.0' }]; - expect(listUpdates(installed, catalog)).toEqual([]); - }); - - it('ignores catalog bricks that are not installed', () => { - const catalog = validCatalog([ - validBrick({ version: '1.0.0' }), - validBrick({ name: 'indexer', version: '2.0.0' }), - ]); - const installed: InstalledBrick[] = [{ name: 'echo', version: '1.0.0' }]; - expect(listUpdates(installed, catalog)).toEqual([]); - }); - - it('never reports a downgrade when the installed version is newer', () => { - const catalog = validCatalog([validBrick({ version: '1.0.0' })]); - const installed: InstalledBrick[] = [{ name: 'echo', version: '1.5.0' }]; - expect(listUpdates(installed, catalog)).toEqual([]); - }); + it('returns empty when every installed brick is at the catalog version', () => { + const catalog = validCatalog([validBrick({ version: '1.0.0' })]); + const installed: InstalledBrick[] = [{ name: 'echo', version: '1.0.0' }]; + expect(listUpdates(installed, catalog)).toEqual([]); + }); + + it('reports an available update when the catalog has a newer version', () => { + const catalog = validCatalog([validBrick({ version: '1.3.0' })]); + const installed: InstalledBrick[] = [{ name: 'echo', version: '1.0.0' }]; + expect(listUpdates(installed, catalog)).toEqual([ + { name: 'echo', installed: '1.0.0', available: '1.3.0' }, + ]); + }); + + it('ignores bricks installed locally but missing from the catalog', () => { + const catalog = validCatalog([validBrick()]); + const installed: InstalledBrick[] = [{ name: 'ghost', version: '0.1.0' }]; + expect(listUpdates(installed, catalog)).toEqual([]); + }); + + it('ignores catalog bricks that are not installed', () => { + const catalog = validCatalog([ + validBrick({ version: '1.0.0' }), + validBrick({ name: 'indexer', version: '2.0.0' }), + ]); + const installed: InstalledBrick[] = [{ name: 'echo', version: '1.0.0' }]; + expect(listUpdates(installed, catalog)).toEqual([]); + }); + + it('never reports a downgrade when the installed version is newer', () => { + const catalog = validCatalog([validBrick({ version: '1.0.0' })]); + const installed: InstalledBrick[] = [{ name: 'echo', version: '1.5.0' }]; + expect(listUpdates(installed, catalog)).toEqual([]); + }); }); diff --git a/packages/core/src/marketplace/resolver.ts b/packages/core/src/marketplace/resolver.ts index 86b572d..6766ba6 100644 --- a/packages/core/src/marketplace/resolver.ts +++ b/packages/core/src/marketplace/resolver.ts @@ -13,62 +13,62 @@ */ export interface CatalogOwner { - readonly name: string; - readonly url?: string; - readonly email?: string; + readonly name: string; + readonly url?: string; + readonly email?: string; } export interface CatalogTool { - readonly name: string; - readonly description: string; - readonly inputSchema?: unknown; + readonly name: string; + readonly description: string; + readonly inputSchema?: unknown; } export type CatalogBrickSource = - | { readonly type: 'local'; readonly path: string } - | { readonly type: 'url'; readonly url: string; readonly sha?: string } - | { - readonly type: 'git-subdir'; - readonly url: string; - readonly path: string; - readonly ref: string; - readonly sha?: string; - }; + | { readonly type: 'local'; readonly path: string } + | { readonly type: 'url'; readonly url: string; readonly sha?: string } + | { + readonly type: 'git-subdir'; + readonly url: string; + readonly path: string; + readonly ref: string; + readonly sha?: string; + }; export interface CatalogBrick { - readonly name: string; - readonly version: string; - readonly description: string; - readonly tags?: readonly string[]; - readonly dependencies: readonly string[]; - readonly tools: readonly CatalogTool[]; - readonly source: CatalogBrickSource; - readonly tarballUrl?: string; - readonly integrity?: string; - readonly publishedAt?: string; - readonly license?: string; - readonly homepage?: string; - readonly publisher?: string; + readonly name: string; + readonly version: string; + readonly description: string; + readonly tags?: readonly string[]; + readonly dependencies: readonly string[]; + readonly tools: readonly CatalogTool[]; + readonly source: CatalogBrickSource; + readonly tarballUrl?: string; + readonly integrity?: string; + readonly publishedAt?: string; + readonly license?: string; + readonly homepage?: string; + readonly publisher?: string; } export interface Catalog { - readonly $schema?: string; - readonly name: string; - readonly description?: string; - readonly owner: CatalogOwner; - readonly updated: string; - readonly bricks: readonly CatalogBrick[]; + readonly $schema?: string; + readonly name: string; + readonly description?: string; + readonly owner: CatalogOwner; + readonly updated: string; + readonly bricks: readonly CatalogBrick[]; } export interface InstalledBrick { - readonly name: string; - readonly version: string; + readonly name: string; + readonly version: string; } export interface UpdateInfo { - readonly name: string; - readonly installed: string; - readonly available: string; + readonly name: string; + readonly installed: string; + readonly available: string; } // ---------- Constants ---------- @@ -77,266 +77,266 @@ const KEBAB_NAME = /^[a-z][a-z0-9-]*$/; // SemVer 2.0 — strict pre-release (no leading-zero numeric identifiers) + optional build metadata. // Groups: 1=major, 2=minor, 3=patch, 4=pre-release (without the leading dash). const SEMVER = - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; // ---------- parseCatalog ---------- export function parseCatalog(raw: unknown): Catalog { - const obj = requireObject(raw, 'catalog'); - const name = requireString(obj, 'name', 'catalog'); - const owner = requireObject(obj['owner'], 'catalog.owner'); - const ownerName = requireString(owner, 'name', 'catalog.owner'); - const updated = requireString(obj, 'updated', 'catalog'); - const bricksRaw = obj['bricks']; - if (!Array.isArray(bricksRaw)) { - throw new Error('catalog.bricks must be an array'); - } - const bricks = bricksRaw.map((b, i) => parseBrick(b, i)); - - const schema = optionalString(obj, '$schema', 'catalog'); - const description = optionalString(obj, 'description', 'catalog'); - const ownerUrl = optionalString(owner, 'url', 'catalog.owner'); - const ownerEmail = optionalString(owner, 'email', 'catalog.owner'); - - return { - name, - owner: { - name: ownerName, - ...(ownerUrl !== undefined ? { url: ownerUrl } : {}), - ...(ownerEmail !== undefined ? { email: ownerEmail } : {}), - }, - updated, - bricks, - ...(schema !== undefined ? { $schema: schema } : {}), - ...(description !== undefined ? { description } : {}), - }; + const obj = requireObject(raw, 'catalog'); + const name = requireString(obj, 'name', 'catalog'); + const owner = requireObject(obj['owner'], 'catalog.owner'); + const ownerName = requireString(owner, 'name', 'catalog.owner'); + const updated = requireString(obj, 'updated', 'catalog'); + const bricksRaw = obj['bricks']; + if (!Array.isArray(bricksRaw)) { + throw new Error('catalog.bricks must be an array'); + } + const bricks = bricksRaw.map((b, i) => parseBrick(b, i)); + + const schema = optionalString(obj, '$schema', 'catalog'); + const description = optionalString(obj, 'description', 'catalog'); + const ownerUrl = optionalString(owner, 'url', 'catalog.owner'); + const ownerEmail = optionalString(owner, 'email', 'catalog.owner'); + + return { + name, + owner: { + name: ownerName, + ...(ownerUrl !== undefined ? { url: ownerUrl } : {}), + ...(ownerEmail !== undefined ? { email: ownerEmail } : {}), + }, + updated, + bricks, + ...(schema !== undefined ? { $schema: schema } : {}), + ...(description !== undefined ? { description } : {}), + }; } function parseBrick(raw: unknown, index: number): CatalogBrick { - const loc = `bricks[${index}]`; - const obj = requireObject(raw, loc); - const name = requireString(obj, 'name', loc); - if (!KEBAB_NAME.test(name)) { - throw new Error(`${loc}.name "${name}" is not kebab-case`); - } - const version = requireString(obj, 'version', loc); - if (!SEMVER.test(version)) { - throw new Error(`${loc}.version "${version}" is not a valid semver`); - } - const description = requireString(obj, 'description', loc); - const dependencies = requireStringArray(obj, 'dependencies', loc); - const tools = requireArray(obj, 'tools', loc).map((t, ti) => parseTool(t, loc, ti)); - const source = parseSource(obj['source'], loc); - const tags = optionalStringArray(obj, 'tags', loc); - const tarballUrl = optionalString(obj, 'tarballUrl', loc); - const integrity = optionalString(obj, 'integrity', loc); - const publishedAt = optionalString(obj, 'publishedAt', loc); - const license = optionalString(obj, 'license', loc); - const homepage = optionalString(obj, 'homepage', loc); - const publisher = optionalString(obj, 'publisher', loc); - - return { - name, - version, - description, - dependencies, - tools, - source, - ...(tags !== undefined ? { tags } : {}), - ...(tarballUrl !== undefined ? { tarballUrl } : {}), - ...(integrity !== undefined ? { integrity } : {}), - ...(publishedAt !== undefined ? { publishedAt } : {}), - ...(license !== undefined ? { license } : {}), - ...(homepage !== undefined ? { homepage } : {}), - ...(publisher !== undefined ? { publisher } : {}), - }; + const loc = `bricks[${index}]`; + const obj = requireObject(raw, loc); + const name = requireString(obj, 'name', loc); + if (!KEBAB_NAME.test(name)) { + throw new Error(`${loc}.name "${name}" is not kebab-case`); + } + const version = requireString(obj, 'version', loc); + if (!SEMVER.test(version)) { + throw new Error(`${loc}.version "${version}" is not a valid semver`); + } + const description = requireString(obj, 'description', loc); + const dependencies = requireStringArray(obj, 'dependencies', loc); + const tools = requireArray(obj, 'tools', loc).map((t, ti) => parseTool(t, loc, ti)); + const source = parseSource(obj['source'], loc); + const tags = optionalStringArray(obj, 'tags', loc); + const tarballUrl = optionalString(obj, 'tarballUrl', loc); + const integrity = optionalString(obj, 'integrity', loc); + const publishedAt = optionalString(obj, 'publishedAt', loc); + const license = optionalString(obj, 'license', loc); + const homepage = optionalString(obj, 'homepage', loc); + const publisher = optionalString(obj, 'publisher', loc); + + return { + name, + version, + description, + dependencies, + tools, + source, + ...(tags !== undefined ? { tags } : {}), + ...(tarballUrl !== undefined ? { tarballUrl } : {}), + ...(integrity !== undefined ? { integrity } : {}), + ...(publishedAt !== undefined ? { publishedAt } : {}), + ...(license !== undefined ? { license } : {}), + ...(homepage !== undefined ? { homepage } : {}), + ...(publisher !== undefined ? { publisher } : {}), + }; } function parseTool(raw: unknown, parentLoc: string, toolIndex: number): CatalogTool { - const loc = `${parentLoc}.tools[${toolIndex}]`; - const obj = requireObject(raw, loc); - const inputSchema = obj['inputSchema']; - return { - name: requireString(obj, 'name', loc), - description: requireString(obj, 'description', loc), - ...(inputSchema !== undefined ? { inputSchema } : {}), - }; + const loc = `${parentLoc}.tools[${toolIndex}]`; + const obj = requireObject(raw, loc); + const inputSchema = obj['inputSchema']; + return { + name: requireString(obj, 'name', loc), + description: requireString(obj, 'description', loc), + ...(inputSchema !== undefined ? { inputSchema } : {}), + }; } function parseSource(raw: unknown, parentLoc: string): CatalogBrickSource { - const loc = `${parentLoc}.source`; - const obj = requireObject(raw, loc); - const type = obj['type']; - if (type === 'local') { - return { type: 'local', path: requireString(obj, 'path', loc) }; - } - if (type === 'url') { - const sha = optionalString(obj, 'sha', loc); - return { - type: 'url', - url: requireString(obj, 'url', loc), - ...(sha !== undefined ? { sha } : {}), - }; - } - if (type === 'git-subdir') { - const sha = optionalString(obj, 'sha', loc); - return { - type: 'git-subdir', - url: requireString(obj, 'url', loc), - path: requireString(obj, 'path', loc), - ref: requireString(obj, 'ref', loc), - ...(sha !== undefined ? { sha } : {}), - }; - } - throw new Error( - `${loc}.type must be "local", "url" or "git-subdir", got ${JSON.stringify(type)}`, - ); + const loc = `${parentLoc}.source`; + const obj = requireObject(raw, loc); + const type = obj['type']; + if (type === 'local') { + return { type: 'local', path: requireString(obj, 'path', loc) }; + } + if (type === 'url') { + const sha = optionalString(obj, 'sha', loc); + return { + type: 'url', + url: requireString(obj, 'url', loc), + ...(sha !== undefined ? { sha } : {}), + }; + } + if (type === 'git-subdir') { + const sha = optionalString(obj, 'sha', loc); + return { + type: 'git-subdir', + url: requireString(obj, 'url', loc), + path: requireString(obj, 'path', loc), + ref: requireString(obj, 'ref', loc), + ...(sha !== undefined ? { sha } : {}), + }; + } + throw new Error( + `${loc}.type must be "local", "url" or "git-subdir", got ${JSON.stringify(type)}`, + ); } // ---------- findBrick ---------- export function findBrick(catalog: Catalog, name: string): CatalogBrick | undefined { - return catalog.bricks.find((b) => b.name === name); + return catalog.bricks.find((b) => b.name === name); } // ---------- compareSemver ---------- /** -1 if a < b, 0 if equal, 1 if a > b. Throws on malformed input. Build metadata is ignored per SemVer 2.0 §10. */ export function compareSemver(a: string, b: string): -1 | 0 | 1 { - const pa = parseSemver(a); - const pb = parseSemver(b); - - for (let i = 0; i < 3; i++) { - const av = pa.core[i] as number; - const bv = pb.core[i] as number; - if (av !== bv) return av < bv ? -1 : 1; - } - - // Core equal — compare pre-release per semver §11. - if (pa.pre === undefined && pb.pre === undefined) return 0; - if (pa.pre === undefined) return 1; // non-pre > pre - if (pb.pre === undefined) return -1; - return comparePreRelease(pa.pre, pb.pre); + const pa = parseSemver(a); + const pb = parseSemver(b); + + for (let i = 0; i < 3; i++) { + const av = pa.core[i] as number; + const bv = pb.core[i] as number; + if (av !== bv) return av < bv ? -1 : 1; + } + + // Core equal — compare pre-release per semver §11. + if (pa.pre === undefined && pb.pre === undefined) return 0; + if (pa.pre === undefined) return 1; // non-pre > pre + if (pb.pre === undefined) return -1; + return comparePreRelease(pa.pre, pb.pre); } function parseSemver(version: string): { - readonly core: readonly [number, number, number]; - readonly pre: string | undefined; + readonly core: readonly [number, number, number]; + readonly pre: string | undefined; } { - const match = SEMVER.exec(version); - if (!match) throw new Error(`"${version}" is not a valid semver`); - return { - core: [Number(match[1]), Number(match[2]), Number(match[3])] as const, - pre: match[4], - }; + const match = SEMVER.exec(version); + if (!match) throw new Error(`"${version}" is not a valid semver`); + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])] as const, + pre: match[4], + }; } function comparePreRelease(a: string, b: string): -1 | 0 | 1 { - const as = a.split('.'); - const bs = b.split('.'); - const maxLen = Math.max(as.length, bs.length); - for (let i = 0; i < maxLen; i++) { - const cmp = comparePreReleaseId(as[i], bs[i]); - if (cmp !== 0) return cmp; - } - return 0; + const as = a.split('.'); + const bs = b.split('.'); + const maxLen = Math.max(as.length, bs.length); + for (let i = 0; i < maxLen; i++) { + const cmp = comparePreReleaseId(as[i], bs[i]); + if (cmp !== 0) return cmp; + } + return 0; } function comparePreReleaseId(a: string | undefined, b: string | undefined): -1 | 0 | 1 { - // Per semver §11.4.4: shorter set of identifiers is lower. - if (a === undefined) return -1; - if (b === undefined) return 1; - const an = /^\d+$/.test(a); - const bn = /^\d+$/.test(b); - if (an && bn) { - const ai = Number(a); - const bi = Number(b); - if (ai === bi) return 0; - return ai < bi ? -1 : 1; - } - if (an) return -1; // numeric < non-numeric - if (bn) return 1; - if (a === b) return 0; - return a < b ? -1 : 1; + // Per semver §11.4.4: shorter set of identifiers is lower. + if (a === undefined) return -1; + if (b === undefined) return 1; + const an = /^\d+$/.test(a); + const bn = /^\d+$/.test(b); + if (an && bn) { + const ai = Number(a); + const bi = Number(b); + if (ai === bi) return 0; + return ai < bi ? -1 : 1; + } + if (an) return -1; // numeric < non-numeric + if (bn) return 1; + if (a === b) return 0; + return a < b ? -1 : 1; } // ---------- listUpdates ---------- export function listUpdates( - installed: readonly InstalledBrick[], - catalog: Catalog, + installed: readonly InstalledBrick[], + catalog: Catalog, ): readonly UpdateInfo[] { - const updates: UpdateInfo[] = []; - for (const inst of installed) { - const entry = findBrick(catalog, inst.name); - if (!entry) continue; - if (compareSemver(entry.version, inst.version) === 1) { - updates.push({ name: inst.name, installed: inst.version, available: entry.version }); + const updates: UpdateInfo[] = []; + for (const inst of installed) { + const entry = findBrick(catalog, inst.name); + if (!entry) continue; + if (compareSemver(entry.version, inst.version) === 1) { + updates.push({ name: inst.name, installed: inst.version, available: entry.version }); + } } - } - return updates; + return updates; } // ---------- helpers ---------- function requireObject(raw: unknown, loc: string): Record { - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error(`${loc} must be an object`); - } - return raw as Record; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`${loc} must be an object`); + } + return raw as Record; } function requireString(obj: Record, key: string, parentLoc: string): string { - const value = obj[key]; - if (typeof value !== 'string' || value.length === 0) { - throw new Error(`${parentLoc}.${key} must be a non-empty string`); - } - return value; + const value = obj[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${parentLoc}.${key} must be a non-empty string`); + } + return value; } function optionalString( - obj: Record, - key: string, - parentLoc: string, + obj: Record, + key: string, + parentLoc: string, ): string | undefined { - const value = obj[key]; - if (value === undefined) return undefined; - if (typeof value !== 'string') { - throw new Error(`${parentLoc}.${key} must be a string when provided`); - } - return value; + const value = obj[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string') { + throw new Error(`${parentLoc}.${key} must be a string when provided`); + } + return value; } function requireArray( - obj: Record, - key: string, - parentLoc: string, + obj: Record, + key: string, + parentLoc: string, ): readonly unknown[] { - const value = obj[key]; - if (!Array.isArray(value)) throw new Error(`${parentLoc}.${key} must be an array`); - return value; + const value = obj[key]; + if (!Array.isArray(value)) throw new Error(`${parentLoc}.${key} must be an array`); + return value; } function requireStringArray( - obj: Record, - key: string, - parentLoc: string, + obj: Record, + key: string, + parentLoc: string, ): readonly string[] { - const arr = requireArray(obj, key, parentLoc); - for (const item of arr) { - if (typeof item !== 'string') { - throw new Error(`${parentLoc}.${key} must contain only strings`); + const arr = requireArray(obj, key, parentLoc); + for (const item of arr) { + if (typeof item !== 'string') { + throw new Error(`${parentLoc}.${key} must contain only strings`); + } } - } - return arr as readonly string[]; + return arr as readonly string[]; } function optionalStringArray( - obj: Record, - key: string, - parentLoc: string, + obj: Record, + key: string, + parentLoc: string, ): readonly string[] | undefined { - const value = obj[key]; - if (value === undefined) return undefined; - return requireStringArray(obj, key, parentLoc); + const value = obj[key]; + if (value === undefined) return undefined; + return requireStringArray(obj, key, parentLoc); } diff --git a/packages/core/src/observability/async-storage.ts b/packages/core/src/observability/async-storage.ts index f539aa8..a773b93 100644 --- a/packages/core/src/observability/async-storage.ts +++ b/packages/core/src/observability/async-storage.ts @@ -13,19 +13,19 @@ * (proposal TC39 stage 3) quand shipped. */ export class AsyncLocalStorage { - #store: T | undefined; + #store: T | undefined; - getStore(): T | undefined { - return this.#store; - } + getStore(): T | undefined { + return this.#store; + } - run(store: T, fn: () => R): R { - const prev = this.#store; - this.#store = store; - try { - return fn(); - } finally { - this.#store = prev; + run(store: T, fn: () => R): R { + const prev = this.#store; + this.#store = store; + try { + return fn(); + } finally { + this.#store = prev; + } } - } } diff --git a/packages/core/src/observability/logger.ts b/packages/core/src/observability/logger.ts index 4c40ed2..1ac0ff8 100644 --- a/packages/core/src/observability/logger.ts +++ b/packages/core/src/observability/logger.ts @@ -15,85 +15,85 @@ const LEVELS = ['trace', 'debug', 'info', 'warn', 'error'] as const; export type LogLevel = (typeof LEVELS)[number]; const LEVEL_RANK: Readonly> = { - trace: 10, - debug: 20, - info: 30, - warn: 40, - error: 50, + trace: 10, + debug: 20, + info: 30, + warn: 40, + error: 50, }; const SECRET_KEYS = new Set(['password', 'token', 'secret', 'apikey', 'authorization', 'cookie']); export interface Logger { - trace(msg: string, meta?: Record): void; - debug(msg: string, meta?: Record): void; - info(msg: string, meta?: Record): void; - warn(msg: string, meta?: Record): void; - error(msg: string, meta?: Record): void; - child(bindings: Record): Logger; + trace(msg: string, meta?: Record): void; + debug(msg: string, meta?: Record): void; + info(msg: string, meta?: Record): void; + warn(msg: string, meta?: Record): void; + error(msg: string, meta?: Record): void; + child(bindings: Record): Logger; } interface LoggerState { - readonly minLevel: LogLevel; - readonly bindings: Readonly>; + readonly minLevel: LogLevel; + readonly bindings: Readonly>; } function getMinLevel(): LogLevel { - const envLevel = getEnvVar('FOCUS_LOG_LEVEL')?.toLowerCase(); - if (envLevel && LEVELS.includes(envLevel as LogLevel)) return envLevel as LogLevel; - return 'info'; + const envLevel = getEnvVar('FOCUS_LOG_LEVEL')?.toLowerCase(); + if (envLevel && LEVELS.includes(envLevel as LogLevel)) return envLevel as LogLevel; + return 'info'; } function getEnvVar(name: string): string | undefined { - if (typeof process !== 'undefined' && process.env) return process.env[name]; - return undefined; + if (typeof process !== 'undefined' && process.env) return process.env[name]; + return undefined; } function redact(value: unknown): unknown { - if (value === null || typeof value !== 'object') return value; - if (Array.isArray(value)) return value.map(redact); - const out: Record = {}; - for (const [k, v] of Object.entries(value as Record)) { - out[k] = SECRET_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v); - } - return out; + if (value === null || typeof value !== 'object') return value; + if (Array.isArray(value)) return value.map(redact); + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = SECRET_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v); + } + return out; } function emit( - state: LoggerState, - level: LogLevel, - msg: string, - meta?: Record, + state: LoggerState, + level: LogLevel, + msg: string, + meta?: Record, ): void { - if (LEVEL_RANK[level] < LEVEL_RANK[state.minLevel]) return; - const payload = { - time: new Date().toISOString(), - level, - ...state.bindings, - ...(meta ? (redact(meta) as Record) : {}), - msg, - }; - const line = JSON.stringify(payload); - const sink = level === 'error' || level === 'warn' ? console.error : console.log; - sink(line); + if (LEVEL_RANK[level] < LEVEL_RANK[state.minLevel]) return; + const payload = { + time: new Date().toISOString(), + level, + ...state.bindings, + ...(meta ? (redact(meta) as Record) : {}), + msg, + }; + const line = JSON.stringify(payload); + const sink = level === 'error' || level === 'warn' ? console.error : console.log; + sink(line); } function make(state: LoggerState): Logger { - return { - trace: (msg, meta) => emit(state, 'trace', msg, meta), - debug: (msg, meta) => emit(state, 'debug', msg, meta), - info: (msg, meta) => emit(state, 'info', msg, meta), - warn: (msg, meta) => emit(state, 'warn', msg, meta), - error: (msg, meta) => emit(state, 'error', msg, meta), - child: (bindings) => make({ ...state, bindings: { ...state.bindings, ...bindings } }), - }; + return { + trace: (msg, meta) => emit(state, 'trace', msg, meta), + debug: (msg, meta) => emit(state, 'debug', msg, meta), + info: (msg, meta) => emit(state, 'info', msg, meta), + warn: (msg, meta) => emit(state, 'warn', msg, meta), + error: (msg, meta) => emit(state, 'error', msg, meta), + child: (bindings) => make({ ...state, bindings: { ...state.bindings, ...bindings } }), + }; } export const rootLogger: Logger = make({ - minLevel: getMinLevel(), - bindings: { service: 'focusmcp' }, + minLevel: getMinLevel(), + bindings: { service: 'focusmcp' }, }); export function createLogger(component: string, bindings: Record = {}): Logger { - return rootLogger.child({ component, ...bindings }); + return rootLogger.child({ component, ...bindings }); } diff --git a/packages/core/src/observability/tracing.ts b/packages/core/src/observability/tracing.ts index b5f4d37..439bf41 100644 --- a/packages/core/src/observability/tracing.ts +++ b/packages/core/src/observability/tracing.ts @@ -7,7 +7,7 @@ const TRACER_NAME = 'focusmcp'; const TRACER_VERSION = '0.0.0'; export function getTracer(): Tracer { - return trace.getTracer(TRACER_NAME, TRACER_VERSION); + return trace.getTracer(TRACER_NAME, TRACER_VERSION); } export { trace } from '@opentelemetry/api'; diff --git a/packages/core/src/registry/permission-provider.test.ts b/packages/core/src/registry/permission-provider.test.ts index 7ac1118..8ea1328 100644 --- a/packages/core/src/registry/permission-provider.test.ts +++ b/packages/core/src/registry/permission-provider.test.ts @@ -7,37 +7,37 @@ import { permissionProviderFromRegistry } from './permission-provider.ts'; import { InMemoryRegistry } from './registry.ts'; function makeBrick(name: string, dependencies: readonly string[]): Brick { - return { - manifest: { name, version: '1.0.0', description: `${name}`, dependencies, tools: [] }, - start: () => {}, - stop: () => {}, - }; + return { + manifest: { name, version: '1.0.0', description: `${name}`, dependencies, tools: [] }, + start: () => {}, + stop: () => {}, + }; } describe('permissionProviderFromRegistry', () => { - it('retourne les dépendances déclarées dans le manifeste', () => { - const registry = new InMemoryRegistry(); - registry.register(makeBrick('php', ['indexer', 'cache'])); - const provider = permissionProviderFromRegistry(registry); - - expect(provider('php')).toEqual(['indexer', 'cache']); - }); - - it('retourne un tableau vide si la brique est inconnue', () => { - const registry = new InMemoryRegistry(); - const provider = permissionProviderFromRegistry(registry); - - expect(provider('ghost')).toEqual([]); - }); - - it('reflète les changements live du registry (lecture paresseuse)', () => { - const registry = new InMemoryRegistry(); - const provider = permissionProviderFromRegistry(registry); - - expect(provider('php')).toEqual([]); - registry.register(makeBrick('php', ['indexer'])); - expect(provider('php')).toEqual(['indexer']); - registry.unregister('php'); - expect(provider('php')).toEqual([]); - }); + it('retourne les dépendances déclarées dans le manifeste', () => { + const registry = new InMemoryRegistry(); + registry.register(makeBrick('php', ['indexer', 'cache'])); + const provider = permissionProviderFromRegistry(registry); + + expect(provider('php')).toEqual(['indexer', 'cache']); + }); + + it('retourne un tableau vide si la brique est inconnue', () => { + const registry = new InMemoryRegistry(); + const provider = permissionProviderFromRegistry(registry); + + expect(provider('ghost')).toEqual([]); + }); + + it('reflète les changements live du registry (lecture paresseuse)', () => { + const registry = new InMemoryRegistry(); + const provider = permissionProviderFromRegistry(registry); + + expect(provider('php')).toEqual([]); + registry.register(makeBrick('php', ['indexer'])); + expect(provider('php')).toEqual(['indexer']); + registry.unregister('php'); + expect(provider('php')).toEqual([]); + }); }); diff --git a/packages/core/src/registry/permission-provider.ts b/packages/core/src/registry/permission-provider.ts index 2a548b2..bc71f96 100644 --- a/packages/core/src/registry/permission-provider.ts +++ b/packages/core/src/registry/permission-provider.ts @@ -8,7 +8,7 @@ import type { Registry } from '../types/registry.ts'; * Lit les dépendances déclarées dans le manifeste de chaque brique, en live. */ export function permissionProviderFromRegistry( - registry: Registry, + registry: Registry, ): (source: string) => readonly string[] { - return (source) => registry.getBrick(source)?.manifest.dependencies ?? []; + return (source) => registry.getBrick(source)?.manifest.dependencies ?? []; } diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts index c132a84..5d3cd3b 100644 --- a/packages/core/src/registry/registry.test.ts +++ b/packages/core/src/registry/registry.test.ts @@ -7,210 +7,230 @@ import type { BrickManifest } from '../types/manifest.ts'; import { InMemoryRegistry } from './registry.ts'; function fakeBrick(manifest: Partial & Pick): Brick { - return { - manifest: { - version: '1.0.0', - description: '', - dependencies: [], - tools: [], - ...manifest, - }, - start: () => {}, - stop: () => {}, - }; + return { + manifest: { + version: '1.0.0', + description: '', + dependencies: [], + tools: [], + ...manifest, + }, + start: () => {}, + stop: () => {}, + }; } describe('InMemoryRegistry — register / unregister', () => { - it('enregistre une brique et la rend récupérable par son nom', () => { - const registry = new InMemoryRegistry(); - const brick = fakeBrick({ name: 'indexer' }); + it('enregistre une brique et la rend récupérable par son nom', () => { + const registry = new InMemoryRegistry(); + const brick = fakeBrick({ name: 'indexer' }); - registry.register(brick); + registry.register(brick); - expect(registry.getBrick('indexer')).toBe(brick); - expect(registry.getBricks()).toContain(brick); - }); + expect(registry.getBrick('indexer')).toBe(brick); + expect(registry.getBricks()).toContain(brick); + }); - it("rejette l'enregistrement si une brique du même nom existe déjà", () => { - const registry = new InMemoryRegistry(); - registry.register(fakeBrick({ name: 'indexer' })); + it("rejette l'enregistrement si une brique du même nom existe déjà", () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'indexer' })); - expect(() => registry.register(fakeBrick({ name: 'indexer' }))).toThrow( - expect.objectContaining({ name: 'RegistryError', code: 'BRICK_ALREADY_REGISTERED' }), - ); - }); + expect(() => registry.register(fakeBrick({ name: 'indexer' }))).toThrow( + expect.objectContaining({ name: 'RegistryError', code: 'BRICK_ALREADY_REGISTERED' }), + ); + }); - it('désenregistre une brique sans dépendants', () => { - const registry = new InMemoryRegistry(); - registry.register(fakeBrick({ name: 'indexer' })); + it('désenregistre une brique sans dépendants', () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'indexer' })); - registry.unregister('indexer'); + registry.unregister('indexer'); - expect(registry.getBrick('indexer')).toBeUndefined(); - }); + expect(registry.getBrick('indexer')).toBeUndefined(); + }); - it("refuse de désenregistrer une brique dont d'autres briques running dépendent", () => { - const registry = new InMemoryRegistry(); - registry.register(fakeBrick({ name: 'indexer' })); - registry.register(fakeBrick({ name: 'php', dependencies: ['indexer'] })); - registry.setStatus('php', 'running'); + it("refuse de désenregistrer une brique dont d'autres briques running dépendent", () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'indexer' })); + registry.register(fakeBrick({ name: 'php', dependencies: ['indexer'] })); + registry.setStatus('php', 'running'); - expect(() => registry.unregister('indexer')).toThrow( - expect.objectContaining({ name: 'RegistryError', code: 'DEPENDENT_BRICKS_RUNNING' }), - ); - }); + expect(() => registry.unregister('indexer')).toThrow( + expect.objectContaining({ name: 'RegistryError', code: 'DEPENDENT_BRICKS_RUNNING' }), + ); + }); }); describe('InMemoryRegistry — resolve (graphe de dépendances)', () => { - it("retourne les briques dans l'ordre de démarrage (dépendances d'abord)", () => { - const registry = new InMemoryRegistry(); - registry.register(fakeBrick({ name: 'cache' })); - registry.register(fakeBrick({ name: 'indexer', dependencies: ['cache'] })); - registry.register(fakeBrick({ name: 'php', dependencies: ['indexer'] })); - - const order = registry.resolve('php').map((b) => b.manifest.name); - - expect(order).toEqual(['cache', 'indexer', 'php']); - }); - - it('détecte les cycles (CYCLE_DETECTED)', () => { - const registry = new InMemoryRegistry(); - registry.register(fakeBrick({ name: 'a', dependencies: ['b'] })); - registry.register(fakeBrick({ name: 'b', dependencies: ['a'] })); - - expect(() => registry.resolve('a')).toThrow( - expect.objectContaining({ name: 'RegistryError', code: 'CYCLE_DETECTED' }), - ); - }); - - it('signale les dépendances manquantes (MISSING_DEPENDENCY)', () => { - const registry = new InMemoryRegistry(); - registry.register(fakeBrick({ name: 'php', dependencies: ['indexer'] })); - - expect(() => registry.resolve('php')).toThrow( - expect.objectContaining({ name: 'RegistryError', code: 'MISSING_DEPENDENCY' }), - ); - }); + it("retourne les briques dans l'ordre de démarrage (dépendances d'abord)", () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'cache' })); + registry.register(fakeBrick({ name: 'indexer', dependencies: ['cache'] })); + registry.register(fakeBrick({ name: 'php', dependencies: ['indexer'] })); + + const order = registry.resolve('php').map((b) => b.manifest.name); + + expect(order).toEqual(['cache', 'indexer', 'php']); + }); + + it('détecte les cycles (CYCLE_DETECTED)', () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'a', dependencies: ['b'] })); + registry.register(fakeBrick({ name: 'b', dependencies: ['a'] })); + + expect(() => registry.resolve('a')).toThrow( + expect.objectContaining({ name: 'RegistryError', code: 'CYCLE_DETECTED' }), + ); + }); + + it('signale les dépendances manquantes (MISSING_DEPENDENCY)', () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'php', dependencies: ['indexer'] })); + + expect(() => registry.resolve('php')).toThrow( + expect.objectContaining({ name: 'RegistryError', code: 'MISSING_DEPENDENCY' }), + ); + }); }); describe('InMemoryRegistry — erreurs BRICK_NOT_FOUND', () => { - it("unregister d'une brique inexistante rejette avec BRICK_NOT_FOUND", () => { - const registry = new InMemoryRegistry(); + it("unregister d'une brique inexistante rejette avec BRICK_NOT_FOUND", () => { + const registry = new InMemoryRegistry(); - expect(() => registry.unregister('ghost')).toThrow( - expect.objectContaining({ name: 'RegistryError', code: 'BRICK_NOT_FOUND' }), - ); - }); + expect(() => registry.unregister('ghost')).toThrow( + expect.objectContaining({ name: 'RegistryError', code: 'BRICK_NOT_FOUND' }), + ); + }); - it("getStatus d'une brique inexistante rejette avec BRICK_NOT_FOUND", () => { - const registry = new InMemoryRegistry(); + it("getStatus d'une brique inexistante rejette avec BRICK_NOT_FOUND", () => { + const registry = new InMemoryRegistry(); - expect(() => registry.getStatus('ghost')).toThrow( - expect.objectContaining({ name: 'RegistryError', code: 'BRICK_NOT_FOUND' }), - ); - }); + expect(() => registry.getStatus('ghost')).toThrow( + expect.objectContaining({ name: 'RegistryError', code: 'BRICK_NOT_FOUND' }), + ); + }); - it("setStatus d'une brique inexistante rejette avec BRICK_NOT_FOUND", () => { - const registry = new InMemoryRegistry(); + it("setStatus d'une brique inexistante rejette avec BRICK_NOT_FOUND", () => { + const registry = new InMemoryRegistry(); - expect(() => registry.setStatus('ghost', 'running')).toThrow( - expect.objectContaining({ name: 'RegistryError', code: 'BRICK_NOT_FOUND' }), - ); - }); + expect(() => registry.setStatus('ghost', 'running')).toThrow( + expect.objectContaining({ name: 'RegistryError', code: 'BRICK_NOT_FOUND' }), + ); + }); }); describe('InMemoryRegistry — status', () => { - it("la valeur initiale d'une brique enregistrée est 'stopped'", () => { - const registry = new InMemoryRegistry(); - registry.register(fakeBrick({ name: 'indexer' })); + it("la valeur initiale d'une brique enregistrée est 'stopped'", () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'indexer' })); - expect(registry.getStatus('indexer')).toBe('stopped'); - }); + expect(registry.getStatus('indexer')).toBe('stopped'); + }); - it('met à jour le statut via setStatus', () => { - const registry = new InMemoryRegistry(); - registry.register(fakeBrick({ name: 'indexer' })); + it('met à jour le statut via setStatus', () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'indexer' })); - registry.setStatus('indexer', 'running'); + registry.setStatus('indexer', 'running'); - expect(registry.getStatus('indexer')).toBe('running'); - }); + expect(registry.getStatus('indexer')).toBe('running'); + }); }); describe('InMemoryRegistry — getBrickForTool', () => { - it('retourne le nom de la brique qui expose le tool', () => { - const registry = new InMemoryRegistry(); - registry.register( - fakeBrick({ - name: 'indexer', - tools: [{ name: 'indexer_search', description: '', inputSchema: { type: 'object' } }], - }), - ); - - expect(registry.getBrickForTool('indexer_search')).toBe('indexer'); - }); - - it("retourne undefined si le tool n'est exposé par aucune brique", () => { - const registry = new InMemoryRegistry(); - - expect(registry.getBrickForTool('ghost_tool')).toBeUndefined(); - }); - - it("cherche parmi plusieurs tools d'une brique et plusieurs briques", () => { - const registry = new InMemoryRegistry(); - registry.register( - fakeBrick({ - name: 'indexer', - tools: [ - { name: 'indexer_search', description: '', inputSchema: { type: 'object' } }, - { name: 'indexer_stats', description: '', inputSchema: { type: 'object' } }, - ], - }), - ); - registry.register( - fakeBrick({ - name: 'php', - tools: [{ name: 'php_analyze', description: '', inputSchema: { type: 'object' } }], - }), - ); - - expect(registry.getBrickForTool('indexer_stats')).toBe('indexer'); - expect(registry.getBrickForTool('php_analyze')).toBe('php'); - }); + it('retourne le nom de la brique qui expose le tool', () => { + const registry = new InMemoryRegistry(); + registry.register( + fakeBrick({ + name: 'indexer', + tools: [ + { name: 'indexer_search', description: '', inputSchema: { type: 'object' } }, + ], + }), + ); + + expect(registry.getBrickForTool('indexer_search')).toBe('indexer'); + }); + + it("retourne undefined si le tool n'est exposé par aucune brique", () => { + const registry = new InMemoryRegistry(); + + expect(registry.getBrickForTool('ghost_tool')).toBeUndefined(); + }); + + it("cherche parmi plusieurs tools d'une brique et plusieurs briques", () => { + const registry = new InMemoryRegistry(); + registry.register( + fakeBrick({ + name: 'indexer', + tools: [ + { name: 'indexer_search', description: '', inputSchema: { type: 'object' } }, + { name: 'indexer_stats', description: '', inputSchema: { type: 'object' } }, + ], + }), + ); + registry.register( + fakeBrick({ + name: 'php', + tools: [{ name: 'php_analyze', description: '', inputSchema: { type: 'object' } }], + }), + ); + + expect(registry.getBrickForTool('indexer_stats')).toBe('indexer'); + expect(registry.getBrickForTool('php_analyze')).toBe('php'); + }); }); describe('InMemoryRegistry — getTools', () => { - it('agrège les tools de toutes les briques running', () => { - const registry = new InMemoryRegistry(); - registry.register( - fakeBrick({ - name: 'indexer', - tools: [{ name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }], - }), - ); - registry.register( - fakeBrick({ - name: 'php', - tools: [{ name: 'php_analyze', description: 'analyze', inputSchema: { type: 'object' } }], - }), - ); - registry.setStatus('indexer', 'running'); - registry.setStatus('php', 'running'); - - const toolNames = registry.getTools().map((t) => t.name); - - expect(toolNames).toEqual(expect.arrayContaining(['indexer_search', 'php_analyze'])); - }); - - it("n'inclut PAS les tools des briques non-running", () => { - const registry = new InMemoryRegistry(); - registry.register( - fakeBrick({ - name: 'indexer', - tools: [{ name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }], - }), - ); - - expect(registry.getTools()).toEqual([]); - }); + it('agrège les tools de toutes les briques running', () => { + const registry = new InMemoryRegistry(); + registry.register( + fakeBrick({ + name: 'indexer', + tools: [ + { + name: 'indexer_search', + description: 'search', + inputSchema: { type: 'object' }, + }, + ], + }), + ); + registry.register( + fakeBrick({ + name: 'php', + tools: [ + { + name: 'php_analyze', + description: 'analyze', + inputSchema: { type: 'object' }, + }, + ], + }), + ); + registry.setStatus('indexer', 'running'); + registry.setStatus('php', 'running'); + + const toolNames = registry.getTools().map((t) => t.name); + + expect(toolNames).toEqual(expect.arrayContaining(['indexer_search', 'php_analyze'])); + }); + + it("n'inclut PAS les tools des briques non-running", () => { + const registry = new InMemoryRegistry(); + registry.register( + fakeBrick({ + name: 'indexer', + tools: [ + { + name: 'indexer_search', + description: 'search', + inputSchema: { type: 'object' }, + }, + ], + }), + ); + + expect(registry.getTools()).toEqual([]); + }); }); diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index ab6b2ce..a6918ec 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -6,111 +6,115 @@ import { type Registry, RegistryError } from '../types/registry.ts'; import type { ToolDefinition } from '../types/tool.ts'; interface RegistryEntry { - readonly brick: Brick; - status: BrickStatus; + readonly brick: Brick; + status: BrickStatus; } export class InMemoryRegistry implements Registry { - readonly #entries = new Map(); + readonly #entries = new Map(); - register(brick: Brick): void { - const { name } = brick.manifest; - if (this.#entries.has(name)) { - throw new RegistryError(`Brick "${name}" is already registered`, 'BRICK_ALREADY_REGISTERED', { - name, - }); + register(brick: Brick): void { + const { name } = brick.manifest; + if (this.#entries.has(name)) { + throw new RegistryError( + `Brick "${name}" is already registered`, + 'BRICK_ALREADY_REGISTERED', + { + name, + }, + ); + } + this.#entries.set(name, { brick, status: 'stopped' }); } - this.#entries.set(name, { brick, status: 'stopped' }); - } - unregister(name: string): void { - if (!this.#entries.has(name)) { - throw new RegistryError(`Brick "${name}" not found`, 'BRICK_NOT_FOUND', { name }); + unregister(name: string): void { + if (!this.#entries.has(name)) { + throw new RegistryError(`Brick "${name}" not found`, 'BRICK_NOT_FOUND', { name }); + } + for (const [otherName, entry] of this.#entries) { + if (otherName === name) continue; + if (entry.status === 'running' && entry.brick.manifest.dependencies.includes(name)) { + throw new RegistryError( + `Cannot unregister "${name}": "${otherName}" is running and depends on it`, + 'DEPENDENT_BRICKS_RUNNING', + { name, dependent: otherName }, + ); + } + } + this.#entries.delete(name); } - for (const [otherName, entry] of this.#entries) { - if (otherName === name) continue; - if (entry.status === 'running' && entry.brick.manifest.dependencies.includes(name)) { - throw new RegistryError( - `Cannot unregister "${name}": "${otherName}" is running and depends on it`, - 'DEPENDENT_BRICKS_RUNNING', - { name, dependent: otherName }, - ); - } - } - this.#entries.delete(name); - } - resolve(name: string): readonly Brick[] { - const visited = new Set(); - const visiting = new Set(); - const order: Brick[] = []; + resolve(name: string): readonly Brick[] { + const visited = new Set(); + const visiting = new Set(); + const order: Brick[] = []; - const visit = (target: string): void => { - if (visited.has(target)) return; - if (visiting.has(target)) { - throw new RegistryError(`Cycle detected involving "${target}"`, 'CYCLE_DETECTED', { - name: target, - }); - } - const entry = this.#entries.get(target); - if (!entry) { - throw new RegistryError(`Missing dependency "${target}"`, 'MISSING_DEPENDENCY', { - name: target, - }); - } - visiting.add(target); - for (const dep of entry.brick.manifest.dependencies) { - visit(dep); - } - visiting.delete(target); - visited.add(target); - order.push(entry.brick); - }; + const visit = (target: string): void => { + if (visited.has(target)) return; + if (visiting.has(target)) { + throw new RegistryError(`Cycle detected involving "${target}"`, 'CYCLE_DETECTED', { + name: target, + }); + } + const entry = this.#entries.get(target); + if (!entry) { + throw new RegistryError(`Missing dependency "${target}"`, 'MISSING_DEPENDENCY', { + name: target, + }); + } + visiting.add(target); + for (const dep of entry.brick.manifest.dependencies) { + visit(dep); + } + visiting.delete(target); + visited.add(target); + order.push(entry.brick); + }; - visit(name); - return order; - } + visit(name); + return order; + } - getStatus(name: string): BrickStatus { - const entry = this.#entries.get(name); - if (!entry) { - throw new RegistryError(`Brick "${name}" not found`, 'BRICK_NOT_FOUND', { name }); + getStatus(name: string): BrickStatus { + const entry = this.#entries.get(name); + if (!entry) { + throw new RegistryError(`Brick "${name}" not found`, 'BRICK_NOT_FOUND', { name }); + } + return entry.status; } - return entry.status; - } - setStatus(name: string, status: BrickStatus): void { - const entry = this.#entries.get(name); - if (!entry) { - throw new RegistryError(`Brick "${name}" not found`, 'BRICK_NOT_FOUND', { name }); + setStatus(name: string, status: BrickStatus): void { + const entry = this.#entries.get(name); + if (!entry) { + throw new RegistryError(`Brick "${name}" not found`, 'BRICK_NOT_FOUND', { name }); + } + entry.status = status; } - entry.status = status; - } - getBricks(): readonly Brick[] { - return [...this.#entries.values()].map((entry) => entry.brick); - } + getBricks(): readonly Brick[] { + return [...this.#entries.values()].map((entry) => entry.brick); + } - getBrick(name: string): Brick | undefined { - return this.#entries.get(name)?.brick; - } + getBrick(name: string): Brick | undefined { + return this.#entries.get(name)?.brick; + } - getTools(): readonly ToolDefinition[] { - const tools: ToolDefinition[] = []; - for (const entry of this.#entries.values()) { - if (entry.status === 'running') { - tools.push(...entry.brick.manifest.tools); - } + getTools(): readonly ToolDefinition[] { + const tools: ToolDefinition[] = []; + for (const entry of this.#entries.values()) { + if (entry.status === 'running') { + tools.push(...entry.brick.manifest.tools); + } + } + return tools; } - return tools; - } - getBrickForTool(toolName: string): string | undefined { - for (const [name, entry] of this.#entries) { - for (const tool of entry.brick.manifest.tools) { - if (tool.name === toolName) return name; - } + getBrickForTool(toolName: string): string | undefined { + for (const [name, entry] of this.#entries) { + for (const tool of entry.brick.manifest.tools) { + if (tool.name === toolName) return name; + } + } + return undefined; } - return undefined; - } } diff --git a/packages/core/src/router/router.test.ts b/packages/core/src/router/router.test.ts index b571b8e..9275276 100644 --- a/packages/core/src/router/router.test.ts +++ b/packages/core/src/router/router.test.ts @@ -10,126 +10,156 @@ import type { ToolResult } from '../types/tool.ts'; import { McpRouter } from './router.ts'; function fakeBrick(manifest: Partial & Pick): Brick { - return { - manifest: { - version: '1.0.0', - description: '', - dependencies: [], - tools: [], - ...manifest, - }, - start: () => {}, - stop: () => {}, - }; + return { + manifest: { + version: '1.0.0', + description: '', + dependencies: [], + tools: [], + ...manifest, + }, + start: () => {}, + stop: () => {}, + }; } function setupRouter(): { - router: McpRouter; - registry: InMemoryRegistry; - bus: InProcessEventBus; + router: McpRouter; + registry: InMemoryRegistry; + bus: InProcessEventBus; } { - const registry = new InMemoryRegistry(); - const bus = new InProcessEventBus(); - const router = new McpRouter({ registry, bus }); - return { router, registry, bus }; + const registry = new InMemoryRegistry(); + const bus = new InProcessEventBus(); + const router = new McpRouter({ registry, bus }); + return { router, registry, bus }; } describe('McpRouter — listTools', () => { - it('agrège les tools de toutes les briques running (via Registry)', () => { - const { router, registry } = setupRouter(); - - registry.register( - fakeBrick({ - name: 'indexer', - tools: [{ name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }], - }), - ); - registry.setStatus('indexer', 'running'); - - const tools = router.listTools().map((t) => t.name); - - expect(tools).toEqual(['indexer_search']); - }); - - it('ne retourne pas les tools de briques non-running', () => { - const { router, registry } = setupRouter(); - - registry.register( - fakeBrick({ - name: 'indexer', - tools: [{ name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }], - }), - ); - - expect(router.listTools()).toEqual([]); - }); + it('agrège les tools de toutes les briques running (via Registry)', () => { + const { router, registry } = setupRouter(); + + registry.register( + fakeBrick({ + name: 'indexer', + tools: [ + { + name: 'indexer_search', + description: 'search', + inputSchema: { type: 'object' }, + }, + ], + }), + ); + registry.setStatus('indexer', 'running'); + + const tools = router.listTools().map((t) => t.name); + + expect(tools).toEqual(['indexer_search']); + }); + + it('ne retourne pas les tools de briques non-running', () => { + const { router, registry } = setupRouter(); + + registry.register( + fakeBrick({ + name: 'indexer', + tools: [ + { + name: 'indexer_search', + description: 'search', + inputSchema: { type: 'object' }, + }, + ], + }), + ); + + expect(router.listTools()).toEqual([]); + }); }); describe('McpRouter — callTool', () => { - it("dispatch l'appel vers la brique propriétaire via l'EventBus (target brick:tool)", async () => { - const { router, registry, bus } = setupRouter(); - - registry.register( - fakeBrick({ - name: 'indexer', - tools: [{ name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }], - }), - ); - registry.setStatus('indexer', 'running'); - - const expected: ToolResult = { content: [{ type: 'text', text: 'ok' }] }; - bus.handle('indexer:indexer_search', () => expected); - - const result = await router.callTool('indexer_search', { pattern: '*.ts' }); - - expect(result).toBe(expected); - }); - - it('propage les arguments au handler', async () => { - const { router, registry, bus } = setupRouter(); - - registry.register( - fakeBrick({ - name: 'indexer', - tools: [{ name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }], - }), - ); - registry.setStatus('indexer', 'running'); - - let captured: unknown; - bus.handle('indexer:indexer_search', (args) => { - captured = args; - return { content: [] }; + it("dispatch l'appel vers la brique propriétaire via l'EventBus (target brick:tool)", async () => { + const { router, registry, bus } = setupRouter(); + + registry.register( + fakeBrick({ + name: 'indexer', + tools: [ + { + name: 'indexer_search', + description: 'search', + inputSchema: { type: 'object' }, + }, + ], + }), + ); + registry.setStatus('indexer', 'running'); + + const expected: ToolResult = { content: [{ type: 'text', text: 'ok' }] }; + bus.handle('indexer:indexer_search', () => expected); + + const result = await router.callTool('indexer_search', { pattern: '*.ts' }); + + expect(result).toBe(expected); }); - await router.callTool('indexer_search', { pattern: '*.ts' }); - - expect(captured).toEqual({ pattern: '*.ts' }); - }); + it('propage les arguments au handler', async () => { + const { router, registry, bus } = setupRouter(); + + registry.register( + fakeBrick({ + name: 'indexer', + tools: [ + { + name: 'indexer_search', + description: 'search', + inputSchema: { type: 'object' }, + }, + ], + }), + ); + registry.setStatus('indexer', 'running'); + + let captured: unknown; + bus.handle('indexer:indexer_search', (args) => { + captured = args; + return { content: [] }; + }); + + await router.callTool('indexer_search', { pattern: '*.ts' }); + + expect(captured).toEqual({ pattern: '*.ts' }); + }); - it("rejette avec TOOL_NOT_FOUND si le tool n'existe dans aucune brique", async () => { - const { router } = setupRouter(); + it("rejette avec TOOL_NOT_FOUND si le tool n'existe dans aucune brique", async () => { + const { router } = setupRouter(); - await expect(router.callTool('unknown_tool', {})).rejects.toMatchObject({ - name: 'RouterError', - code: 'TOOL_NOT_FOUND', + await expect(router.callTool('unknown_tool', {})).rejects.toMatchObject({ + name: 'RouterError', + code: 'TOOL_NOT_FOUND', + }); }); - }); - - it("rejette avec BRICK_NOT_RUNNING si la brique propriétaire n'est pas running", async () => { - const { router, registry } = setupRouter(); - - registry.register( - fakeBrick({ - name: 'indexer', - tools: [{ name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }], - }), - ); - // pas de setStatus('running') - - await expect(router.callTool('indexer_search', {})).rejects.toMatchObject({ - name: 'RouterError', - code: 'BRICK_NOT_RUNNING', + + it("rejette avec BRICK_NOT_RUNNING si la brique propriétaire n'est pas running", async () => { + const { router, registry } = setupRouter(); + + registry.register( + fakeBrick({ + name: 'indexer', + tools: [ + { + name: 'indexer_search', + description: 'search', + inputSchema: { type: 'object' }, + }, + ], + }), + ); + // pas de setStatus('running') + + await expect(router.callTool('indexer_search', {})).rejects.toMatchObject({ + name: 'RouterError', + code: 'BRICK_NOT_RUNNING', + }); }); - }); }); diff --git a/packages/core/src/router/router.ts b/packages/core/src/router/router.ts index c6e7ff6..0df7aa2 100644 --- a/packages/core/src/router/router.ts +++ b/packages/core/src/router/router.ts @@ -7,8 +7,8 @@ import { type Router, RouterError } from '../types/router.ts'; import type { ToolDefinition, ToolResult } from '../types/tool.ts'; export interface McpRouterOptions { - readonly registry: Registry; - readonly bus: EventBus; + readonly registry: Registry; + readonly bus: EventBus; } /** @@ -20,33 +20,33 @@ export interface McpRouterOptions { * Convention event target : `:`. */ export class McpRouter implements Router { - readonly #registry: Registry; - readonly #bus: EventBus; - - constructor(options: McpRouterOptions) { - this.#registry = options.registry; - this.#bus = options.bus; - } - - listTools(): readonly ToolDefinition[] { - return this.#registry.getTools(); - } - - async callTool(name: string, args: unknown): Promise { - const brickName = this.#registry.getBrickForTool(name); - if (brickName === undefined) { - throw new RouterError(`Tool "${name}" not found`, 'TOOL_NOT_FOUND', { tool: name }); + readonly #registry: Registry; + readonly #bus: EventBus; + + constructor(options: McpRouterOptions) { + this.#registry = options.registry; + this.#bus = options.bus; } - if (this.#registry.getStatus(brickName) !== 'running') { - throw new RouterError( - `Brick "${brickName}" is not running (required for tool "${name}")`, - 'BRICK_NOT_RUNNING', - { tool: name, brick: brickName }, - ); + listTools(): readonly ToolDefinition[] { + return this.#registry.getTools(); } - const target = `${brickName}:${name}`; - return await this.#bus.request(target, args); - } + async callTool(name: string, args: unknown): Promise { + const brickName = this.#registry.getBrickForTool(name); + if (brickName === undefined) { + throw new RouterError(`Tool "${name}" not found`, 'TOOL_NOT_FOUND', { tool: name }); + } + + if (this.#registry.getStatus(brickName) !== 'running') { + throw new RouterError( + `Brick "${brickName}" is not running (required for tool "${name}")`, + 'BRICK_NOT_RUNNING', + { tool: name, brick: brickName }, + ); + } + + const target = `${brickName}:${name}`; + return await this.#bus.request(target, args); + } } diff --git a/packages/core/src/types/brick.ts b/packages/core/src/types/brick.ts index fbb1164..9ef0065 100644 --- a/packages/core/src/types/brick.ts +++ b/packages/core/src/types/brick.ts @@ -8,33 +8,33 @@ import type { BrickManifest } from './manifest.ts'; * Contrat qu'une brique doit implémenter pour être chargée par FocusMCP. */ export interface Brick { - readonly manifest: BrickManifest; + readonly manifest: BrickManifest; - /** - * Cycle de vie : démarrage de la brique. Reçoit l'EventBus filtré - * (n'expose que les permissions déclarées dans le manifeste). - */ - start(ctx: BrickContext): Promise | void; + /** + * Cycle de vie : démarrage de la brique. Reçoit l'EventBus filtré + * (n'expose que les permissions déclarées dans le manifeste). + */ + start(ctx: BrickContext): Promise | void; - /** Cycle de vie : arrêt propre de la brique. */ - stop(): Promise | void; + /** Cycle de vie : arrêt propre de la brique. */ + stop(): Promise | void; } export interface BrickContext { - /** EventBus filtré selon les permissions du manifeste. */ - readonly bus: EventBus; - /** Configuration résolue de la brique (depuis center.json). */ - readonly config: Readonly>; - /** Logger spécifique à la brique. */ - readonly logger: BrickLogger; + /** EventBus filtré selon les permissions du manifeste. */ + readonly bus: EventBus; + /** Configuration résolue de la brique (depuis center.json). */ + readonly config: Readonly>; + /** Logger spécifique à la brique. */ + readonly logger: BrickLogger; } export interface BrickLogger { - trace(msg: string, meta?: Record): void; - debug(msg: string, meta?: Record): void; - info(msg: string, meta?: Record): void; - warn(msg: string, meta?: Record): void; - error(msg: string, meta?: Record): void; + trace(msg: string, meta?: Record): void; + debug(msg: string, meta?: Record): void; + info(msg: string, meta?: Record): void; + warn(msg: string, meta?: Record): void; + error(msg: string, meta?: Record): void; } export type BrickStatus = 'stopped' | 'starting' | 'running' | 'error'; diff --git a/packages/core/src/types/event-bus.ts b/packages/core/src/types/event-bus.ts index b29fc0b..76e1b61 100644 --- a/packages/core/src/types/event-bus.ts +++ b/packages/core/src/types/event-bus.ts @@ -6,99 +6,99 @@ * passent par cet EventBus. Garde-fous appliqués automatiquement. */ export interface EventBus { - /** - * Pub/sub fire-and-forget. Notifie tous les handlers abonnés à `event`. - * Retourne quand tous les handlers ont été appelés (sans attendre leur résolution). - */ - emit(event: string, payload: T): void; + /** + * Pub/sub fire-and-forget. Notifie tous les handlers abonnés à `event`. + * Retourne quand tous les handlers ont été appelés (sans attendre leur résolution). + */ + emit(event: string, payload: T): void; - /** - * Abonne un handler à un événement. Retourne une fonction de désabonnement. - */ - on(event: string, handler: EventHandler): Unsubscribe; + /** + * Abonne un handler à un événement. Retourne une fonction de désabonnement. + */ + on(event: string, handler: EventHandler): Unsubscribe; - /** - * Request/response synchrone. La cible (`brick:action`) doit avoir - * enregistré un handler via `handle()`. Soumis aux garde-fous. - */ - request( - target: string, - payload: TRequest, - options?: RequestOptions, - ): Promise; + /** + * Request/response synchrone. La cible (`brick:action`) doit avoir + * enregistré un handler via `handle()`. Soumis aux garde-fous. + */ + request( + target: string, + payload: TRequest, + options?: RequestOptions, + ): Promise; - /** - * Enregistre un handler pour les requêtes ciblant `target`. - * Une seule brique peut enregistrer un target donné (rejet si déjà pris). - */ - handle( - target: string, - handler: RequestHandler, - ): Unsubscribe; + /** + * Enregistre un handler pour les requêtes ciblant `target`. + * Une seule brique peut enregistrer un target donné (rejet si déjà pris). + */ + handle( + target: string, + handler: RequestHandler, + ): Unsubscribe; } export type EventHandler = (payload: T, meta: EventMeta) => void | Promise; export type RequestHandler = ( - payload: TRequest, - meta: EventMeta, + payload: TRequest, + meta: EventMeta, ) => TResponse | Promise; export type Unsubscribe = () => void; export interface RequestOptions { - /** Timeout en ms (override de la valeur des garde-fous). */ - readonly timeoutMs?: number; - /** Trace ID pour suivre une chaîne de requêtes. */ - readonly traceId?: string; + /** Timeout en ms (override de la valeur des garde-fous). */ + readonly timeoutMs?: number; + /** Trace ID pour suivre une chaîne de requêtes. */ + readonly traceId?: string; } export interface EventMeta { - /** Brique qui a émis l'événement / la requête. Vide pour le Router. */ - readonly source: string; - /** Trace ID pour traçabilité distribuée. */ - readonly traceId: string; - /** Profondeur d'appel (pour max-depth guard). */ - readonly depth: number; - /** Timestamp d'émission (ms epoch). */ - readonly emittedAt: number; + /** Brique qui a émis l'événement / la requête. Vide pour le Router. */ + readonly source: string; + /** Trace ID pour traçabilité distribuée. */ + readonly traceId: string; + /** Profondeur d'appel (pour max-depth guard). */ + readonly depth: number; + /** Timestamp d'émission (ms epoch). */ + readonly emittedAt: number; } /** * Configuration des garde-fous appliqués par l'EventBus. */ export interface EventBusGuards { - readonly maxDepth: number; - readonly defaultTimeoutMs: number; - readonly maxPayloadBytes: number; - readonly rateLimit: { - readonly callsPerSecond: number; - readonly burstSize: number; - }; - readonly circuitBreaker: { - readonly failureThreshold: number; - readonly cooldownMs: number; - }; + readonly maxDepth: number; + readonly defaultTimeoutMs: number; + readonly maxPayloadBytes: number; + readonly rateLimit: { + readonly callsPerSecond: number; + readonly burstSize: number; + }; + readonly circuitBreaker: { + readonly failureThreshold: number; + readonly cooldownMs: number; + }; } export class EventBusError extends Error { - constructor( - message: string, - public readonly code: EventBusErrorCode, - public readonly meta?: Record, - ) { - super(message); - this.name = 'EventBusError'; - } + constructor( + message: string, + public readonly code: EventBusErrorCode, + public readonly meta?: Record, + ) { + super(message); + this.name = 'EventBusError'; + } } export type EventBusErrorCode = - | 'TIMEOUT' - | 'MAX_DEPTH_EXCEEDED' - | 'RATE_LIMIT_EXCEEDED' - | 'PERMISSION_DENIED' - | 'PAYLOAD_TOO_LARGE' - | 'CIRCUIT_OPEN' - | 'NO_HANDLER' - | 'HANDLER_ALREADY_REGISTERED' - | 'HANDLER_ERROR'; + | 'TIMEOUT' + | 'MAX_DEPTH_EXCEEDED' + | 'RATE_LIMIT_EXCEEDED' + | 'PERMISSION_DENIED' + | 'PAYLOAD_TOO_LARGE' + | 'CIRCUIT_OPEN' + | 'NO_HANDLER' + | 'HANDLER_ALREADY_REGISTERED' + | 'HANDLER_ERROR'; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 314e49c..7e46d03 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -3,14 +3,14 @@ export type { Brick, BrickContext, BrickLogger, BrickStatus } from './brick.ts'; export type { - EventBus, - EventBusErrorCode, - EventBusGuards, - EventHandler, - EventMeta, - RequestHandler, - RequestOptions, - Unsubscribe, + EventBus, + EventBusErrorCode, + EventBusGuards, + EventHandler, + EventMeta, + RequestHandler, + RequestOptions, + Unsubscribe, } from './event-bus.ts'; export { EventBusError } from './event-bus.ts'; export type { BrickManifest, ConfigField } from './manifest.ts'; @@ -19,9 +19,9 @@ export { RegistryError } from './registry.ts'; export type { Router, RouterErrorCode } from './router.ts'; export { RouterError } from './router.ts'; export type { - JsonSchema, - JsonSchemaProperty, - ToolContentItem, - ToolDefinition, - ToolResult, + JsonSchema, + JsonSchemaProperty, + ToolContentItem, + ToolDefinition, + ToolResult, } from './tool.ts'; diff --git a/packages/core/src/types/manifest.ts b/packages/core/src/types/manifest.ts index ca02884..2517da5 100644 --- a/packages/core/src/types/manifest.ts +++ b/packages/core/src/types/manifest.ts @@ -9,25 +9,25 @@ import type { ToolDefinition } from './tool.ts'; * conforme à cette interface. */ export interface BrickManifest { - /** Identifiant unique de la brique (kebab-case, ex: "indexer", "sf-router"). */ - readonly name: string; - /** Version SemVer de la brique. */ - readonly version: string; - /** Description courte (une ligne). */ - readonly description: string; - /** Liste des briques dont cette brique dépend (whitelist EventBus). */ - readonly dependencies: readonly string[]; - /** Tools exposés par cette brique au MCP Router. */ - readonly tools: readonly ToolDefinition[]; - /** Schéma de configuration (JSON Schema partiel, optionnel). */ - readonly config?: Readonly>; - /** Tags pour la recherche/découverte dans le marketplace. */ - readonly tags?: readonly string[]; + /** Identifiant unique de la brique (kebab-case, ex: "indexer", "sf-router"). */ + readonly name: string; + /** Version SemVer de la brique. */ + readonly version: string; + /** Description courte (une ligne). */ + readonly description: string; + /** Liste des briques dont cette brique dépend (whitelist EventBus). */ + readonly dependencies: readonly string[]; + /** Tools exposés par cette brique au MCP Router. */ + readonly tools: readonly ToolDefinition[]; + /** Schéma de configuration (JSON Schema partiel, optionnel). */ + readonly config?: Readonly>; + /** Tags pour la recherche/découverte dans le marketplace. */ + readonly tags?: readonly string[]; } export interface ConfigField { - readonly type: 'string' | 'number' | 'boolean' | 'array' | 'object'; - readonly description: string; - readonly default?: unknown; - readonly required?: boolean; + readonly type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + readonly description: string; + readonly default?: unknown; + readonly required?: boolean; } diff --git a/packages/core/src/types/registry.ts b/packages/core/src/types/registry.ts index 80c6d20..8bb8181 100644 --- a/packages/core/src/types/registry.ts +++ b/packages/core/src/types/registry.ts @@ -9,51 +9,51 @@ import type { ToolDefinition } from './tool.ts'; * leurs dépendances et leur état. */ export interface Registry { - /** Enregistre une brique. Erreur si le nom est déjà pris. */ - register(brick: Brick): void; + /** Enregistre une brique. Erreur si le nom est déjà pris. */ + register(brick: Brick): void; - /** Désenregistre une brique. Erreur si d'autres briques en dépendent. */ - unregister(name: string): void; + /** Désenregistre une brique. Erreur si d'autres briques en dépendent. */ + unregister(name: string): void; - /** - * Résout l'arbre de dépendances dans l'ordre de démarrage. - * Détecte les cycles (lance RegistryError CYCLE_DETECTED). - */ - resolve(name: string): readonly Brick[]; + /** + * Résout l'arbre de dépendances dans l'ordre de démarrage. + * Détecte les cycles (lance RegistryError CYCLE_DETECTED). + */ + resolve(name: string): readonly Brick[]; - /** État d'une brique enregistrée. */ - getStatus(name: string): BrickStatus; + /** État d'une brique enregistrée. */ + getStatus(name: string): BrickStatus; - /** Met à jour l'état d'une brique. */ - setStatus(name: string, status: BrickStatus): void; + /** Met à jour l'état d'une brique. */ + setStatus(name: string, status: BrickStatus): void; - /** Liste toutes les briques enregistrées. */ - getBricks(): readonly Brick[]; + /** Liste toutes les briques enregistrées. */ + getBricks(): readonly Brick[]; - /** Brique par son nom (undefined si non enregistrée). */ - getBrick(name: string): Brick | undefined; + /** Brique par son nom (undefined si non enregistrée). */ + getBrick(name: string): Brick | undefined; - /** Tools agrégés de toutes les briques running. */ - getTools(): readonly ToolDefinition[]; + /** Tools agrégés de toutes les briques running. */ + getTools(): readonly ToolDefinition[]; - /** Retourne le nom de la brique qui expose le tool (running ou non). */ - getBrickForTool(toolName: string): string | undefined; + /** Retourne le nom de la brique qui expose le tool (running ou non). */ + getBrickForTool(toolName: string): string | undefined; } export class RegistryError extends Error { - constructor( - message: string, - public readonly code: RegistryErrorCode, - public readonly meta?: Record, - ) { - super(message); - this.name = 'RegistryError'; - } + constructor( + message: string, + public readonly code: RegistryErrorCode, + public readonly meta?: Record, + ) { + super(message); + this.name = 'RegistryError'; + } } export type RegistryErrorCode = - | 'BRICK_NOT_FOUND' - | 'BRICK_ALREADY_REGISTERED' - | 'CYCLE_DETECTED' - | 'MISSING_DEPENDENCY' - | 'DEPENDENT_BRICKS_RUNNING'; + | 'BRICK_NOT_FOUND' + | 'BRICK_ALREADY_REGISTERED' + | 'CYCLE_DETECTED' + | 'MISSING_DEPENDENCY' + | 'DEPENDENT_BRICKS_RUNNING'; diff --git a/packages/core/src/types/router.ts b/packages/core/src/types/router.ts index 6658670..3d65233 100644 --- a/packages/core/src/types/router.ts +++ b/packages/core/src/types/router.ts @@ -8,22 +8,22 @@ import type { ToolDefinition, ToolResult } from './tool.ts'; * Reçoit tools/list et tools/call et dispatch vers les briques via l'EventBus. */ export interface Router { - /** Liste des tools agrégés (tools/list). */ - listTools(): readonly ToolDefinition[]; + /** Liste des tools agrégés (tools/list). */ + listTools(): readonly ToolDefinition[]; - /** Appel d'un tool (tools/call). Dispatch vers la brique propriétaire. */ - callTool(name: string, args: unknown): Promise; + /** Appel d'un tool (tools/call). Dispatch vers la brique propriétaire. */ + callTool(name: string, args: unknown): Promise; } export class RouterError extends Error { - constructor( - message: string, - public readonly code: RouterErrorCode, - public readonly meta?: Record, - ) { - super(message); - this.name = 'RouterError'; - } + constructor( + message: string, + public readonly code: RouterErrorCode, + public readonly meta?: Record, + ) { + super(message); + this.name = 'RouterError'; + } } export type RouterErrorCode = 'TOOL_NOT_FOUND' | 'INVALID_ARGS' | 'BRICK_NOT_RUNNING'; diff --git a/packages/core/src/types/tool.ts b/packages/core/src/types/tool.ts index 76865bd..615f13b 100644 --- a/packages/core/src/types/tool.ts +++ b/packages/core/src/types/tool.ts @@ -6,35 +6,35 @@ * Conforme au format MCP officiel (tools/list, tools/call). */ export interface ToolDefinition { - /** Nom du tool (préfixé par la brique au runtime, ex: "indexer_search"). */ - readonly name: string; - /** Description lisible par l'AI. */ - readonly description: string; - /** JSON Schema des arguments d'entrée. */ - readonly inputSchema: JsonSchema; + /** Nom du tool (préfixé par la brique au runtime, ex: "indexer_search"). */ + readonly name: string; + /** Description lisible par l'AI. */ + readonly description: string; + /** JSON Schema des arguments d'entrée. */ + readonly inputSchema: JsonSchema; } export interface JsonSchema { - readonly type: 'object'; - readonly properties?: Readonly>; - readonly required?: readonly string[]; - readonly additionalProperties?: boolean; + readonly type: 'object'; + readonly properties?: Readonly>; + readonly required?: readonly string[]; + readonly additionalProperties?: boolean; } export interface JsonSchemaProperty { - readonly type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object'; - readonly description?: string; - readonly enum?: readonly (string | number)[]; - readonly default?: unknown; - readonly items?: JsonSchemaProperty; - readonly properties?: Readonly>; + readonly type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object'; + readonly description?: string; + readonly enum?: readonly (string | number)[]; + readonly default?: unknown; + readonly items?: JsonSchemaProperty; + readonly properties?: Readonly>; } export interface ToolResult { - readonly content: readonly ToolContentItem[]; - readonly isError?: boolean; + readonly content: readonly ToolContentItem[]; + readonly isError?: boolean; } export type ToolContentItem = - | { readonly type: 'text'; readonly text: string } - | { readonly type: 'json'; readonly data: unknown }; + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'json'; readonly data: unknown }; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 021387c..9c9369a 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../config/tsconfig.base.json", - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] + "extends": "../../config/tsconfig.base.json", + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 0c71432..80fd4f3 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,37 +1,37 @@ { - "name": "@focusmcp/sdk", - "version": "0.0.0", - "description": "FocusMCP SDK — outils et types pour développer une brique", - "license": "MIT", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "name": "@focusmcp/sdk", + "version": "0.0.0", + "description": "FocusMCP SDK — outils et types pour développer une brique", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@focusmcp/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + "vitest": "^3.2.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@focusmcp/core": "workspace:*" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "typescript": "^5.7.0", - "vitest": "^3.2.0" - }, - "publishConfig": { - "access": "public", - "provenance": true - } } diff --git a/packages/sdk/src/define-brick.test.ts b/packages/sdk/src/define-brick.test.ts index bd7e296..8326dcb 100644 --- a/packages/sdk/src/define-brick.test.ts +++ b/packages/sdk/src/define-brick.test.ts @@ -7,174 +7,174 @@ import { describe, expect, it, vi } from 'vitest'; import { BrickDefinitionError, defineBrick } from './define-brick.ts'; const noopLogger: BrickLogger = { - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, }; const validManifest = { - name: 'indexer', - version: '1.0.0', - description: 'Indexation filesystem', - dependencies: [], - tools: [ - { - name: 'indexer_search', - description: 'Search files', - inputSchema: { type: 'object' as const }, - }, - ], + name: 'indexer', + version: '1.0.0', + description: 'Indexation filesystem', + dependencies: [], + tools: [ + { + name: 'indexer_search', + description: 'Search files', + inputSchema: { type: 'object' as const }, + }, + ], }; function makeCtx(overrides: Partial = {}): BrickContext { - return { - bus: overrides.bus ?? new InProcessEventBus(), - config: overrides.config ?? {}, - logger: overrides.logger ?? noopLogger, - }; + return { + bus: overrides.bus ?? new InProcessEventBus(), + config: overrides.config ?? {}, + logger: overrides.logger ?? noopLogger, + }; } describe('defineBrick — shape', () => { - it('retourne un Brick conforme avec manifest, start, stop', () => { - const brick = defineBrick({ - manifest: validManifest, - handlers: { indexer_search: () => ({ files: [] }) }, + it('retourne un Brick conforme avec manifest, start, stop', () => { + const brick = defineBrick({ + manifest: validManifest, + handlers: { indexer_search: () => ({ files: [] }) }, + }); + + expect(brick.manifest.name).toBe('indexer'); + expect(typeof brick.start).toBe('function'); + expect(typeof brick.stop).toBe('function'); }); - expect(brick.manifest.name).toBe('indexer'); - expect(typeof brick.start).toBe('function'); - expect(typeof brick.stop).toBe('function'); - }); - - it('valide le manifeste via parseManifest (propage les erreurs)', () => { - expect(() => - defineBrick({ - manifest: { ...validManifest, name: 'BadName' }, - handlers: { indexer_search: () => 'ok' }, - }), - ).toThrow(expect.objectContaining({ name: 'ManifestError', code: 'INVALID_NAME' })); - }); - - it('MISSING_HANDLER : un tool déclaré sans handler', () => { - expect(() => - defineBrick({ - manifest: validManifest, - handlers: {}, - }), - ).toThrow( - expect.objectContaining({ - name: 'BrickDefinitionError', - code: 'MISSING_HANDLER', - }), - ); - }); - - it('UNKNOWN_HANDLER : un handler sans tool correspondant dans le manifeste', () => { - expect(() => - defineBrick({ - manifest: validManifest, - handlers: { - indexer_search: () => 'ok', - orphan_tool: () => 'ko', - }, - }), - ).toThrow( - expect.objectContaining({ - name: 'BrickDefinitionError', - code: 'UNKNOWN_HANDLER', - }), - ); - }); -}); + it('valide le manifeste via parseManifest (propage les erreurs)', () => { + expect(() => + defineBrick({ + manifest: { ...validManifest, name: 'BadName' }, + handlers: { indexer_search: () => 'ok' }, + }), + ).toThrow(expect.objectContaining({ name: 'ManifestError', code: 'INVALID_NAME' })); + }); -describe('defineBrick — lifecycle', () => { - it('start enregistre chaque handler au format : sur le bus', async () => { - const bus = new InProcessEventBus(); - const brick = defineBrick({ - manifest: validManifest, - handlers: { - indexer_search: (payload) => { - const typed = payload as { q: string }; - return { found: typed.q }; - }, - }, + it('MISSING_HANDLER : un tool déclaré sans handler', () => { + expect(() => + defineBrick({ + manifest: validManifest, + handlers: {}, + }), + ).toThrow( + expect.objectContaining({ + name: 'BrickDefinitionError', + code: 'MISSING_HANDLER', + }), + ); }); - await brick.start(makeCtx({ bus })); + it('UNKNOWN_HANDLER : un handler sans tool correspondant dans le manifeste', () => { + expect(() => + defineBrick({ + manifest: validManifest, + handlers: { + indexer_search: () => 'ok', + orphan_tool: () => 'ko', + }, + }), + ).toThrow( + expect.objectContaining({ + name: 'BrickDefinitionError', + code: 'UNKNOWN_HANDLER', + }), + ); + }); +}); - await expect(bus.request('indexer:indexer_search', { q: 'foo' })).resolves.toEqual({ - found: 'foo', +describe('defineBrick — lifecycle', () => { + it('start enregistre chaque handler au format : sur le bus', async () => { + const bus = new InProcessEventBus(); + const brick = defineBrick({ + manifest: validManifest, + handlers: { + indexer_search: (payload) => { + const typed = payload as { q: string }; + return { found: typed.q }; + }, + }, + }); + + await brick.start(makeCtx({ bus })); + + await expect(bus.request('indexer:indexer_search', { q: 'foo' })).resolves.toEqual({ + found: 'foo', + }); }); - }); - - it('injecte le BrickContext (bus/config/logger) dans chaque handler', async () => { - const bus = new InProcessEventBus(); - const config = { phpVersion: '8.3' } as const; - const logger = { ...noopLogger, info: vi.fn() }; - - const brick = defineBrick({ - manifest: validManifest, - handlers: { - indexer_search: (_payload, ctx) => { - ctx.logger.info('called'); - return { version: ctx.config['phpVersion'] }; - }, - }, + + it('injecte le BrickContext (bus/config/logger) dans chaque handler', async () => { + const bus = new InProcessEventBus(); + const config = { phpVersion: '8.3' } as const; + const logger = { ...noopLogger, info: vi.fn() }; + + const brick = defineBrick({ + manifest: validManifest, + handlers: { + indexer_search: (_payload, ctx) => { + ctx.logger.info('called'); + return { version: ctx.config['phpVersion'] }; + }, + }, + }); + + await brick.start(makeCtx({ bus, config, logger })); + const result = await bus.request('indexer:indexer_search', null); + + expect(result).toEqual({ version: '8.3' }); + expect(logger.info).toHaveBeenCalledWith('called'); }); - await brick.start(makeCtx({ bus, config, logger })); - const result = await bus.request('indexer:indexer_search', null); + it('stop désenregistre tous les handlers', async () => { + const bus = new InProcessEventBus(); + const brick = defineBrick({ + manifest: validManifest, + handlers: { indexer_search: () => 'ok' }, + }); - expect(result).toEqual({ version: '8.3' }); - expect(logger.info).toHaveBeenCalledWith('called'); - }); + await brick.start(makeCtx({ bus })); + await brick.stop(); - it('stop désenregistre tous les handlers', async () => { - const bus = new InProcessEventBus(); - const brick = defineBrick({ - manifest: validManifest, - handlers: { indexer_search: () => 'ok' }, + await expect(bus.request('indexer:indexer_search', null)).rejects.toMatchObject({ + code: 'NO_HANDLER', + }); }); - await brick.start(makeCtx({ bus })); - await brick.stop(); + it('stop avant start ne throw pas', () => { + const brick = defineBrick({ + manifest: validManifest, + handlers: { indexer_search: () => 'ok' }, + }); - await expect(bus.request('indexer:indexer_search', null)).rejects.toMatchObject({ - code: 'NO_HANDLER', + expect(() => brick.stop()).not.toThrow(); }); - }); - it('stop avant start ne throw pas', () => { - const brick = defineBrick({ - manifest: validManifest, - handlers: { indexer_search: () => 'ok' }, + it('double start refuse (ALREADY_STARTED)', async () => { + const bus = new InProcessEventBus(); + const brick = defineBrick({ + manifest: validManifest, + handlers: { indexer_search: () => 'ok' }, + }); + + await brick.start(makeCtx({ bus })); + expect(() => brick.start(makeCtx({ bus }))).toThrow( + expect.objectContaining({ + name: 'BrickDefinitionError', + code: 'ALREADY_STARTED', + }), + ); }); - expect(() => brick.stop()).not.toThrow(); - }); - - it('double start refuse (ALREADY_STARTED)', async () => { - const bus = new InProcessEventBus(); - const brick = defineBrick({ - manifest: validManifest, - handlers: { indexer_search: () => 'ok' }, + it('BrickDefinitionError est une sous-classe d’Error exportée', () => { + const err = new BrickDefinitionError('x', 'MISSING_HANDLER'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('BrickDefinitionError'); + expect(err.code).toBe('MISSING_HANDLER'); }); - - await brick.start(makeCtx({ bus })); - expect(() => brick.start(makeCtx({ bus }))).toThrow( - expect.objectContaining({ - name: 'BrickDefinitionError', - code: 'ALREADY_STARTED', - }), - ); - }); - - it('BrickDefinitionError est une sous-classe d’Error exportée', () => { - const err = new BrickDefinitionError('x', 'MISSING_HANDLER'); - expect(err).toBeInstanceOf(Error); - expect(err.name).toBe('BrickDefinitionError'); - expect(err.code).toBe('MISSING_HANDLER'); - }); }); diff --git a/packages/sdk/src/define-brick.ts b/packages/sdk/src/define-brick.ts index 90b0bbb..2888245 100644 --- a/packages/sdk/src/define-brick.ts +++ b/packages/sdk/src/define-brick.ts @@ -2,36 +2,36 @@ // SPDX-License-Identifier: MIT import { - type Brick, - type BrickContext, - type BrickManifest, - parseManifest, - type Unsubscribe, + type Brick, + type BrickContext, + type BrickManifest, + parseManifest, + type Unsubscribe, } from '@focusmcp/core'; export type BrickDefinitionErrorCode = 'MISSING_HANDLER' | 'UNKNOWN_HANDLER' | 'ALREADY_STARTED'; export class BrickDefinitionError extends Error { - constructor( - message: string, - public readonly code: BrickDefinitionErrorCode, - public readonly meta?: Record, - ) { - super(message); - this.name = 'BrickDefinitionError'; - } + constructor( + message: string, + public readonly code: BrickDefinitionErrorCode, + public readonly meta?: Record, + ) { + super(message); + this.name = 'BrickDefinitionError'; + } } export type BrickToolHandler = ( - payload: TPayload, - ctx: BrickContext, + payload: TPayload, + ctx: BrickContext, ) => TResult | Promise; export interface DefineBrickOptions { - /** Manifeste déclaratif de la brique (validé via parseManifest). */ - readonly manifest: unknown; - /** Map tool → handler. La clé doit correspondre à un tool.name du manifeste. */ - readonly handlers: Readonly>; + /** Manifeste déclaratif de la brique (validé via parseManifest). */ + readonly manifest: unknown; + /** Map tool → handler. La clé doit correspondre à un tool.name du manifeste. */ + readonly handlers: Readonly>; } /** @@ -43,65 +43,65 @@ export interface DefineBrickOptions { * - À `stop()`, désenregistre tous les handlers */ export function defineBrick(options: DefineBrickOptions): Brick { - const manifest = parseManifest(options.manifest); - assertHandlersMatchTools(manifest, options.handlers); + const manifest = parseManifest(options.manifest); + assertHandlersMatchTools(manifest, options.handlers); - let currentCtx: BrickContext | undefined; - let unsubscribes: Unsubscribe[] = []; + let currentCtx: BrickContext | undefined; + let unsubscribes: Unsubscribe[] = []; - return { - manifest, + return { + manifest, - start(ctx: BrickContext): void { - if (currentCtx !== undefined) { - throw new BrickDefinitionError( - `Brick "${manifest.name}" is already started`, - 'ALREADY_STARTED', - { brick: manifest.name }, - ); - } - currentCtx = ctx; - unsubscribes = []; - for (const tool of manifest.tools) { - const handler = options.handlers[tool.name]; - // Déjà vérifié par assertHandlersMatchTools — cast sûr. - const bound = handler as BrickToolHandler; - const unsub = ctx.bus.handle(`${manifest.name}:${tool.name}`, (payload) => - bound(payload, ctx), - ); - unsubscribes.push(unsub); - } - }, + start(ctx: BrickContext): void { + if (currentCtx !== undefined) { + throw new BrickDefinitionError( + `Brick "${manifest.name}" is already started`, + 'ALREADY_STARTED', + { brick: manifest.name }, + ); + } + currentCtx = ctx; + unsubscribes = []; + for (const tool of manifest.tools) { + const handler = options.handlers[tool.name]; + // Déjà vérifié par assertHandlersMatchTools — cast sûr. + const bound = handler as BrickToolHandler; + const unsub = ctx.bus.handle(`${manifest.name}:${tool.name}`, (payload) => + bound(payload, ctx), + ); + unsubscribes.push(unsub); + } + }, - stop(): void { - for (const unsub of unsubscribes) unsub(); - unsubscribes = []; - currentCtx = undefined; - }, - }; + stop(): void { + for (const unsub of unsubscribes) unsub(); + unsubscribes = []; + currentCtx = undefined; + }, + }; } function assertHandlersMatchTools( - manifest: BrickManifest, - handlers: Readonly>, + manifest: BrickManifest, + handlers: Readonly>, ): void { - const toolNames = new Set(manifest.tools.map((t) => t.name)); - for (const tool of manifest.tools) { - if (!(tool.name in handlers)) { - throw new BrickDefinitionError( - `Brick "${manifest.name}" declares tool "${tool.name}" but no handler is provided`, - 'MISSING_HANDLER', - { brick: manifest.name, tool: tool.name }, - ); + const toolNames = new Set(manifest.tools.map((t) => t.name)); + for (const tool of manifest.tools) { + if (!(tool.name in handlers)) { + throw new BrickDefinitionError( + `Brick "${manifest.name}" declares tool "${tool.name}" but no handler is provided`, + 'MISSING_HANDLER', + { brick: manifest.name, tool: tool.name }, + ); + } } - } - for (const handlerName of Object.keys(handlers)) { - if (!toolNames.has(handlerName)) { - throw new BrickDefinitionError( - `Brick "${manifest.name}" provides handler "${handlerName}" but no such tool is declared in the manifest`, - 'UNKNOWN_HANDLER', - { brick: manifest.name, handler: handlerName }, - ); + for (const handlerName of Object.keys(handlers)) { + if (!toolNames.has(handlerName)) { + throw new BrickDefinitionError( + `Brick "${manifest.name}" provides handler "${handlerName}" but no such tool is declared in the manifest`, + 'UNKNOWN_HANDLER', + { brick: manifest.name, handler: handlerName }, + ); + } } - } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 776a21d..df60d1f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: MIT export { - BrickDefinitionError, - type BrickDefinitionErrorCode, - type BrickToolHandler, - type DefineBrickOptions, - defineBrick, + BrickDefinitionError, + type BrickDefinitionErrorCode, + type BrickToolHandler, + type DefineBrickOptions, + defineBrick, } from './define-brick.ts'; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 5077e27..9c1accc 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -1,11 +1,11 @@ { - "extends": "../../config/tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@focusmcp/core": ["../core/src/index.ts"] - } - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@focusmcp/core": ["../core/src/index.ts"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] } diff --git a/packages/validator/package.json b/packages/validator/package.json index 83bf654..df51120 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,37 +1,37 @@ { - "name": "@focusmcp/validator", - "version": "0.0.0", - "description": "FocusMCP validator — vérifie qu'une brique respecte le contrat FocusMCP", - "license": "MIT", - "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "name": "@focusmcp/validator", + "version": "0.0.0", + "description": "FocusMCP validator — vérifie qu'une brique respecte le contrat FocusMCP", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@focusmcp/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "typescript": "^5.7.0", + "vitest": "^3.2.0" + }, + "publishConfig": { + "access": "public", + "provenance": true } - }, - "files": [ - "dist", - "README.md", - "LICENSE" - ], - "scripts": { - "build": "tsup", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@focusmcp/core": "workspace:*" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "typescript": "^5.7.0", - "vitest": "^3.2.0" - }, - "publishConfig": { - "access": "public", - "provenance": true - } } diff --git a/packages/validator/src/index.ts b/packages/validator/src/index.ts index 9cf1b75..b6c9437 100644 --- a/packages/validator/src/index.ts +++ b/packages/validator/src/index.ts @@ -2,9 +2,9 @@ // SPDX-License-Identifier: MIT export { - type ValidateBrickOptions, - type ValidationIssue, - type ValidationIssueCode, - type ValidationReport, - validateBrick, + type ValidateBrickOptions, + type ValidationIssue, + type ValidationIssueCode, + type ValidationReport, + validateBrick, } from './validate-brick.ts'; diff --git a/packages/validator/src/validate-brick.test.ts b/packages/validator/src/validate-brick.test.ts index 255b4cf..acfc6ec 100644 --- a/packages/validator/src/validate-brick.test.ts +++ b/packages/validator/src/validate-brick.test.ts @@ -7,129 +7,131 @@ import { describe, expect, it } from 'vitest'; import { validateBrick } from './validate-brick.ts'; const noopLogger: BrickLogger = { - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, }; function makeCtx(bus: EventBus): BrickContext { - return { bus, config: {}, logger: noopLogger }; + return { bus, config: {}, logger: noopLogger }; } function conformingBrick(): Brick { - let unsubs: Unsubscribe[] = []; - return { - manifest: { - name: 'indexer', - version: '1.0.0', - description: 'indexation', - dependencies: [], - tools: [{ name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }], - }, - start(ctx): void { - unsubs.push( - ctx.bus.handle('indexer:indexer_search', () => ({ - content: [{ type: 'text', text: 'ok' }], - })), - ); - }, - stop(): void { - for (const u of unsubs) u(); - unsubs = []; - }, - }; + let unsubs: Unsubscribe[] = []; + return { + manifest: { + name: 'indexer', + version: '1.0.0', + description: 'indexation', + dependencies: [], + tools: [ + { name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }, + ], + }, + start(ctx): void { + unsubs.push( + ctx.bus.handle('indexer:indexer_search', () => ({ + content: [{ type: 'text', text: 'ok' }], + })), + ); + }, + stop(): void { + for (const u of unsubs) u(); + unsubs = []; + }, + }; } describe('validateBrick — manifeste', () => { - it('ok=true quand la brique est conforme', async () => { - const report = await validateBrick(conformingBrick(), { - ctx: makeCtx(new InProcessEventBus()), + it('ok=true quand la brique est conforme', async () => { + const report = await validateBrick(conformingBrick(), { + ctx: makeCtx(new InProcessEventBus()), + }); + expect(report.ok).toBe(true); + expect(report.issues).toEqual([]); }); - expect(report.ok).toBe(true); - expect(report.issues).toEqual([]); - }); - it('INVALID_MANIFEST : manifeste invalide (nom non kebab-case)', async () => { - const brick: Brick = { - ...conformingBrick(), - manifest: { ...conformingBrick().manifest, name: 'BadName' }, - }; - const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); - expect(report.ok).toBe(false); - expect(report.issues.map((i) => i.code)).toContain('INVALID_MANIFEST'); - }); + it('INVALID_MANIFEST : manifeste invalide (nom non kebab-case)', async () => { + const brick: Brick = { + ...conformingBrick(), + manifest: { ...conformingBrick().manifest, name: 'BadName' }, + }; + const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); + expect(report.ok).toBe(false); + expect(report.issues.map((i) => i.code)).toContain('INVALID_MANIFEST'); + }); }); describe('validateBrick — lifecycle', () => { - it('START_FAILED : start() lève une exception', async () => { - const brick: Brick = { - ...conformingBrick(), - start(): void { - throw new Error('boom'); - }, - }; - const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); - expect(report.ok).toBe(false); - expect(report.issues.map((i) => i.code)).toContain('START_FAILED'); - }); + it('START_FAILED : start() lève une exception', async () => { + const brick: Brick = { + ...conformingBrick(), + start(): void { + throw new Error('boom'); + }, + }; + const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); + expect(report.ok).toBe(false); + expect(report.issues.map((i) => i.code)).toContain('START_FAILED'); + }); - it('MISSING_HANDLER : un tool déclaré n’est pas enregistré après start()', async () => { - const brick: Brick = { - ...conformingBrick(), - start(): void { - /* oublie d'enregistrer */ - }, - }; - const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); - expect(report.ok).toBe(false); - const codes = report.issues.map((i) => i.code); - expect(codes).toContain('MISSING_HANDLER'); - }); + it('MISSING_HANDLER : un tool déclaré n’est pas enregistré après start()', async () => { + const brick: Brick = { + ...conformingBrick(), + start(): void { + /* oublie d'enregistrer */ + }, + }; + const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); + expect(report.ok).toBe(false); + const codes = report.issues.map((i) => i.code); + expect(codes).toContain('MISSING_HANDLER'); + }); - it('HANDLER_LEAK : un handler reste enregistré après stop()', async () => { - const brick: Brick = { - ...conformingBrick(), - stop: () => { - /* ne désenregistre rien */ - }, - }; - const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); - expect(report.ok).toBe(false); - expect(report.issues.map((i) => i.code)).toContain('HANDLER_LEAK'); - }); + it('HANDLER_LEAK : un handler reste enregistré après stop()', async () => { + const brick: Brick = { + ...conformingBrick(), + stop: () => { + /* ne désenregistre rien */ + }, + }; + const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); + expect(report.ok).toBe(false); + expect(report.issues.map((i) => i.code)).toContain('HANDLER_LEAK'); + }); - it('STOP_FAILED : stop() lève une exception', async () => { - const brick: Brick = { - ...conformingBrick(), - stop(): void { - throw new Error('boom'); - }, - }; - const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); - expect(report.ok).toBe(false); - expect(report.issues.map((i) => i.code)).toContain('STOP_FAILED'); - }); + it('STOP_FAILED : stop() lève une exception', async () => { + const brick: Brick = { + ...conformingBrick(), + stop(): void { + throw new Error('boom'); + }, + }; + const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); + expect(report.ok).toBe(false); + expect(report.issues.map((i) => i.code)).toContain('STOP_FAILED'); + }); }); describe('validateBrick — contrat tool/bus', () => { - it('TOOL_CALL_FAILED : un tool déclaré throw à l’appel', async () => { - const brick: Brick = { - ...conformingBrick(), - start(ctx): void { - ctx.bus.handle('indexer:indexer_search', () => { - throw new Error('nope'); - }); - }, - }; - const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); - expect(report.ok).toBe(false); - expect(report.issues.map((i) => i.code)).toContain('TOOL_CALL_FAILED'); - }); + it('TOOL_CALL_FAILED : un tool déclaré throw à l’appel', async () => { + const brick: Brick = { + ...conformingBrick(), + start(ctx): void { + ctx.bus.handle('indexer:indexer_search', () => { + throw new Error('nope'); + }); + }, + }; + const report = await validateBrick(brick, { ctx: makeCtx(new InProcessEventBus()) }); + expect(report.ok).toBe(false); + expect(report.issues.map((i) => i.code)).toContain('TOOL_CALL_FAILED'); + }); - it('fourni un ctx par défaut si aucun n’est passé', async () => { - const report = await validateBrick(conformingBrick()); - expect(report.ok).toBe(true); - }); + it('fourni un ctx par défaut si aucun n’est passé', async () => { + const report = await validateBrick(conformingBrick()); + expect(report.ok).toBe(true); + }); }); diff --git a/packages/validator/src/validate-brick.ts b/packages/validator/src/validate-brick.ts index 91e7578..83a103d 100644 --- a/packages/validator/src/validate-brick.ts +++ b/packages/validator/src/validate-brick.ts @@ -5,39 +5,39 @@ import type { Brick, BrickContext, BrickLogger, EventBus } from '@focusmcp/core' import { InProcessEventBus, ManifestError, parseManifest } from '@focusmcp/core'; export type ValidationIssueCode = - | 'INVALID_MANIFEST' - | 'START_FAILED' - | 'MISSING_HANDLER' - | 'TOOL_CALL_FAILED' - | 'HANDLER_LEAK' - | 'STOP_FAILED'; + | 'INVALID_MANIFEST' + | 'START_FAILED' + | 'MISSING_HANDLER' + | 'TOOL_CALL_FAILED' + | 'HANDLER_LEAK' + | 'STOP_FAILED'; export interface ValidationIssue { - readonly code: ValidationIssueCode; - readonly severity: 'error' | 'warning'; - readonly message: string; - readonly meta?: Record; + readonly code: ValidationIssueCode; + readonly severity: 'error' | 'warning'; + readonly message: string; + readonly meta?: Record; } export interface ValidationReport { - readonly ok: boolean; - readonly issues: readonly ValidationIssue[]; + readonly ok: boolean; + readonly issues: readonly ValidationIssue[]; } export interface ValidateBrickOptions { - /** - * Contexte fourni à `brick.start(ctx)`. Si omis, un contexte par défaut - * est instancié (EventBus neuf, config vide, logger silencieux). - */ - readonly ctx?: BrickContext; + /** + * Contexte fourni à `brick.start(ctx)`. Si omis, un contexte par défaut + * est instancié (EventBus neuf, config vide, logger silencieux). + */ + readonly ctx?: BrickContext; } const silentLogger: BrickLogger = { - trace: () => {}, - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, }; /** @@ -48,138 +48,138 @@ const silentLogger: BrickLogger = { * - `stop()` désenregistre tous les handlers */ export async function validateBrick( - brick: Brick, - options: ValidateBrickOptions = {}, + brick: Brick, + options: ValidateBrickOptions = {}, ): Promise { - const issues: ValidationIssue[] = []; + const issues: ValidationIssue[] = []; - const manifestIssue = validateManifest(brick); - if (manifestIssue) { - issues.push(manifestIssue); - return { ok: false, issues }; - } + const manifestIssue = validateManifest(brick); + if (manifestIssue) { + issues.push(manifestIssue); + return { ok: false, issues }; + } - const bus = options.ctx?.bus ?? new InProcessEventBus(); - const ctx: BrickContext = options.ctx ?? { bus, config: {}, logger: silentLogger }; + const bus = options.ctx?.bus ?? new InProcessEventBus(); + const ctx: BrickContext = options.ctx ?? { bus, config: {}, logger: silentLogger }; - if (!(await runStart(brick, ctx, issues))) { - return { ok: false, issues }; - } + if (!(await runStart(brick, ctx, issues))) { + return { ok: false, issues }; + } - await assertToolsCallable(brick, bus, issues); + await assertToolsCallable(brick, bus, issues); - await runStop(brick, bus, issues); + await runStop(brick, bus, issues); - return { ok: issues.length === 0, issues }; + return { ok: issues.length === 0, issues }; } function validateManifest(brick: Brick): ValidationIssue | undefined { - try { - parseManifest(brick.manifest); - return undefined; - } catch (err) { - const msg = err instanceof ManifestError ? err.message : String(err); - const issue: Mutable = { - code: 'INVALID_MANIFEST', - severity: 'error', - message: `Invalid manifest: ${msg}`, - }; - if (err instanceof ManifestError && err.meta) issue.meta = err.meta; - return issue as ValidationIssue; - } + try { + parseManifest(brick.manifest); + return undefined; + } catch (err) { + const msg = err instanceof ManifestError ? err.message : String(err); + const issue: Mutable = { + code: 'INVALID_MANIFEST', + severity: 'error', + message: `Invalid manifest: ${msg}`, + }; + if (err instanceof ManifestError && err.meta) issue.meta = err.meta; + return issue as ValidationIssue; + } } type Mutable = { -readonly [K in keyof T]: T[K] }; async function runStart( - brick: Brick, - ctx: BrickContext, - issues: ValidationIssue[], + brick: Brick, + ctx: BrickContext, + issues: ValidationIssue[], ): Promise { - try { - await brick.start(ctx); - return true; - } catch (err) { - issues.push({ - code: 'START_FAILED', - severity: 'error', - message: `start() threw: ${err instanceof Error ? err.message : String(err)}`, - }); - return false; - } -} - -async function assertToolsCallable( - brick: Brick, - bus: EventBus, - issues: ValidationIssue[], -): Promise { - for (const tool of brick.manifest.tools) { - const target = `${brick.manifest.name}:${tool.name}`; try { - await bus.request(target, {}); + await brick.start(ctx); + return true; } catch (err) { - const code = extractEventBusCode(err); - if (code === 'NO_HANDLER') { issues.push({ - code: 'MISSING_HANDLER', - severity: 'error', - message: `Tool "${target}" declared but no handler registered after start()`, - meta: { target }, + code: 'START_FAILED', + severity: 'error', + message: `start() threw: ${err instanceof Error ? err.message : String(err)}`, }); - } else { - issues.push({ - code: 'TOOL_CALL_FAILED', - severity: 'error', - message: `Tool "${target}" threw: ${err instanceof Error ? err.message : String(err)}`, - meta: { target }, - }); - } + return false; + } +} + +async function assertToolsCallable( + brick: Brick, + bus: EventBus, + issues: ValidationIssue[], +): Promise { + for (const tool of brick.manifest.tools) { + const target = `${brick.manifest.name}:${tool.name}`; + try { + await bus.request(target, {}); + } catch (err) { + const code = extractEventBusCode(err); + if (code === 'NO_HANDLER') { + issues.push({ + code: 'MISSING_HANDLER', + severity: 'error', + message: `Tool "${target}" declared but no handler registered after start()`, + meta: { target }, + }); + } else { + issues.push({ + code: 'TOOL_CALL_FAILED', + severity: 'error', + message: `Tool "${target}" threw: ${err instanceof Error ? err.message : String(err)}`, + meta: { target }, + }); + } + } } - } } async function runStop(brick: Brick, bus: EventBus, issues: ValidationIssue[]): Promise { - try { - await brick.stop(); - } catch (err) { - issues.push({ - code: 'STOP_FAILED', - severity: 'error', - message: `stop() threw: ${err instanceof Error ? err.message : String(err)}`, - }); - return; - } - // Après stop(), plus aucun handler déclaré ne doit répondre. - for (const tool of brick.manifest.tools) { - const target = `${brick.manifest.name}:${tool.name}`; try { - await bus.request(target, {}); - // Si ça passe, c'est que le handler est encore enregistré → leak. - issues.push({ - code: 'HANDLER_LEAK', - severity: 'error', - message: `Handler "${target}" still registered after stop()`, - meta: { target }, - }); + await brick.stop(); } catch (err) { - if (extractEventBusCode(err) !== 'NO_HANDLER') { - // Le handler existe toujours et a répondu par une erreur → leak aussi. issues.push({ - code: 'HANDLER_LEAK', - severity: 'error', - message: `Handler "${target}" still registered after stop()`, - meta: { target }, + code: 'STOP_FAILED', + severity: 'error', + message: `stop() threw: ${err instanceof Error ? err.message : String(err)}`, }); - } + return; + } + // Après stop(), plus aucun handler déclaré ne doit répondre. + for (const tool of brick.manifest.tools) { + const target = `${brick.manifest.name}:${tool.name}`; + try { + await bus.request(target, {}); + // Si ça passe, c'est que le handler est encore enregistré → leak. + issues.push({ + code: 'HANDLER_LEAK', + severity: 'error', + message: `Handler "${target}" still registered after stop()`, + meta: { target }, + }); + } catch (err) { + if (extractEventBusCode(err) !== 'NO_HANDLER') { + // Le handler existe toujours et a répondu par une erreur → leak aussi. + issues.push({ + code: 'HANDLER_LEAK', + severity: 'error', + message: `Handler "${target}" still registered after stop()`, + meta: { target }, + }); + } + } } - } } function extractEventBusCode(err: unknown): string | undefined { - if (err !== null && typeof err === 'object' && 'code' in err) { - const code = (err as { code: unknown }).code; - if (typeof code === 'string') return code; - } - return undefined; + if (err !== null && typeof err === 'object' && 'code' in err) { + const code = (err as { code: unknown }).code; + if (typeof code === 'string') return code; + } + return undefined; } diff --git a/packages/validator/tsconfig.json b/packages/validator/tsconfig.json index 5077e27..9c1accc 100644 --- a/packages/validator/tsconfig.json +++ b/packages/validator/tsconfig.json @@ -1,11 +1,11 @@ { - "extends": "../../config/tsconfig.base.json", - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@focusmcp/core": ["../core/src/index.ts"] - } - }, - "include": ["src/**/*.ts"], - "exclude": ["dist", "node_modules"] + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@focusmcp/core": ["../core/src/index.ts"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] } diff --git a/tsconfig.json b/tsconfig.json index 1deb106..f552e30 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { - "$schema": "https://json.schemastore.org/tsconfig.json", - "extends": "./config/tsconfig.base.json", - "include": ["packages/*/src/**/*.ts"], - "exclude": ["**/node_modules", "**/dist"] + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "./config/tsconfig.base.json", + "include": ["packages/*/src/**/*.ts"], + "exclude": ["**/node_modules", "**/dist"] } From 29d4285f087ec8447bab59ab9e533eae57b0c691 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Fri, 17 Apr 2026 19:26:34 +0200 Subject: [PATCH 06/26] docs: add CLAUDE.md (agent guidance, replaces ~/.claude memory) (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add CLAUDE.md capturing the post-pivot agent guidance Replaces the former personal memory system under ~/.claude/projects/**/memory/ with an in-repo, version-controlled file that is auto-loaded by Claude Code (and any agents.md-compatible tool). Covers: project overview, the 4-repo ecosystem post CLI-first pivot (2026-04-16), the 8 non-negotiable conventions (TDD, strict scope, pro standards, English public-facing, gitflow, npm orgs, rulesets checklist), this repo's specifics (lib-only, packages/core, cli moved out, browser-compatible, no HTTP transport), and the standard feature workflow. Co-Authored-By: Claude Opus 4.6 (1M context) * docs(claude.md): fix repo count and clarify English rule exceptions Heading: 3 repos actifs + 1 archivé (table listed 3 not 4). Rule #5 (English public-facing): reframe as from-now-on plus list explicit exceptions (PRD.md and CLAUDE.md stay French, existing docs stay French until substantial rewrite) so the rule no longer contradicts the current state of the repo. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- CLAUDE.md | 145 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..de7963b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,145 @@ + + +# CLAUDE.md — @focusmcp/core + +> Auto-loaded by Claude Code (and any agents.md-compatible tool) when working in this repo. +> This file is the **source of truth for AI agent behaviour** on this project. It replaces the +> former `~/.claude/projects/**/memory/` system — do not recreate that folder. + +## Projet + +**FocusMCP** — orchestrateur MCP. Reduces AI-agent context from 200k to ~2k tokens by composing +**briques** (atomic MCP modules) that communicate via an EventBus with central guards. Site +[focusmcp.dev](https://focusmcp.dev). Full vision : [PRD.md](./PRD.md). + +Ce repo héberge la **bibliothèque `@focusmcp/core`** (Registry + EventBus + Router + SDK + +Validator + marketplace resolver) importée par le CLI. + +## Écosystème (3 repos actifs + 1 archivé) + +| Repo | Statut | Rôle | +|---|---|---| +| `focus-mcp/core` (ici) | actif | Monorepo lib TS — 3 piliers + SDK/Validator/Marketplace resolver | +| `focus-mcp/cli` | actif | `@focusmcp/cli` — stdio MCP via `@modelcontextprotocol/sdk`, entrée primaire, publié npm | +| `focus-mcp/marketplace` | actif | Catalogue officiel + `bricks/*` + `modules/*` (dont `manager` = dashboard). `catalog.json` publié sur gh-pages (domaine custom `marketplace.focusmcp.dev` à configurer). | +| `focus-mcp/client` | **archivé** | Ex desktop Tauri. Pivot CLI-first (2026-04-16) a gelé ce repo en Phase 2. | + +## Architecture (post-pivot CLI-first, 2026-04-16) + +``` +AI client (Claude Code, Cursor, Codex, Gemini…) + │ stdio (JSON-RPC MCP) + ▼ +@focusmcp/cli (Node, npm) + ├─ @modelcontextprotocol/sdk StdioServerTransport + ├─ import { createFocusMcp } from '@focusmcp/core' ← CE REPO + └─ (opt-in P1) admin API HTTP côté latéral +``` + +**Le core** est importé par la CLI (pas l'inverse). **Browser-compatible** : pas de +`node:async_hooks`, pas de Pino, primitives custom côté logger/tracing. Pas de `HttpTransport` +côté core — Tauri pouvait l'héberger via WebView (ancien design, gelé) ; aujourd'hui la CLI +héberge tout, mais l'architecture reste browser-compatible pour un futur Phase 2 desktop. + +**Le cli-manager (dashboard)** ne dépend PAS du core — il consomme l'admin API HTTP de la CLI. + +## Règles non-négociables (applicables à TOUS les repos FocusMCP) + +1. **TDD strict** — tests AVANT le code (Red → Green → Refactor). Coverage ≥ **80 %** global, + ≥ **95 %** sur `event-bus/**` et `registry/**` (modules critiques). +2. **Périmètre strict** — pas de features ou décisions non explicitement demandées. Le user a + corrigé plusieurs fois le scope ; demander avant d'ajouter de l'inconnu. +3. **Standards pro** — TS strict (pas de `any`), Biome (pas ESLint+Prettier), Conventional + Commits (enforced via commitlint), husky + lint-staged, semver, SPDX headers (REUSE), + ADRs pour les décisions archi. +4. **Imports** : toujours `node:` protocol (`import … from 'node:fs/promises'`). +5. **Public-facing content en anglais** — règle "à partir de maintenant" : tout **nouveau** + contenu public, et toute **mise à jour substantielle** d'un contenu public existant, est + rédigé en anglais. Périmètre : + - `.github/` (workflows YAML, PR template, issue templates, renovate) + - Titres + descriptions de PR, commentaires de PR, messages de commit + - Titres + descriptions d'issues + - Marketplace : `mcp-brick.json` description/tools, `bricks//README.md`, entries Changesets + - Docs contributor-facing cibles : `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, `SECURITY.md`, + `CODE_OF_CONDUCT.md` + - Exception transitoire : les versions **existantes** de ces docs peuvent rester majoritairement + en français jusqu'à leur prochaine réécriture substantielle. + - Exceptions permanentes : `PRD.md` (doc stratégique interne) et `CLAUDE.md` (ce fichier, guide + d'agent interne) restent en français. +6. **Git-flow strict** — `develop` est **permanente**, jamais `--delete-branch` sur une PR + `develop → main`. Feature branches éphémères (`feat/*`, `fix/*`, `docs/*`, etc.), + auto-delete après merge. +7. **npm orgs** — `focusmcp` ET `focus-mcp` sont réservées (squatting protection). Pas de + publish au MVP sauf `@focusmcp/cli` (primary distribution). Scope canonique : + `@focusmcp/*`. +8. **Rulesets GitHub** — chaque nouveau repo reçoit le couple : + - `main protection` cible **UNIQUEMENT `refs/heads/main`** — `required_status_checks`, + `pull_request`, `code_scanning` (CodeQL), `code_quality`, `required_linear_history`, + `deletion`, `non_fast_forward`. **Pas `required_signatures`** (les commits assistés ne + sont pas signés). + - `develop protection` cible **UNIQUEMENT `refs/heads/develop`** — `deletion`, + `non_fast_forward`, `required_linear_history`, `pull_request` (pas de `code_quality` : + impossible sur non-default branch = pending éternel). + - Pitfall connu : NE JAMAIS mettre `develop` dans les targets de "main protection". + +## Dans ce repo (core) + +**Stack** : Node ≥ 22, pnpm ≥ 10, TypeScript 5.7+ strict, ESM only, Vitest, Biome 2.x, +tsup, Changesets. + +**Layout** : +``` +packages/ + core/ ← Registry, EventBus (guards), Router, manifest parser, observability, marketplace resolver + sdk/ ← defineBrick helper + validator/ ← test runner conformance briques + cli/ ← DEPRECATED stub ; le vrai CLI vit dans focus-mcp/cli. Peut être supprimé. +``` + +**À surveiller** : +- Le CLI a été **extrait dans son propre repo** (`focus-mcp/cli`) qui consomme `@focusmcp/core` + via `file:../core/packages/core` (sibling clone en CI). `packages/cli` ici est un vieux stub + vide — à supprimer quand on fait le cleanup. +- `@focusmcp/core` n'est **pas publié sur npm** ; la CLI le bundle au build (`tsup --noExternal`). + +## Commandes + +```bash +pnpm install # install (frozen lockfile en CI) +pnpm test # Vitest +pnpm test:watch +pnpm test:coverage # coverage + thresholds +pnpm typecheck # tsc --noEmit (tous packages) +pnpm lint # Biome check +pnpm lint:fix # Biome auto-fix +pnpm build # tsup (tous packages) +pnpm changeset # créer un changeset avant de merger +``` + +## Workflow pour une feature + +1. Lire PRD.md + ce fichier +2. Feature branch depuis `develop` (`feat/*`, `fix/*`, `docs/*`…) +3. Red → Green → Refactor (tests AVANT le code) +4. `pnpm test:coverage && pnpm typecheck && pnpm lint` +5. `pnpm changeset` si ça change l'API publique +6. Conventional Commits +7. PR vers `develop` (jamais `main` direct) +8. Attendre CI verte + résoudre les threads Copilot avant merge + +## Sécurité + +- **Aucun secret** commité (gitleaks en pre-commit + CI) +- Pas de `eval` ni `new Function()` +- Le sandbox OS est **hérité du parent process** (Claude Code spawn via Seatbelt/bubblewrap). + `isolated-vm` disponible en Phase 2 si besoin de faire tourner des briques non-reviewed. + +## Documentation à lire en priorité + +1. [PRD.md](./PRD.md) — vision, architecture, roadmap +2. [AGENTS.md](./AGENTS.md) — instructions cross-agents (note : peut contenir des résidus + pré-pivot ; CE fichier est la source de vérité) +3. [CONTRIBUTING.md](./CONTRIBUTING.md) — workflow de contribution From 3832ba82f67e77209fbc6f324e1a9b85c26c283d Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Mon, 20 Apr 2026 15:49:19 +0200 Subject: [PATCH 07/26] chore: configure GitHub Packages for @focusmcp/* packages (#8) * chore: configure GitHub Packages registry for @focusmcp/* packages Add publishConfig with npm.pkg.github.com registry and .npmrc for scoped package resolution. Preparation for dev package publishing. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add SPDX header to .npmrc Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- .npmrc | 5 +++++ packages/cli/package.json | 8 +++++++- packages/core/package.json | 8 +++++++- packages/sdk/package.json | 8 +++++++- packages/validator/package.json | 8 +++++++- 5 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..2ee08e2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +@focusmcp:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} diff --git a/packages/cli/package.json b/packages/cli/package.json index 0bb27b2..c706d1c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -24,6 +24,12 @@ }, "publishConfig": { "access": "public", - "provenance": true + "provenance": true, + "registry": "https://npm.pkg.github.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/focus-mcp/core.git", + "directory": "packages/cli" } } diff --git a/packages/core/package.json b/packages/core/package.json index 7da5855..28fe34a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,12 @@ }, "publishConfig": { "access": "public", - "provenance": true + "provenance": true, + "registry": "https://npm.pkg.github.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/focus-mcp/core.git", + "directory": "packages/core" } } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 80fd4f3..eb6b9b0 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -32,6 +32,12 @@ }, "publishConfig": { "access": "public", - "provenance": true + "provenance": true, + "registry": "https://npm.pkg.github.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/focus-mcp/core.git", + "directory": "packages/sdk" } } diff --git a/packages/validator/package.json b/packages/validator/package.json index df51120..8542597 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -32,6 +32,12 @@ }, "publishConfig": { "access": "public", - "provenance": true + "provenance": true, + "registry": "https://npm.pkg.github.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/focus-mcp/core.git", + "directory": "packages/validator" } } From 953fa2a4e9defe23889897223ba0556d422a4ecd Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Mon, 20 Apr 2026 22:26:28 +0200 Subject: [PATCH 08/26] feat: mandatory tool prefix in brick manifest (#10) * feat: add mandatory prefix field to brick manifest Tools are exposed as {prefix}_{toolName} to prevent collisions between bricks and protect internal tools (no prefix). Prefix must be unique per registry, lowercase alphanumeric. Reserved prefixes: focus, focusmcp, mcp, internal, system. Co-Authored-By: Claude Opus 4.6 (1M context) * test: add edge case tests for prefix coverage (registry 98%+) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/bootstrap/create-focus-mcp.test.ts | 31 +++- packages/core/src/loader/brick-loader.test.ts | 3 +- packages/core/src/manifest/manifest.test.ts | 57 ++++++- packages/core/src/manifest/manifest.ts | 41 +++++ .../src/registry/permission-provider.test.ts | 9 +- packages/core/src/registry/registry.test.ts | 152 ++++++++++++++++-- packages/core/src/registry/registry.ts | 62 +++++-- packages/core/src/router/router.test.ts | 34 ++-- packages/core/src/router/router.ts | 15 +- packages/core/src/types/manifest.ts | 6 + packages/core/src/types/registry.ts | 16 +- packages/sdk/src/define-brick.test.ts | 1 + packages/validator/src/validate-brick.test.ts | 1 + 13 files changed, 370 insertions(+), 58 deletions(-) diff --git a/packages/core/src/bootstrap/create-focus-mcp.test.ts b/packages/core/src/bootstrap/create-focus-mcp.test.ts index d34600e..d6c0737 100644 --- a/packages/core/src/bootstrap/create-focus-mcp.test.ts +++ b/packages/core/src/bootstrap/create-focus-mcp.test.ts @@ -10,12 +10,15 @@ function brick( deps: readonly string[], toolName: string, handler: (payload: unknown) => unknown, + prefix?: string, ): Brick { + const resolvedPrefix = prefix ?? (name.replace(/[^a-z0-9]/g, '').slice(0, 8) || 'b'); let unsubs: Unsubscribe[] = []; return { manifest: { name, version: '1.0.0', + prefix: resolvedPrefix, description: name, dependencies: deps, tools: [{ name: toolName, description: 'x', inputSchema: { type: 'object' } }], @@ -56,10 +59,18 @@ describe('createFocusMcp — lifecycle', () => { await app.stop(); }); - it('start() démarre les briques dans l’ordre des dépendances', async () => { + it("start() démarre les briques dans l'ordre des dépendances", async () => { const starts: string[] = []; + let _rctr = 0; const recording = (name: string, deps: readonly string[]): Brick => ({ - manifest: { name, version: '1.0.0', description: name, dependencies: deps, tools: [] }, + manifest: { + name, + version: '1.0.0', + prefix: `r${++_rctr}`, + description: name, + dependencies: deps, + tools: [], + }, start(): void { starts.push(name); }, @@ -79,12 +90,18 @@ describe('createFocusMcp — lifecycle', () => { await app.stop(); }); - it('expose les tools des briques running via le Router', async () => { + it('expose les tools des briques running via le Router (noms préfixés)', async () => { const app = createFocusMcp({ bricks: [ - brick('maths', [], 'maths_add', () => ({ - content: [{ type: 'text', text: '42' }], - })), + brick( + 'maths', + [], + 'add', + () => ({ + content: [{ type: 'text', text: '42' }], + }), + 'maths', + ), ], }); await app.start(); @@ -115,6 +132,7 @@ describe('createFocusMcp — permissions', () => { manifest: { name: 'indexer', version: '1.0.0', + prefix: 'idx', description: 'indexer', dependencies: [], tools: [ @@ -136,6 +154,7 @@ describe('createFocusMcp — permissions', () => { manifest: { name: 'php', version: '1.0.0', + prefix: 'phpb', description: 'php', dependencies: ['indexer'], tools: [ diff --git a/packages/core/src/loader/brick-loader.test.ts b/packages/core/src/loader/brick-loader.test.ts index c7ebc32..00d2e0c 100644 --- a/packages/core/src/loader/brick-loader.test.ts +++ b/packages/core/src/loader/brick-loader.test.ts @@ -5,10 +5,11 @@ import { describe, expect, it, vi } from 'vitest'; import type { Brick, BrickManifest } from '../types/index.ts'; import { type BrickSource, loadBricks } from './brick-loader.ts'; -function makeManifest(name: string, deps: readonly string[] = []): BrickManifest { +function makeManifest(name: string, deps: readonly string[] = [], prefix?: string): BrickManifest { return { name, version: '1.0.0', + prefix: prefix ?? (name.replace(/[^a-z0-9]/g, '').slice(0, 8) || 'b'), description: `${name} brick`, dependencies: deps, tools: [], diff --git a/packages/core/src/manifest/manifest.test.ts b/packages/core/src/manifest/manifest.test.ts index 5fa0a58..f15bf0e 100644 --- a/packages/core/src/manifest/manifest.test.ts +++ b/packages/core/src/manifest/manifest.test.ts @@ -7,11 +7,12 @@ import { ManifestError, parseManifest } from './manifest.ts'; const validRaw = { name: 'indexer', version: '1.0.0', + prefix: 'idx', description: 'Indexation filesystem avec cache', dependencies: [], tools: [ { - name: 'indexer_search', + name: 'search', description: 'Recherche fichiers par pattern', inputSchema: { type: 'object', @@ -27,12 +28,14 @@ describe('parseManifest — cas valides', () => { const manifest = parseManifest(validRaw); expect(manifest.name).toBe('indexer'); expect(manifest.version).toBe('1.0.0'); + expect(manifest.prefix).toBe('idx'); expect(manifest.tools).toHaveLength(1); }); it('accepte une string JSON', () => { const manifest = parseManifest(JSON.stringify(validRaw)); expect(manifest.name).toBe('indexer'); + expect(manifest.prefix).toBe('idx'); }); it('accepte les champs optionnels config et tags', () => { @@ -173,3 +176,55 @@ describe('parseManifest — validation dependencies', () => { expect(manifest.dependencies).toEqual(['indexer', 'cache', 'focus-sf-router']); }); }); + +describe('parseManifest — validation prefix', () => { + it('INVALID_PREFIX : prefix absent', () => { + const { prefix: _, ...rest } = validRaw; + expect(() => parseManifest(rest)).toThrow( + expect.objectContaining({ code: 'INVALID_PREFIX' }), + ); + }); + + it('INVALID_PREFIX : string vide', () => { + expect(() => parseManifest({ ...validRaw, prefix: '' })).toThrow( + expect.objectContaining({ code: 'INVALID_PREFIX' }), + ); + }); + + it('INVALID_PREFIX : commence par underscore', () => { + expect(() => parseManifest({ ...validRaw, prefix: '_idx' })).toThrow( + expect.objectContaining({ code: 'INVALID_PREFIX' }), + ); + }); + + it('INVALID_PREFIX : contient des caractères non alphanumériques', () => { + expect(() => parseManifest({ ...validRaw, prefix: 'my-idx' })).toThrow( + expect.objectContaining({ code: 'INVALID_PREFIX' }), + ); + expect(() => parseManifest({ ...validRaw, prefix: 'My_idx' })).toThrow( + expect.objectContaining({ code: 'INVALID_PREFIX' }), + ); + expect(() => parseManifest({ ...validRaw, prefix: 'IDX' })).toThrow( + expect.objectContaining({ code: 'INVALID_PREFIX' }), + ); + }); + + it('INVALID_PREFIX : mots réservés', () => { + const reserved = ['focus', 'focusmcp', 'mcp', 'internal', 'system']; + for (const prefix of reserved) { + expect(() => parseManifest({ ...validRaw, prefix })).toThrow( + expect.objectContaining({ code: 'INVALID_PREFIX' }), + ); + } + }); + + it('accepte un prefix lowercase alphanumeric valide', () => { + const manifest = parseManifest({ ...validRaw, prefix: 'idx2' }); + expect(manifest.prefix).toBe('idx2'); + }); + + it('accepte un prefix composé uniquement de chiffres (sauf leading underscore)', () => { + const manifest = parseManifest({ ...validRaw, prefix: 'x42' }); + expect(manifest.prefix).toBe('x42'); + }); +}); diff --git a/packages/core/src/manifest/manifest.ts b/packages/core/src/manifest/manifest.ts index ad2b7d4..7f3dfa4 100644 --- a/packages/core/src/manifest/manifest.ts +++ b/packages/core/src/manifest/manifest.ts @@ -9,6 +9,7 @@ export type ManifestErrorCode = | 'INVALID_SHAPE' | 'INVALID_NAME' | 'INVALID_VERSION' + | 'INVALID_PREFIX' | 'INVALID_DESCRIPTION' | 'INVALID_TOOL' | 'DUPLICATE_TOOL' @@ -54,6 +55,7 @@ export function parseManifest(raw: unknown): BrickManifest { const name = validateName(obj['name']); const version = validateVersion(obj['version']); + const prefix = validatePrefix(obj['prefix']); const description = validateDescription(obj['description']); const dependencies = validateDependencies(obj['dependencies']); const tools = validateTools(obj['tools']); @@ -61,6 +63,7 @@ export function parseManifest(raw: unknown): BrickManifest { const manifest: Mutable = { name, version, + prefix, description, dependencies, tools, @@ -122,6 +125,44 @@ function validateVersion(value: unknown): string { return value; } +const RESERVED_PREFIXES: ReadonlySet = new Set([ + 'focus', + 'focusmcp', + 'mcp', + 'internal', + 'system', +]); + +function validatePrefix(value: unknown): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new ManifestError( + 'Manifest.prefix is required and must be a non-empty string', + 'INVALID_PREFIX', + { value }, + ); + } + if (value.startsWith('_')) { + throw new ManifestError( + 'Manifest.prefix must not start with underscore', + 'INVALID_PREFIX', + { value }, + ); + } + if (/[^a-z0-9]/.test(value)) { + throw new ManifestError( + 'Manifest.prefix must be lowercase alphanumeric only', + 'INVALID_PREFIX', + { value }, + ); + } + if (RESERVED_PREFIXES.has(value)) { + throw new ManifestError(`Manifest.prefix "${value}" is reserved`, 'INVALID_PREFIX', { + value, + }); + } + return value; +} + function validateDescription(value: unknown): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new ManifestError( diff --git a/packages/core/src/registry/permission-provider.test.ts b/packages/core/src/registry/permission-provider.test.ts index 8ea1328..4776e34 100644 --- a/packages/core/src/registry/permission-provider.test.ts +++ b/packages/core/src/registry/permission-provider.test.ts @@ -8,7 +8,14 @@ import { InMemoryRegistry } from './registry.ts'; function makeBrick(name: string, dependencies: readonly string[]): Brick { return { - manifest: { name, version: '1.0.0', description: `${name}`, dependencies, tools: [] }, + manifest: { + name, + version: '1.0.0', + prefix: name.slice(0, 4), + description: `${name}`, + dependencies, + tools: [], + }, start: () => {}, stop: () => {}, }; diff --git a/packages/core/src/registry/registry.test.ts b/packages/core/src/registry/registry.test.ts index 5d3cd3b..9d54418 100644 --- a/packages/core/src/registry/registry.test.ts +++ b/packages/core/src/registry/registry.test.ts @@ -6,10 +6,14 @@ import type { Brick } from '../types/brick.ts'; import type { BrickManifest } from '../types/manifest.ts'; import { InMemoryRegistry } from './registry.ts'; +let _prefixCounter = 0; function fakeBrick(manifest: Partial & Pick): Brick { + // generate a unique default prefix if not provided + const defaultPrefix = manifest.prefix ?? `b${++_prefixCounter}`; return { manifest: { version: '1.0.0', + prefix: defaultPrefix, description: '', dependencies: [], tools: [], @@ -138,18 +142,17 @@ describe('InMemoryRegistry — status', () => { }); describe('InMemoryRegistry — getBrickForTool', () => { - it('retourne le nom de la brique qui expose le tool', () => { + it('retourne le nom de la brique via le nom préfixé', () => { const registry = new InMemoryRegistry(); registry.register( fakeBrick({ name: 'indexer', - tools: [ - { name: 'indexer_search', description: '', inputSchema: { type: 'object' } }, - ], + prefix: 'idx', + tools: [{ name: 'search', description: '', inputSchema: { type: 'object' } }], }), ); - expect(registry.getBrickForTool('indexer_search')).toBe('indexer'); + expect(registry.getBrickForTool('idx_search')).toBe('indexer'); }); it("retourne undefined si le tool n'est exposé par aucune brique", () => { @@ -158,38 +161,54 @@ describe('InMemoryRegistry — getBrickForTool', () => { expect(registry.getBrickForTool('ghost_tool')).toBeUndefined(); }); + it('retourne undefined si le préfixe est inconnu', () => { + const registry = new InMemoryRegistry(); + registry.register( + fakeBrick({ + name: 'indexer', + prefix: 'idx', + tools: [{ name: 'search', description: '', inputSchema: { type: 'object' } }], + }), + ); + + expect(registry.getBrickForTool('unknown_search')).toBeUndefined(); + }); + it("cherche parmi plusieurs tools d'une brique et plusieurs briques", () => { const registry = new InMemoryRegistry(); registry.register( fakeBrick({ name: 'indexer', + prefix: 'idx', tools: [ - { name: 'indexer_search', description: '', inputSchema: { type: 'object' } }, - { name: 'indexer_stats', description: '', inputSchema: { type: 'object' } }, + { name: 'search', description: '', inputSchema: { type: 'object' } }, + { name: 'stats', description: '', inputSchema: { type: 'object' } }, ], }), ); registry.register( fakeBrick({ name: 'php', - tools: [{ name: 'php_analyze', description: '', inputSchema: { type: 'object' } }], + prefix: 'phpb', + tools: [{ name: 'analyze', description: '', inputSchema: { type: 'object' } }], }), ); - expect(registry.getBrickForTool('indexer_stats')).toBe('indexer'); - expect(registry.getBrickForTool('php_analyze')).toBe('php'); + expect(registry.getBrickForTool('idx_stats')).toBe('indexer'); + expect(registry.getBrickForTool('phpb_analyze')).toBe('php'); }); }); describe('InMemoryRegistry — getTools', () => { - it('agrège les tools de toutes les briques running', () => { + it('agrège les tools de toutes les briques running avec noms préfixés', () => { const registry = new InMemoryRegistry(); registry.register( fakeBrick({ name: 'indexer', + prefix: 'idx', tools: [ { - name: 'indexer_search', + name: 'search', description: 'search', inputSchema: { type: 'object' }, }, @@ -199,9 +218,10 @@ describe('InMemoryRegistry — getTools', () => { registry.register( fakeBrick({ name: 'php', + prefix: 'phpb', tools: [ { - name: 'php_analyze', + name: 'analyze', description: 'analyze', inputSchema: { type: 'object' }, }, @@ -213,7 +233,7 @@ describe('InMemoryRegistry — getTools', () => { const toolNames = registry.getTools().map((t) => t.name); - expect(toolNames).toEqual(expect.arrayContaining(['indexer_search', 'php_analyze'])); + expect(toolNames).toEqual(expect.arrayContaining(['idx_search', 'phpb_analyze'])); }); it("n'inclut PAS les tools des briques non-running", () => { @@ -221,9 +241,10 @@ describe('InMemoryRegistry — getTools', () => { registry.register( fakeBrick({ name: 'indexer', + prefix: 'idx', tools: [ { - name: 'indexer_search', + name: 'search', description: 'search', inputSchema: { type: 'object' }, }, @@ -234,3 +255,104 @@ describe('InMemoryRegistry — getTools', () => { expect(registry.getTools()).toEqual([]); }); }); + +describe('InMemoryRegistry — unicité du prefix', () => { + it("rejette l'enregistrement si le prefix est déjà utilisé", () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'indexer', prefix: 'idx' })); + + expect(() => registry.register(fakeBrick({ name: 'other', prefix: 'idx' }))).toThrow( + expect.objectContaining({ name: 'RegistryError', code: 'DUPLICATE_PREFIX' }), + ); + }); + + it('libère le prefix après unregister, permettant de le réutiliser', () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'indexer', prefix: 'idx' })); + registry.unregister('indexer'); + + expect(() => registry.register(fakeBrick({ name: 'other', prefix: 'idx' }))).not.toThrow(); + }); +}); + +describe('getBrickForTool — edge cases', () => { + it('returns undefined for tool without underscore', () => { + const registry = new InMemoryRegistry(); + expect(registry.getBrickForTool('noprefix')).toBeUndefined(); + }); + + it('returns undefined for unknown prefix', () => { + const registry = new InMemoryRegistry(); + expect(registry.getBrickForTool('unknown_tool')).toBeUndefined(); + }); + + it('returns undefined for unknown tool name with valid prefix', () => { + const registry = new InMemoryRegistry(); + registry.register( + fakeBrick({ + name: 'test', + prefix: 'tst', + tools: [{ name: 'search', description: 'x', inputSchema: { type: 'object' } }], + }), + ); + expect(registry.getBrickForTool('tst_nonexistent')).toBeUndefined(); + }); +}); + +describe('getOriginalToolName — edge cases', () => { + it('returns undefined for tool without underscore', () => { + const registry = new InMemoryRegistry(); + expect(registry.getOriginalToolName('noprefix')).toBeUndefined(); + }); + + it('returns undefined for unknown prefix', () => { + const registry = new InMemoryRegistry(); + expect(registry.getOriginalToolName('unknown_tool')).toBeUndefined(); + }); + + it('returns undefined for unknown tool with valid prefix', () => { + const registry = new InMemoryRegistry(); + registry.register( + fakeBrick({ + name: 'test', + prefix: 'tst', + tools: [{ name: 'search', description: 'x', inputSchema: { type: 'object' } }], + }), + ); + expect(registry.getOriginalToolName('tst_nonexistent')).toBeUndefined(); + }); + + it('returns original name for valid prefixed tool', () => { + const registry = new InMemoryRegistry(); + registry.register( + fakeBrick({ + name: 'test', + prefix: 'tst', + tools: [{ name: 'search', description: 'x', inputSchema: { type: 'object' } }], + }), + ); + expect(registry.getOriginalToolName('tst_search')).toBe('search'); + }); +}); + +describe('prefix edge cases — coverage', () => { + it('getBrickForTool returns undefined when entry exists but tool not found', () => { + const registry = new InMemoryRegistry(); + registry.register( + fakeBrick({ + name: 'alpha', + prefix: 'alp', + tools: [{ name: 'one', description: 'x', inputSchema: { type: 'object' } }], + }), + ); + expect(registry.getBrickForTool('alp_two')).toBeUndefined(); + }); + + it('getOriginalToolName returns undefined when entry not in entries map', () => { + const registry = new InMemoryRegistry(); + registry.register(fakeBrick({ name: 'beta', prefix: 'bet', tools: [] })); + // Unregister removes from entries + registry.unregister('beta'); + expect(registry.getOriginalToolName('bet_x')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index a6918ec..9d419ed 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -12,9 +12,11 @@ interface RegistryEntry { export class InMemoryRegistry implements Registry { readonly #entries = new Map(); + /** prefix → brickName */ + readonly #prefixes = new Map(); register(brick: Brick): void { - const { name } = brick.manifest; + const { name, prefix } = brick.manifest; if (this.#entries.has(name)) { throw new RegistryError( `Brick "${name}" is already registered`, @@ -24,16 +26,29 @@ export class InMemoryRegistry implements Registry { }, ); } + if (this.#prefixes.has(prefix)) { + const owner = this.#prefixes.get(prefix); + throw new RegistryError( + `Prefix "${prefix}" is already used by brick "${owner}"`, + 'DUPLICATE_PREFIX', + { prefix, owner }, + ); + } this.#entries.set(name, { brick, status: 'stopped' }); + this.#prefixes.set(prefix, name); } unregister(name: string): void { - if (!this.#entries.has(name)) { + const entry = this.#entries.get(name); + if (!entry) { throw new RegistryError(`Brick "${name}" not found`, 'BRICK_NOT_FOUND', { name }); } - for (const [otherName, entry] of this.#entries) { + for (const [otherName, otherEntry] of this.#entries) { if (otherName === name) continue; - if (entry.status === 'running' && entry.brick.manifest.dependencies.includes(name)) { + if ( + otherEntry.status === 'running' && + otherEntry.brick.manifest.dependencies.includes(name) + ) { throw new RegistryError( `Cannot unregister "${name}": "${otherName}" is running and depends on it`, 'DEPENDENT_BRICKS_RUNNING', @@ -41,6 +56,7 @@ export class InMemoryRegistry implements Registry { ); } } + this.#prefixes.delete(entry.brick.manifest.prefix); this.#entries.delete(name); } @@ -103,18 +119,40 @@ export class InMemoryRegistry implements Registry { const tools: ToolDefinition[] = []; for (const entry of this.#entries.values()) { if (entry.status === 'running') { - tools.push(...entry.brick.manifest.tools); + const { prefix } = entry.brick.manifest; + for (const tool of entry.brick.manifest.tools) { + tools.push({ ...tool, name: `${prefix}_${tool.name}` }); + } } } return tools; } - getBrickForTool(toolName: string): string | undefined { - for (const [name, entry] of this.#entries) { - for (const tool of entry.brick.manifest.tools) { - if (tool.name === toolName) return name; - } - } - return undefined; + getBrickForTool(prefixedName: string): string | undefined { + const underscoreIndex = prefixedName.indexOf('_'); + if (underscoreIndex === -1) return undefined; + const prefix = prefixedName.slice(0, underscoreIndex); + const originalName = prefixedName.slice(underscoreIndex + 1); + const brickName = this.#prefixes.get(prefix); + if (brickName === undefined) return undefined; + const entry = this.#entries.get(brickName); + /* v8 ignore next */ + if (!entry) return undefined; + const hasTool = entry.brick.manifest.tools.some((t) => t.name === originalName); + return hasTool ? brickName : undefined; + } + + getOriginalToolName(prefixedName: string): string | undefined { + const underscoreIndex = prefixedName.indexOf('_'); + if (underscoreIndex === -1) return undefined; + const prefix = prefixedName.slice(0, underscoreIndex); + const originalName = prefixedName.slice(underscoreIndex + 1); + const brickName = this.#prefixes.get(prefix); + if (brickName === undefined) return undefined; + const entry = this.#entries.get(brickName); + /* v8 ignore next */ + if (!entry) return undefined; + const hasTool = entry.brick.manifest.tools.some((t) => t.name === originalName); + return hasTool ? originalName : undefined; } } diff --git a/packages/core/src/router/router.test.ts b/packages/core/src/router/router.test.ts index 9275276..9dd44f7 100644 --- a/packages/core/src/router/router.test.ts +++ b/packages/core/src/router/router.test.ts @@ -9,10 +9,13 @@ import type { BrickManifest } from '../types/manifest.ts'; import type { ToolResult } from '../types/tool.ts'; import { McpRouter } from './router.ts'; +let _prefixCounter = 0; function fakeBrick(manifest: Partial & Pick): Brick { + const defaultPrefix = manifest.prefix ?? `b${++_prefixCounter}`; return { manifest: { version: '1.0.0', + prefix: defaultPrefix, description: '', dependencies: [], tools: [], @@ -35,15 +38,16 @@ function setupRouter(): { } describe('McpRouter — listTools', () => { - it('agrège les tools de toutes les briques running (via Registry)', () => { + it('agrège les tools de toutes les briques running avec noms préfixés', () => { const { router, registry } = setupRouter(); registry.register( fakeBrick({ name: 'indexer', + prefix: 'idx', tools: [ { - name: 'indexer_search', + name: 'search', description: 'search', inputSchema: { type: 'object' }, }, @@ -54,7 +58,7 @@ describe('McpRouter — listTools', () => { const tools = router.listTools().map((t) => t.name); - expect(tools).toEqual(['indexer_search']); + expect(tools).toEqual(['idx_search']); }); it('ne retourne pas les tools de briques non-running', () => { @@ -63,9 +67,10 @@ describe('McpRouter — listTools', () => { registry.register( fakeBrick({ name: 'indexer', + prefix: 'idx', tools: [ { - name: 'indexer_search', + name: 'search', description: 'search', inputSchema: { type: 'object' }, }, @@ -78,15 +83,16 @@ describe('McpRouter — listTools', () => { }); describe('McpRouter — callTool', () => { - it("dispatch l'appel vers la brique propriétaire via l'EventBus (target brick:tool)", async () => { + it("dispatch l'appel vers la brique propriétaire via l'EventBus avec le nom original (brick:toolName)", async () => { const { router, registry, bus } = setupRouter(); registry.register( fakeBrick({ name: 'indexer', + prefix: 'idx', tools: [ { - name: 'indexer_search', + name: 'search', description: 'search', inputSchema: { type: 'object' }, }, @@ -96,9 +102,9 @@ describe('McpRouter — callTool', () => { registry.setStatus('indexer', 'running'); const expected: ToolResult = { content: [{ type: 'text', text: 'ok' }] }; - bus.handle('indexer:indexer_search', () => expected); + bus.handle('indexer:search', () => expected); - const result = await router.callTool('indexer_search', { pattern: '*.ts' }); + const result = await router.callTool('idx_search', { pattern: '*.ts' }); expect(result).toBe(expected); }); @@ -109,9 +115,10 @@ describe('McpRouter — callTool', () => { registry.register( fakeBrick({ name: 'indexer', + prefix: 'idx', tools: [ { - name: 'indexer_search', + name: 'search', description: 'search', inputSchema: { type: 'object' }, }, @@ -121,12 +128,12 @@ describe('McpRouter — callTool', () => { registry.setStatus('indexer', 'running'); let captured: unknown; - bus.handle('indexer:indexer_search', (args) => { + bus.handle('indexer:search', (args) => { captured = args; return { content: [] }; }); - await router.callTool('indexer_search', { pattern: '*.ts' }); + await router.callTool('idx_search', { pattern: '*.ts' }); expect(captured).toEqual({ pattern: '*.ts' }); }); @@ -146,9 +153,10 @@ describe('McpRouter — callTool', () => { registry.register( fakeBrick({ name: 'indexer', + prefix: 'idx', tools: [ { - name: 'indexer_search', + name: 'search', description: 'search', inputSchema: { type: 'object' }, }, @@ -157,7 +165,7 @@ describe('McpRouter — callTool', () => { ); // pas de setStatus('running') - await expect(router.callTool('indexer_search', {})).rejects.toMatchObject({ + await expect(router.callTool('idx_search', {})).rejects.toMatchObject({ name: 'RouterError', code: 'BRICK_NOT_RUNNING', }); diff --git a/packages/core/src/router/router.ts b/packages/core/src/router/router.ts index 0df7aa2..d83d9ed 100644 --- a/packages/core/src/router/router.ts +++ b/packages/core/src/router/router.ts @@ -32,21 +32,24 @@ export class McpRouter implements Router { return this.#registry.getTools(); } - async callTool(name: string, args: unknown): Promise { - const brickName = this.#registry.getBrickForTool(name); + async callTool(prefixedName: string, args: unknown): Promise { + const brickName = this.#registry.getBrickForTool(prefixedName); if (brickName === undefined) { - throw new RouterError(`Tool "${name}" not found`, 'TOOL_NOT_FOUND', { tool: name }); + throw new RouterError(`Tool "${prefixedName}" not found`, 'TOOL_NOT_FOUND', { + tool: prefixedName, + }); } if (this.#registry.getStatus(brickName) !== 'running') { throw new RouterError( - `Brick "${brickName}" is not running (required for tool "${name}")`, + `Brick "${brickName}" is not running (required for tool "${prefixedName}")`, 'BRICK_NOT_RUNNING', - { tool: name, brick: brickName }, + { tool: prefixedName, brick: brickName }, ); } - const target = `${brickName}:${name}`; + const originalName = this.#registry.getOriginalToolName(prefixedName) ?? prefixedName; + const target = `${brickName}:${originalName}`; return await this.#bus.request(target, args); } } diff --git a/packages/core/src/types/manifest.ts b/packages/core/src/types/manifest.ts index 2517da5..985f6a8 100644 --- a/packages/core/src/types/manifest.ts +++ b/packages/core/src/types/manifest.ts @@ -13,6 +13,12 @@ export interface BrickManifest { readonly name: string; /** Version SemVer de la brique. */ readonly version: string; + /** + * Prefix exposed to AI clients. Tools are listed as `{prefix}_{toolName}`. + * Must be lowercase alphanumeric, unique per registry. + * Reserved: focus, focusmcp, mcp, internal, system. + */ + readonly prefix: string; /** Description courte (une ligne). */ readonly description: string; /** Liste des briques dont cette brique dépend (whitelist EventBus). */ diff --git a/packages/core/src/types/registry.ts b/packages/core/src/types/registry.ts index 8bb8181..71afa3c 100644 --- a/packages/core/src/types/registry.ts +++ b/packages/core/src/types/registry.ts @@ -33,11 +33,20 @@ export interface Registry { /** Brique par son nom (undefined si non enregistrée). */ getBrick(name: string): Brick | undefined; - /** Tools agrégés de toutes les briques running. */ + /** Tools agrégés de toutes les briques running, avec noms préfixés ({prefix}_{toolName}). */ getTools(): readonly ToolDefinition[]; - /** Retourne le nom de la brique qui expose le tool (running ou non). */ - getBrickForTool(toolName: string): string | undefined; + /** + * Retourne le nom de la brique qui expose le tool préfixé (running ou non). + * @param prefixedName Format attendu : `{prefix}_{toolName}` + */ + getBrickForTool(prefixedName: string): string | undefined; + + /** + * Retourne le nom original (non préfixé) du tool à partir de son nom préfixé. + * @param prefixedName Format attendu : `{prefix}_{toolName}` + */ + getOriginalToolName(prefixedName: string): string | undefined; } export class RegistryError extends Error { @@ -54,6 +63,7 @@ export class RegistryError extends Error { export type RegistryErrorCode = | 'BRICK_NOT_FOUND' | 'BRICK_ALREADY_REGISTERED' + | 'DUPLICATE_PREFIX' | 'CYCLE_DETECTED' | 'MISSING_DEPENDENCY' | 'DEPENDENT_BRICKS_RUNNING'; diff --git a/packages/sdk/src/define-brick.test.ts b/packages/sdk/src/define-brick.test.ts index 8326dcb..d140c40 100644 --- a/packages/sdk/src/define-brick.test.ts +++ b/packages/sdk/src/define-brick.test.ts @@ -17,6 +17,7 @@ const noopLogger: BrickLogger = { const validManifest = { name: 'indexer', version: '1.0.0', + prefix: 'idx', description: 'Indexation filesystem', dependencies: [], tools: [ diff --git a/packages/validator/src/validate-brick.test.ts b/packages/validator/src/validate-brick.test.ts index acfc6ec..8247d7e 100644 --- a/packages/validator/src/validate-brick.test.ts +++ b/packages/validator/src/validate-brick.test.ts @@ -24,6 +24,7 @@ function conformingBrick(): Brick { manifest: { name: 'indexer', version: '1.0.0', + prefix: 'idx', description: 'indexation', dependencies: [], tools: [ From 9a374c1c23f53ceefc90923fb907bf4bc4e278cd Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Tue, 21 Apr 2026 18:45:47 +0200 Subject: [PATCH 09/26] ci: add Claude Code Review action (#11) * ci: add Claude Code Review action * ci: use Claude Max OAuth instead of API key Co-Authored-By: Claude Sonnet 4.6 * fix(ci): add id-token permission for OIDC auth --------- Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/claude-review.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/claude-review.yml diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml new file mode 100644 index 0000000..49a3689 --- /dev/null +++ b/.github/workflows/claude-review.yml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + issue_comment: + types: [created] + +jobs: + review: + if: | + (github.event_name == 'pull_request') || + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + steps: + - uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} From 80bdeb524d39527763331cadb691d0befa03073e Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Tue, 21 Apr 2026 22:28:19 +0200 Subject: [PATCH 10/26] chore(ci): bump GitHub Actions to v5 (Node.js 24) (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/checkout v4 → v5 - actions/setup-node v4 → v5 - actions/upload-artifact v4 → v5 - github/codeql-action/init v3 → v4 - github/codeql-action/analyze v3 → v4 Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 40 +++++++++++++++++----------------- .github/workflows/codeql.yml | 6 ++--- .github/workflows/mutation.yml | 6 ++--- .github/workflows/release.yml | 4 ++-- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac1c46b..289d961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,9 @@ jobs: name: Lint (Biome) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm @@ -35,11 +35,11 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm @@ -50,9 +50,9 @@ jobs: name: Typecheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm @@ -63,15 +63,15 @@ jobs: name: Test + Coverage runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test:coverage - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 if: always() with: name: coverage @@ -84,9 +84,9 @@ jobs: if: github.event_name == 'pull_request' continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm @@ -102,16 +102,16 @@ jobs: name: REUSE compliance runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: fsfe/reuse-action@v5 audit: name: Dependency audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm @@ -125,7 +125,7 @@ jobs: name: Gitleaks (secret scan) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: Install gitleaks @@ -141,15 +141,15 @@ jobs: if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') continue-on-error: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm sbom - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 if: always() with: name: sbom @@ -161,9 +161,9 @@ jobs: runs-on: ubuntu-latest needs: [typecheck, test] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 06cffcc..478547f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -31,11 +31,11 @@ jobs: matrix: language: [typescript] steps: - - uses: actions/checkout@v4 - - uses: github/codeql-action/init@v3 + - uses: actions/checkout@v5 + - uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} config-file: ./.github/codeql-config.yml - - uses: github/codeql-action/analyze@v3 + - uses: github/codeql-action/analyze@v4 with: category: /language:${{ matrix.language }} diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 043dffe..e2c5f03 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -16,16 +16,16 @@ jobs: name: Stryker runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test:mutation continue-on-error: true - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 if: always() with: name: mutation-report diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f41bc2b..99c4c98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,11 +21,11 @@ jobs: name: Release via Changesets runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: 22 cache: pnpm From b74dda0d7c07044e187df60bbb3002510bcd4461 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 22 Apr 2026 09:49:16 +0200 Subject: [PATCH 11/26] =?UTF-8?q?feat:=20enforce=20bare=20tool=20names=20i?= =?UTF-8?q?n=20manifest=20=E2=80=94=20prefix=20applied=20at=20runtime=20(#?= =?UTF-8?q?15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool names in mcp-brick.json must now be bare alphanumeric (e.g. "search" not "indexer_search"). The prefix is added by the runtime when exposing tools via MCP (prefix_toolname). - manifest.ts: reject tool names containing non-alphanumeric chars - tool.ts: update JSDoc to reflect new convention - Tests updated to use bare tool names throughout Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- .../src/bootstrap/create-focus-mcp.test.ts | 28 ++++++++----------- packages/core/src/manifest/manifest.ts | 7 +++++ .../core/src/marketplace/resolver.test.ts | 2 +- packages/core/src/types/tool.ts | 2 +- packages/sdk/src/define-brick.test.ts | 26 ++++++++--------- packages/validator/src/validate-brick.test.ts | 8 ++---- 6 files changed, 36 insertions(+), 37 deletions(-) diff --git a/packages/core/src/bootstrap/create-focus-mcp.test.ts b/packages/core/src/bootstrap/create-focus-mcp.test.ts index d6c0737..acd9d99 100644 --- a/packages/core/src/bootstrap/create-focus-mcp.test.ts +++ b/packages/core/src/bootstrap/create-focus-mcp.test.ts @@ -43,7 +43,7 @@ describe('createFocusMcp — assembly', () => { it('enregistre les briques passées dans options.bricks', () => { const app = createFocusMcp({ - bricks: [brick('indexer', [], 'indexer_search', () => 'ok')], + bricks: [brick('indexer', [], 'search', () => 'ok')], }); expect(app.registry.getBrick('indexer')).toBeDefined(); }); @@ -52,7 +52,7 @@ describe('createFocusMcp — assembly', () => { describe('createFocusMcp — lifecycle', () => { it('start() passe les briques en running', async () => { const app = createFocusMcp({ - bricks: [brick('maths', [], 'maths_add', () => ({ ok: true }))], + bricks: [brick('maths', [], 'add', () => ({ ok: true }))], }); await app.start(); expect(app.registry.getStatus('maths')).toBe('running'); @@ -117,7 +117,7 @@ describe('createFocusMcp — lifecycle', () => { it('stop() passe les briques en stopped', async () => { const app = createFocusMcp({ - bricks: [brick('maths', [], 'maths_add', () => 'ok')], + bricks: [brick('maths', [], 'add', () => 'ok')], }); await app.start(); await app.stop(); @@ -135,12 +135,10 @@ describe('createFocusMcp — permissions', () => { prefix: 'idx', description: 'indexer', dependencies: [], - tools: [ - { name: 'indexer_search', description: 'x', inputSchema: { type: 'object' } }, - ], + tools: [{ name: 'search', description: 'x', inputSchema: { type: 'object' } }], }, start(ctx): void { - unsubs.push(ctx.bus.handle('indexer:indexer_search', () => ({ files: [] }))); + unsubs.push(ctx.bus.handle('indexer:search', () => ({ files: [] }))); }, stop(): void { for (const u of unsubs) u(); @@ -158,19 +156,15 @@ describe('createFocusMcp — permissions', () => { description: 'php', dependencies: ['indexer'], tools: [ - { name: 'php_ok', description: 'allowed', inputSchema: { type: 'object' } }, - { name: 'php_ko', description: 'denied', inputSchema: { type: 'object' } }, + { name: 'ok', description: 'allowed', inputSchema: { type: 'object' } }, + { name: 'ko', description: 'denied', inputSchema: { type: 'object' } }, ], }, start(ctx): void { phpUnsubs.push( - ctx.bus.handle('php:php_ok', () => - ctx.bus.request('indexer:indexer_search', {}), - ), - ); - phpUnsubs.push( - ctx.bus.handle('php:php_ko', () => ctx.bus.request('cache:cache_get', {})), + ctx.bus.handle('php:ok', () => ctx.bus.request('indexer:search', {})), ); + phpUnsubs.push(ctx.bus.handle('php:ko', () => ctx.bus.request('cache:get', {}))); }, stop(): void { for (const u of phpUnsubs) u(); @@ -181,8 +175,8 @@ describe('createFocusMcp — permissions', () => { const app = createFocusMcp({ bricks: [indexer, php] }); await app.start(); - await expect(app.bus.request('php:php_ok', {})).resolves.toEqual({ files: [] }); - await expect(app.bus.request('php:php_ko', {})).rejects.toMatchObject({ + await expect(app.bus.request('php:ok', {})).resolves.toEqual({ files: [] }); + await expect(app.bus.request('php:ko', {})).rejects.toMatchObject({ code: 'PERMISSION_DENIED', }); diff --git a/packages/core/src/manifest/manifest.ts b/packages/core/src/manifest/manifest.ts index 7f3dfa4..d0744d9 100644 --- a/packages/core/src/manifest/manifest.ts +++ b/packages/core/src/manifest/manifest.ts @@ -226,6 +226,13 @@ function validateTool(raw: unknown): ToolDefinition { tool: rec, }); } + if (/[^a-z0-9]/.test(name)) { + throw new ManifestError( + `Tool.name "${name}" must be lowercase alphanumeric only (no underscores or special chars)`, + 'INVALID_TOOL', + { tool: name }, + ); + } if (typeof description !== 'string' || description.length === 0) { throw new ManifestError( `Tool "${name}" must have a non-empty description`, diff --git a/packages/core/src/marketplace/resolver.test.ts b/packages/core/src/marketplace/resolver.test.ts index 6dc6cd6..77024a4 100644 --- a/packages/core/src/marketplace/resolver.test.ts +++ b/packages/core/src/marketplace/resolver.test.ts @@ -29,7 +29,7 @@ function validBrick(overrides: Partial = {}): CatalogBrick { version: '1.0.0', description: 'Hello-world brick', dependencies: [], - tools: [{ name: 'echo_say', description: 'Echo' }], + tools: [{ name: 'say', description: 'Echo' }], source: { type: 'local', path: 'bricks/echo' }, ...overrides, }; diff --git a/packages/core/src/types/tool.ts b/packages/core/src/types/tool.ts index 615f13b..4390a0a 100644 --- a/packages/core/src/types/tool.ts +++ b/packages/core/src/types/tool.ts @@ -6,7 +6,7 @@ * Conforme au format MCP officiel (tools/list, tools/call). */ export interface ToolDefinition { - /** Nom du tool (préfixé par la brique au runtime, ex: "indexer_search"). */ + /** Nom du tool sans préfixe (ex: "search"). Exposé au MCP sous la forme "{prefix}_{name}". */ readonly name: string; /** Description lisible par l'AI. */ readonly description: string; diff --git a/packages/sdk/src/define-brick.test.ts b/packages/sdk/src/define-brick.test.ts index d140c40..77af408 100644 --- a/packages/sdk/src/define-brick.test.ts +++ b/packages/sdk/src/define-brick.test.ts @@ -22,7 +22,7 @@ const validManifest = { dependencies: [], tools: [ { - name: 'indexer_search', + name: 'search', description: 'Search files', inputSchema: { type: 'object' as const }, }, @@ -41,7 +41,7 @@ describe('defineBrick — shape', () => { it('retourne un Brick conforme avec manifest, start, stop', () => { const brick = defineBrick({ manifest: validManifest, - handlers: { indexer_search: () => ({ files: [] }) }, + handlers: { search: () => ({ files: [] }) }, }); expect(brick.manifest.name).toBe('indexer'); @@ -53,7 +53,7 @@ describe('defineBrick — shape', () => { expect(() => defineBrick({ manifest: { ...validManifest, name: 'BadName' }, - handlers: { indexer_search: () => 'ok' }, + handlers: { search: () => 'ok' }, }), ).toThrow(expect.objectContaining({ name: 'ManifestError', code: 'INVALID_NAME' })); }); @@ -77,8 +77,8 @@ describe('defineBrick — shape', () => { defineBrick({ manifest: validManifest, handlers: { - indexer_search: () => 'ok', - orphan_tool: () => 'ko', + search: () => 'ok', + orphan: () => 'ko', }, }), ).toThrow( @@ -96,7 +96,7 @@ describe('defineBrick — lifecycle', () => { const brick = defineBrick({ manifest: validManifest, handlers: { - indexer_search: (payload) => { + search: (payload) => { const typed = payload as { q: string }; return { found: typed.q }; }, @@ -105,7 +105,7 @@ describe('defineBrick — lifecycle', () => { await brick.start(makeCtx({ bus })); - await expect(bus.request('indexer:indexer_search', { q: 'foo' })).resolves.toEqual({ + await expect(bus.request('indexer:search', { q: 'foo' })).resolves.toEqual({ found: 'foo', }); }); @@ -118,7 +118,7 @@ describe('defineBrick — lifecycle', () => { const brick = defineBrick({ manifest: validManifest, handlers: { - indexer_search: (_payload, ctx) => { + search: (_payload, ctx) => { ctx.logger.info('called'); return { version: ctx.config['phpVersion'] }; }, @@ -126,7 +126,7 @@ describe('defineBrick — lifecycle', () => { }); await brick.start(makeCtx({ bus, config, logger })); - const result = await bus.request('indexer:indexer_search', null); + const result = await bus.request('indexer:search', null); expect(result).toEqual({ version: '8.3' }); expect(logger.info).toHaveBeenCalledWith('called'); @@ -136,13 +136,13 @@ describe('defineBrick — lifecycle', () => { const bus = new InProcessEventBus(); const brick = defineBrick({ manifest: validManifest, - handlers: { indexer_search: () => 'ok' }, + handlers: { search: () => 'ok' }, }); await brick.start(makeCtx({ bus })); await brick.stop(); - await expect(bus.request('indexer:indexer_search', null)).rejects.toMatchObject({ + await expect(bus.request('indexer:search', null)).rejects.toMatchObject({ code: 'NO_HANDLER', }); }); @@ -150,7 +150,7 @@ describe('defineBrick — lifecycle', () => { it('stop avant start ne throw pas', () => { const brick = defineBrick({ manifest: validManifest, - handlers: { indexer_search: () => 'ok' }, + handlers: { search: () => 'ok' }, }); expect(() => brick.stop()).not.toThrow(); @@ -160,7 +160,7 @@ describe('defineBrick — lifecycle', () => { const bus = new InProcessEventBus(); const brick = defineBrick({ manifest: validManifest, - handlers: { indexer_search: () => 'ok' }, + handlers: { search: () => 'ok' }, }); await brick.start(makeCtx({ bus })); diff --git a/packages/validator/src/validate-brick.test.ts b/packages/validator/src/validate-brick.test.ts index 8247d7e..996bfe5 100644 --- a/packages/validator/src/validate-brick.test.ts +++ b/packages/validator/src/validate-brick.test.ts @@ -27,13 +27,11 @@ function conformingBrick(): Brick { prefix: 'idx', description: 'indexation', dependencies: [], - tools: [ - { name: 'indexer_search', description: 'search', inputSchema: { type: 'object' } }, - ], + tools: [{ name: 'search', description: 'search', inputSchema: { type: 'object' } }], }, start(ctx): void { unsubs.push( - ctx.bus.handle('indexer:indexer_search', () => ({ + ctx.bus.handle('indexer:search', () => ({ content: [{ type: 'text', text: 'ok' }], })), ); @@ -121,7 +119,7 @@ describe('validateBrick — contrat tool/bus', () => { const brick: Brick = { ...conformingBrick(), start(ctx): void { - ctx.bus.handle('indexer:indexer_search', () => { + ctx.bus.handle('indexer:search', () => { throw new Error('nope'); }); }, From 8df9f55d3b1046579af3c83a9ff09a931c986663 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 22 Apr 2026 11:36:07 +0200 Subject: [PATCH 12/26] feat(marketplace): add catalog-store, catalog-fetcher, installer modules (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(marketplace): add catalog-store, catalog-fetcher, installer modules Pure, browser-compatible marketplace management for FocusMCP core: - catalog-store: manage catalog source URLs (CRUD, multi-marketplace) - catalog-fetcher: fetch + aggregate catalogs from multiple sources - installer: plan + execute brick install/remove via npm - resolver: extend CatalogBrickSource with npm source type All modules use dependency injection (IO interfaces) — no direct node: imports. The CLI provides concrete implementations. 257 tests passing, 0 typecheck errors, 0 lint errors. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: export marketplace modules from core package entry point Add catalog-store, catalog-fetcher, and installer exports to index.ts so they are part of the public API and pass knip unused-export checks. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: clean up knip config — remove stale entries Remove deprecated packages/cli workspace, redundant entry patterns (vitest.config, playwright.config), and unused playwright binary ignore. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract shared validation helpers to reduce duplication Move requireObject, requireString, optionalString, requireArray, requireStringArray, optionalStringArray, requireBoolean into a shared helpers.ts module. Imported by resolver, catalog-store, and installer. Reduces jscpd duplication from 1.85% to 0.36% (threshold: 1%). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- config/knip.json | 7 +- packages/core/src/index.ts | 42 ++ .../src/marketplace/catalog-fetcher.test.ts | 277 +++++++ .../core/src/marketplace/catalog-fetcher.ts | 143 ++++ .../src/marketplace/catalog-store.test.ts | 278 +++++++ .../core/src/marketplace/catalog-store.ts | 126 ++++ packages/core/src/marketplace/helpers.ts | 85 +++ .../core/src/marketplace/installer.test.ts | 702 ++++++++++++++++++ packages/core/src/marketplace/installer.ts | 244 ++++++ packages/core/src/marketplace/resolver.ts | 86 +-- 10 files changed, 1918 insertions(+), 72 deletions(-) create mode 100644 packages/core/src/marketplace/catalog-fetcher.test.ts create mode 100644 packages/core/src/marketplace/catalog-fetcher.ts create mode 100644 packages/core/src/marketplace/catalog-store.test.ts create mode 100644 packages/core/src/marketplace/catalog-store.ts create mode 100644 packages/core/src/marketplace/helpers.ts create mode 100644 packages/core/src/marketplace/installer.test.ts create mode 100644 packages/core/src/marketplace/installer.ts diff --git a/config/knip.json b/config/knip.json index fa25cec..fec0e25 100644 --- a/config/knip.json +++ b/config/knip.json @@ -4,8 +4,6 @@ "workspaces": { ".": { "entry": [ - "config/vitest.config.ts", - "config/playwright.config.ts", "config/commitlint.config.js", "config/lint-staged.config.js", "config/stryker.config.json" @@ -14,9 +12,6 @@ }, "packages/core": {}, "packages/sdk": {}, - "packages/cli": { - "entry": ["src/index.ts", "src/bin/*.ts"] - }, "packages/validator": {} }, "ignoreDependencies": [ @@ -24,5 +19,5 @@ "@stryker-mutator/vitest-runner", "fast-check" ], - "ignoreBinaries": ["playwright", "reuse"] + "ignoreBinaries": ["reuse"] } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 68ebcf9..1ddb1c6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,48 @@ export { type ManifestErrorCode, parseManifest, } from './manifest/manifest.ts'; +export { + type AggregatedBrick, + type AggregatedCatalog, + aggregateCatalogs, + type FetchIO, + type FetchResult, + fetchAllCatalogs, + fetchCatalog, + findBrickAcrossCatalogs, + searchBricks, +} from './marketplace/catalog-fetcher.ts'; +export { + addSource, + type CatalogSource, + type CatalogStoreData, + type CatalogStoreIO, + createDefaultStore, + DEFAULT_CATALOG_URL, + disableSource, + enableSource, + getEnabledSources, + listSources, + parseCatalogStore, + removeSource, +} from './marketplace/catalog-store.ts'; +export { + type CenterEntry, + type CenterJson, + type CenterLock, + type CenterLockEntry, + executeInstall, + executeRemove, + type InstallerIO, + type InstallPlan, + parseCenterJson, + parseCenterLock, + planInstall, + planRemove, + satisfiesRange, + serializeCenterJson, + serializeCenterLock, +} from './marketplace/installer.ts'; export { type Catalog, type CatalogBrick, diff --git a/packages/core/src/marketplace/catalog-fetcher.test.ts b/packages/core/src/marketplace/catalog-fetcher.test.ts new file mode 100644 index 0000000..37aee70 --- /dev/null +++ b/packages/core/src/marketplace/catalog-fetcher.test.ts @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from 'vitest'; +import { + type AggregatedBrick, + type AggregatedCatalog, + aggregateCatalogs, + type FetchIO, + type FetchResult, + fetchAllCatalogs, + fetchCatalog, + findBrickAcrossCatalogs, + searchBricks, +} from './catalog-fetcher.ts'; +import type { Catalog, CatalogBrick } from './resolver.ts'; + +// ---------- helpers ---------- + +function validCatalog(overrides: Partial = {}): Catalog { + return { + name: 'Test Catalog', + owner: { name: 'Tester' }, + updated: '2026-04-22T00:00:00.000Z', + bricks: [], + ...overrides, + }; +} + +function validBrick(overrides: Partial = {}): CatalogBrick { + return { + name: 'echo', + version: '1.0.0', + description: 'Hello-world brick', + dependencies: [], + tools: [{ name: 'say', description: 'Echo' }], + source: { type: 'local', path: 'bricks/echo' }, + ...overrides, + }; +} + +function makeFetchIO(map: Record): FetchIO { + return { + fetchJson: async (url: string) => { + if (url in map) return map[url]; + throw new Error(`Not found: ${url}`); + }, + }; +} + +const URL_A = 'https://catalog-a.example.com/catalog.json'; +const URL_B = 'https://catalog-b.example.com/catalog.json'; + +// ---------- fetchCatalog ---------- + +describe('fetchCatalog', () => { + it('returns a FetchResult for a valid catalog URL', async () => { + const catalog = validCatalog({ bricks: [validBrick()] }); + const io = makeFetchIO({ [URL_A]: catalog }); + const result = await fetchCatalog(io, URL_A); + expect('catalog' in result).toBe(true); + if ('catalog' in result) { + expect(result.url).toBe(URL_A); + expect(result.catalog.bricks).toHaveLength(1); + } + }); + + it('returns a FetchError when the URL is not reachable', async () => { + const io = makeFetchIO({}); + const result = await fetchCatalog(io, URL_A); + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.url).toBe(URL_A); + expect(result.error).toMatch(/not found/i); + } + }); + + it('returns a FetchError when the JSON is not a valid catalog', async () => { + const io = makeFetchIO({ [URL_A]: { invalid: true } }); + const result = await fetchCatalog(io, URL_A); + expect('error' in result).toBe(true); + }); + + it('never throws — even on unexpected errors', async () => { + const io: FetchIO = { + fetchJson: async () => { + throw new TypeError('Network failure'); + }, + }; + await expect(fetchCatalog(io, URL_A)).resolves.toHaveProperty('error'); + }); +}); + +// ---------- fetchAllCatalogs ---------- + +describe('fetchAllCatalogs', () => { + it('returns results and errors separately', async () => { + const catalog = validCatalog(); + const io = makeFetchIO({ [URL_A]: catalog }); + const { results, errors } = await fetchAllCatalogs(io, [URL_A, URL_B]); + expect(results).toHaveLength(1); + expect(errors).toHaveLength(1); + }); + + it('handles an empty URL list', async () => { + const io = makeFetchIO({}); + const { results, errors } = await fetchAllCatalogs(io, []); + expect(results).toHaveLength(0); + expect(errors).toHaveLength(0); + }); + + it('returns all results when all URLs are valid', async () => { + const io = makeFetchIO({ + [URL_A]: validCatalog({ name: 'Catalog A' }), + [URL_B]: validCatalog({ name: 'Catalog B' }), + }); + const { results, errors } = await fetchAllCatalogs(io, [URL_A, URL_B]); + expect(results).toHaveLength(2); + expect(errors).toHaveLength(0); + }); +}); + +// ---------- aggregateCatalogs ---------- + +describe('aggregateCatalogs', () => { + it('returns an empty brick list when given no results', () => { + const agg = aggregateCatalogs([]); + expect(agg.bricks).toHaveLength(0); + }); + + it('merges bricks from multiple catalogs', () => { + const resultA: FetchResult = { + url: URL_A, + catalog: validCatalog({ bricks: [validBrick({ name: 'echo' })] }), + }; + const resultB: FetchResult = { + url: URL_B, + catalog: validCatalog({ bricks: [validBrick({ name: 'indexer' })] }), + }; + const agg = aggregateCatalogs([resultA, resultB]); + expect(agg.bricks).toHaveLength(2); + }); + + it('keeps the highest semver when the same brick appears in multiple catalogs', () => { + const resultA: FetchResult = { + url: URL_A, + catalog: validCatalog({ bricks: [validBrick({ name: 'echo', version: '1.0.0' })] }), + }; + const resultB: FetchResult = { + url: URL_B, + catalog: validCatalog({ bricks: [validBrick({ name: 'echo', version: '2.0.0' })] }), + }; + const agg = aggregateCatalogs([resultA, resultB]); + expect(agg.bricks).toHaveLength(1); + expect(agg.bricks[0]?.version).toBe('2.0.0'); + expect(agg.bricks[0]?.catalogUrl).toBe(URL_B); + }); + + it('keeps the first entry when versions are equal', () => { + const resultA: FetchResult = { + url: URL_A, + catalog: validCatalog({ bricks: [validBrick({ name: 'echo', version: '1.0.0' })] }), + }; + const resultB: FetchResult = { + url: URL_B, + catalog: validCatalog({ bricks: [validBrick({ name: 'echo', version: '1.0.0' })] }), + }; + const agg = aggregateCatalogs([resultA, resultB]); + expect(agg.bricks).toHaveLength(1); + expect(agg.bricks[0]?.catalogUrl).toBe(URL_A); + }); + + it('attaches catalogUrl and catalogName to each aggregated brick', () => { + const result: FetchResult = { + url: URL_A, + catalog: validCatalog({ name: 'My Catalog', bricks: [validBrick()] }), + }; + const agg = aggregateCatalogs([result]); + const brick = agg.bricks[0] as AggregatedBrick; + expect(brick.catalogUrl).toBe(URL_A); + expect(brick.catalogName).toBe('My Catalog'); + }); + + it('does not keep a lower version when a higher one was already seen', () => { + const resultA: FetchResult = { + url: URL_A, + catalog: validCatalog({ bricks: [validBrick({ name: 'echo', version: '2.0.0' })] }), + }; + const resultB: FetchResult = { + url: URL_B, + catalog: validCatalog({ bricks: [validBrick({ name: 'echo', version: '1.5.0' })] }), + }; + const agg = aggregateCatalogs([resultA, resultB]); + expect(agg.bricks[0]?.version).toBe('2.0.0'); + }); +}); + +// ---------- searchBricks ---------- + +describe('searchBricks', () => { + function makeAgg(bricks: AggregatedBrick[]): AggregatedCatalog { + return { bricks, errors: [] }; + } + + function aggBrick(overrides: Partial = {}): AggregatedBrick { + return { + ...validBrick(), + catalogUrl: URL_A, + catalogName: 'Test Catalog', + ...overrides, + }; + } + + it('matches by name (case-insensitive)', () => { + const agg = makeAgg([aggBrick({ name: 'echo' }), aggBrick({ name: 'indexer' })]); + expect(searchBricks(agg, 'ECHO')).toHaveLength(1); + expect(searchBricks(agg, 'ECHO')[0]?.name).toBe('echo'); + }); + + it('matches by description (case-insensitive)', () => { + const agg = makeAgg([ + aggBrick({ description: 'Hello world brick' }), + aggBrick({ name: 'other', description: 'Something else' }), + ]); + expect(searchBricks(agg, 'HELLO')).toHaveLength(1); + }); + + it('matches by tag (case-insensitive)', () => { + const agg = makeAgg([ + aggBrick({ tags: ['Search', 'Filesystem'] }), + aggBrick({ name: 'other', tags: ['database'] }), + ]); + expect(searchBricks(agg, 'filesystem')).toHaveLength(1); + }); + + it('returns empty when no bricks match', () => { + const agg = makeAgg([aggBrick()]); + expect(searchBricks(agg, 'zzznomatch')).toHaveLength(0); + }); + + it('returns all bricks when query matches all', () => { + const agg = makeAgg([aggBrick({ name: 'alpha' }), aggBrick({ name: 'alpha-v2' })]); + expect(searchBricks(agg, 'alpha')).toHaveLength(2); + }); +}); + +// ---------- findBrickAcrossCatalogs ---------- + +describe('findBrickAcrossCatalogs', () => { + function makeAgg(bricks: AggregatedBrick[]): AggregatedCatalog { + return { bricks, errors: [] }; + } + + function aggBrick(overrides: Partial = {}): AggregatedBrick { + return { + ...validBrick(), + catalogUrl: URL_A, + catalogName: 'Test Catalog', + ...overrides, + }; + } + + it('returns the brick when found by exact name', () => { + const agg = makeAgg([aggBrick({ name: 'echo' }), aggBrick({ name: 'indexer' })]); + expect(findBrickAcrossCatalogs(agg, 'indexer')?.name).toBe('indexer'); + }); + + it('returns undefined when the brick is not found', () => { + const agg = makeAgg([aggBrick()]); + expect(findBrickAcrossCatalogs(agg, 'missing')).toBeUndefined(); + }); + + it('returns undefined for an empty catalog', () => { + const agg = makeAgg([]); + expect(findBrickAcrossCatalogs(agg, 'echo')).toBeUndefined(); + }); +}); diff --git a/packages/core/src/marketplace/catalog-fetcher.ts b/packages/core/src/marketplace/catalog-fetcher.ts new file mode 100644 index 0000000..3651434 --- /dev/null +++ b/packages/core/src/marketplace/catalog-fetcher.ts @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * Catalog fetcher — pure, browser-compatible. + * + * Fetches and aggregates catalogs from multiple sources. Does no + * direct network I/O: the host injects a FetchIO implementation. + * Deduplication keeps the entry with the highest semver across + * catalogs so bricks from different mirrors compose cleanly. + */ + +import { type Catalog, type CatalogBrick, compareSemver, parseCatalog } from './resolver.ts'; + +export interface FetchIO { + fetchJson(url: string): Promise; +} + +export interface FetchResult { + readonly url: string; + readonly catalog: Catalog; +} + +export interface FetchError { + readonly url: string; + readonly error: string; +} + +export interface AggregatedBrick extends CatalogBrick { + readonly catalogUrl: string; + readonly catalogName: string; +} + +export interface AggregatedCatalog { + readonly bricks: readonly AggregatedBrick[]; + readonly errors: readonly FetchError[]; +} + +// ---------- fetchCatalog ---------- + +/** Fetches and parses a single catalog. Never throws — returns an error string on failure. */ +export async function fetchCatalog(io: FetchIO, url: string): Promise { + try { + const raw = await io.fetchJson(url); + const catalog = parseCatalog(raw); + return { url, catalog }; + } catch (err) { + return { url, error: err instanceof Error ? err.message : String(err) }; + } +} + +// ---------- fetchAllCatalogs ---------- + +export async function fetchAllCatalogs( + io: FetchIO, + urls: readonly string[], +): Promise<{ readonly results: readonly FetchResult[]; readonly errors: readonly FetchError[] }> { + const settled = await Promise.allSettled(urls.map((url) => fetchCatalog(io, url))); + const results: FetchResult[] = []; + const errors: FetchError[] = []; + + for (const outcome of settled) { + if (outcome.status === 'rejected') { + // fetchCatalog itself never rejects, but guard defensively. + errors.push({ url: 'unknown', error: String(outcome.reason) }); + } else { + const value = outcome.value; + if ('catalog' in value) { + results.push(value); + } else { + errors.push(value); + } + } + } + + return { results, errors }; +} + +// ---------- aggregateCatalogs ---------- + +/** + * Merges results from multiple catalogs, keeping only the highest semver + * for each brick name. Bricks from later results override earlier ones + * only when their version is strictly greater. + */ +export function aggregateCatalogs(results: readonly FetchResult[]): AggregatedCatalog { + const map = new Map(); + const errors: FetchError[] = []; + + for (const result of results) { + for (const brick of result.catalog.bricks) { + const existing = map.get(brick.name); + if (existing === undefined) { + map.set(brick.name, { + ...brick, + catalogUrl: result.url, + catalogName: result.catalog.name, + }); + } else { + try { + if (compareSemver(brick.version, existing.version) === 1) { + map.set(brick.name, { + ...brick, + catalogUrl: result.url, + catalogName: result.catalog.name, + }); + } + } catch { + errors.push({ + url: result.url, + error: `Invalid semver for brick "${brick.name}": ${brick.version}`, + }); + } + } + } + } + + return { bricks: Array.from(map.values()), errors }; +} + +// ---------- searchBricks ---------- + +export function searchBricks( + catalog: AggregatedCatalog, + query: string, +): readonly AggregatedBrick[] { + const q = query.toLowerCase(); + return catalog.bricks.filter( + (b) => + b.name.toLowerCase().includes(q) || + b.description.toLowerCase().includes(q) || + (b.tags ?? []).some((t) => t.toLowerCase().includes(q)), + ); +} + +// ---------- findBrickAcrossCatalogs ---------- + +export function findBrickAcrossCatalogs( + catalog: AggregatedCatalog, + name: string, +): AggregatedBrick | undefined { + return catalog.bricks.find((b) => b.name === name); +} diff --git a/packages/core/src/marketplace/catalog-store.test.ts b/packages/core/src/marketplace/catalog-store.test.ts new file mode 100644 index 0000000..9892274 --- /dev/null +++ b/packages/core/src/marketplace/catalog-store.test.ts @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from 'vitest'; +import { + addSource, + type CatalogSource, + type CatalogStoreData, + createDefaultStore, + DEFAULT_CATALOG_URL, + disableSource, + enableSource, + getEnabledSources, + listSources, + parseCatalogStore, + removeSource, +} from './catalog-store.ts'; + +const NOW = '2026-04-22T00:00:00.000Z'; +const EXTRA_URL = 'https://example.com/catalog.json'; + +function makeStore(overrides: Partial = {}): CatalogStoreData { + return { + sources: [ + { + url: DEFAULT_CATALOG_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: NOW, + }, + ], + ...overrides, + }; +} + +function makeSource(overrides: Partial = {}): CatalogSource { + return { + url: EXTRA_URL, + name: 'Extra Catalog', + enabled: true, + addedAt: NOW, + ...overrides, + }; +} + +// ---------- createDefaultStore ---------- + +describe('createDefaultStore', () => { + it('returns a store with one enabled source pointing to the default URL', () => { + const store = createDefaultStore(); + expect(store.sources).toHaveLength(1); + expect(store.sources[0]?.url).toBe(DEFAULT_CATALOG_URL); + expect(store.sources[0]?.enabled).toBe(true); + }); + + it('sets addedAt to the current ISO date', () => { + const before = Date.now(); + const store = createDefaultStore(); + const after = Date.now(); + const source = store.sources[0]; + if (!source) throw new Error('expected at least one source'); + const addedAt = new Date(source.addedAt).getTime(); + expect(addedAt).toBeGreaterThanOrEqual(before); + expect(addedAt).toBeLessThanOrEqual(after); + }); +}); + +// ---------- parseCatalogStore ---------- + +describe('parseCatalogStore', () => { + it('parses a valid store object', () => { + const raw = { + sources: [{ url: DEFAULT_CATALOG_URL, name: 'FocusMCP', enabled: true, addedAt: NOW }], + }; + const store = parseCatalogStore(raw); + expect(store.sources).toHaveLength(1); + expect(store.sources[0]?.url).toBe(DEFAULT_CATALOG_URL); + }); + + it('parses an empty sources array', () => { + const store = parseCatalogStore({ sources: [] }); + expect(store.sources).toHaveLength(0); + }); + + it('rejects non-objects', () => { + expect(() => parseCatalogStore(null)).toThrow(/store/i); + expect(() => parseCatalogStore('string')).toThrow(/store/i); + expect(() => parseCatalogStore(42)).toThrow(/store/i); + }); + + it('rejects missing sources array', () => { + expect(() => parseCatalogStore({})).toThrow(/sources/i); + }); + + it('rejects a source missing url', () => { + expect(() => + parseCatalogStore({ + sources: [{ name: 'X', enabled: true, addedAt: NOW }], + }), + ).toThrow(/url/i); + }); + + it('rejects a source missing name', () => { + expect(() => + parseCatalogStore({ + sources: [{ url: DEFAULT_CATALOG_URL, enabled: true, addedAt: NOW }], + }), + ).toThrow(/name/i); + }); + + it('rejects a source with non-boolean enabled', () => { + expect(() => + parseCatalogStore({ + sources: [{ url: DEFAULT_CATALOG_URL, name: 'X', enabled: 'yes', addedAt: NOW }], + }), + ).toThrow(/enabled/i); + }); +}); + +// ---------- addSource ---------- + +describe('addSource', () => { + it('adds a new source to the store', () => { + const store = makeStore(); + const updated = addSource(store, EXTRA_URL, 'Extra Catalog', NOW); + expect(updated.sources).toHaveLength(2); + expect(updated.sources[1]?.url).toBe(EXTRA_URL); + expect(updated.sources[1]?.enabled).toBe(true); + expect(updated.sources[1]?.addedAt).toBe(NOW); + }); + + it('does not mutate the original store', () => { + const store = makeStore(); + addSource(store, EXTRA_URL, 'Extra', NOW); + expect(store.sources).toHaveLength(1); + }); + + it('rejects adding a duplicate URL', () => { + const store = makeStore(); + expect(() => addSource(store, DEFAULT_CATALOG_URL, 'Duplicate', NOW)).toThrow( + /already exists/i, + ); + }); +}); + +// ---------- removeSource ---------- + +describe('removeSource', () => { + it('removes an existing non-default source', () => { + const store = makeStore({ + sources: [ + { url: DEFAULT_CATALOG_URL, name: 'FocusMCP', enabled: true, addedAt: NOW }, + makeSource(), + ], + }); + const updated = removeSource(store, EXTRA_URL); + expect(updated.sources).toHaveLength(1); + expect(updated.sources[0]?.url).toBe(DEFAULT_CATALOG_URL); + }); + + it('does not mutate the original store', () => { + const store = makeStore({ + sources: [ + { url: DEFAULT_CATALOG_URL, name: 'FocusMCP', enabled: true, addedAt: NOW }, + makeSource(), + ], + }); + removeSource(store, EXTRA_URL); + expect(store.sources).toHaveLength(2); + }); + + it('rejects removing the default catalog URL', () => { + const store = makeStore(); + expect(() => removeSource(store, DEFAULT_CATALOG_URL)).toThrow(/default/i); + }); + + it('rejects removing a URL that does not exist', () => { + const store = makeStore(); + expect(() => removeSource(store, 'https://unknown.example.com/catalog.json')).toThrow( + /not found/i, + ); + }); +}); + +// ---------- enableSource / disableSource ---------- + +describe('enableSource', () => { + it('enables a disabled source', () => { + const store = makeStore({ + sources: [makeSource({ enabled: false })], + }); + const updated = enableSource(store, EXTRA_URL); + expect(updated.sources[0]?.enabled).toBe(true); + }); + + it('is idempotent when already enabled', () => { + const store = makeStore({ sources: [makeSource({ enabled: true })] }); + const updated = enableSource(store, EXTRA_URL); + expect(updated.sources[0]?.enabled).toBe(true); + }); + + it('throws when the source does not exist', () => { + const store = makeStore(); + expect(() => enableSource(store, 'https://ghost.example.com/catalog.json')).toThrow( + /not found/i, + ); + }); +}); + +describe('disableSource', () => { + it('disables an enabled source', () => { + const store = makeStore({ sources: [makeSource({ enabled: true })] }); + const updated = disableSource(store, EXTRA_URL); + expect(updated.sources[0]?.enabled).toBe(false); + }); + + it('is idempotent when already disabled', () => { + const store = makeStore({ sources: [makeSource({ enabled: false })] }); + const updated = disableSource(store, EXTRA_URL); + expect(updated.sources[0]?.enabled).toBe(false); + }); + + it('throws when the source does not exist', () => { + const store = makeStore(); + expect(() => disableSource(store, 'https://ghost.example.com/catalog.json')).toThrow( + /not found/i, + ); + }); +}); + +// ---------- listSources ---------- + +describe('listSources', () => { + it('returns all sources regardless of enabled state', () => { + const store = makeStore({ + sources: [ + makeSource({ url: 'https://a.example.com/catalog.json', enabled: true }), + makeSource({ url: 'https://b.example.com/catalog.json', enabled: false }), + ], + }); + const sources = listSources(store); + expect(sources).toHaveLength(2); + }); + + it('returns an empty array when the store has no sources', () => { + const store = makeStore({ sources: [] }); + expect(listSources(store)).toHaveLength(0); + }); +}); + +// ---------- getEnabledSources ---------- + +describe('getEnabledSources', () => { + it('returns only enabled sources', () => { + const store = makeStore({ + sources: [ + makeSource({ url: 'https://a.example.com/catalog.json', enabled: true }), + makeSource({ url: 'https://b.example.com/catalog.json', enabled: false }), + makeSource({ url: 'https://c.example.com/catalog.json', enabled: true }), + ], + }); + const enabled = getEnabledSources(store); + expect(enabled).toHaveLength(2); + expect(enabled.every((s) => s.enabled)).toBe(true); + }); + + it('returns an empty array when all sources are disabled', () => { + const store = makeStore({ + sources: [makeSource({ enabled: false })], + }); + expect(getEnabledSources(store)).toHaveLength(0); + }); + + it('returns an empty array when the store has no sources', () => { + const store = makeStore({ sources: [] }); + expect(getEnabledSources(store)).toHaveLength(0); + }); +}); diff --git a/packages/core/src/marketplace/catalog-store.ts b/packages/core/src/marketplace/catalog-store.ts new file mode 100644 index 0000000..9e29e33 --- /dev/null +++ b/packages/core/src/marketplace/catalog-store.ts @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { requireBoolean, requireObject, requireString } from './helpers.ts'; + +/** + * Catalog store — pure, browser-compatible. + * + * Manages the list of catalog source URLs the user has registered. + * Does no I/O: the host injects a CatalogStoreIO implementation that + * reads/writes the persisted store. This module validates, normalises + * and mutates in-memory state only. + */ + +export const DEFAULT_CATALOG_URL = 'https://focus-mcp.github.io/marketplace/catalog.json'; + +export interface CatalogSource { + readonly url: string; + readonly name: string; + readonly enabled: boolean; + readonly addedAt: string; +} + +export interface CatalogStoreData { + readonly sources: readonly CatalogSource[]; +} + +export interface CatalogStoreIO { + readStore(): Promise; + writeStore(data: CatalogStoreData): Promise; +} + +// ---------- createDefaultStore ---------- + +export function createDefaultStore(): CatalogStoreData { + return { + sources: [ + { + url: DEFAULT_CATALOG_URL, + name: 'FocusMCP Marketplace', + enabled: true, + addedAt: new Date().toISOString(), + }, + ], + }; +} + +// ---------- parseCatalogStore ---------- + +export function parseCatalogStore(raw: unknown): CatalogStoreData { + const obj = requireObject(raw, 'store'); + const sourcesRaw = obj['sources']; + if (!Array.isArray(sourcesRaw)) { + throw new Error('store.sources must be an array'); + } + const sources = sourcesRaw.map((s, i) => parseSource(s, i)); + return { sources }; +} + +function parseSource(raw: unknown, index: number): CatalogSource { + const loc = `store.sources[${index}]`; + const obj = requireObject(raw, loc); + const url = requireString(obj, 'url', loc); + const name = requireString(obj, 'name', loc); + const enabled = requireBoolean(obj, 'enabled', loc); + const addedAt = requireString(obj, 'addedAt', loc); + return { url, name, enabled, addedAt }; +} + +// ---------- addSource ---------- + +export function addSource( + store: CatalogStoreData, + url: string, + name: string, + now: string = new Date().toISOString(), +): CatalogStoreData { + if (store.sources.some((s) => s.url === url)) { + throw new Error(`Catalog source already exists: ${url}`); + } + const newSource: CatalogSource = { url, name, enabled: true, addedAt: now }; + return { sources: [...store.sources, newSource] }; +} + +// ---------- removeSource ---------- + +export function removeSource(store: CatalogStoreData, url: string): CatalogStoreData { + if (url === DEFAULT_CATALOG_URL) { + throw new Error('Cannot remove the default catalog source'); + } + const filtered = store.sources.filter((s) => s.url !== url); + if (filtered.length === store.sources.length) { + throw new Error(`Catalog source not found: ${url}`); + } + return { sources: filtered }; +} + +// ---------- enableSource / disableSource ---------- + +export function enableSource(store: CatalogStoreData, url: string): CatalogStoreData { + return setEnabled(store, url, true); +} + +export function disableSource(store: CatalogStoreData, url: string): CatalogStoreData { + return setEnabled(store, url, false); +} + +function setEnabled(store: CatalogStoreData, url: string, enabled: boolean): CatalogStoreData { + const found = store.sources.some((s) => s.url === url); + if (!found) { + throw new Error(`Catalog source not found: ${url}`); + } + return { + sources: store.sources.map((s) => (s.url === url ? { ...s, enabled } : s)), + }; +} + +// ---------- listSources / getEnabledSources ---------- + +export function listSources(store: CatalogStoreData): readonly CatalogSource[] { + return store.sources; +} + +export function getEnabledSources(store: CatalogStoreData): readonly CatalogSource[] { + return store.sources.filter((s) => s.enabled); +} diff --git a/packages/core/src/marketplace/helpers.ts b/packages/core/src/marketplace/helpers.ts new file mode 100644 index 0000000..caccdc4 --- /dev/null +++ b/packages/core/src/marketplace/helpers.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * Shared validation helpers for marketplace parsers. + * Pure, browser-compatible — no I/O. + */ + +export function requireObject(raw: unknown, loc: string): Record { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`${loc} must be an object`); + } + return raw as Record; +} + +export function requireString( + obj: Record, + key: string, + parentLoc: string, +): string { + const value = obj[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${parentLoc}.${key} must be a non-empty string`); + } + return value; +} + +export function optionalString( + obj: Record, + key: string, + parentLoc: string, +): string | undefined { + const value = obj[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string') { + throw new Error(`${parentLoc}.${key} must be a string when provided`); + } + return value; +} + +export function requireArray( + obj: Record, + key: string, + parentLoc: string, +): readonly unknown[] { + const value = obj[key]; + if (!Array.isArray(value)) throw new Error(`${parentLoc}.${key} must be an array`); + return value; +} + +export function requireStringArray( + obj: Record, + key: string, + parentLoc: string, +): readonly string[] { + const arr = requireArray(obj, key, parentLoc); + for (const item of arr) { + if (typeof item !== 'string') { + throw new Error(`${parentLoc}.${key} must contain only strings`); + } + } + return arr as readonly string[]; +} + +export function optionalStringArray( + obj: Record, + key: string, + parentLoc: string, +): readonly string[] | undefined { + const value = obj[key]; + if (value === undefined) return undefined; + return requireStringArray(obj, key, parentLoc); +} + +export function requireBoolean( + obj: Record, + key: string, + parentLoc: string, +): boolean { + const value = obj[key]; + if (typeof value !== 'boolean') { + throw new Error(`${parentLoc}.${key} must be a boolean`); + } + return value; +} diff --git a/packages/core/src/marketplace/installer.test.ts b/packages/core/src/marketplace/installer.test.ts new file mode 100644 index 0000000..9efe415 --- /dev/null +++ b/packages/core/src/marketplace/installer.test.ts @@ -0,0 +1,702 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type CenterJson, + type CenterLock, + executeInstall, + executeRemove, + type InstallerIO, + type InstallPlan, + parseCenterJson, + parseCenterLock, + planInstall, + planRemove, + satisfiesRange, + serializeCenterJson, + serializeCenterLock, +} from './installer.ts'; +import type { CatalogBrick } from './resolver.ts'; + +// ---------- helpers ---------- + +/** Extracts the first argument of the first call of a vi.fn() mock. */ +function firstCallArg(mock: InstallerIO[keyof InstallerIO]): T { + const calls = (mock as ReturnType).mock.calls as unknown[][]; + return (calls[0] as unknown[])[0] as T; +} + +function makeIO(overrides: Partial = {}): InstallerIO { + return { + npmInstall: vi.fn().mockResolvedValue(undefined), + npmUninstall: vi.fn().mockResolvedValue(undefined), + writeCenterJson: vi.fn().mockResolvedValue(undefined), + writeCenterLock: vi.fn().mockResolvedValue(undefined), + readCenterJson: vi.fn().mockResolvedValue({}), + readCenterLock: vi.fn().mockResolvedValue({}), + ...overrides, + }; +} + +function validCenterJson(overrides: Partial = {}): CenterJson { + return { + bricks: { + echo: { version: '1.0.0', enabled: true }, + }, + ...overrides, + }; +} + +function validCenterLock(overrides: Partial = {}): CenterLock { + return { + bricks: { + echo: { + version: '1.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + npmPackage: '@focusmcp/brick-echo', + installedAt: '2026-04-01T00:00:00.000Z', + }, + }, + ...overrides, + }; +} + +function validNpmBrick(overrides: Partial = {}): CatalogBrick { + return { + name: 'echo', + version: '1.2.3', + description: 'Echo brick', + dependencies: [], + tools: [{ name: 'say', description: 'Echo text' }], + source: { type: 'npm', package: '@focusmcp/brick-echo' }, + ...overrides, + }; +} + +// ---------- parseCenterJson ---------- + +describe('parseCenterJson', () => { + it('parses a well-formed center.json', () => { + const raw = { + bricks: { + echo: { version: '1.0.0', enabled: true }, + }, + }; + const result = parseCenterJson(raw); + expect(result.bricks['echo']).toEqual({ version: '1.0.0', enabled: true }); + }); + + it('parses an entry with an optional config field', () => { + const raw = { + bricks: { + echo: { version: '1.0.0', enabled: false, config: { timeout: 5000 } }, + }, + }; + const result = parseCenterJson(raw); + expect(result.bricks['echo']?.config).toEqual({ timeout: 5000 }); + }); + + it('parses an empty bricks object', () => { + const result = parseCenterJson({ bricks: {} }); + expect(result.bricks).toEqual({}); + }); + + it('rejects non-objects at the root', () => { + expect(() => parseCenterJson(null)).toThrow(/center\.json/i); + expect(() => parseCenterJson('string')).toThrow(/center\.json/i); + expect(() => parseCenterJson(42)).toThrow(/center\.json/i); + expect(() => parseCenterJson([])).toThrow(/center\.json/i); + }); + + it('rejects when bricks is not an object', () => { + expect(() => parseCenterJson({ bricks: [] })).toThrow(/bricks/i); + expect(() => parseCenterJson({ bricks: 'nope' })).toThrow(/bricks/i); + expect(() => parseCenterJson({ bricks: null })).toThrow(/bricks/i); + }); + + it('rejects a brick entry missing version', () => { + expect(() => parseCenterJson({ bricks: { echo: { enabled: true } } })).toThrow(/version/i); + }); + + it('rejects a brick entry with an empty version string', () => { + expect(() => parseCenterJson({ bricks: { echo: { version: '', enabled: true } } })).toThrow( + /version/i, + ); + }); + + it('rejects a brick entry missing enabled', () => { + expect(() => parseCenterJson({ bricks: { echo: { version: '1.0.0' } } })).toThrow( + /enabled/i, + ); + }); + + it('rejects a brick entry where enabled is not a boolean', () => { + expect(() => + parseCenterJson({ bricks: { echo: { version: '1.0.0', enabled: 'yes' } } }), + ).toThrow(/enabled/i); + }); + + it('rejects a brick entry where config is not an object', () => { + expect(() => + parseCenterJson({ + bricks: { echo: { version: '1.0.0', enabled: true, config: 'bad' } }, + }), + ).toThrow(/config/i); + }); +}); + +// ---------- parseCenterLock ---------- + +describe('parseCenterLock', () => { + it('parses a well-formed center.lock', () => { + const raw = { + bricks: { + echo: { + version: '1.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + npmPackage: '@focusmcp/brick-echo', + installedAt: '2026-04-01T00:00:00.000Z', + }, + }, + }; + const result = parseCenterLock(raw); + expect(result.bricks['echo']).toEqual(raw.bricks.echo); + }); + + it('parses an empty bricks object', () => { + const result = parseCenterLock({ bricks: {} }); + expect(result.bricks).toEqual({}); + }); + + it('rejects non-objects at the root', () => { + expect(() => parseCenterLock(null)).toThrow(/center\.lock/i); + expect(() => parseCenterLock(42)).toThrow(/center\.lock/i); + expect(() => parseCenterLock([])).toThrow(/center\.lock/i); + }); + + it('rejects when bricks is not an object', () => { + expect(() => parseCenterLock({ bricks: [] })).toThrow(/bricks/i); + expect(() => parseCenterLock({ bricks: null })).toThrow(/bricks/i); + }); + + it('rejects a lock entry missing version', () => { + expect(() => + parseCenterLock({ + bricks: { + echo: { + catalogUrl: 'https://x.example', + npmPackage: '@x/y', + installedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }), + ).toThrow(/version/i); + }); + + it('rejects a lock entry missing catalogUrl', () => { + expect(() => + parseCenterLock({ + bricks: { + echo: { + version: '1.0.0', + npmPackage: '@x/y', + installedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }), + ).toThrow(/catalogUrl/i); + }); + + it('rejects a lock entry missing npmPackage', () => { + expect(() => + parseCenterLock({ + bricks: { + echo: { + version: '1.0.0', + catalogUrl: 'https://x.example', + installedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }), + ).toThrow(/npmPackage/i); + }); + + it('rejects a lock entry missing installedAt', () => { + expect(() => + parseCenterLock({ + bricks: { + echo: { + version: '1.0.0', + catalogUrl: 'https://x.example', + npmPackage: '@x/y', + }, + }, + }), + ).toThrow(/installedAt/i); + }); +}); + +// ---------- serializeCenterJson ---------- + +describe('serializeCenterJson', () => { + it('roundtrips through parseCenterJson', () => { + const original = parseCenterJson({ + bricks: { + echo: { version: '1.0.0', enabled: true }, + indexer: { version: '2.3.4', enabled: false, config: { limit: 100 } }, + }, + }); + const serialized = serializeCenterJson(original); + const reparsed = parseCenterJson(serialized); + expect(reparsed).toEqual(original); + }); + + it('returns an object with a bricks key', () => { + const data: CenterJson = { bricks: { echo: { version: '1.0.0', enabled: true } } }; + const result = serializeCenterJson(data) as Record; + expect(result).toHaveProperty('bricks'); + expect((result['bricks'] as Record)['echo']).toEqual({ + version: '1.0.0', + enabled: true, + }); + }); + + it('returns an empty bricks object when there are no entries', () => { + const data: CenterJson = { bricks: {} }; + const result = serializeCenterJson(data) as { bricks: Record }; + expect(result.bricks).toEqual({}); + }); +}); + +// ---------- serializeCenterLock ---------- + +describe('serializeCenterLock', () => { + it('roundtrips through parseCenterLock', () => { + const raw = { + bricks: { + echo: { + version: '1.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + npmPackage: '@focusmcp/brick-echo', + installedAt: '2026-04-01T00:00:00.000Z', + }, + }, + }; + const original = parseCenterLock(raw); + const serialized = serializeCenterLock(original); + const reparsed = parseCenterLock(serialized); + expect(reparsed).toEqual(original); + }); + + it('returns an object with a bricks key', () => { + const data: CenterLock = { + bricks: { + echo: { + version: '1.0.0', + catalogUrl: 'https://x', + npmPackage: '@x/y', + installedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }; + const result = serializeCenterLock(data) as Record; + expect(result).toHaveProperty('bricks'); + }); + + it('returns an empty bricks object when there are no entries', () => { + const data: CenterLock = { bricks: {} }; + const result = serializeCenterLock(data) as { bricks: Record }; + expect(result.bricks).toEqual({}); + }); +}); + +// ---------- planInstall ---------- + +describe('planInstall', () => { + const catalogUrl = 'https://marketplace.focusmcp.dev/catalog.json'; + + it('returns a valid InstallPlan for an npm brick', () => { + const brick = validNpmBrick(); + const plan = planInstall(brick, catalogUrl); + expect(plan).toEqual({ + name: 'echo', + npmPackage: '@focusmcp/brick-echo', + version: '1.2.3', + catalogUrl, + }); + }); + + it('includes registry when the source specifies one', () => { + const brick = validNpmBrick({ + source: { + type: 'npm', + package: '@focusmcp/brick-echo', + registry: 'https://my.registry', + }, + }); + const plan = planInstall(brick, catalogUrl); + expect(plan.registry).toBe('https://my.registry'); + }); + + it('omits registry when the source does not specify one', () => { + const brick = validNpmBrick(); + const plan = planInstall(brick, catalogUrl); + expect(plan).not.toHaveProperty('registry'); + }); + + it('throws when the source type is not npm', () => { + const brick = validNpmBrick({ + source: { type: 'local', path: 'bricks/echo' }, + }); + expect(() => planInstall(brick, catalogUrl)).toThrow(/npm/i); + expect(() => planInstall(brick, catalogUrl)).toThrow(/echo/i); + }); + + it('throws for url source type', () => { + const brick = validNpmBrick({ + source: { type: 'url', url: 'https://example.com/brick.tgz' }, + }); + expect(() => planInstall(brick, catalogUrl)).toThrow(/url/i); + }); +}); + +// ---------- planRemove ---------- + +describe('planRemove', () => { + it('returns the npm package for an installed brick', () => { + const centerJson = validCenterJson(); + const centerLock = validCenterLock(); + const result = planRemove('echo', centerJson, centerLock); + expect(result.npmPackage).toBe('@focusmcp/brick-echo'); + }); + + it('throws when the brick is not in center.json', () => { + const centerJson = validCenterJson(); + const centerLock = validCenterLock(); + expect(() => planRemove('missing', centerJson, centerLock)).toThrow(/missing/i); + expect(() => planRemove('missing', centerJson, centerLock)).toThrow(/not installed/i); + }); + + it('throws when the brick is in center.json but has no lock entry', () => { + const centerJson: CenterJson = { + bricks: { orphan: { version: '1.0.0', enabled: true } }, + }; + const centerLock: CenterLock = { bricks: {} }; + expect(() => planRemove('orphan', centerJson, centerLock)).toThrow(/orphan/i); + expect(() => planRemove('orphan', centerJson, centerLock)).toThrow(/lock entry/i); + }); +}); + +// ---------- executeInstall ---------- + +describe('executeInstall', () => { + let io: InstallerIO; + const now = '2026-04-22T00:00:00.000Z'; + + beforeEach(() => { + io = makeIO(); + }); + + it('calls npmInstall with the correct package and version', async () => { + const plan: InstallPlan = { + name: 'echo', + npmPackage: '@focusmcp/brick-echo', + version: '1.2.3', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + }; + await executeInstall(io, plan, validCenterJson(), validCenterLock(), now); + expect(io.npmInstall).toHaveBeenCalledWith('@focusmcp/brick-echo', '1.2.3', {}); + }); + + it('passes the registry option to npmInstall when provided', async () => { + const plan: InstallPlan = { + name: 'echo', + npmPackage: '@focusmcp/brick-echo', + version: '1.2.3', + registry: 'https://my.registry', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + }; + await executeInstall(io, plan, validCenterJson(), validCenterLock(), now); + expect(io.npmInstall).toHaveBeenCalledWith('@focusmcp/brick-echo', '1.2.3', { + registry: 'https://my.registry', + }); + }); + + it('writes the updated center.json with the new brick entry', async () => { + const plan: InstallPlan = { + name: 'indexer', + npmPackage: '@focusmcp/brick-indexer', + version: '2.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + }; + const centerJson: CenterJson = { bricks: {} }; + const centerLock: CenterLock = { bricks: {} }; + await executeInstall(io, plan, centerJson, centerLock, now); + + const written = firstCallArg(io.writeCenterJson); + expect(written.bricks['indexer']).toEqual({ version: '2.0.0', enabled: true }); + }); + + it('writes the updated center.lock with the new lock entry', async () => { + const plan: InstallPlan = { + name: 'indexer', + npmPackage: '@focusmcp/brick-indexer', + version: '2.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + }; + const centerJson: CenterJson = { bricks: {} }; + const centerLock: CenterLock = { bricks: {} }; + await executeInstall(io, plan, centerJson, centerLock, now); + + const written = firstCallArg(io.writeCenterLock); + expect(written.bricks['indexer']).toEqual({ + version: '2.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + npmPackage: '@focusmcp/brick-indexer', + installedAt: now, + }); + }); + + it('preserves existing entries when adding a new brick', async () => { + const plan: InstallPlan = { + name: 'indexer', + npmPackage: '@focusmcp/brick-indexer', + version: '2.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + }; + await executeInstall(io, plan, validCenterJson(), validCenterLock(), now); + + const writtenJson = firstCallArg(io.writeCenterJson); + expect(writtenJson.bricks['echo']).toBeDefined(); + expect(writtenJson.bricks['indexer']).toBeDefined(); + }); + + it('calls writeCenterJson before writeCenterLock', async () => { + const callOrder: string[] = []; + io = makeIO({ + writeCenterJson: vi.fn().mockImplementation(() => { + callOrder.push('json'); + return Promise.resolve(); + }), + writeCenterLock: vi.fn().mockImplementation(() => { + callOrder.push('lock'); + return Promise.resolve(); + }), + }); + const plan: InstallPlan = { + name: 'echo', + npmPackage: '@focusmcp/brick-echo', + version: '1.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + }; + await executeInstall(io, plan, { bricks: {} }, { bricks: {} }, now); + expect(callOrder).toEqual(['json', 'lock']); + }); + + it('uses the current timestamp when now is not provided', async () => { + const before = new Date().toISOString(); + const plan: InstallPlan = { + name: 'echo', + npmPackage: '@focusmcp/brick-echo', + version: '1.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + }; + await executeInstall(io, plan, { bricks: {} }, { bricks: {} }); + const after = new Date().toISOString(); + + const written = firstCallArg(io.writeCenterLock); + const installedAt = (written.bricks['echo'] as { installedAt: string }).installedAt; + expect(installedAt >= before).toBe(true); + expect(installedAt <= after).toBe(true); + }); +}); + +// ---------- executeRemove ---------- + +describe('executeRemove', () => { + let io: InstallerIO; + + beforeEach(() => { + io = makeIO(); + }); + + it('calls npmUninstall with the correct package name', async () => { + await executeRemove( + io, + 'echo', + '@focusmcp/brick-echo', + validCenterJson(), + validCenterLock(), + ); + expect(io.npmUninstall).toHaveBeenCalledWith('@focusmcp/brick-echo'); + }); + + it('removes the brick entry from center.json', async () => { + await executeRemove( + io, + 'echo', + '@focusmcp/brick-echo', + validCenterJson(), + validCenterLock(), + ); + + const written = firstCallArg(io.writeCenterJson); + expect(written.bricks['echo']).toBeUndefined(); + }); + + it('removes the brick entry from center.lock', async () => { + await executeRemove( + io, + 'echo', + '@focusmcp/brick-echo', + validCenterJson(), + validCenterLock(), + ); + + const written = firstCallArg(io.writeCenterLock); + expect(written.bricks['echo']).toBeUndefined(); + }); + + it('preserves other bricks when removing one', async () => { + const centerJson: CenterJson = { + bricks: { + echo: { version: '1.0.0', enabled: true }, + indexer: { version: '2.0.0', enabled: true }, + }, + }; + const centerLock: CenterLock = { + bricks: { + echo: { + version: '1.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + npmPackage: '@focusmcp/brick-echo', + installedAt: '2026-04-01T00:00:00.000Z', + }, + indexer: { + version: '2.0.0', + catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', + npmPackage: '@focusmcp/brick-indexer', + installedAt: '2026-04-02T00:00:00.000Z', + }, + }, + }; + await executeRemove(io, 'echo', '@focusmcp/brick-echo', centerJson, centerLock); + + const writtenJson = firstCallArg(io.writeCenterJson); + expect(writtenJson.bricks['echo']).toBeUndefined(); + expect(writtenJson.bricks['indexer']).toBeDefined(); + + const writtenLock = firstCallArg(io.writeCenterLock); + expect(writtenLock.bricks['echo']).toBeUndefined(); + expect(writtenLock.bricks['indexer']).toBeDefined(); + }); + + it('writes center.json and center.lock exactly once each', async () => { + await executeRemove( + io, + 'echo', + '@focusmcp/brick-echo', + validCenterJson(), + validCenterLock(), + ); + expect(io.writeCenterJson).toHaveBeenCalledTimes(1); + expect(io.writeCenterLock).toHaveBeenCalledTimes(1); + }); +}); + +// ---------- satisfiesRange ---------- + +describe('satisfiesRange', () => { + describe('wildcard (*)', () => { + it('matches any version', () => { + expect(satisfiesRange('1.0.0', '*')).toBe(true); + expect(satisfiesRange('0.0.1', '*')).toBe(true); + expect(satisfiesRange('99.99.99', '*')).toBe(true); + expect(satisfiesRange('1.0.0-alpha', '*')).toBe(true); + }); + }); + + describe('exact match', () => { + it('returns true for the exact same version', () => { + expect(satisfiesRange('1.2.3', '1.2.3')).toBe(true); + }); + + it('returns false for different versions', () => { + expect(satisfiesRange('1.2.3', '1.2.4')).toBe(false); + expect(satisfiesRange('1.2.3', '1.3.0')).toBe(false); + expect(satisfiesRange('2.0.0', '1.0.0')).toBe(false); + }); + + it('returns true for equal pre-release versions', () => { + expect(satisfiesRange('1.0.0-alpha', '1.0.0-alpha')).toBe(true); + }); + + it('returns false for mismatched pre-release identifiers', () => { + expect(satisfiesRange('1.0.0-alpha', '1.0.0-beta')).toBe(false); + }); + }); + + describe('caret range (^)', () => { + it('accepts the exact target version', () => { + expect(satisfiesRange('1.2.3', '^1.2.3')).toBe(true); + }); + + it('accepts a newer patch within the same major', () => { + expect(satisfiesRange('1.2.4', '^1.2.3')).toBe(true); + expect(satisfiesRange('1.9.0', '^1.2.3')).toBe(true); + }); + + it('accepts a newer minor within the same major', () => { + expect(satisfiesRange('1.3.0', '^1.2.3')).toBe(true); + }); + + it('rejects a version older than the target', () => { + expect(satisfiesRange('1.2.2', '^1.2.3')).toBe(false); + expect(satisfiesRange('1.1.9', '^1.2.3')).toBe(false); + }); + + it('rejects a version with a different major', () => { + expect(satisfiesRange('2.0.0', '^1.2.3')).toBe(false); + expect(satisfiesRange('0.9.0', '^1.0.0')).toBe(false); + }); + }); + + describe('tilde range (~)', () => { + it('accepts the exact target version', () => { + expect(satisfiesRange('1.2.3', '~1.2.3')).toBe(true); + }); + + it('accepts a newer patch within the same major.minor', () => { + expect(satisfiesRange('1.2.4', '~1.2.3')).toBe(true); + expect(satisfiesRange('1.2.9', '~1.2.3')).toBe(true); + }); + + it('rejects a version with a different minor', () => { + expect(satisfiesRange('1.3.0', '~1.2.3')).toBe(false); + expect(satisfiesRange('1.1.9', '~1.2.3')).toBe(false); + }); + + it('rejects a version with a different major', () => { + expect(satisfiesRange('2.2.3', '~1.2.3')).toBe(false); + }); + + it('rejects a version older than the target', () => { + expect(satisfiesRange('1.2.2', '~1.2.3')).toBe(false); + }); + }); + + describe('malformed input', () => { + it('throws on a malformed version', () => { + expect(() => satisfiesRange('not-semver', '1.0.0')).toThrow(/semver/i); + }); + + it('throws on a malformed caret range target', () => { + expect(() => satisfiesRange('1.0.0', '^not-semver')).toThrow(/semver/i); + }); + + it('throws on a malformed tilde range target', () => { + expect(() => satisfiesRange('1.0.0', '~not-semver')).toThrow(/semver/i); + }); + }); +}); diff --git a/packages/core/src/marketplace/installer.ts b/packages/core/src/marketplace/installer.ts new file mode 100644 index 0000000..e3797a5 --- /dev/null +++ b/packages/core/src/marketplace/installer.ts @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: 2026 FocusMCP contributors +// SPDX-License-Identifier: MIT + +/** + * Installer — pure, browser-compatible. + * + * Plans and executes install/remove operations for catalog bricks. + * Does no direct I/O: the host injects an InstallerIO implementation + * that calls npm and reads/writes the center JSON and lock files. + */ + +import { requireBoolean, requireObject, requireString } from './helpers.ts'; +import { type CatalogBrick, compareSemver } from './resolver.ts'; + +export { compareSemver }; + +export interface InstallerIO { + npmInstall(pkg: string, version: string, opts?: { registry?: string }): Promise; + npmUninstall(pkg: string, opts?: { registry?: string }): Promise; + writeCenterJson(data: CenterJson): Promise; + writeCenterLock(data: CenterLock): Promise; + readCenterJson(): Promise; + readCenterLock(): Promise; +} + +export interface CenterEntry { + readonly version: string; + readonly enabled: boolean; + readonly config?: Record; +} + +export interface CenterLockEntry { + readonly version: string; + readonly catalogUrl: string; + readonly npmPackage: string; + readonly installedAt: string; +} + +export interface CenterJson { + readonly bricks: Record; +} + +export interface CenterLock { + readonly bricks: Record; +} + +export interface InstallPlan { + readonly name: string; + readonly npmPackage: string; + readonly version: string; + readonly registry?: string; + readonly catalogUrl: string; +} + +// ---------- parseCenterJson ---------- + +export function parseCenterJson(raw: unknown): CenterJson { + const obj = requireObject(raw, 'center.json'); + const bricksRaw = obj['bricks']; + const bricksObj = requireObject(bricksRaw, 'center.json.bricks'); + const bricks: Record = {}; + for (const [key, value] of Object.entries(bricksObj)) { + bricks[key] = parseCenterEntry(value, `center.json.bricks.${key}`); + } + return { bricks }; +} + +function parseCenterEntry(raw: unknown, loc: string): CenterEntry { + const obj = requireObject(raw, loc); + const version = requireString(obj, 'version', loc); + const enabled = requireBoolean(obj, 'enabled', loc); + const config = optionalRecord(obj, 'config', loc); + return { version, enabled, ...(config !== undefined ? { config } : {}) }; +} + +// ---------- parseCenterLock ---------- + +export function parseCenterLock(raw: unknown): CenterLock { + const obj = requireObject(raw, 'center.lock'); + const bricksRaw = obj['bricks']; + const bricksObj = requireObject(bricksRaw, 'center.lock.bricks'); + const bricks: Record = {}; + for (const [key, value] of Object.entries(bricksObj)) { + bricks[key] = parseLockEntry(value, `center.lock.bricks.${key}`); + } + return { bricks }; +} + +function parseLockEntry(raw: unknown, loc: string): CenterLockEntry { + const obj = requireObject(raw, loc); + const version = requireString(obj, 'version', loc); + const catalogUrl = requireString(obj, 'catalogUrl', loc); + const npmPackage = requireString(obj, 'npmPackage', loc); + const installedAt = requireString(obj, 'installedAt', loc); + return { version, catalogUrl, npmPackage, installedAt }; +} + +// ---------- serializeCenterJson ---------- + +export function serializeCenterJson(data: CenterJson): unknown { + return { bricks: data.bricks }; +} + +// ---------- serializeCenterLock ---------- + +export function serializeCenterLock(data: CenterLock): unknown { + return { bricks: data.bricks }; +} + +// ---------- planInstall ---------- + +export function planInstall(brick: CatalogBrick, catalogUrl: string): InstallPlan { + const source = brick.source; + if (source.type !== 'npm') { + throw new Error( + `Cannot plan npm install for brick "${brick.name}": source type is "${source.type}", expected "npm"`, + ); + } + return { + name: brick.name, + npmPackage: source.package, + version: brick.version, + ...(source.registry !== undefined ? { registry: source.registry } : {}), + catalogUrl, + }; +} + +// ---------- planRemove ---------- + +export function planRemove( + name: string, + centerJson: CenterJson, + centerLock: CenterLock, +): { readonly npmPackage: string } { + if (!(name in centerJson.bricks)) { + throw new Error(`Brick "${name}" is not installed`); + } + const lock = centerLock.bricks[name]; + if (lock === undefined) { + throw new Error(`Lock entry not found for brick "${name}"`); + } + return { npmPackage: lock.npmPackage }; +} + +// ---------- executeInstall ---------- + +export async function executeInstall( + io: InstallerIO, + plan: InstallPlan, + centerJson: CenterJson, + centerLock: CenterLock, + now: string = new Date().toISOString(), +): Promise { + await io.npmInstall(plan.npmPackage, plan.version, { + ...(plan.registry !== undefined ? { registry: plan.registry } : {}), + }); + + const newEntry: CenterEntry = { version: plan.version, enabled: true }; + const newLockEntry: CenterLockEntry = { + version: plan.version, + catalogUrl: plan.catalogUrl, + npmPackage: plan.npmPackage, + installedAt: now, + }; + + const updatedJson: CenterJson = { + bricks: { ...centerJson.bricks, [plan.name]: newEntry }, + }; + const updatedLock: CenterLock = { + bricks: { ...centerLock.bricks, [plan.name]: newLockEntry }, + }; + + await io.writeCenterJson(updatedJson); + await io.writeCenterLock(updatedLock); +} + +// ---------- executeRemove ---------- + +export async function executeRemove( + io: InstallerIO, + name: string, + npmPackage: string, + centerJson: CenterJson, + centerLock: CenterLock, +): Promise { + await io.npmUninstall(npmPackage); + + const updatedBricksJson = { ...centerJson.bricks }; + delete updatedBricksJson[name]; + const updatedJson: CenterJson = { bricks: updatedBricksJson }; + + const updatedBricksLock = { ...centerLock.bricks }; + delete updatedBricksLock[name]; + const updatedLock: CenterLock = { bricks: updatedBricksLock }; + + await io.writeCenterJson(updatedJson); + await io.writeCenterLock(updatedLock); +} + +// ---------- satisfiesRange ---------- + +/** + * Checks whether `version` satisfies `range`. + * Supports: `*` (any), `^` (same major), `~` (same major.minor), exact match. + * Throws on malformed semver input (delegated to compareSemver). + */ +export function satisfiesRange(version: string, range: string): boolean { + if (range === '*') return true; + + if (range.startsWith('^')) { + const target = range.slice(1); + const cmp = compareSemver(version, target); + if (cmp === -1) return false; + const [vMaj] = version.split('.'); + const [tMaj] = target.split('.'); + return vMaj === tMaj; + } + + if (range.startsWith('~')) { + const target = range.slice(1); + const cmp = compareSemver(version, target); + if (cmp === -1) return false; + const [vMaj, vMin] = version.split('.'); + const [tMaj, tMin] = target.split('.'); + return vMaj === tMaj && vMin === tMin; + } + + return compareSemver(version, range) === 0; +} + +// ---------- helpers ---------- + +function optionalRecord( + obj: Record, + key: string, + parentLoc: string, +): Record | undefined { + const value = obj[key]; + if (value === undefined) return undefined; + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${parentLoc}.${key} must be an object when provided`); + } + return value as Record; +} diff --git a/packages/core/src/marketplace/resolver.ts b/packages/core/src/marketplace/resolver.ts index 6766ba6..5b440b6 100644 --- a/packages/core/src/marketplace/resolver.ts +++ b/packages/core/src/marketplace/resolver.ts @@ -1,6 +1,15 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT +import { + optionalString, + optionalStringArray, + requireArray, + requireObject, + requireString, + requireStringArray, +} from './helpers.ts'; + /** * Marketplace resolver — pure, browser-compatible. * @@ -33,7 +42,8 @@ export type CatalogBrickSource = readonly path: string; readonly ref: string; readonly sha?: string; - }; + } + | { readonly type: 'npm'; readonly package: string; readonly registry?: string }; export interface CatalogBrick { readonly name: string; @@ -188,8 +198,16 @@ function parseSource(raw: unknown, parentLoc: string): CatalogBrickSource { ...(sha !== undefined ? { sha } : {}), }; } + if (type === 'npm') { + const registry = optionalString(obj, 'registry', loc); + return { + type: 'npm', + package: requireString(obj, 'package', loc), + ...(registry !== undefined ? { registry } : {}), + }; + } throw new Error( - `${loc}.type must be "local", "url" or "git-subdir", got ${JSON.stringify(type)}`, + `${loc}.type must be "local", "url", "git-subdir" or "npm", got ${JSON.stringify(type)}`, ); } @@ -276,67 +294,3 @@ export function listUpdates( } return updates; } - -// ---------- helpers ---------- - -function requireObject(raw: unknown, loc: string): Record { - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - throw new Error(`${loc} must be an object`); - } - return raw as Record; -} - -function requireString(obj: Record, key: string, parentLoc: string): string { - const value = obj[key]; - if (typeof value !== 'string' || value.length === 0) { - throw new Error(`${parentLoc}.${key} must be a non-empty string`); - } - return value; -} - -function optionalString( - obj: Record, - key: string, - parentLoc: string, -): string | undefined { - const value = obj[key]; - if (value === undefined) return undefined; - if (typeof value !== 'string') { - throw new Error(`${parentLoc}.${key} must be a string when provided`); - } - return value; -} - -function requireArray( - obj: Record, - key: string, - parentLoc: string, -): readonly unknown[] { - const value = obj[key]; - if (!Array.isArray(value)) throw new Error(`${parentLoc}.${key} must be an array`); - return value; -} - -function requireStringArray( - obj: Record, - key: string, - parentLoc: string, -): readonly string[] { - const arr = requireArray(obj, key, parentLoc); - for (const item of arr) { - if (typeof item !== 'string') { - throw new Error(`${parentLoc}.${key} must contain only strings`); - } - } - return arr as readonly string[]; -} - -function optionalStringArray( - obj: Record, - key: string, - parentLoc: string, -): readonly string[] | undefined { - const value = obj[key]; - if (value === undefined) return undefined; - return requireStringArray(obj, key, parentLoc); -} From 843d164682c0f7b573c3c93bd20fa09666eccc41 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 22 Apr 2026 14:02:36 +0200 Subject: [PATCH 13/26] feat: update default catalog URL to raw.githubusercontent.com (#17) Switch from gh-pages to raw GitHub content serving for the catalog. URL: https://raw.githubusercontent.com/focus-mcp/marketplace/develop/publish/catalog.json Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- packages/core/src/marketplace/catalog-store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/marketplace/catalog-store.ts b/packages/core/src/marketplace/catalog-store.ts index 9e29e33..a79f542 100644 --- a/packages/core/src/marketplace/catalog-store.ts +++ b/packages/core/src/marketplace/catalog-store.ts @@ -12,7 +12,8 @@ import { requireBoolean, requireObject, requireString } from './helpers.ts'; * and mutates in-memory state only. */ -export const DEFAULT_CATALOG_URL = 'https://focus-mcp.github.io/marketplace/catalog.json'; +export const DEFAULT_CATALOG_URL = + 'https://raw.githubusercontent.com/focus-mcp/marketplace/develop/publish/catalog.json'; export interface CatalogSource { readonly url: string; From 2866ec64e1f553bc71ca7340b4869d66b346cc89 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 22 Apr 2026 14:39:42 +0200 Subject: [PATCH 14/26] ci: add GitHub Packages publish workflow for dev channel (#18) - Add .github/workflows/publish-dev.yml: publishes @focusmcp/* to GitHub Packages on push to develop (skips packages with private:true) - Set "private": false in packages/core, sdk, validator package.json - Add direct_prompt to claude-review.yml for inline PR review guidance Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/claude-review.yml | 3 +++ .github/workflows/publish-dev.yml | 42 +++++++++++++++++++++++++++++ packages/core/package.json | 1 + packages/sdk/package.json | 1 + packages/validator/package.json | 1 + 5 files changed, 48 insertions(+) create mode 100644 .github/workflows/publish-dev.yml diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 49a3689..6171a09 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -24,3 +24,6 @@ jobs: - uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + direct_prompt: | + Review this PR. Focus on code quality, security, consistency, and test coverage. + Leave inline comments on specific issues. Approve if clean, request changes if not. diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml new file mode 100644 index 0000000..01082f3 --- /dev/null +++ b/.github/workflows/publish-dev.yml @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +name: Publish to GitHub Packages (dev) + +on: + push: + branches: [develop] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + name: Publish @focusmcp/* to GitHub Packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + registry-url: https://npm.pkg.github.com + scope: '@focusmcp' + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: | + for dir in packages/*/; do + name=$(node -e "console.log(require('./${dir}package.json').name)") + private=$(node -e "console.log(require('./${dir}package.json').private ?? true)") + if [ "$private" = "false" ]; then + echo "Publishing $name..." + cd "$dir" + npm publish --access public 2>&1 || echo " → skipped (already published or error)" + cd ../.. + fi + done + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/core/package.json b/packages/core/package.json index 28fe34a..b816fe0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,7 @@ { "name": "@focusmcp/core", "version": "0.0.0", + "private": false, "description": "FocusMCP core — Registry, EventBus, Router, manifest parser (browser+node compatible)", "license": "MIT", "type": "module", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index eb6b9b0..a6c0f0c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,7 @@ { "name": "@focusmcp/sdk", "version": "0.0.0", + "private": false, "description": "FocusMCP SDK — outils et types pour développer une brique", "license": "MIT", "type": "module", diff --git a/packages/validator/package.json b/packages/validator/package.json index 8542597..476500c 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,6 +1,7 @@ { "name": "@focusmcp/validator", "version": "0.0.0", + "private": false, "description": "FocusMCP validator — vérifie qu'une brique respecte le contrat FocusMCP", "license": "MIT", "type": "module", From d7c120ea010b5e7060c8bd6b571cfd5fd3aa4197 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 22 Apr 2026 15:08:18 +0200 Subject: [PATCH 15/26] feat: rename npm scope from @focusmcp to @focus-mcp (#21) Rename all package names, workflow scopes, .npmrc registry bindings, and source/test file references from @focusmcp/* to @focus-mcp/*. Email addresses unchanged. Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 --- .changeset/config.json | 4 +- .github/workflows/publish-dev.yml | 4 +- .npmrc | 2 +- AGENTS.md | 2 +- CLAUDE.md | 18 +++---- CONTRIBUTING.md | 2 +- PRD.md | 28 +++++------ config/vitest.config.ts | 6 +-- docs/ROADMAP.md | 2 +- package.json | 8 +-- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- .../core/src/marketplace/installer.test.ts | 50 +++++++++---------- packages/sdk/package.json | 4 +- packages/sdk/src/define-brick.test.ts | 4 +- packages/sdk/src/define-brick.ts | 2 +- packages/sdk/tsconfig.json | 2 +- packages/validator/package.json | 4 +- packages/validator/src/validate-brick.test.ts | 4 +- packages/validator/src/validate-brick.ts | 4 +- packages/validator/tsconfig.json | 2 +- pnpm-lock.yaml | 4 +- 22 files changed, 80 insertions(+), 80 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 89be74a..392e6a4 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -3,11 +3,11 @@ "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], - "linked": [["@focusmcp/core", "@focusmcp/sdk"]], + "linked": [["@focus-mcp/core", "@focus-mcp/sdk"]], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@focusmcp/ui", "@focusmcp/tauri-app"], + "ignore": ["@focus-mcp/ui", "@focus-mcp/tauri-app"], "privatePackages": { "version": false, "tag": false diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 01082f3..40ed9cf 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -14,7 +14,7 @@ permissions: jobs: publish: - name: Publish @focusmcp/* to GitHub Packages + name: Publish @focus-mcp/* to GitHub Packages runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -24,7 +24,7 @@ jobs: node-version: 22 cache: pnpm registry-url: https://npm.pkg.github.com - scope: '@focusmcp' + scope: '@focus-mcp' - run: pnpm install --frozen-lockfile - run: pnpm build - run: | diff --git a/.npmrc b/.npmrc index 2ee08e2..3b0bf31 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: 2026 FocusMCP contributors # SPDX-License-Identifier: MIT -@focusmcp:registry=https://npm.pkg.github.com +@focus-mcp:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} diff --git a/AGENTS.md b/AGENTS.md index ce49408..028dfd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Lire [PRD.md](./PRD.md) pour la vision complète, l'architecture (3 piliers : Re - Repos compagnons : `focus-mcp/client` (Tauri), `focus-mcp/marketplace` (briques) - Tests : **Vitest** (unit), **fast-check** (property-based), **Stryker** (mutation), **Playwright** (E2E) - Lint/format : **Biome 2.x** (pas ESLint+Prettier) -- Logs : **pino** (`@focusmcp/core/observability/logger`) +- Logs : **pino** (`@focus-mcp/core/observability/logger`) - Tracing : **OpenTelemetry** ## Organisation des fichiers diff --git a/CLAUDE.md b/CLAUDE.md index de7963b..4d69712 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# CLAUDE.md — @focusmcp/core +# CLAUDE.md — @focus-mcp/core > Auto-loaded by Claude Code (and any agents.md-compatible tool) when working in this repo. > This file is the **source of truth for AI agent behaviour** on this project. It replaces the @@ -15,7 +15,7 @@ SPDX-License-Identifier: MIT **briques** (atomic MCP modules) that communicate via an EventBus with central guards. Site [focusmcp.dev](https://focusmcp.dev). Full vision : [PRD.md](./PRD.md). -Ce repo héberge la **bibliothèque `@focusmcp/core`** (Registry + EventBus + Router + SDK + +Ce repo héberge la **bibliothèque `@focus-mcp/core`** (Registry + EventBus + Router + SDK + Validator + marketplace resolver) importée par le CLI. ## Écosystème (3 repos actifs + 1 archivé) @@ -23,7 +23,7 @@ Validator + marketplace resolver) importée par le CLI. | Repo | Statut | Rôle | |---|---|---| | `focus-mcp/core` (ici) | actif | Monorepo lib TS — 3 piliers + SDK/Validator/Marketplace resolver | -| `focus-mcp/cli` | actif | `@focusmcp/cli` — stdio MCP via `@modelcontextprotocol/sdk`, entrée primaire, publié npm | +| `focus-mcp/cli` | actif | `@focus-mcp/cli` — stdio MCP via `@modelcontextprotocol/sdk`, entrée primaire, publié npm | | `focus-mcp/marketplace` | actif | Catalogue officiel + `bricks/*` + `modules/*` (dont `manager` = dashboard). `catalog.json` publié sur gh-pages (domaine custom `marketplace.focusmcp.dev` à configurer). | | `focus-mcp/client` | **archivé** | Ex desktop Tauri. Pivot CLI-first (2026-04-16) a gelé ce repo en Phase 2. | @@ -33,9 +33,9 @@ Validator + marketplace resolver) importée par le CLI. AI client (Claude Code, Cursor, Codex, Gemini…) │ stdio (JSON-RPC MCP) ▼ -@focusmcp/cli (Node, npm) +@focus-mcp/cli (Node, npm) ├─ @modelcontextprotocol/sdk StdioServerTransport - ├─ import { createFocusMcp } from '@focusmcp/core' ← CE REPO + ├─ import { createFocusMcp } from '@focus-mcp/core' ← CE REPO └─ (opt-in P1) admin API HTTP côté latéral ``` @@ -73,8 +73,8 @@ héberge tout, mais l'architecture reste browser-compatible pour un futur Phase `develop → main`. Feature branches éphémères (`feat/*`, `fix/*`, `docs/*`, etc.), auto-delete après merge. 7. **npm orgs** — `focusmcp` ET `focus-mcp` sont réservées (squatting protection). Pas de - publish au MVP sauf `@focusmcp/cli` (primary distribution). Scope canonique : - `@focusmcp/*`. + publish au MVP sauf `@focus-mcp/cli` (primary distribution). Scope canonique : + `@focus-mcp/*`. 8. **Rulesets GitHub** — chaque nouveau repo reçoit le couple : - `main protection` cible **UNIQUEMENT `refs/heads/main`** — `required_status_checks`, `pull_request`, `code_scanning` (CodeQL), `code_quality`, `required_linear_history`, @@ -100,10 +100,10 @@ packages/ ``` **À surveiller** : -- Le CLI a été **extrait dans son propre repo** (`focus-mcp/cli`) qui consomme `@focusmcp/core` +- Le CLI a été **extrait dans son propre repo** (`focus-mcp/cli`) qui consomme `@focus-mcp/core` via `file:../core/packages/core` (sibling clone en CI). `packages/cli` ici est un vieux stub vide — à supprimer quand on fait le cleanup. -- `@focusmcp/core` n'est **pas publié sur npm** ; la CLI le bundle au build (`tsup --noExternal`). +- `@focus-mcp/core` n'est **pas publié sur npm** ; la CLI le bundle au build (`tsup --noExternal`). ## Commandes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18b1b73..2014bfd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ Tous les contributeurs s'engagent à respecter le [Code of Conduct](./CODE_OF_CO - **Conventional Commits** : `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `perf`, `build`, `ci`, `style`, `revert` - **SPDX headers** dans tous les fichiers source (`SPDX-License-Identifier: MIT`) - **REUSE compliance** vérifiée en CI -- **Pas de console.log** : utiliser le logger pino exposé par `@focusmcp/core` +- **Pas de console.log** : utiliser le logger pino exposé par `@focus-mcp/core` - **Pas de `any`** : TypeScript strict + Biome `noExplicitAny` ## Développer une brique diff --git a/PRD.md b/PRD.md index 92b3ebe..5e6f6e1 100644 --- a/PRD.md +++ b/PRD.md @@ -3,9 +3,9 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# @focusmcp/core — Product Requirements Document +# @focus-mcp/core — Product Requirements Document -> Périmètre de ce document : la **bibliothèque TypeScript** `@focusmcp/core` (package `packages/core` du monorepo). +> Périmètre de ce document : la **bibliothèque TypeScript** `@focus-mcp/core` (package `packages/core` du monorepo). > Pour l'app desktop : voir le repo [`focus-mcp/client`](https://github.com/focus-mcp/client). Pour le catalogue de briques : voir le repo [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace). ## Vision (rappel) @@ -21,9 +21,9 @@ Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. --- -## Rôle de `@focusmcp/core` dans l'écosystème +## Rôle de `@focus-mcp/core` dans l'écosystème -`@focusmcp/core` est la **bibliothèque TypeScript** qui implémente toute la logique MCP : +`@focus-mcp/core` est la **bibliothèque TypeScript** qui implémente toute la logique MCP : - **Importée par l'app desktop** (`client/`, Tauri) directement dans la WebView — pas de sidecar Node.js - **Aucun transport HTTP** : Tauri (Rust) est le **seul gardien HTTP** (Streamable HTTP MCP côté client) @@ -39,7 +39,7 @@ Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. │ Tauri commands (IPC) ┌──────────────▼─────────────────────┐ │ WebView — UI Svelte │ -│ └─ @focusmcp/core (this lib) │ +│ └─ @focus-mcp/core (this lib) │ │ Registry + EventBus + Router │ │ + briques (modules TS) │ └────────────────────────────────────┘ @@ -51,10 +51,10 @@ Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. | Package | Rôle | |---|---| -| `@focusmcp/core` | Registry, EventBus, Router, Manifest, Bootstrap, Observability | -| `@focusmcp/sdk` | Helper `defineBrick` pour auteurs de briques | -| `@focusmcp/validator` | Test runner conformance (manifeste, namespace, dépendances, garde-fous) | -| `@focusmcp/cli` | CLI `focus` — gestion des briques installées | +| `@focus-mcp/core` | Registry, EventBus, Router, Manifest, Bootstrap, Observability | +| `@focus-mcp/sdk` | Helper `defineBrick` pour auteurs de briques | +| `@focus-mcp/validator` | Test runner conformance (manifeste, namespace, dépendances, garde-fous) | +| `@focus-mcp/cli` | CLI `focus` — gestion des briques installées | --- @@ -169,12 +169,12 @@ Validation stricte (par `parseManifest`) : nom en kebab-case (ex: `php`, `indexe --- -## SDK — `@focusmcp/sdk` +## SDK — `@focus-mcp/sdk` Helper `defineBrick` pour les auteurs de briques : ```typescript -import { defineBrick } from '@focusmcp/sdk' +import { defineBrick } from '@focus-mcp/sdk' export default defineBrick({ manifest: { /* mcp-brick.json inline ou import */ }, @@ -189,7 +189,7 @@ export default defineBrick({ --- -## Validator — `@focusmcp/validator` +## Validator — `@focus-mcp/validator` Test runner qui valide qu'une brique respecte le contrat FocusMCP. Checks actuellement implémentés : - Manifeste valide (`INVALID_MANIFEST` via `parseManifest`) @@ -202,7 +202,7 @@ Lancé en CI sur chaque brique du marketplace officiel et utilisable par les dé --- -## CLI — `@focusmcp/cli` +## CLI — `@focus-mcp/cli` Commandes (inspirées npm/yarn) opérant sur `~/.focus/center.json` + `~/.focus/center.lock` : @@ -344,7 +344,7 @@ Les patterns transverses applicables par toutes les briques. Implémentés dans - **Indexation + cache** — index FTS5 partagé (brique `focus-indexer`) - **Reasoning externalisé** — chaînes de pensées persistées (brique `focus-thinking`) -`@focusmcp/core` ne contient aucune brique — il fournit l'infrastructure qui les rend possibles. +`@focus-mcp/core` ne contient aucune brique — il fournit l'infrastructure qui les rend possibles. --- diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 277efe6..88644de 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -10,9 +10,9 @@ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export default defineConfig({ resolve: { alias: { - '@focusmcp/core': resolve(projectRoot, 'packages/core/src/index.ts'), - '@focusmcp/sdk': resolve(projectRoot, 'packages/sdk/src/index.ts'), - '@focusmcp/validator': resolve(projectRoot, 'packages/validator/src/index.ts'), + '@focus-mcp/core': resolve(projectRoot, 'packages/core/src/index.ts'), + '@focus-mcp/sdk': resolve(projectRoot, 'packages/sdk/src/index.ts'), + '@focus-mcp/validator': resolve(projectRoot, 'packages/validator/src/index.ts'), }, }, test: { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 63b710a..0c750a2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ Voir [PRD.md](../PRD.md) pour les détails fonctionnels complets. - [x] `McpRouter` (TDD, coverage 100%) - [x] Transport HTTP + HTTPS (spec MCP 2025-03-26 via SDK officiel) - [ ] `focus-validator` — test runner pour briques tierces -- [ ] SDK brique (`@focusmcp/sdk`) — helpers pour écrire une brique +- [ ] SDK brique (`@focus-mcp/sdk`) — helpers pour écrire une brique ## Phase 1 — CLI et ergonomie diff --git a/package.json b/package.json index 7ffaca7..c98efff 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/root", + "name": "@focus-mcp/root", "version": "0.0.0", "private": true, "description": "FocusMCP — Focaliser les agents AI sur l'essentiel", @@ -43,19 +43,19 @@ }, "size-limit": [ { - "name": "@focusmcp/core (gzip)", + "name": "@focus-mcp/core (gzip)", "path": "packages/core/dist/index.js", "limit": "30 KB", "gzip": true }, { - "name": "@focusmcp/sdk (gzip)", + "name": "@focus-mcp/sdk (gzip)", "path": "packages/sdk/dist/index.js", "limit": "10 KB", "gzip": true }, { - "name": "@focusmcp/cli (gzip)", + "name": "@focus-mcp/cli (gzip)", "path": "packages/cli/dist/index.js", "limit": "50 KB", "gzip": true diff --git a/packages/cli/package.json b/packages/cli/package.json index c706d1c..2187b50 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/cli", + "name": "@focus-mcp/cli", "version": "0.0.0", "description": "FocusMCP CLI — focus start, add, remove, update...", "license": "MIT", diff --git a/packages/core/package.json b/packages/core/package.json index b816fe0..6f8cdf3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/core", + "name": "@focus-mcp/core", "version": "0.0.0", "private": false, "description": "FocusMCP core — Registry, EventBus, Router, manifest parser (browser+node compatible)", diff --git a/packages/core/src/marketplace/installer.test.ts b/packages/core/src/marketplace/installer.test.ts index 9efe415..f5af10c 100644 --- a/packages/core/src/marketplace/installer.test.ts +++ b/packages/core/src/marketplace/installer.test.ts @@ -54,7 +54,7 @@ function validCenterLock(overrides: Partial = {}): CenterLock { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, }, @@ -69,7 +69,7 @@ function validNpmBrick(overrides: Partial = {}): CatalogBrick { description: 'Echo brick', dependencies: [], tools: [{ name: 'say', description: 'Echo text' }], - source: { type: 'npm', package: '@focusmcp/brick-echo' }, + source: { type: 'npm', package: '@focus-mcp/brick-echo' }, ...overrides, }; } @@ -155,7 +155,7 @@ describe('parseCenterLock', () => { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, }, @@ -278,7 +278,7 @@ describe('serializeCenterLock', () => { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, }, @@ -321,7 +321,7 @@ describe('planInstall', () => { const plan = planInstall(brick, catalogUrl); expect(plan).toEqual({ name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.2.3', catalogUrl, }); @@ -331,7 +331,7 @@ describe('planInstall', () => { const brick = validNpmBrick({ source: { type: 'npm', - package: '@focusmcp/brick-echo', + package: '@focus-mcp/brick-echo', registry: 'https://my.registry', }, }); @@ -368,7 +368,7 @@ describe('planRemove', () => { const centerJson = validCenterJson(); const centerLock = validCenterLock(); const result = planRemove('echo', centerJson, centerLock); - expect(result.npmPackage).toBe('@focusmcp/brick-echo'); + expect(result.npmPackage).toBe('@focus-mcp/brick-echo'); }); it('throws when the brick is not in center.json', () => { @@ -401,24 +401,24 @@ describe('executeInstall', () => { it('calls npmInstall with the correct package and version', async () => { const plan: InstallPlan = { name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.2.3', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; await executeInstall(io, plan, validCenterJson(), validCenterLock(), now); - expect(io.npmInstall).toHaveBeenCalledWith('@focusmcp/brick-echo', '1.2.3', {}); + expect(io.npmInstall).toHaveBeenCalledWith('@focus-mcp/brick-echo', '1.2.3', {}); }); it('passes the registry option to npmInstall when provided', async () => { const plan: InstallPlan = { name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.2.3', registry: 'https://my.registry', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; await executeInstall(io, plan, validCenterJson(), validCenterLock(), now); - expect(io.npmInstall).toHaveBeenCalledWith('@focusmcp/brick-echo', '1.2.3', { + expect(io.npmInstall).toHaveBeenCalledWith('@focus-mcp/brick-echo', '1.2.3', { registry: 'https://my.registry', }); }); @@ -426,7 +426,7 @@ describe('executeInstall', () => { it('writes the updated center.json with the new brick entry', async () => { const plan: InstallPlan = { name: 'indexer', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -441,7 +441,7 @@ describe('executeInstall', () => { it('writes the updated center.lock with the new lock entry', async () => { const plan: InstallPlan = { name: 'indexer', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -453,7 +453,7 @@ describe('executeInstall', () => { expect(written.bricks['indexer']).toEqual({ version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', installedAt: now, }); }); @@ -461,7 +461,7 @@ describe('executeInstall', () => { it('preserves existing entries when adding a new brick', async () => { const plan: InstallPlan = { name: 'indexer', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -486,7 +486,7 @@ describe('executeInstall', () => { }); const plan: InstallPlan = { name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -498,7 +498,7 @@ describe('executeInstall', () => { const before = new Date().toISOString(); const plan: InstallPlan = { name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -525,18 +525,18 @@ describe('executeRemove', () => { await executeRemove( io, 'echo', - '@focusmcp/brick-echo', + '@focus-mcp/brick-echo', validCenterJson(), validCenterLock(), ); - expect(io.npmUninstall).toHaveBeenCalledWith('@focusmcp/brick-echo'); + expect(io.npmUninstall).toHaveBeenCalledWith('@focus-mcp/brick-echo'); }); it('removes the brick entry from center.json', async () => { await executeRemove( io, 'echo', - '@focusmcp/brick-echo', + '@focus-mcp/brick-echo', validCenterJson(), validCenterLock(), ); @@ -549,7 +549,7 @@ describe('executeRemove', () => { await executeRemove( io, 'echo', - '@focusmcp/brick-echo', + '@focus-mcp/brick-echo', validCenterJson(), validCenterLock(), ); @@ -570,18 +570,18 @@ describe('executeRemove', () => { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, indexer: { version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', installedAt: '2026-04-02T00:00:00.000Z', }, }, }; - await executeRemove(io, 'echo', '@focusmcp/brick-echo', centerJson, centerLock); + await executeRemove(io, 'echo', '@focus-mcp/brick-echo', centerJson, centerLock); const writtenJson = firstCallArg(io.writeCenterJson); expect(writtenJson.bricks['echo']).toBeUndefined(); @@ -596,7 +596,7 @@ describe('executeRemove', () => { await executeRemove( io, 'echo', - '@focusmcp/brick-echo', + '@focus-mcp/brick-echo', validCenterJson(), validCenterLock(), ); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a6c0f0c..106a13c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/sdk", + "name": "@focus-mcp/sdk", "version": "0.0.0", "private": false, "description": "FocusMCP SDK — outils et types pour développer une brique", @@ -24,7 +24,7 @@ "test": "vitest run" }, "dependencies": { - "@focusmcp/core": "workspace:*" + "@focus-mcp/core": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/sdk/src/define-brick.test.ts b/packages/sdk/src/define-brick.test.ts index 77af408..4f0d33f 100644 --- a/packages/sdk/src/define-brick.test.ts +++ b/packages/sdk/src/define-brick.test.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -import type { BrickContext, BrickLogger } from '@focusmcp/core'; -import { InProcessEventBus } from '@focusmcp/core'; +import type { BrickContext, BrickLogger } from '@focus-mcp/core'; +import { InProcessEventBus } from '@focus-mcp/core'; import { describe, expect, it, vi } from 'vitest'; import { BrickDefinitionError, defineBrick } from './define-brick.ts'; diff --git a/packages/sdk/src/define-brick.ts b/packages/sdk/src/define-brick.ts index 2888245..c1542f7 100644 --- a/packages/sdk/src/define-brick.ts +++ b/packages/sdk/src/define-brick.ts @@ -7,7 +7,7 @@ import { type BrickManifest, parseManifest, type Unsubscribe, -} from '@focusmcp/core'; +} from '@focus-mcp/core'; export type BrickDefinitionErrorCode = 'MISSING_HANDLER' | 'UNKNOWN_HANDLER' | 'ALREADY_STARTED'; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 9c1accc..505317c 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@focusmcp/core": ["../core/src/index.ts"] + "@focus-mcp/core": ["../core/src/index.ts"] } }, "include": ["src/**/*.ts"], diff --git a/packages/validator/package.json b/packages/validator/package.json index 476500c..309b23f 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/validator", + "name": "@focus-mcp/validator", "version": "0.0.0", "private": false, "description": "FocusMCP validator — vérifie qu'une brique respecte le contrat FocusMCP", @@ -24,7 +24,7 @@ "test": "vitest run" }, "dependencies": { - "@focusmcp/core": "workspace:*" + "@focus-mcp/core": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/validator/src/validate-brick.test.ts b/packages/validator/src/validate-brick.test.ts index 996bfe5..f9f9855 100644 --- a/packages/validator/src/validate-brick.test.ts +++ b/packages/validator/src/validate-brick.test.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -import type { Brick, BrickContext, BrickLogger, EventBus, Unsubscribe } from '@focusmcp/core'; -import { InProcessEventBus } from '@focusmcp/core'; +import type { Brick, BrickContext, BrickLogger, EventBus, Unsubscribe } from '@focus-mcp/core'; +import { InProcessEventBus } from '@focus-mcp/core'; import { describe, expect, it } from 'vitest'; import { validateBrick } from './validate-brick.ts'; diff --git a/packages/validator/src/validate-brick.ts b/packages/validator/src/validate-brick.ts index 83a103d..615aca5 100644 --- a/packages/validator/src/validate-brick.ts +++ b/packages/validator/src/validate-brick.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -import type { Brick, BrickContext, BrickLogger, EventBus } from '@focusmcp/core'; -import { InProcessEventBus, ManifestError, parseManifest } from '@focusmcp/core'; +import type { Brick, BrickContext, BrickLogger, EventBus } from '@focus-mcp/core'; +import { InProcessEventBus, ManifestError, parseManifest } from '@focus-mcp/core'; export type ValidationIssueCode = | 'INVALID_MANIFEST' diff --git a/packages/validator/tsconfig.json b/packages/validator/tsconfig.json index 9c1accc..505317c 100644 --- a/packages/validator/tsconfig.json +++ b/packages/validator/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@focusmcp/core": ["../core/src/index.ts"] + "@focus-mcp/core": ["../core/src/index.ts"] } }, "include": ["src/**/*.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 448022f..d586d42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: packages/sdk: dependencies: - '@focusmcp/core': + '@focus-mcp/core': specifier: workspace:* version: link:../core devDependencies: @@ -131,7 +131,7 @@ importers: packages/validator: dependencies: - '@focusmcp/core': + '@focus-mcp/core': specifier: workspace:* version: link:../core devDependencies: From 8d820b58c591a89938fedb35b20323fe42c9f171 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 22 Apr 2026 15:28:59 +0200 Subject: [PATCH 16/26] fix(ci): add id-token permission for npm provenance (#22) Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 --- .github/workflows/publish-dev.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 40ed9cf..dde7d55 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -11,6 +11,7 @@ on: permissions: contents: read packages: write + id-token: write jobs: publish: From d7ddffa9713d5fa10d054492691b0a61690d52e1 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Wed, 22 Apr 2026 21:04:20 +0200 Subject: [PATCH 17/26] feat(ci): dev publish workflow (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ci): add dev publish workflow with auto-versioning - dev-publish.yml: publish @focus-mcp/{core,sdk,validator} to npmjs.org with --tag dev - Auto-computed version: -dev. (N = commits since last tag) - Mark packages/cli stub as private to prevent accidental publish Co-Authored-By: Claude Opus 4.6 (1M context) * feat: rename scope @focus-mcp → @focusmcp + dev publish workflow - Rename @focus-mcp/{core,sdk,validator} → @focusmcp/{core,sdk,validator} - Add dev-publish.yml for auto-versioned dev releases - Mark packages/cli stub as private - Update all docs, workflows, configs Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate pnpm-lock.yaml after scope rename Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- .changeset/config.json | 4 +- .github/workflows/dev-publish.yml | 76 +++++++++++++++++++ .github/workflows/publish-dev.yml | 2 +- AGENTS.md | 2 +- CLAUDE.md | 18 ++--- CONTRIBUTING.md | 2 +- PRD.md | 28 +++---- config/vitest.config.ts | 6 +- docs/ROADMAP.md | 2 +- package.json | 8 +- packages/cli/package.json | 3 +- packages/core/package.json | 2 +- .../core/src/marketplace/installer.test.ts | 50 ++++++------ packages/sdk/package.json | 4 +- packages/sdk/src/define-brick.test.ts | 4 +- packages/sdk/src/define-brick.ts | 2 +- packages/sdk/tsconfig.json | 2 +- packages/validator/package.json | 4 +- packages/validator/src/validate-brick.test.ts | 4 +- packages/validator/src/validate-brick.ts | 4 +- packages/validator/tsconfig.json | 2 +- pnpm-lock.yaml | 4 +- 22 files changed, 155 insertions(+), 78 deletions(-) create mode 100644 .github/workflows/dev-publish.yml diff --git a/.changeset/config.json b/.changeset/config.json index 392e6a4..89be74a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -3,11 +3,11 @@ "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], - "linked": [["@focus-mcp/core", "@focus-mcp/sdk"]], + "linked": [["@focusmcp/core", "@focusmcp/sdk"]], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@focus-mcp/ui", "@focus-mcp/tauri-app"], + "ignore": ["@focusmcp/ui", "@focusmcp/tauri-app"], "privatePackages": { "version": false, "tag": false diff --git a/.github/workflows/dev-publish.yml b/.github/workflows/dev-publish.yml new file mode 100644 index 0000000..6484a2f --- /dev/null +++ b/.github/workflows/dev-publish.yml @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +name: Dev Publish + +on: + push: + branches: [develop] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dev-publish-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish-dev: + name: Publish @focusmcp/* dev packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + scope: '@focus-mcp' + - run: pnpm install --frozen-lockfile + - name: Compute dev version + id: version + run: | + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD) + DEV_NUM=$(git rev-list --count ${LAST_TAG}..HEAD) + BASE_VERSION=$(node -e "const p=require('./package.json'); console.log(p.version || '0.1.0')") + DEV_VERSION="${BASE_VERSION}-dev.${DEV_NUM}" + echo "version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" + echo "Dev version: ${DEV_VERSION}" + - name: Set dev versions on all packages + env: + DEV_VERSION: ${{ steps.version.outputs.version }} + run: | + for dir in packages/*/; do + if [ -f "${dir}package.json" ]; then + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('${dir}package.json', 'utf-8')); + if (pkg.private !== true) { + pkg.version = process.env.DEV_VERSION; + fs.writeFileSync('${dir}package.json', JSON.stringify(pkg, null, 4) + '\n'); + console.log(' → ' + pkg.name + '@' + process.env.DEV_VERSION); + } + " + fi + done + - run: pnpm build + - name: Publish all packages with dev tag + run: | + for dir in packages/*/; do + if [ -f "${dir}package.json" ]; then + PRIVATE=$(node -e "console.log(require('./${dir}package.json').private)") + if [ "$PRIVATE" != "true" ]; then + NAME=$(node -e "console.log(require('./${dir}package.json').name)") + echo "Publishing ${NAME}..." + cd "$dir" + npm publish --tag dev --access public 2>&1 || echo " → skipped" + cd ../.. + fi + fi + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index dde7d55..e31bf01 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -15,7 +15,7 @@ permissions: jobs: publish: - name: Publish @focus-mcp/* to GitHub Packages + name: Publish @focusmcp/* to GitHub Packages runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 diff --git a/AGENTS.md b/AGENTS.md index 028dfd3..ce49408 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Lire [PRD.md](./PRD.md) pour la vision complète, l'architecture (3 piliers : Re - Repos compagnons : `focus-mcp/client` (Tauri), `focus-mcp/marketplace` (briques) - Tests : **Vitest** (unit), **fast-check** (property-based), **Stryker** (mutation), **Playwright** (E2E) - Lint/format : **Biome 2.x** (pas ESLint+Prettier) -- Logs : **pino** (`@focus-mcp/core/observability/logger`) +- Logs : **pino** (`@focusmcp/core/observability/logger`) - Tracing : **OpenTelemetry** ## Organisation des fichiers diff --git a/CLAUDE.md b/CLAUDE.md index 4d69712..de7963b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# CLAUDE.md — @focus-mcp/core +# CLAUDE.md — @focusmcp/core > Auto-loaded by Claude Code (and any agents.md-compatible tool) when working in this repo. > This file is the **source of truth for AI agent behaviour** on this project. It replaces the @@ -15,7 +15,7 @@ SPDX-License-Identifier: MIT **briques** (atomic MCP modules) that communicate via an EventBus with central guards. Site [focusmcp.dev](https://focusmcp.dev). Full vision : [PRD.md](./PRD.md). -Ce repo héberge la **bibliothèque `@focus-mcp/core`** (Registry + EventBus + Router + SDK + +Ce repo héberge la **bibliothèque `@focusmcp/core`** (Registry + EventBus + Router + SDK + Validator + marketplace resolver) importée par le CLI. ## Écosystème (3 repos actifs + 1 archivé) @@ -23,7 +23,7 @@ Validator + marketplace resolver) importée par le CLI. | Repo | Statut | Rôle | |---|---|---| | `focus-mcp/core` (ici) | actif | Monorepo lib TS — 3 piliers + SDK/Validator/Marketplace resolver | -| `focus-mcp/cli` | actif | `@focus-mcp/cli` — stdio MCP via `@modelcontextprotocol/sdk`, entrée primaire, publié npm | +| `focus-mcp/cli` | actif | `@focusmcp/cli` — stdio MCP via `@modelcontextprotocol/sdk`, entrée primaire, publié npm | | `focus-mcp/marketplace` | actif | Catalogue officiel + `bricks/*` + `modules/*` (dont `manager` = dashboard). `catalog.json` publié sur gh-pages (domaine custom `marketplace.focusmcp.dev` à configurer). | | `focus-mcp/client` | **archivé** | Ex desktop Tauri. Pivot CLI-first (2026-04-16) a gelé ce repo en Phase 2. | @@ -33,9 +33,9 @@ Validator + marketplace resolver) importée par le CLI. AI client (Claude Code, Cursor, Codex, Gemini…) │ stdio (JSON-RPC MCP) ▼ -@focus-mcp/cli (Node, npm) +@focusmcp/cli (Node, npm) ├─ @modelcontextprotocol/sdk StdioServerTransport - ├─ import { createFocusMcp } from '@focus-mcp/core' ← CE REPO + ├─ import { createFocusMcp } from '@focusmcp/core' ← CE REPO └─ (opt-in P1) admin API HTTP côté latéral ``` @@ -73,8 +73,8 @@ héberge tout, mais l'architecture reste browser-compatible pour un futur Phase `develop → main`. Feature branches éphémères (`feat/*`, `fix/*`, `docs/*`, etc.), auto-delete après merge. 7. **npm orgs** — `focusmcp` ET `focus-mcp` sont réservées (squatting protection). Pas de - publish au MVP sauf `@focus-mcp/cli` (primary distribution). Scope canonique : - `@focus-mcp/*`. + publish au MVP sauf `@focusmcp/cli` (primary distribution). Scope canonique : + `@focusmcp/*`. 8. **Rulesets GitHub** — chaque nouveau repo reçoit le couple : - `main protection` cible **UNIQUEMENT `refs/heads/main`** — `required_status_checks`, `pull_request`, `code_scanning` (CodeQL), `code_quality`, `required_linear_history`, @@ -100,10 +100,10 @@ packages/ ``` **À surveiller** : -- Le CLI a été **extrait dans son propre repo** (`focus-mcp/cli`) qui consomme `@focus-mcp/core` +- Le CLI a été **extrait dans son propre repo** (`focus-mcp/cli`) qui consomme `@focusmcp/core` via `file:../core/packages/core` (sibling clone en CI). `packages/cli` ici est un vieux stub vide — à supprimer quand on fait le cleanup. -- `@focus-mcp/core` n'est **pas publié sur npm** ; la CLI le bundle au build (`tsup --noExternal`). +- `@focusmcp/core` n'est **pas publié sur npm** ; la CLI le bundle au build (`tsup --noExternal`). ## Commandes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2014bfd..18b1b73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ Tous les contributeurs s'engagent à respecter le [Code of Conduct](./CODE_OF_CO - **Conventional Commits** : `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `perf`, `build`, `ci`, `style`, `revert` - **SPDX headers** dans tous les fichiers source (`SPDX-License-Identifier: MIT`) - **REUSE compliance** vérifiée en CI -- **Pas de console.log** : utiliser le logger pino exposé par `@focus-mcp/core` +- **Pas de console.log** : utiliser le logger pino exposé par `@focusmcp/core` - **Pas de `any`** : TypeScript strict + Biome `noExplicitAny` ## Développer une brique diff --git a/PRD.md b/PRD.md index 5e6f6e1..92b3ebe 100644 --- a/PRD.md +++ b/PRD.md @@ -3,9 +3,9 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# @focus-mcp/core — Product Requirements Document +# @focusmcp/core — Product Requirements Document -> Périmètre de ce document : la **bibliothèque TypeScript** `@focus-mcp/core` (package `packages/core` du monorepo). +> Périmètre de ce document : la **bibliothèque TypeScript** `@focusmcp/core` (package `packages/core` du monorepo). > Pour l'app desktop : voir le repo [`focus-mcp/client`](https://github.com/focus-mcp/client). Pour le catalogue de briques : voir le repo [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace). ## Vision (rappel) @@ -21,9 +21,9 @@ Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. --- -## Rôle de `@focus-mcp/core` dans l'écosystème +## Rôle de `@focusmcp/core` dans l'écosystème -`@focus-mcp/core` est la **bibliothèque TypeScript** qui implémente toute la logique MCP : +`@focusmcp/core` est la **bibliothèque TypeScript** qui implémente toute la logique MCP : - **Importée par l'app desktop** (`client/`, Tauri) directement dans la WebView — pas de sidecar Node.js - **Aucun transport HTTP** : Tauri (Rust) est le **seul gardien HTTP** (Streamable HTTP MCP côté client) @@ -39,7 +39,7 @@ Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. │ Tauri commands (IPC) ┌──────────────▼─────────────────────┐ │ WebView — UI Svelte │ -│ └─ @focus-mcp/core (this lib) │ +│ └─ @focusmcp/core (this lib) │ │ Registry + EventBus + Router │ │ + briques (modules TS) │ └────────────────────────────────────┘ @@ -51,10 +51,10 @@ Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. | Package | Rôle | |---|---| -| `@focus-mcp/core` | Registry, EventBus, Router, Manifest, Bootstrap, Observability | -| `@focus-mcp/sdk` | Helper `defineBrick` pour auteurs de briques | -| `@focus-mcp/validator` | Test runner conformance (manifeste, namespace, dépendances, garde-fous) | -| `@focus-mcp/cli` | CLI `focus` — gestion des briques installées | +| `@focusmcp/core` | Registry, EventBus, Router, Manifest, Bootstrap, Observability | +| `@focusmcp/sdk` | Helper `defineBrick` pour auteurs de briques | +| `@focusmcp/validator` | Test runner conformance (manifeste, namespace, dépendances, garde-fous) | +| `@focusmcp/cli` | CLI `focus` — gestion des briques installées | --- @@ -169,12 +169,12 @@ Validation stricte (par `parseManifest`) : nom en kebab-case (ex: `php`, `indexe --- -## SDK — `@focus-mcp/sdk` +## SDK — `@focusmcp/sdk` Helper `defineBrick` pour les auteurs de briques : ```typescript -import { defineBrick } from '@focus-mcp/sdk' +import { defineBrick } from '@focusmcp/sdk' export default defineBrick({ manifest: { /* mcp-brick.json inline ou import */ }, @@ -189,7 +189,7 @@ export default defineBrick({ --- -## Validator — `@focus-mcp/validator` +## Validator — `@focusmcp/validator` Test runner qui valide qu'une brique respecte le contrat FocusMCP. Checks actuellement implémentés : - Manifeste valide (`INVALID_MANIFEST` via `parseManifest`) @@ -202,7 +202,7 @@ Lancé en CI sur chaque brique du marketplace officiel et utilisable par les dé --- -## CLI — `@focus-mcp/cli` +## CLI — `@focusmcp/cli` Commandes (inspirées npm/yarn) opérant sur `~/.focus/center.json` + `~/.focus/center.lock` : @@ -344,7 +344,7 @@ Les patterns transverses applicables par toutes les briques. Implémentés dans - **Indexation + cache** — index FTS5 partagé (brique `focus-indexer`) - **Reasoning externalisé** — chaînes de pensées persistées (brique `focus-thinking`) -`@focus-mcp/core` ne contient aucune brique — il fournit l'infrastructure qui les rend possibles. +`@focusmcp/core` ne contient aucune brique — il fournit l'infrastructure qui les rend possibles. --- diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 88644de..277efe6 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -10,9 +10,9 @@ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export default defineConfig({ resolve: { alias: { - '@focus-mcp/core': resolve(projectRoot, 'packages/core/src/index.ts'), - '@focus-mcp/sdk': resolve(projectRoot, 'packages/sdk/src/index.ts'), - '@focus-mcp/validator': resolve(projectRoot, 'packages/validator/src/index.ts'), + '@focusmcp/core': resolve(projectRoot, 'packages/core/src/index.ts'), + '@focusmcp/sdk': resolve(projectRoot, 'packages/sdk/src/index.ts'), + '@focusmcp/validator': resolve(projectRoot, 'packages/validator/src/index.ts'), }, }, test: { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c750a2..63b710a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ Voir [PRD.md](../PRD.md) pour les détails fonctionnels complets. - [x] `McpRouter` (TDD, coverage 100%) - [x] Transport HTTP + HTTPS (spec MCP 2025-03-26 via SDK officiel) - [ ] `focus-validator` — test runner pour briques tierces -- [ ] SDK brique (`@focus-mcp/sdk`) — helpers pour écrire une brique +- [ ] SDK brique (`@focusmcp/sdk`) — helpers pour écrire une brique ## Phase 1 — CLI et ergonomie diff --git a/package.json b/package.json index c98efff..7ffaca7 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@focus-mcp/root", + "name": "@focusmcp/root", "version": "0.0.0", "private": true, "description": "FocusMCP — Focaliser les agents AI sur l'essentiel", @@ -43,19 +43,19 @@ }, "size-limit": [ { - "name": "@focus-mcp/core (gzip)", + "name": "@focusmcp/core (gzip)", "path": "packages/core/dist/index.js", "limit": "30 KB", "gzip": true }, { - "name": "@focus-mcp/sdk (gzip)", + "name": "@focusmcp/sdk (gzip)", "path": "packages/sdk/dist/index.js", "limit": "10 KB", "gzip": true }, { - "name": "@focus-mcp/cli (gzip)", + "name": "@focusmcp/cli (gzip)", "path": "packages/cli/dist/index.js", "limit": "50 KB", "gzip": true diff --git a/packages/cli/package.json b/packages/cli/package.json index 2187b50..17b3e51 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,7 @@ { - "name": "@focus-mcp/cli", + "name": "@focusmcp/cli", "version": "0.0.0", + "private": true, "description": "FocusMCP CLI — focus start, add, remove, update...", "license": "MIT", "type": "module", diff --git a/packages/core/package.json b/packages/core/package.json index 6f8cdf3..b816fe0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,5 +1,5 @@ { - "name": "@focus-mcp/core", + "name": "@focusmcp/core", "version": "0.0.0", "private": false, "description": "FocusMCP core — Registry, EventBus, Router, manifest parser (browser+node compatible)", diff --git a/packages/core/src/marketplace/installer.test.ts b/packages/core/src/marketplace/installer.test.ts index f5af10c..9efe415 100644 --- a/packages/core/src/marketplace/installer.test.ts +++ b/packages/core/src/marketplace/installer.test.ts @@ -54,7 +54,7 @@ function validCenterLock(overrides: Partial = {}): CenterLock { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focus-mcp/brick-echo', + npmPackage: '@focusmcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, }, @@ -69,7 +69,7 @@ function validNpmBrick(overrides: Partial = {}): CatalogBrick { description: 'Echo brick', dependencies: [], tools: [{ name: 'say', description: 'Echo text' }], - source: { type: 'npm', package: '@focus-mcp/brick-echo' }, + source: { type: 'npm', package: '@focusmcp/brick-echo' }, ...overrides, }; } @@ -155,7 +155,7 @@ describe('parseCenterLock', () => { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focus-mcp/brick-echo', + npmPackage: '@focusmcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, }, @@ -278,7 +278,7 @@ describe('serializeCenterLock', () => { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focus-mcp/brick-echo', + npmPackage: '@focusmcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, }, @@ -321,7 +321,7 @@ describe('planInstall', () => { const plan = planInstall(brick, catalogUrl); expect(plan).toEqual({ name: 'echo', - npmPackage: '@focus-mcp/brick-echo', + npmPackage: '@focusmcp/brick-echo', version: '1.2.3', catalogUrl, }); @@ -331,7 +331,7 @@ describe('planInstall', () => { const brick = validNpmBrick({ source: { type: 'npm', - package: '@focus-mcp/brick-echo', + package: '@focusmcp/brick-echo', registry: 'https://my.registry', }, }); @@ -368,7 +368,7 @@ describe('planRemove', () => { const centerJson = validCenterJson(); const centerLock = validCenterLock(); const result = planRemove('echo', centerJson, centerLock); - expect(result.npmPackage).toBe('@focus-mcp/brick-echo'); + expect(result.npmPackage).toBe('@focusmcp/brick-echo'); }); it('throws when the brick is not in center.json', () => { @@ -401,24 +401,24 @@ describe('executeInstall', () => { it('calls npmInstall with the correct package and version', async () => { const plan: InstallPlan = { name: 'echo', - npmPackage: '@focus-mcp/brick-echo', + npmPackage: '@focusmcp/brick-echo', version: '1.2.3', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; await executeInstall(io, plan, validCenterJson(), validCenterLock(), now); - expect(io.npmInstall).toHaveBeenCalledWith('@focus-mcp/brick-echo', '1.2.3', {}); + expect(io.npmInstall).toHaveBeenCalledWith('@focusmcp/brick-echo', '1.2.3', {}); }); it('passes the registry option to npmInstall when provided', async () => { const plan: InstallPlan = { name: 'echo', - npmPackage: '@focus-mcp/brick-echo', + npmPackage: '@focusmcp/brick-echo', version: '1.2.3', registry: 'https://my.registry', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; await executeInstall(io, plan, validCenterJson(), validCenterLock(), now); - expect(io.npmInstall).toHaveBeenCalledWith('@focus-mcp/brick-echo', '1.2.3', { + expect(io.npmInstall).toHaveBeenCalledWith('@focusmcp/brick-echo', '1.2.3', { registry: 'https://my.registry', }); }); @@ -426,7 +426,7 @@ describe('executeInstall', () => { it('writes the updated center.json with the new brick entry', async () => { const plan: InstallPlan = { name: 'indexer', - npmPackage: '@focus-mcp/brick-indexer', + npmPackage: '@focusmcp/brick-indexer', version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -441,7 +441,7 @@ describe('executeInstall', () => { it('writes the updated center.lock with the new lock entry', async () => { const plan: InstallPlan = { name: 'indexer', - npmPackage: '@focus-mcp/brick-indexer', + npmPackage: '@focusmcp/brick-indexer', version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -453,7 +453,7 @@ describe('executeInstall', () => { expect(written.bricks['indexer']).toEqual({ version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focus-mcp/brick-indexer', + npmPackage: '@focusmcp/brick-indexer', installedAt: now, }); }); @@ -461,7 +461,7 @@ describe('executeInstall', () => { it('preserves existing entries when adding a new brick', async () => { const plan: InstallPlan = { name: 'indexer', - npmPackage: '@focus-mcp/brick-indexer', + npmPackage: '@focusmcp/brick-indexer', version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -486,7 +486,7 @@ describe('executeInstall', () => { }); const plan: InstallPlan = { name: 'echo', - npmPackage: '@focus-mcp/brick-echo', + npmPackage: '@focusmcp/brick-echo', version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -498,7 +498,7 @@ describe('executeInstall', () => { const before = new Date().toISOString(); const plan: InstallPlan = { name: 'echo', - npmPackage: '@focus-mcp/brick-echo', + npmPackage: '@focusmcp/brick-echo', version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -525,18 +525,18 @@ describe('executeRemove', () => { await executeRemove( io, 'echo', - '@focus-mcp/brick-echo', + '@focusmcp/brick-echo', validCenterJson(), validCenterLock(), ); - expect(io.npmUninstall).toHaveBeenCalledWith('@focus-mcp/brick-echo'); + expect(io.npmUninstall).toHaveBeenCalledWith('@focusmcp/brick-echo'); }); it('removes the brick entry from center.json', async () => { await executeRemove( io, 'echo', - '@focus-mcp/brick-echo', + '@focusmcp/brick-echo', validCenterJson(), validCenterLock(), ); @@ -549,7 +549,7 @@ describe('executeRemove', () => { await executeRemove( io, 'echo', - '@focus-mcp/brick-echo', + '@focusmcp/brick-echo', validCenterJson(), validCenterLock(), ); @@ -570,18 +570,18 @@ describe('executeRemove', () => { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focus-mcp/brick-echo', + npmPackage: '@focusmcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, indexer: { version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focus-mcp/brick-indexer', + npmPackage: '@focusmcp/brick-indexer', installedAt: '2026-04-02T00:00:00.000Z', }, }, }; - await executeRemove(io, 'echo', '@focus-mcp/brick-echo', centerJson, centerLock); + await executeRemove(io, 'echo', '@focusmcp/brick-echo', centerJson, centerLock); const writtenJson = firstCallArg(io.writeCenterJson); expect(writtenJson.bricks['echo']).toBeUndefined(); @@ -596,7 +596,7 @@ describe('executeRemove', () => { await executeRemove( io, 'echo', - '@focus-mcp/brick-echo', + '@focusmcp/brick-echo', validCenterJson(), validCenterLock(), ); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 106a13c..a6c0f0c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,5 +1,5 @@ { - "name": "@focus-mcp/sdk", + "name": "@focusmcp/sdk", "version": "0.0.0", "private": false, "description": "FocusMCP SDK — outils et types pour développer une brique", @@ -24,7 +24,7 @@ "test": "vitest run" }, "dependencies": { - "@focus-mcp/core": "workspace:*" + "@focusmcp/core": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/sdk/src/define-brick.test.ts b/packages/sdk/src/define-brick.test.ts index 4f0d33f..77af408 100644 --- a/packages/sdk/src/define-brick.test.ts +++ b/packages/sdk/src/define-brick.test.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -import type { BrickContext, BrickLogger } from '@focus-mcp/core'; -import { InProcessEventBus } from '@focus-mcp/core'; +import type { BrickContext, BrickLogger } from '@focusmcp/core'; +import { InProcessEventBus } from '@focusmcp/core'; import { describe, expect, it, vi } from 'vitest'; import { BrickDefinitionError, defineBrick } from './define-brick.ts'; diff --git a/packages/sdk/src/define-brick.ts b/packages/sdk/src/define-brick.ts index c1542f7..2888245 100644 --- a/packages/sdk/src/define-brick.ts +++ b/packages/sdk/src/define-brick.ts @@ -7,7 +7,7 @@ import { type BrickManifest, parseManifest, type Unsubscribe, -} from '@focus-mcp/core'; +} from '@focusmcp/core'; export type BrickDefinitionErrorCode = 'MISSING_HANDLER' | 'UNKNOWN_HANDLER' | 'ALREADY_STARTED'; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 505317c..9c1accc 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@focus-mcp/core": ["../core/src/index.ts"] + "@focusmcp/core": ["../core/src/index.ts"] } }, "include": ["src/**/*.ts"], diff --git a/packages/validator/package.json b/packages/validator/package.json index 309b23f..476500c 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,5 +1,5 @@ { - "name": "@focus-mcp/validator", + "name": "@focusmcp/validator", "version": "0.0.0", "private": false, "description": "FocusMCP validator — vérifie qu'une brique respecte le contrat FocusMCP", @@ -24,7 +24,7 @@ "test": "vitest run" }, "dependencies": { - "@focus-mcp/core": "workspace:*" + "@focusmcp/core": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/validator/src/validate-brick.test.ts b/packages/validator/src/validate-brick.test.ts index f9f9855..996bfe5 100644 --- a/packages/validator/src/validate-brick.test.ts +++ b/packages/validator/src/validate-brick.test.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -import type { Brick, BrickContext, BrickLogger, EventBus, Unsubscribe } from '@focus-mcp/core'; -import { InProcessEventBus } from '@focus-mcp/core'; +import type { Brick, BrickContext, BrickLogger, EventBus, Unsubscribe } from '@focusmcp/core'; +import { InProcessEventBus } from '@focusmcp/core'; import { describe, expect, it } from 'vitest'; import { validateBrick } from './validate-brick.ts'; diff --git a/packages/validator/src/validate-brick.ts b/packages/validator/src/validate-brick.ts index 615aca5..83a103d 100644 --- a/packages/validator/src/validate-brick.ts +++ b/packages/validator/src/validate-brick.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -import type { Brick, BrickContext, BrickLogger, EventBus } from '@focus-mcp/core'; -import { InProcessEventBus, ManifestError, parseManifest } from '@focus-mcp/core'; +import type { Brick, BrickContext, BrickLogger, EventBus } from '@focusmcp/core'; +import { InProcessEventBus, ManifestError, parseManifest } from '@focusmcp/core'; export type ValidationIssueCode = | 'INVALID_MANIFEST' diff --git a/packages/validator/tsconfig.json b/packages/validator/tsconfig.json index 505317c..9c1accc 100644 --- a/packages/validator/tsconfig.json +++ b/packages/validator/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@focus-mcp/core": ["../core/src/index.ts"] + "@focusmcp/core": ["../core/src/index.ts"] } }, "include": ["src/**/*.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d586d42..448022f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: packages/sdk: dependencies: - '@focus-mcp/core': + '@focusmcp/core': specifier: workspace:* version: link:../core devDependencies: @@ -131,7 +131,7 @@ importers: packages/validator: dependencies: - '@focus-mcp/core': + '@focusmcp/core': specifier: workspace:* version: link:../core devDependencies: From db99bbb91782341312a7797633fd90c12f9bb83e Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Thu, 23 Apr 2026 11:10:50 +0200 Subject: [PATCH 18/26] fix(ci): use GitHub Packages for dev publish (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): use GitHub Packages registry instead of npmjs.org - registry-url → npm.pkg.github.com - NODE_AUTH_TOKEN → GITHUB_TOKEN (no separate secret needed) - Add packages: write permission Co-Authored-By: Claude Opus 4.6 (1M context) * fix: rename scope to @focus-mcp + npmjs.org for dev publish - All packages: @focus-mcp/{core,sdk,validator} - dev-publish.yml: registry.npmjs.org + NPM_TOKEN - Regenerated lockfile Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate lockfile Co-Authored-By: Claude Opus 4.6 (1M context) * docs: update scope references to @focus-mcp in docs Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- .changeset/config.json | 4 +- .github/workflows/dev-publish.yml | 3 +- .github/workflows/publish-dev.yml | 6 +-- AGENTS.md | 2 +- CLAUDE.md | 18 +++---- CONTRIBUTING.md | 2 +- PRD.md | 28 +++++------ config/vitest.config.ts | 6 +-- docs/ROADMAP.md | 2 +- package.json | 8 +-- packages/cli/package.json | 4 +- packages/core/package.json | 4 +- .../core/src/marketplace/installer.test.ts | 50 +++++++++---------- packages/sdk/package.json | 6 +-- packages/sdk/src/define-brick.test.ts | 4 +- packages/sdk/src/define-brick.ts | 2 +- packages/sdk/tsconfig.json | 2 +- packages/validator/package.json | 6 +-- packages/validator/src/validate-brick.test.ts | 4 +- packages/validator/src/validate-brick.ts | 4 +- packages/validator/tsconfig.json | 2 +- pnpm-lock.yaml | 4 +- 22 files changed, 86 insertions(+), 85 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 89be74a..392e6a4 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -3,11 +3,11 @@ "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], - "linked": [["@focusmcp/core", "@focusmcp/sdk"]], + "linked": [["@focus-mcp/core", "@focus-mcp/sdk"]], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@focusmcp/ui", "@focusmcp/tauri-app"], + "ignore": ["@focus-mcp/ui", "@focus-mcp/tauri-app"], "privatePackages": { "version": false, "tag": false diff --git a/.github/workflows/dev-publish.yml b/.github/workflows/dev-publish.yml index 6484a2f..a78037e 100644 --- a/.github/workflows/dev-publish.yml +++ b/.github/workflows/dev-publish.yml @@ -10,6 +10,7 @@ on: permissions: contents: read + packages: write concurrency: group: dev-publish-${{ github.ref }} @@ -17,7 +18,7 @@ concurrency: jobs: publish-dev: - name: Publish @focusmcp/* dev packages + name: Publish @focus-mcp/* dev packages runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index e31bf01..d505016 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -15,7 +15,7 @@ permissions: jobs: publish: - name: Publish @focusmcp/* to GitHub Packages + name: Publish @focus-mcp/* to npmjs.org runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -24,7 +24,7 @@ jobs: with: node-version: 22 cache: pnpm - registry-url: https://npm.pkg.github.com + registry-url: https://registry.npmjs.org scope: '@focus-mcp' - run: pnpm install --frozen-lockfile - run: pnpm build @@ -40,4 +40,4 @@ jobs: fi done env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index ce49408..028dfd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Lire [PRD.md](./PRD.md) pour la vision complète, l'architecture (3 piliers : Re - Repos compagnons : `focus-mcp/client` (Tauri), `focus-mcp/marketplace` (briques) - Tests : **Vitest** (unit), **fast-check** (property-based), **Stryker** (mutation), **Playwright** (E2E) - Lint/format : **Biome 2.x** (pas ESLint+Prettier) -- Logs : **pino** (`@focusmcp/core/observability/logger`) +- Logs : **pino** (`@focus-mcp/core/observability/logger`) - Tracing : **OpenTelemetry** ## Organisation des fichiers diff --git a/CLAUDE.md b/CLAUDE.md index de7963b..4d69712 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# CLAUDE.md — @focusmcp/core +# CLAUDE.md — @focus-mcp/core > Auto-loaded by Claude Code (and any agents.md-compatible tool) when working in this repo. > This file is the **source of truth for AI agent behaviour** on this project. It replaces the @@ -15,7 +15,7 @@ SPDX-License-Identifier: MIT **briques** (atomic MCP modules) that communicate via an EventBus with central guards. Site [focusmcp.dev](https://focusmcp.dev). Full vision : [PRD.md](./PRD.md). -Ce repo héberge la **bibliothèque `@focusmcp/core`** (Registry + EventBus + Router + SDK + +Ce repo héberge la **bibliothèque `@focus-mcp/core`** (Registry + EventBus + Router + SDK + Validator + marketplace resolver) importée par le CLI. ## Écosystème (3 repos actifs + 1 archivé) @@ -23,7 +23,7 @@ Validator + marketplace resolver) importée par le CLI. | Repo | Statut | Rôle | |---|---|---| | `focus-mcp/core` (ici) | actif | Monorepo lib TS — 3 piliers + SDK/Validator/Marketplace resolver | -| `focus-mcp/cli` | actif | `@focusmcp/cli` — stdio MCP via `@modelcontextprotocol/sdk`, entrée primaire, publié npm | +| `focus-mcp/cli` | actif | `@focus-mcp/cli` — stdio MCP via `@modelcontextprotocol/sdk`, entrée primaire, publié npm | | `focus-mcp/marketplace` | actif | Catalogue officiel + `bricks/*` + `modules/*` (dont `manager` = dashboard). `catalog.json` publié sur gh-pages (domaine custom `marketplace.focusmcp.dev` à configurer). | | `focus-mcp/client` | **archivé** | Ex desktop Tauri. Pivot CLI-first (2026-04-16) a gelé ce repo en Phase 2. | @@ -33,9 +33,9 @@ Validator + marketplace resolver) importée par le CLI. AI client (Claude Code, Cursor, Codex, Gemini…) │ stdio (JSON-RPC MCP) ▼ -@focusmcp/cli (Node, npm) +@focus-mcp/cli (Node, npm) ├─ @modelcontextprotocol/sdk StdioServerTransport - ├─ import { createFocusMcp } from '@focusmcp/core' ← CE REPO + ├─ import { createFocusMcp } from '@focus-mcp/core' ← CE REPO └─ (opt-in P1) admin API HTTP côté latéral ``` @@ -73,8 +73,8 @@ héberge tout, mais l'architecture reste browser-compatible pour un futur Phase `develop → main`. Feature branches éphémères (`feat/*`, `fix/*`, `docs/*`, etc.), auto-delete après merge. 7. **npm orgs** — `focusmcp` ET `focus-mcp` sont réservées (squatting protection). Pas de - publish au MVP sauf `@focusmcp/cli` (primary distribution). Scope canonique : - `@focusmcp/*`. + publish au MVP sauf `@focus-mcp/cli` (primary distribution). Scope canonique : + `@focus-mcp/*`. 8. **Rulesets GitHub** — chaque nouveau repo reçoit le couple : - `main protection` cible **UNIQUEMENT `refs/heads/main`** — `required_status_checks`, `pull_request`, `code_scanning` (CodeQL), `code_quality`, `required_linear_history`, @@ -100,10 +100,10 @@ packages/ ``` **À surveiller** : -- Le CLI a été **extrait dans son propre repo** (`focus-mcp/cli`) qui consomme `@focusmcp/core` +- Le CLI a été **extrait dans son propre repo** (`focus-mcp/cli`) qui consomme `@focus-mcp/core` via `file:../core/packages/core` (sibling clone en CI). `packages/cli` ici est un vieux stub vide — à supprimer quand on fait le cleanup. -- `@focusmcp/core` n'est **pas publié sur npm** ; la CLI le bundle au build (`tsup --noExternal`). +- `@focus-mcp/core` n'est **pas publié sur npm** ; la CLI le bundle au build (`tsup --noExternal`). ## Commandes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18b1b73..2014bfd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,7 +29,7 @@ Tous les contributeurs s'engagent à respecter le [Code of Conduct](./CODE_OF_CO - **Conventional Commits** : `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `perf`, `build`, `ci`, `style`, `revert` - **SPDX headers** dans tous les fichiers source (`SPDX-License-Identifier: MIT`) - **REUSE compliance** vérifiée en CI -- **Pas de console.log** : utiliser le logger pino exposé par `@focusmcp/core` +- **Pas de console.log** : utiliser le logger pino exposé par `@focus-mcp/core` - **Pas de `any`** : TypeScript strict + Biome `noExplicitAny` ## Développer une brique diff --git a/PRD.md b/PRD.md index 92b3ebe..5e6f6e1 100644 --- a/PRD.md +++ b/PRD.md @@ -3,9 +3,9 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# @focusmcp/core — Product Requirements Document +# @focus-mcp/core — Product Requirements Document -> Périmètre de ce document : la **bibliothèque TypeScript** `@focusmcp/core` (package `packages/core` du monorepo). +> Périmètre de ce document : la **bibliothèque TypeScript** `@focus-mcp/core` (package `packages/core` du monorepo). > Pour l'app desktop : voir le repo [`focus-mcp/client`](https://github.com/focus-mcp/client). Pour le catalogue de briques : voir le repo [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace). ## Vision (rappel) @@ -21,9 +21,9 @@ Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. --- -## Rôle de `@focusmcp/core` dans l'écosystème +## Rôle de `@focus-mcp/core` dans l'écosystème -`@focusmcp/core` est la **bibliothèque TypeScript** qui implémente toute la logique MCP : +`@focus-mcp/core` est la **bibliothèque TypeScript** qui implémente toute la logique MCP : - **Importée par l'app desktop** (`client/`, Tauri) directement dans la WebView — pas de sidecar Node.js - **Aucun transport HTTP** : Tauri (Rust) est le **seul gardien HTTP** (Streamable HTTP MCP côté client) @@ -39,7 +39,7 @@ Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. │ Tauri commands (IPC) ┌──────────────▼─────────────────────┐ │ WebView — UI Svelte │ -│ └─ @focusmcp/core (this lib) │ +│ └─ @focus-mcp/core (this lib) │ │ Registry + EventBus + Router │ │ + briques (modules TS) │ └────────────────────────────────────┘ @@ -51,10 +51,10 @@ Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. | Package | Rôle | |---|---| -| `@focusmcp/core` | Registry, EventBus, Router, Manifest, Bootstrap, Observability | -| `@focusmcp/sdk` | Helper `defineBrick` pour auteurs de briques | -| `@focusmcp/validator` | Test runner conformance (manifeste, namespace, dépendances, garde-fous) | -| `@focusmcp/cli` | CLI `focus` — gestion des briques installées | +| `@focus-mcp/core` | Registry, EventBus, Router, Manifest, Bootstrap, Observability | +| `@focus-mcp/sdk` | Helper `defineBrick` pour auteurs de briques | +| `@focus-mcp/validator` | Test runner conformance (manifeste, namespace, dépendances, garde-fous) | +| `@focus-mcp/cli` | CLI `focus` — gestion des briques installées | --- @@ -169,12 +169,12 @@ Validation stricte (par `parseManifest`) : nom en kebab-case (ex: `php`, `indexe --- -## SDK — `@focusmcp/sdk` +## SDK — `@focus-mcp/sdk` Helper `defineBrick` pour les auteurs de briques : ```typescript -import { defineBrick } from '@focusmcp/sdk' +import { defineBrick } from '@focus-mcp/sdk' export default defineBrick({ manifest: { /* mcp-brick.json inline ou import */ }, @@ -189,7 +189,7 @@ export default defineBrick({ --- -## Validator — `@focusmcp/validator` +## Validator — `@focus-mcp/validator` Test runner qui valide qu'une brique respecte le contrat FocusMCP. Checks actuellement implémentés : - Manifeste valide (`INVALID_MANIFEST` via `parseManifest`) @@ -202,7 +202,7 @@ Lancé en CI sur chaque brique du marketplace officiel et utilisable par les dé --- -## CLI — `@focusmcp/cli` +## CLI — `@focus-mcp/cli` Commandes (inspirées npm/yarn) opérant sur `~/.focus/center.json` + `~/.focus/center.lock` : @@ -344,7 +344,7 @@ Les patterns transverses applicables par toutes les briques. Implémentés dans - **Indexation + cache** — index FTS5 partagé (brique `focus-indexer`) - **Reasoning externalisé** — chaînes de pensées persistées (brique `focus-thinking`) -`@focusmcp/core` ne contient aucune brique — il fournit l'infrastructure qui les rend possibles. +`@focus-mcp/core` ne contient aucune brique — il fournit l'infrastructure qui les rend possibles. --- diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 277efe6..88644de 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -10,9 +10,9 @@ const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export default defineConfig({ resolve: { alias: { - '@focusmcp/core': resolve(projectRoot, 'packages/core/src/index.ts'), - '@focusmcp/sdk': resolve(projectRoot, 'packages/sdk/src/index.ts'), - '@focusmcp/validator': resolve(projectRoot, 'packages/validator/src/index.ts'), + '@focus-mcp/core': resolve(projectRoot, 'packages/core/src/index.ts'), + '@focus-mcp/sdk': resolve(projectRoot, 'packages/sdk/src/index.ts'), + '@focus-mcp/validator': resolve(projectRoot, 'packages/validator/src/index.ts'), }, }, test: { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 63b710a..0c750a2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ Voir [PRD.md](../PRD.md) pour les détails fonctionnels complets. - [x] `McpRouter` (TDD, coverage 100%) - [x] Transport HTTP + HTTPS (spec MCP 2025-03-26 via SDK officiel) - [ ] `focus-validator` — test runner pour briques tierces -- [ ] SDK brique (`@focusmcp/sdk`) — helpers pour écrire une brique +- [ ] SDK brique (`@focus-mcp/sdk`) — helpers pour écrire une brique ## Phase 1 — CLI et ergonomie diff --git a/package.json b/package.json index 7ffaca7..c98efff 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/root", + "name": "@focus-mcp/root", "version": "0.0.0", "private": true, "description": "FocusMCP — Focaliser les agents AI sur l'essentiel", @@ -43,19 +43,19 @@ }, "size-limit": [ { - "name": "@focusmcp/core (gzip)", + "name": "@focus-mcp/core (gzip)", "path": "packages/core/dist/index.js", "limit": "30 KB", "gzip": true }, { - "name": "@focusmcp/sdk (gzip)", + "name": "@focus-mcp/sdk (gzip)", "path": "packages/sdk/dist/index.js", "limit": "10 KB", "gzip": true }, { - "name": "@focusmcp/cli (gzip)", + "name": "@focus-mcp/cli (gzip)", "path": "packages/cli/dist/index.js", "limit": "50 KB", "gzip": true diff --git a/packages/cli/package.json b/packages/cli/package.json index 17b3e51..e79f7d8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/cli", + "name": "@focus-mcp/cli", "version": "0.0.0", "private": true, "description": "FocusMCP CLI — focus start, add, remove, update...", @@ -26,7 +26,7 @@ "publishConfig": { "access": "public", "provenance": true, - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org" }, "repository": { "type": "git", diff --git a/packages/core/package.json b/packages/core/package.json index b816fe0..f35764f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/core", + "name": "@focus-mcp/core", "version": "0.0.0", "private": false, "description": "FocusMCP core — Registry, EventBus, Router, manifest parser (browser+node compatible)", @@ -36,7 +36,7 @@ "publishConfig": { "access": "public", "provenance": true, - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org" }, "repository": { "type": "git", diff --git a/packages/core/src/marketplace/installer.test.ts b/packages/core/src/marketplace/installer.test.ts index 9efe415..f5af10c 100644 --- a/packages/core/src/marketplace/installer.test.ts +++ b/packages/core/src/marketplace/installer.test.ts @@ -54,7 +54,7 @@ function validCenterLock(overrides: Partial = {}): CenterLock { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, }, @@ -69,7 +69,7 @@ function validNpmBrick(overrides: Partial = {}): CatalogBrick { description: 'Echo brick', dependencies: [], tools: [{ name: 'say', description: 'Echo text' }], - source: { type: 'npm', package: '@focusmcp/brick-echo' }, + source: { type: 'npm', package: '@focus-mcp/brick-echo' }, ...overrides, }; } @@ -155,7 +155,7 @@ describe('parseCenterLock', () => { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, }, @@ -278,7 +278,7 @@ describe('serializeCenterLock', () => { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, }, @@ -321,7 +321,7 @@ describe('planInstall', () => { const plan = planInstall(brick, catalogUrl); expect(plan).toEqual({ name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.2.3', catalogUrl, }); @@ -331,7 +331,7 @@ describe('planInstall', () => { const brick = validNpmBrick({ source: { type: 'npm', - package: '@focusmcp/brick-echo', + package: '@focus-mcp/brick-echo', registry: 'https://my.registry', }, }); @@ -368,7 +368,7 @@ describe('planRemove', () => { const centerJson = validCenterJson(); const centerLock = validCenterLock(); const result = planRemove('echo', centerJson, centerLock); - expect(result.npmPackage).toBe('@focusmcp/brick-echo'); + expect(result.npmPackage).toBe('@focus-mcp/brick-echo'); }); it('throws when the brick is not in center.json', () => { @@ -401,24 +401,24 @@ describe('executeInstall', () => { it('calls npmInstall with the correct package and version', async () => { const plan: InstallPlan = { name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.2.3', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; await executeInstall(io, plan, validCenterJson(), validCenterLock(), now); - expect(io.npmInstall).toHaveBeenCalledWith('@focusmcp/brick-echo', '1.2.3', {}); + expect(io.npmInstall).toHaveBeenCalledWith('@focus-mcp/brick-echo', '1.2.3', {}); }); it('passes the registry option to npmInstall when provided', async () => { const plan: InstallPlan = { name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.2.3', registry: 'https://my.registry', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; await executeInstall(io, plan, validCenterJson(), validCenterLock(), now); - expect(io.npmInstall).toHaveBeenCalledWith('@focusmcp/brick-echo', '1.2.3', { + expect(io.npmInstall).toHaveBeenCalledWith('@focus-mcp/brick-echo', '1.2.3', { registry: 'https://my.registry', }); }); @@ -426,7 +426,7 @@ describe('executeInstall', () => { it('writes the updated center.json with the new brick entry', async () => { const plan: InstallPlan = { name: 'indexer', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -441,7 +441,7 @@ describe('executeInstall', () => { it('writes the updated center.lock with the new lock entry', async () => { const plan: InstallPlan = { name: 'indexer', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -453,7 +453,7 @@ describe('executeInstall', () => { expect(written.bricks['indexer']).toEqual({ version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', installedAt: now, }); }); @@ -461,7 +461,7 @@ describe('executeInstall', () => { it('preserves existing entries when adding a new brick', async () => { const plan: InstallPlan = { name: 'indexer', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -486,7 +486,7 @@ describe('executeInstall', () => { }); const plan: InstallPlan = { name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -498,7 +498,7 @@ describe('executeInstall', () => { const before = new Date().toISOString(); const plan: InstallPlan = { name: 'echo', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', }; @@ -525,18 +525,18 @@ describe('executeRemove', () => { await executeRemove( io, 'echo', - '@focusmcp/brick-echo', + '@focus-mcp/brick-echo', validCenterJson(), validCenterLock(), ); - expect(io.npmUninstall).toHaveBeenCalledWith('@focusmcp/brick-echo'); + expect(io.npmUninstall).toHaveBeenCalledWith('@focus-mcp/brick-echo'); }); it('removes the brick entry from center.json', async () => { await executeRemove( io, 'echo', - '@focusmcp/brick-echo', + '@focus-mcp/brick-echo', validCenterJson(), validCenterLock(), ); @@ -549,7 +549,7 @@ describe('executeRemove', () => { await executeRemove( io, 'echo', - '@focusmcp/brick-echo', + '@focus-mcp/brick-echo', validCenterJson(), validCenterLock(), ); @@ -570,18 +570,18 @@ describe('executeRemove', () => { echo: { version: '1.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-echo', + npmPackage: '@focus-mcp/brick-echo', installedAt: '2026-04-01T00:00:00.000Z', }, indexer: { version: '2.0.0', catalogUrl: 'https://marketplace.focusmcp.dev/catalog.json', - npmPackage: '@focusmcp/brick-indexer', + npmPackage: '@focus-mcp/brick-indexer', installedAt: '2026-04-02T00:00:00.000Z', }, }, }; - await executeRemove(io, 'echo', '@focusmcp/brick-echo', centerJson, centerLock); + await executeRemove(io, 'echo', '@focus-mcp/brick-echo', centerJson, centerLock); const writtenJson = firstCallArg(io.writeCenterJson); expect(writtenJson.bricks['echo']).toBeUndefined(); @@ -596,7 +596,7 @@ describe('executeRemove', () => { await executeRemove( io, 'echo', - '@focusmcp/brick-echo', + '@focus-mcp/brick-echo', validCenterJson(), validCenterLock(), ); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a6c0f0c..85618ac 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/sdk", + "name": "@focus-mcp/sdk", "version": "0.0.0", "private": false, "description": "FocusMCP SDK — outils et types pour développer une brique", @@ -24,7 +24,7 @@ "test": "vitest run" }, "dependencies": { - "@focusmcp/core": "workspace:*" + "@focus-mcp/core": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", @@ -34,7 +34,7 @@ "publishConfig": { "access": "public", "provenance": true, - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org" }, "repository": { "type": "git", diff --git a/packages/sdk/src/define-brick.test.ts b/packages/sdk/src/define-brick.test.ts index 77af408..4f0d33f 100644 --- a/packages/sdk/src/define-brick.test.ts +++ b/packages/sdk/src/define-brick.test.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -import type { BrickContext, BrickLogger } from '@focusmcp/core'; -import { InProcessEventBus } from '@focusmcp/core'; +import type { BrickContext, BrickLogger } from '@focus-mcp/core'; +import { InProcessEventBus } from '@focus-mcp/core'; import { describe, expect, it, vi } from 'vitest'; import { BrickDefinitionError, defineBrick } from './define-brick.ts'; diff --git a/packages/sdk/src/define-brick.ts b/packages/sdk/src/define-brick.ts index 2888245..c1542f7 100644 --- a/packages/sdk/src/define-brick.ts +++ b/packages/sdk/src/define-brick.ts @@ -7,7 +7,7 @@ import { type BrickManifest, parseManifest, type Unsubscribe, -} from '@focusmcp/core'; +} from '@focus-mcp/core'; export type BrickDefinitionErrorCode = 'MISSING_HANDLER' | 'UNKNOWN_HANDLER' | 'ALREADY_STARTED'; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 9c1accc..505317c 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@focusmcp/core": ["../core/src/index.ts"] + "@focus-mcp/core": ["../core/src/index.ts"] } }, "include": ["src/**/*.ts"], diff --git a/packages/validator/package.json b/packages/validator/package.json index 476500c..48f9b3f 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,5 +1,5 @@ { - "name": "@focusmcp/validator", + "name": "@focus-mcp/validator", "version": "0.0.0", "private": false, "description": "FocusMCP validator — vérifie qu'une brique respecte le contrat FocusMCP", @@ -24,7 +24,7 @@ "test": "vitest run" }, "dependencies": { - "@focusmcp/core": "workspace:*" + "@focus-mcp/core": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", @@ -34,7 +34,7 @@ "publishConfig": { "access": "public", "provenance": true, - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org" }, "repository": { "type": "git", diff --git a/packages/validator/src/validate-brick.test.ts b/packages/validator/src/validate-brick.test.ts index 996bfe5..f9f9855 100644 --- a/packages/validator/src/validate-brick.test.ts +++ b/packages/validator/src/validate-brick.test.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -import type { Brick, BrickContext, BrickLogger, EventBus, Unsubscribe } from '@focusmcp/core'; -import { InProcessEventBus } from '@focusmcp/core'; +import type { Brick, BrickContext, BrickLogger, EventBus, Unsubscribe } from '@focus-mcp/core'; +import { InProcessEventBus } from '@focus-mcp/core'; import { describe, expect, it } from 'vitest'; import { validateBrick } from './validate-brick.ts'; diff --git a/packages/validator/src/validate-brick.ts b/packages/validator/src/validate-brick.ts index 83a103d..615aca5 100644 --- a/packages/validator/src/validate-brick.ts +++ b/packages/validator/src/validate-brick.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 FocusMCP contributors // SPDX-License-Identifier: MIT -import type { Brick, BrickContext, BrickLogger, EventBus } from '@focusmcp/core'; -import { InProcessEventBus, ManifestError, parseManifest } from '@focusmcp/core'; +import type { Brick, BrickContext, BrickLogger, EventBus } from '@focus-mcp/core'; +import { InProcessEventBus, ManifestError, parseManifest } from '@focus-mcp/core'; export type ValidationIssueCode = | 'INVALID_MANIFEST' diff --git a/packages/validator/tsconfig.json b/packages/validator/tsconfig.json index 9c1accc..505317c 100644 --- a/packages/validator/tsconfig.json +++ b/packages/validator/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@focusmcp/core": ["../core/src/index.ts"] + "@focus-mcp/core": ["../core/src/index.ts"] } }, "include": ["src/**/*.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 448022f..d586d42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: packages/sdk: dependencies: - '@focusmcp/core': + '@focus-mcp/core': specifier: workspace:* version: link:../core devDependencies: @@ -131,7 +131,7 @@ importers: packages/validator: dependencies: - '@focusmcp/core': + '@focus-mcp/core': specifier: workspace:* version: link:../core devDependencies: From ca068891698e95e71b08a455c39b70e0183efc6a Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Thu, 23 Apr 2026 12:05:21 +0200 Subject: [PATCH 19/26] fix(ci): add id-token:write permission + remove duplicate workflows (#26) - Add id-token:write to dev-publish.yml (required for npm provenance) - Remove publish-dev.yml (duplicate, was targeting GitHub Packages) Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/dev-publish.yml | 1 + .github/workflows/publish-dev.yml | 43 ------------------------------- 2 files changed, 1 insertion(+), 43 deletions(-) delete mode 100644 .github/workflows/publish-dev.yml diff --git a/.github/workflows/dev-publish.yml b/.github/workflows/dev-publish.yml index a78037e..974c436 100644 --- a/.github/workflows/dev-publish.yml +++ b/.github/workflows/dev-publish.yml @@ -11,6 +11,7 @@ on: permissions: contents: read packages: write + id-token: write concurrency: group: dev-publish-${{ github.ref }} diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml deleted file mode 100644 index d505016..0000000 --- a/.github/workflows/publish-dev.yml +++ /dev/null @@ -1,43 +0,0 @@ -# SPDX-FileCopyrightText: 2026 FocusMCP contributors -# SPDX-License-Identifier: MIT - -name: Publish to GitHub Packages (dev) - -on: - push: - branches: [develop] - workflow_dispatch: - -permissions: - contents: read - packages: write - id-token: write - -jobs: - publish: - name: Publish @focus-mcp/* to npmjs.org - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 - with: - node-version: 22 - cache: pnpm - registry-url: https://registry.npmjs.org - scope: '@focus-mcp' - - run: pnpm install --frozen-lockfile - - run: pnpm build - - run: | - for dir in packages/*/; do - name=$(node -e "console.log(require('./${dir}package.json').name)") - private=$(node -e "console.log(require('./${dir}package.json').private ?? true)") - if [ "$private" = "false" ]; then - echo "Publishing $name..." - cd "$dir" - npm publish --access public 2>&1 || echo " → skipped (already published or error)" - cd ../.. - fi - done - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From d0b760bb38125faff47ec99ec0e6ca2c393f3275 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Thu, 23 Apr 2026 15:44:16 +0200 Subject: [PATCH 20/26] chore(release): v1.0.0 + stable-publish workflow (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(release): bump @focus-mcp/{core,sdk,validator} to 1.0.0 - Bump all 3 public packages from 0.0.0 to 1.0.0 - Add stable-publish.yml workflow (triggers on push to main → npm @latest) Co-Authored-By: Claude Opus 4.6 (1M context) * chore(ci): remove release.yml — stable-publish.yml handles releases Co-Authored-By: Claude Opus 4.6 (1M context) * chore(ci): remove claude-review.yml (fails on workflow changes) Co-Authored-By: Claude Opus 4.6 (1M context) * revert: restore claude-review.yml — will work after main merge Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/release.yml | 44 ------------------------ .github/workflows/stable-publish.yml | 51 ++++++++++++++++++++++++++++ packages/core/package.json | 2 +- packages/sdk/package.json | 2 +- packages/validator/package.json | 2 +- 5 files changed, 54 insertions(+), 47 deletions(-) delete mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/stable-publish.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 99c4c98..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,44 +0,0 @@ -# SPDX-FileCopyrightText: 2026 FocusMCP contributors -# SPDX-License-Identifier: MIT -# -# Release workflow — disabled on push until NPM_TOKEN secret is configured. -# Trigger manually via workflow_dispatch when ready to publish. - -name: Release - -on: - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - id-token: write - -concurrency: release-${{ github.ref }} - -jobs: - release: - name: Release via Changesets - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v5 - with: - node-version: 22 - cache: pnpm - registry-url: https://registry.npmjs.org - - run: pnpm install --frozen-lockfile - - run: pnpm build - - uses: changesets/action@v1 - with: - publish: pnpm release - version: pnpm version - commit: 'chore(release): version packages' - title: 'chore(release): version packages' - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_PROVENANCE: true diff --git a/.github/workflows/stable-publish.yml b/.github/workflows/stable-publish.yml new file mode 100644 index 0000000..e9843d7 --- /dev/null +++ b/.github/workflows/stable-publish.yml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: 2026 FocusMCP contributors +# SPDX-License-Identifier: MIT + +name: Stable Publish + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + packages: write + id-token: write + +concurrency: + group: stable-publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish-stable: + name: Publish @focus-mcp/* packages with @latest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + scope: '@focus-mcp' + - run: pnpm install --frozen-lockfile + - run: pnpm build + - name: Publish all packages with latest tag + run: | + for dir in packages/*/; do + if [ -f "${dir}package.json" ]; then + PRIVATE=$(node -e "console.log(require('./${dir}package.json').private)") + if [ "$PRIVATE" != "true" ]; then + NAME=$(node -e "console.log(require('./${dir}package.json').name)") + VERSION=$(node -e "console.log(require('./${dir}package.json').version)") + echo "Publishing ${NAME}@${VERSION}..." + cd "$dir" + npm publish --access public 2>&1 || echo " → skipped (already published?)" + cd ../.. + fi + fi + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/core/package.json b/packages/core/package.json index f35764f..b856666 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@focus-mcp/core", - "version": "0.0.0", + "version": "1.0.0", "private": false, "description": "FocusMCP core — Registry, EventBus, Router, manifest parser (browser+node compatible)", "license": "MIT", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 85618ac..1ef0b4c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@focus-mcp/sdk", - "version": "0.0.0", + "version": "1.0.0", "private": false, "description": "FocusMCP SDK — outils et types pour développer une brique", "license": "MIT", diff --git a/packages/validator/package.json b/packages/validator/package.json index 48f9b3f..ae20d48 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,6 +1,6 @@ { "name": "@focus-mcp/validator", - "version": "0.0.0", + "version": "1.0.0", "private": false, "description": "FocusMCP validator — vérifie qu'une brique respecte le contrat FocusMCP", "license": "MIT", From abb8c081063430d39e2993020120a05a12a0e36d Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Thu, 23 Apr 2026 22:12:38 +0200 Subject: [PATCH 21/26] =?UTF-8?q?docs:=20v1=20cleanup=20=E2=80=94=20README?= =?UTF-8?q?,=20VISION,=20ARCHITECTURE=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: v1 cleanup — rewrite README, add VISION.md and ARCHITECTURE.md - README.md: English public-facing with install, quick start, architecture - VISION.md: short "why" doc (1 page) - ARCHITECTURE.md: technical doc for contributors - AGENTS.md, CONTRIBUTING.md, ROADMAP.md: updated for v1.0.0 state - Archived pre-v1 PRD (internal French planning doc) - npm metadata (description, keywords, author, homepage) on sdk and validator Co-Authored-By: Claude Opus 4.7 (1M context) * docs: consolidate AGENTS.md + AI transparency - merge CLAUDE.md into AGENTS.md (single source of truth per agents.md spec) - add AI-assisted development section to README - add AI-assisted contributions section to CONTRIBUTING Co-Authored-By: Claude Opus 4.7 (1M context) * chore(ci): allow `release` commit type in commitlint Release commits (e.g. `release: v1.0.0`) were rejected by the type-enum. Add `release` as a valid type since we already use it on main branches. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(deps): pin uuid to ^14.0.0 via pnpm override (GHSA-w5hq-g745-h8pq) Transitive from @cyclonedx/cdxgen (dev only). Not in runtime bundle. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.7 (1M context) --- AGENTS.md | 184 +++++++++------ ARCHITECTURE.md | 77 +++++++ CLAUDE.md | 145 ------------ CONTRIBUTING.md | 97 +++++--- PRD.md | 384 -------------------------------- README.md | 153 ++++++++++--- SECURITY.md | 69 +++--- VISION.md | 40 ++++ config/commitlint.config.js | 1 + docs/GOVERNANCE.md | 44 ++-- docs/ROADMAP.md | 78 ++++--- docs/adr/README.md | 2 +- package.json | 3 +- packages/core/package.json | 24 +- packages/sdk/package.json | 18 +- packages/validator/package.json | 18 +- pnpm-lock.yaml | 18 +- 17 files changed, 603 insertions(+), 752 deletions(-) create mode 100644 ARCHITECTURE.md delete mode 100644 CLAUDE.md delete mode 100644 PRD.md create mode 100644 VISION.md diff --git a/AGENTS.md b/AGENTS.md index 028dfd3..b929698 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,101 +5,161 @@ SPDX-License-Identifier: MIT # AGENTS.md -> Instructions pour les agents AI travaillant sur ce dépôt (Claude Code, Cursor, Codex, Copilot, Gemini CLI, Aider, etc.). -> Format inspiré de la convention émergente [agents.md](https://agentsmd.net/). +> This file is the **single source of truth for AI agent behavior** on this project. +> It follows the [agents.md](https://agents.md) standard and is read by Claude Code, +> Cursor, Aider, GitHub Copilot, and any other AI coding tool. +> +> Humans, this file is for you too — it documents our conventions and expectations. -## Projet +## Project -**FocusMCP** — orchestrateur de briques MCP atomiques. Site : https://focusmcp.dev. -Lire [PRD.md](./PRD.md) pour la vision complète, l'architecture (3 piliers : Registry + EventBus + Router), et les décisions prises. +**FocusMCP** — atomic MCP brick orchestrator. Site: https://focusmcp.dev. +Read [VISION.md](./VISION.md) for the full vision, architecture (3 pillars: Registry + EventBus + Router), and design principles. + +This repo (`focus-mcp/core`) hosts the **`@focus-mcp/core` library** (Registry + EventBus + Router + SDK + Validator + marketplace resolver), imported by `@focus-mcp/cli`. + +## Ecosystem + +| Repo | Status | Role | +|---|---|---| +| `focus-mcp/core` (here) | active | TS monorepo lib — 3 pillars + SDK/Validator/Marketplace resolver | +| `focus-mcp/cli` | active | `@focus-mcp/cli` — stdio MCP via `@modelcontextprotocol/sdk`, primary entry point, published on npmjs.org | +| `focus-mcp/marketplace` | active | Official catalog + `bricks/*` + `modules/*`. `catalog.json` served via raw GitHub. | +| `focus-mcp/client` | **archived** | Former Tauri desktop app. Frozen in Phase 2 after CLI-first pivot (2026-04-16). | + +## Architecture (post CLI-first pivot, 2026-04-16) + +``` +AI agent (Claude Code, Cursor, Codex, Gemini…) + │ stdio (JSON-RPC MCP) + ▼ +@focus-mcp/cli (Node, npm) + ├─ @modelcontextprotocol/sdk StdioServerTransport + ├─ import { createFocusMcp } from '@focus-mcp/core' ← THIS REPO + └─ (opt-in P1) lateral HTTP admin API +``` + +The core is imported by the CLI, not the other way around. **Browser-compatible**: no `node:async_hooks`, no Pino — custom logger/tracer primitives only. + +The cli-manager (dashboard) does NOT depend on core — it consumes the CLI's HTTP admin API. ## Stack - **Node.js ≥ 22** (LTS), **pnpm ≥ 10**, **TypeScript 5.7+** strict -- **ESM only** (`"type": "module"`, pas de CJS) -- Monorepo **pnpm workspaces** : `packages/{core,sdk,cli}` (ce repo = `core`) -- Repos compagnons : `focus-mcp/client` (Tauri), `focus-mcp/marketplace` (briques) -- Tests : **Vitest** (unit), **fast-check** (property-based), **Stryker** (mutation), **Playwright** (E2E) -- Lint/format : **Biome 2.x** (pas ESLint+Prettier) -- Logs : **pino** (`@focus-mcp/core/observability/logger`) -- Tracing : **OpenTelemetry** +- **ESM only** (`"type": "module"`, no CJS) +- **pnpm workspaces** monorepo: `packages/{core,sdk,validator,cli}` (this repo = `core`) +- Companion repos: `focus-mcp/cli` (primary CLI), `focus-mcp/marketplace` (bricks) +- Tests: **Vitest** (unit), **fast-check** (property-based), **Stryker** (mutation), **Playwright** (E2E) +- Lint/format: **Biome 2.x** (not ESLint + Prettier) +- Logger: browser-compatible custom logger (not Pino — incompatible with WebView) +- Tracing: browser-compatible custom tracer (not `node:async_hooks`) + +## File layout -## Organisation des fichiers +All tool configs live in **`config/`** (biome, vitest, playwright, stryker, knip, jscpd, commitlint, lint-staged, gitleaks, tsconfig.base). Root-level files follow strict conventions only (README, LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, CHANGELOG, AGENTS, PRD, package.json, pnpm-workspace.yaml, tsconfig.json, dotfiles). -Toutes les configs outils sont regroupées dans **`config/`** (biome, vitest, playwright, stryker, knip, jscpd, commitlint, lint-staged, gitleaks, tsconfig.base). À la racine on garde uniquement les conventions strictes (README, LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, CHANGELOG, AGENTS, PRD, package.json, pnpm-workspace.yaml, tsconfig.json, .gitlab-ci.yml, dotfiles standards). +Long-form docs in **`docs/`** (ROADMAP, GOVERNANCE, ADRs). -Docs longue forme dans **`docs/`** (ROADMAP, GOVERNANCE, ADRs). +``` +packages/ + core/ ← Registry, EventBus (guards), Router, manifest parser, observability, marketplace resolver + sdk/ ← defineBrick helper + validator/ ← test runner conformance for bricks + cli/ ← DEPRECATED STUB — real CLI lives in focus-mcp/cli. Remove on next cleanup. +``` + +## Non-negotiable rules + +1. **TDD strict** — write the test BEFORE the code (Red → Green → Refactor) + - Coverage: ≥ 80% global, ≥ 95% on `event-bus/**` and `registry/**` (critical modules) +2. **No `any`**, no `console.log` (use the browser-compatible logger from `@focus-mcp/core/observability/logger`) +3. **SPDX header** in every source file: `SPDX-FileCopyrightText: 2026 FocusMCP contributors` + `SPDX-License-Identifier: MIT` +4. **Imports**: use `node:` protocol (`import { readFile } from 'node:fs/promises'`) +5. **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) — allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` +6. **Brick atomicity**: 1 brick = 1 domain. No catch-all bricks. Convention: `focus-` or `focus--` +7. **No unrequested features** — respect scope strictly +8. **npm scope is `@focus-mcp`** (with hyphen). Never write `@focusmcp` (no hyphen). +9. **Public-facing content in English** — scope: `.github/` (workflows, PR/issue templates, renovate), PR/issue titles + bodies + comments, commit messages, marketplace `mcp-brick.json` descriptions, `bricks//README.md`, contributor-facing docs (README, AGENTS, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT). + +## GitHub Rulesets -## Règles non-négociables +Every active repo in the FocusMCP org has two rulesets — do not modify without discussion: -1. **TDD strict** — écrire le test AVANT le code (Red → Green → Refactor) - - Coverage : ≥ 80% global, ≥ 95% sur `event-bus/**` et `registry/**` -2. **Pas de `any`**, pas de `console.log` (utiliser le logger pino) -3. **SPDX header** dans tous les fichiers source : `SPDX-FileCopyrightText: 2026 FocusMCP contributors` + `SPDX-License-Identifier: MIT` -4. **Imports** : `node:` protocol (`import { readFile } from 'node:fs/promises'`) -5. **Commits** : [Conventional Commits](https://www.conventionalcommits.org/) — types autorisés : `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` -6. **Atomicité des briques** : 1 brique = 1 domaine. Pas de brique fourre-tout. Convention `focus-` ou `focus--` -7. **Pas de feature non demandée** — respecter strictement le périmètre +- **`main protection`** — targets `refs/heads/main` ONLY: `required_status_checks`, `pull_request`, `code_scanning` (CodeQL), `code_quality`, `required_linear_history`, `deletion`, `non_fast_forward`. **No `required_signatures`** (AI-assisted commits are not signed). +- **`develop protection`** — targets `refs/heads/develop` ONLY: `deletion`, `non_fast_forward`, `required_linear_history`, `pull_request` (no `code_quality` — this check is not available on non-default branches). +- **Known pitfall**: NEVER include `develop` in the targets of "main protection". -## Commandes +## Commands ```bash -pnpm install # install (frozen lockfile en CI) -pnpm test # tests +pnpm install # install (frozen lockfile in CI) +pnpm test # run tests pnpm test:watch # watch mode -pnpm test:coverage # avec coverage + thresholds -pnpm typecheck # tsc --noEmit sur tous les packages +pnpm test:coverage # with coverage + thresholds +pnpm typecheck # tsc --noEmit on all packages pnpm lint # Biome check pnpm lint:fix # Biome auto-fix -pnpm build # build des packages -pnpm knip # détection de dead code +pnpm build # build all packages +pnpm knip # dead code detection pnpm size # bundle size budget -pnpm changeset # créer un changeset (avant de merger) ``` -## Structure attendue d'un module dans `packages/core` +## Expected structure for a module in `packages/core` ``` packages/core/src/ event-bus/ - event-bus.ts # implémentation - event-bus.test.ts # tests Vitest (TDD) - event-bus.spec.ts # property-based avec fast-check - types.ts # types publics - index.ts # exports publics + event-bus.ts # implementation + event-bus.test.ts # Vitest tests (TDD) + event-bus.spec.ts # property-based with fast-check + types.ts # public types + index.ts # public exports ``` -## Workflow type pour ajouter une feature +## Workflow for adding a feature -1. **Lire** le PRD pour comprendre le contexte -2. **Créer un ADR** dans `docs/adr/` si décision architecturale -3. **Écrire les specs** (tests) — Red -4. **Implémenter** le minimum — Green +1. **Read** [VISION.md](./VISION.md) to understand context +2. **Create an ADR** in `docs/adr/` if it involves an architectural decision +3. **Write specs** (tests) — Red +4. **Implement** the minimum — Green 5. **Refactor** -6. **Coverage** : `pnpm test:coverage` doit passer sans warning -7. **Lint + typecheck** : `pnpm lint && pnpm typecheck` -8. **Changeset** : `pnpm changeset` -9. **Commit** Conventional Commits -10. **MR** vers `main` +6. **Coverage**: `pnpm test:coverage` must pass without warnings +7. **Lint + typecheck**: `pnpm lint && pnpm typecheck` +8. **Commit** using Conventional Commits +9. **PR** towards `develop` (never directly to `main`) -## Sécurité +## Release process -- **Aucun secret** dans le code (gitleaks bloque en pre-commit) -- **Pas de `eval`**, pas de `new Function()` -- Toute exécution de code arbitraire passe par la brique `focus-sandbox` (V8 isolé) -- Tout accès filesystem/réseau côté brique passe par Tauri (rust) +- **Stable** (`@latest`): push to `main` → `stable-publish.yml` publishes all non-private packages to **npmjs.org** +- **Dev** (`@dev`): push to `develop` → `dev-publish.yml` publishes a versioned dev snapshot to **npmjs.org** +- No Changesets release workflow — publishing is handled directly by the CI workflows above +- Published packages: `@focus-mcp/core`, `@focus-mcp/sdk`, `@focus-mcp/validator` (all at v1.0.0+) + +## Catalog + +The official brick catalog is served via raw GitHub: + +``` +https://raw.githubusercontent.com/focus-mcp/marketplace/main/publish/catalog.json +``` -## Remote Git +## Security -- **origin** : `git@github.com:focus-mcp/core.git` (GitHub, CI principale via GitHub Actions) +- **No secrets** in code (gitleaks blocks in pre-commit and CI) +- **No `eval`** and no dynamic code construction — use static imports only +- No direct OS filesystem/network access from bricks — goes through injected providers +- OS sandbox is inherited from the parent process. `isolated-vm` available in Phase 2 if needed. -## Inspirations / sources +## Git-flow -Voir la section "Inspirations" dans [PRD.md](./PRD.md). Notamment : Context Mode (sandbox + memory), Claude Octopus (worktrees + reactor), modelcontextprotocol/servers (concept Sequential Thinking). +- **origin**: `git@github.com:focus-mcp/core.git` (GitHub, primary CI via GitHub Actions) +- `develop` is **permanent** — never delete it. **Never `--delete-branch` on a develop→main PR.** +- Feature branches (`feat/*`, `fix/*`, `docs/*`) are ephemeral and auto-deleted after merge. +- All PRs target `develop` (never directly to `main`). -## Documentation à consulter en priorité +## Priority reading -1. [PRD.md](./PRD.md) — vision et architecture complète -2. [CONTRIBUTING.md](./CONTRIBUTING.md) — workflow contribution -3. [docs/adr/](./docs/adr/) — décisions architecturales -4. [ROADMAP.md](./ROADMAP.md) — phases et priorités +1. [VISION.md](./VISION.md) — full vision and design principles +2. [CONTRIBUTING.md](./CONTRIBUTING.md) — contribution workflow +3. [docs/adr/](./docs/adr/) — architectural decisions +4. [docs/ROADMAP.md](./docs/ROADMAP.md) — phases and priorities diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..fbba088 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,77 @@ + + +# Architecture — @focus-mcp/core + +## Overview + +`@focus-mcp/core` is the runtime library behind FocusMCP. It provides the building blocks that +hosts (CLI, desktop app, IDE plugin) use to orchestrate MCP bricks. + +``` +┌──────────────────────────────────────────────────────┐ +│ Host (@focus-mcp/cli, custom server, ...) │ +│ └─ imports @focus-mcp/core │ +│ ├─ Registry — brick lifecycle & state │ +│ ├─ EventBus — tool routing & guards │ +│ ├─ Router — MCP ↔ bus translation │ +│ ├─ Loader — dynamic brick loading │ +│ └─ Marketplace — catalog fetch & resolve │ +└──────────────────────────────────────────────────────┘ +``` + +## Package layout + +- `packages/core` — the 3 pillars + loader + marketplace modules + observability +- `packages/sdk` — `defineBrick()` helper for brick authors +- `packages/validator` — conformance test runner for bricks + +## Core pillars + +### Registry (`packages/core/src/registry/`) + +In-memory state machine tracking each brick's lifecycle: `registered → started → running → stopped`. +Exposes `registerBrick()`, `startBrick()`, `stopBrick()`, `getBrick()`, `listBricks()`. + +### EventBus (`packages/core/src/event-bus/`) + +Routes tool calls and events between bricks. **Central guards**: +- Rate limiting (per-brick, per-tool) +- Permission checks (dependency-based) +- Tracing (OpenTelemetry compatible) +- Error isolation + +All bricks talk through the bus — no direct imports between bricks. + +### Router (`packages/core/src/router/`) + +Translates between the MCP protocol (JSON-RPC tools/list, tools/call) and internal bus events. +Hosts attach their transport (stdio, HTTP) and the router handles the rest. + +## Supporting modules + +### Loader (`packages/core/src/loader/`) +Dynamically imports brick packages, validates their manifests, and registers them. + +### Marketplace (`packages/core/src/marketplace/`) +- `catalog-fetcher` — HTTP fetch of remote `catalog.json` +- `catalog-store` — local persistence of enabled catalogs +- `resolver` — parses catalog, finds bricks by name, semver matching +- `installer` — npm install/uninstall orchestration (for hosts that need it) + +### Observability (`packages/core/src/observability/`) +Logger and tracing primitives. Browser-compatible (no `async_hooks`). + +## Design principles + +1. **Browser-compatible** — no Node-only modules in the hot path (no `async_hooks`, no Pino) +2. **Zero-dep** — only `@opentelemetry/api` as a runtime dependency +3. **Pure core, I/O at the edges** — marketplace modules accept IO adapters; hosts inject them +4. **TypeScript strict** — no `any`, all types exhaustive +5. **TDD** — 100% line coverage on `event-bus`, `registry`; 80% global minimum + +## Testing + +Vitest unit tests + property-based tests (`fast-check`) on critical invariants. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 4d69712..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,145 +0,0 @@ - - -# CLAUDE.md — @focus-mcp/core - -> Auto-loaded by Claude Code (and any agents.md-compatible tool) when working in this repo. -> This file is the **source of truth for AI agent behaviour** on this project. It replaces the -> former `~/.claude/projects/**/memory/` system — do not recreate that folder. - -## Projet - -**FocusMCP** — orchestrateur MCP. Reduces AI-agent context from 200k to ~2k tokens by composing -**briques** (atomic MCP modules) that communicate via an EventBus with central guards. Site -[focusmcp.dev](https://focusmcp.dev). Full vision : [PRD.md](./PRD.md). - -Ce repo héberge la **bibliothèque `@focus-mcp/core`** (Registry + EventBus + Router + SDK + -Validator + marketplace resolver) importée par le CLI. - -## Écosystème (3 repos actifs + 1 archivé) - -| Repo | Statut | Rôle | -|---|---|---| -| `focus-mcp/core` (ici) | actif | Monorepo lib TS — 3 piliers + SDK/Validator/Marketplace resolver | -| `focus-mcp/cli` | actif | `@focus-mcp/cli` — stdio MCP via `@modelcontextprotocol/sdk`, entrée primaire, publié npm | -| `focus-mcp/marketplace` | actif | Catalogue officiel + `bricks/*` + `modules/*` (dont `manager` = dashboard). `catalog.json` publié sur gh-pages (domaine custom `marketplace.focusmcp.dev` à configurer). | -| `focus-mcp/client` | **archivé** | Ex desktop Tauri. Pivot CLI-first (2026-04-16) a gelé ce repo en Phase 2. | - -## Architecture (post-pivot CLI-first, 2026-04-16) - -``` -AI client (Claude Code, Cursor, Codex, Gemini…) - │ stdio (JSON-RPC MCP) - ▼ -@focus-mcp/cli (Node, npm) - ├─ @modelcontextprotocol/sdk StdioServerTransport - ├─ import { createFocusMcp } from '@focus-mcp/core' ← CE REPO - └─ (opt-in P1) admin API HTTP côté latéral -``` - -**Le core** est importé par la CLI (pas l'inverse). **Browser-compatible** : pas de -`node:async_hooks`, pas de Pino, primitives custom côté logger/tracing. Pas de `HttpTransport` -côté core — Tauri pouvait l'héberger via WebView (ancien design, gelé) ; aujourd'hui la CLI -héberge tout, mais l'architecture reste browser-compatible pour un futur Phase 2 desktop. - -**Le cli-manager (dashboard)** ne dépend PAS du core — il consomme l'admin API HTTP de la CLI. - -## Règles non-négociables (applicables à TOUS les repos FocusMCP) - -1. **TDD strict** — tests AVANT le code (Red → Green → Refactor). Coverage ≥ **80 %** global, - ≥ **95 %** sur `event-bus/**` et `registry/**` (modules critiques). -2. **Périmètre strict** — pas de features ou décisions non explicitement demandées. Le user a - corrigé plusieurs fois le scope ; demander avant d'ajouter de l'inconnu. -3. **Standards pro** — TS strict (pas de `any`), Biome (pas ESLint+Prettier), Conventional - Commits (enforced via commitlint), husky + lint-staged, semver, SPDX headers (REUSE), - ADRs pour les décisions archi. -4. **Imports** : toujours `node:` protocol (`import … from 'node:fs/promises'`). -5. **Public-facing content en anglais** — règle "à partir de maintenant" : tout **nouveau** - contenu public, et toute **mise à jour substantielle** d'un contenu public existant, est - rédigé en anglais. Périmètre : - - `.github/` (workflows YAML, PR template, issue templates, renovate) - - Titres + descriptions de PR, commentaires de PR, messages de commit - - Titres + descriptions d'issues - - Marketplace : `mcp-brick.json` description/tools, `bricks//README.md`, entries Changesets - - Docs contributor-facing cibles : `README.md`, `AGENTS.md`, `CONTRIBUTING.md`, `SECURITY.md`, - `CODE_OF_CONDUCT.md` - - Exception transitoire : les versions **existantes** de ces docs peuvent rester majoritairement - en français jusqu'à leur prochaine réécriture substantielle. - - Exceptions permanentes : `PRD.md` (doc stratégique interne) et `CLAUDE.md` (ce fichier, guide - d'agent interne) restent en français. -6. **Git-flow strict** — `develop` est **permanente**, jamais `--delete-branch` sur une PR - `develop → main`. Feature branches éphémères (`feat/*`, `fix/*`, `docs/*`, etc.), - auto-delete après merge. -7. **npm orgs** — `focusmcp` ET `focus-mcp` sont réservées (squatting protection). Pas de - publish au MVP sauf `@focus-mcp/cli` (primary distribution). Scope canonique : - `@focus-mcp/*`. -8. **Rulesets GitHub** — chaque nouveau repo reçoit le couple : - - `main protection` cible **UNIQUEMENT `refs/heads/main`** — `required_status_checks`, - `pull_request`, `code_scanning` (CodeQL), `code_quality`, `required_linear_history`, - `deletion`, `non_fast_forward`. **Pas `required_signatures`** (les commits assistés ne - sont pas signés). - - `develop protection` cible **UNIQUEMENT `refs/heads/develop`** — `deletion`, - `non_fast_forward`, `required_linear_history`, `pull_request` (pas de `code_quality` : - impossible sur non-default branch = pending éternel). - - Pitfall connu : NE JAMAIS mettre `develop` dans les targets de "main protection". - -## Dans ce repo (core) - -**Stack** : Node ≥ 22, pnpm ≥ 10, TypeScript 5.7+ strict, ESM only, Vitest, Biome 2.x, -tsup, Changesets. - -**Layout** : -``` -packages/ - core/ ← Registry, EventBus (guards), Router, manifest parser, observability, marketplace resolver - sdk/ ← defineBrick helper - validator/ ← test runner conformance briques - cli/ ← DEPRECATED stub ; le vrai CLI vit dans focus-mcp/cli. Peut être supprimé. -``` - -**À surveiller** : -- Le CLI a été **extrait dans son propre repo** (`focus-mcp/cli`) qui consomme `@focus-mcp/core` - via `file:../core/packages/core` (sibling clone en CI). `packages/cli` ici est un vieux stub - vide — à supprimer quand on fait le cleanup. -- `@focus-mcp/core` n'est **pas publié sur npm** ; la CLI le bundle au build (`tsup --noExternal`). - -## Commandes - -```bash -pnpm install # install (frozen lockfile en CI) -pnpm test # Vitest -pnpm test:watch -pnpm test:coverage # coverage + thresholds -pnpm typecheck # tsc --noEmit (tous packages) -pnpm lint # Biome check -pnpm lint:fix # Biome auto-fix -pnpm build # tsup (tous packages) -pnpm changeset # créer un changeset avant de merger -``` - -## Workflow pour une feature - -1. Lire PRD.md + ce fichier -2. Feature branch depuis `develop` (`feat/*`, `fix/*`, `docs/*`…) -3. Red → Green → Refactor (tests AVANT le code) -4. `pnpm test:coverage && pnpm typecheck && pnpm lint` -5. `pnpm changeset` si ça change l'API publique -6. Conventional Commits -7. PR vers `develop` (jamais `main` direct) -8. Attendre CI verte + résoudre les threads Copilot avant merge - -## Sécurité - -- **Aucun secret** commité (gitleaks en pre-commit + CI) -- Pas de `eval` ni `new Function()` -- Le sandbox OS est **hérité du parent process** (Claude Code spawn via Seatbelt/bubblewrap). - `isolated-vm` disponible en Phase 2 si besoin de faire tourner des briques non-reviewed. - -## Documentation à lire en priorité - -1. [PRD.md](./PRD.md) — vision, architecture, roadmap -2. [AGENTS.md](./AGENTS.md) — instructions cross-agents (note : peut contenir des résidus - pré-pivot ; CE fichier est la source de vérité) -3. [CONTRIBUTING.md](./CONTRIBUTING.md) — workflow de contribution diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2014bfd..086ef19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,48 +3,93 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# Contribuer à FocusMCP +# Contributing to FocusMCP -Merci de l'intérêt porté à FocusMCP. Ce document décrit comment contribuer. +Thank you for your interest in FocusMCP. This document explains how to contribute. + +## AI-assisted contributions + +FocusMCP was largely built with Claude Code. We encourage and welcome AI-assisted PRs. + +**You don't need to hide it.** If Claude wrote the code, just say so in the PR description +(`Generated with Claude Code`, `Co-authored by GPT-4`, whatever's accurate). Bonus points +for including the prompt or the key instructions you used. + +**What we care about, regardless of who wrote it:** + +- ✅ Tests pass +- ✅ Types are strict (no `any`, no `@ts-ignore` without a comment) +- ✅ Lint is green (`pnpm lint`) +- ✅ Coverage ≥ 80% (100% on critical modules) +- ✅ Commit messages follow Conventional Commits +- ✅ PR has a clear description — "what, why, how to verify" +- ✅ You understand the diff and can discuss design during review + +**What gets you rejected:** + +- ❌ Obviously untested AI slop (generated code that doesn't run) +- ❌ PRs with no description, just "here's some code" +- ❌ Hidden AI use that makes review confusing + +We don't care if you used AI, we care if the PR is good. ## Code of Conduct -Tous les contributeurs s'engagent à respecter le [Code of Conduct](./CODE_OF_CONDUCT.md). +All contributors are expected to follow the [Code of Conduct](./CODE_OF_CONDUCT.md). ## Workflow -1. **Fork** le repo et crée une branche : `git checkout -b feat/ma-feature` -2. **Code en TDD** : écris les tests AVANT le code (Red → Green → Refactor) -3. **Lint + format** : `pnpm lint:fix` -4. **Typecheck** : `pnpm typecheck` -5. **Test** : `pnpm test` (coverage ≥80% global, ≥95% sur EventBus/Registry) -6. **Commit** en [Conventional Commits](https://www.conventionalcommits.org/) — enforced par commitlint -7. **Changeset** : `pnpm changeset` si la PR introduit un changement utilisateur -8. **Push** et ouvre une Pull Request sur [GitHub](https://github.com/focus-mcp/core/pulls) +1. **Fork** the repo and create a branch: `git checkout -b feat/my-feature` +2. **Code with TDD**: write tests BEFORE the code (Red → Green → Refactor) +3. **Lint + format**: `pnpm lint:fix` +4. **Typecheck**: `pnpm typecheck` +5. **Test**: `pnpm test` (coverage ≥ 80% global, ≥ 95% on EventBus/Registry) +6. **Commit** using [Conventional Commits](https://www.conventionalcommits.org/) — enforced by commitlint +7. **Push** and open a Pull Request on [GitHub](https://github.com/focus-mcp/core/pulls) targeting `develop` + +> PRs must target `develop`, not `main`. The `develop` branch is permanent — never force-delete it. ## Standards -- **TypeScript strict** (configuré dans `tsconfig.base.json`) -- **TDD strict** — coverage thresholds bloquants en CI -- **Conventional Commits** : `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `perf`, `build`, `ci`, `style`, `revert` -- **SPDX headers** dans tous les fichiers source (`SPDX-License-Identifier: MIT`) -- **REUSE compliance** vérifiée en CI -- **Pas de console.log** : utiliser le logger pino exposé par `@focus-mcp/core` -- **Pas de `any`** : TypeScript strict + Biome `noExplicitAny` +- **TypeScript strict** (configured in `config/tsconfig.base.json`) +- **TDD strict** — coverage thresholds are enforced in CI +- **Conventional Commits**: `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `perf`, `build`, `ci`, `style`, `revert` +- **SPDX headers** in every source file (`SPDX-License-Identifier: MIT`) +- **REUSE compliance** verified in CI +- **No `console.log`**: use the logger from `@focus-mcp/core` +- **No `any`**: TypeScript strict + Biome `noExplicitAny` +- **`node:` import protocol**: always prefix Node built-ins with `node:` + +## Release process + +Releases are triggered automatically by CI: + +- **`@dev` tag** — push to `develop` runs `dev-publish.yml`, which publishes a timestamped snapshot to npmjs.org +- **`@latest` tag** — push to `main` runs `stable-publish.yml`, which publishes the stable release to npmjs.org + +There is no manual Changesets release step. Version bumps are managed directly in the package manifests before merging to `main`. + +## npm scope + +All packages are published under the `@focus-mcp` scope (with hyphen): + +- `@focus-mcp/core` +- `@focus-mcp/sdk` +- `@focus-mcp/validator` -## Développer une brique +## Developing a brick -Voir [packages/sdk/README.md](./packages/sdk/README.md) (à venir). +See [packages/sdk/README.md](./packages/sdk/README.md) for the brick authoring guide. ## Architecture Decision Records (ADR) -Toute décision architecturale significative doit être documentée dans [`docs/adr/`](./docs/adr/). -Format : [MADR](https://adr.github.io/madr/). +Any significant architectural decision must be documented in [`docs/adr/`](./docs/adr/). +Format: [MADR](https://adr.github.io/madr/). -## Reporter un bug / proposer une feature +## Reporting a bug / proposing a feature -Ouvre une issue avec le template approprié : [bug](https://github.com/focus-mcp/core/issues/new?template=bug.yml) ou [feature](https://github.com/focus-mcp/core/issues/new?template=feature.yml). +Open an issue using the appropriate template: [bug](https://github.com/focus-mcp/core/issues/new?template=bug.yml) or [feature](https://github.com/focus-mcp/core/issues/new?template=feature.yml). -## Sécurité +## Security -Les vulnérabilités doivent être reportées **en privé** — voir [SECURITY.md](./SECURITY.md). +Vulnerabilities must be reported **privately** — see [SECURITY.md](./SECURITY.md). diff --git a/PRD.md b/PRD.md deleted file mode 100644 index 5e6f6e1..0000000 --- a/PRD.md +++ /dev/null @@ -1,384 +0,0 @@ - - -# @focus-mcp/core — Product Requirements Document - -> Périmètre de ce document : la **bibliothèque TypeScript** `@focus-mcp/core` (package `packages/core` du monorepo). -> Pour l'app desktop : voir le repo [`focus-mcp/client`](https://github.com/focus-mcp/client). Pour le catalogue de briques : voir le repo [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace). - -## Vision (rappel) - -**FocusMCP** — Focaliser les agents AI sur l'essentiel. - -FocusMCP est un **écosystème intelligent de briques MCP** qui communiquent entre elles, travaillent ensemble, et sont chargées à la demande. Les briques optimisent la compréhension du code, filtrent les données et distillent les résultats pour **minimiser les tokens et le contexte** envoyés à l'agent AI. - -Comme **Node.js + npm** : le core est le runtime, les briques sont les packages. - -> **Sans FocusMCP** : l'AI lit 50 fichiers bruts → 200k tokens -> **Avec FocusMCP** : les briques indexent, filtrent, distillent → 2k tokens pertinents - ---- - -## Rôle de `@focus-mcp/core` dans l'écosystème - -`@focus-mcp/core` est la **bibliothèque TypeScript** qui implémente toute la logique MCP : - -- **Importée par l'app desktop** (`client/`, Tauri) directement dans la WebView — pas de sidecar Node.js -- **Aucun transport HTTP** : Tauri (Rust) est le **seul gardien HTTP** (Streamable HTTP MCP côté client) -- **Browser-compatible** : pas de `node:async_hooks`, pas de Pino, primitives compatibles WebView -- **Sans dépendance OS directe** : tout accès filesystem/réseau passe par des fournisseurs injectés (le client Tauri fournit les implémentations sandboxed) - -``` -┌────────────────────────────────────┐ -│ Tauri (Rust) — gateway HTTP MCP │ -│ • Streamable HTTP /mcp │ -│ • Sandbox système (FS, réseau) │ -└──────────────┬─────────────────────┘ - │ Tauri commands (IPC) -┌──────────────▼─────────────────────┐ -│ WebView — UI Svelte │ -│ └─ @focus-mcp/core (this lib) │ -│ Registry + EventBus + Router │ -│ + briques (modules TS) │ -└────────────────────────────────────┘ -``` - ---- - -## Packages du monorepo - -| Package | Rôle | -|---|---| -| `@focus-mcp/core` | Registry, EventBus, Router, Manifest, Bootstrap, Observability | -| `@focus-mcp/sdk` | Helper `defineBrick` pour auteurs de briques | -| `@focus-mcp/validator` | Test runner conformance (manifeste, namespace, dépendances, garde-fous) | -| `@focus-mcp/cli` | CLI `focus` — gestion des briques installées | - ---- - -## Les 3 piliers - -### 1. McpRegistry — L'annuaire - -Le registre central connaît toutes les briques, leurs manifestes, leurs dépendances et leur état. - -```typescript -registry.register(brick) // enregistre une brique + son manifeste -registry.unregister("php") // supprime une brique -registry.resolve("symfony") // résout l'arbre de dépendances complet -registry.getStatus("php") // état : running, stopped, error -registry.getBricks() // liste toutes les briques enregistrées -registry.getTools() // liste tous les tools exposés par toutes les briques -``` - -Responsabilités : -- Stocker les manifestes (`mcp-brick.json`) -- Résoudre le **graphe de dépendances** (ordre de démarrage, détection de cycles) -- Suivre l'**état** de chaque brique (running, stopped, error, starting) -- Valider la **compatibilité** entre versions de briques - -### 2. EventBus — Le système nerveux - -Les briques ne s'appellent **jamais directement entre elles**. Toute communication passe par l'EventBus. - -**Événements (fire & forget)** : -```typescript -eventBus.emit("files:indexed", { path: "src/", files: [...] }) -eventBus.on("files:indexed", (data) => { /* ... */ }) -``` - -**Requêtes (request/response)** : -```typescript -const files = await eventBus.request("indexer:search", { pattern: "*.php" }) -``` - -**Avantages** : découplage total, monitoring gratuit (tout passe par le bus), cache au niveau du bus, extensibilité, résilience. - -#### Garde-fous (intégrés au bus) - -| Garde-fou | Protection | Comportement | -|---|---|---| -| **Max call depth** | Boucles infinies (A → B → A...) | Bloque au-delà de N niveaux | -| **Timeout** | Brique qui ne répond plus | Coupe l'appel après N secondes | -| **Rate limit** | Brique qui spam le bus | Limite appels/sec par source | -| **Permissions** | Appels non autorisés | Whitelist via `dependencies` du manifeste | -| **Payload size** | Données trop volumineuses | Rejette au-delà d'une taille max | -| **Circuit breaker** | Brique instable | Désactivation temporaire après X échecs | - -**Permissions via le manifeste** : -``` -PHP déclare dependencies: ["indexer", "cache"] -PHP → request("indexer:search") ✅ autorisé -PHP → request("symfony:something") ❌ bloqué -``` - -### 3. McpRouter — La gateway - -Reçoit les appels MCP (`tools/list`, `tools/call`) du transport (Tauri HTTP) et les dispatche. - -```typescript -router.handle("symfony_find_controllers", { entity: "User" }) - // 1. Registry : "qui gère ce tool ?" → brique "symfony" - // 2. EventBus : request("symfony:find_controllers", ...) - // 3. Retourne le résultat -``` - -Responsabilités : -- **Agréger les tools** de toutes les briques actives (`tools/list`) -- **Router** chaque appel tool vers la bonne brique via l'EventBus -- Gérer **timeouts** et **erreurs** proprement -- **Aucun transport HTTP propre** : exposé via une API JS consommée par le client Tauri - -### Flux complet - -``` -AI → Tauri HTTP /mcp → IPC → Router → Registry (lookup) - ↓ - EventBus → Brique cible - ↓ - (peut chaîner d'autres briques via le bus) - ↓ - ← résultat ← -``` - ---- - -## Manifeste de brique - -Format `mcp-brick.json` (parsé par `parseManifest`) : - -```json -{ - "name": "php", - "version": "1.0.0", - "description": "Compréhension avancée du langage PHP", - "dependencies": ["indexer", "cache"], - "tools": [ - { "name": "php_analyze", "description": "Analyse un fichier PHP" }, - { "name": "php_find_usages", "description": "Trouve les utilisations d'un symbole" } - ], - "config": { - "phpVersion": { "type": "string", "default": "8.3", "description": "Version PHP cible" } - } -} -``` - -Validation stricte (par `parseManifest`) : nom en kebab-case (ex: `php`, `indexer`, `sf-router`), version semver, tools (nom + JSON Schema d'entrée), dépendances déclarées, config typée. La convention `focus-` est appliquée au niveau du marketplace officiel (cf. `marketplace/PRD.md`), pas par le parser. - ---- - -## SDK — `@focus-mcp/sdk` - -Helper `defineBrick` pour les auteurs de briques : - -```typescript -import { defineBrick } from '@focus-mcp/sdk' - -export default defineBrick({ - manifest: { /* mcp-brick.json inline ou import */ }, - setup({ eventBus, logger }) { - eventBus.on('files:indexed', (data) => { /* ... */ }) - return { - 'php:analyze': async ({ file }) => { /* ... */ }, - } - }, -}) -``` - ---- - -## Validator — `@focus-mcp/validator` - -Test runner qui valide qu'une brique respecte le contrat FocusMCP. Checks actuellement implémentés : -- Manifeste valide (`INVALID_MANIFEST` via `parseManifest`) -- Démarrage propre (`START_FAILED` si `start()` lève) -- Handlers/tools enregistrés sur le bus (`MISSING_HANDLER`) -- Tools appelables dans le runtime de validation (`TOOL_CALL_FAILED`) -- Pas de leaks après `stop` (`HANDLER_LEAK`, `STOP_FAILED`) - -Lancé en CI sur chaque brique du marketplace officiel et utilisable par les développeurs tiers. Des validations supplémentaires (conventions de namespace `brique:action`, correspondance dépendances↔appels effectifs, détection de bypass des garde-fous) sont planifiées en P1. - ---- - -## CLI — `@focus-mcp/cli` - -Commandes (inspirées npm/yarn) opérant sur `~/.focus/center.json` + `~/.focus/center.lock` : - -```bash -focus add # installe une brique (+ ses dépendances) -focus remove # supprime une brique -focus update [brick] # met à jour -focus list # liste les briques installées -focus search # cherche dans le marketplace -focus info # détails d'une brique - -focus status # état de chaque brique (running/stopped/error) -focus logs [brick] # logs EventBus - -focus catalog add # ajoute un marketplace tiers (P1) -focus catalog list -focus catalog remove - -focus config get / set -``` - -Note : `focus start/stop` lance l'app desktop (Tauri) — implémenté côté `client/`, exposé via la CLI pour confort. - ---- - -## Marketplace client (résolveur + installer) - -Module du core qui résout/télécharge/installe les briques publiées dans le marketplace. - -### Mapping npm → FocusMCP - -``` -npm/yarn FocusMCP -───────── ────────── -package.json center.json (briques installées + config) -package-lock.json center.lock (versions exactes verrouillées) -node_modules/ bricks/ (code des briques téléchargées) -.npmrc .centerrc (config globale, auth, registries) -npm registry marketplace officiel -``` - -### Structure fichiers - -``` -~/.focus/ -├── .centerrc # config globale (port, auth, catalogues) -├── center.json # briques installées + config par brique -├── center.lock # versions résolues + hash intégrité -└── bricks/ # code des briques téléchargées - ├── indexer/ - └── php/ -``` - -### Format `center.json` - -```json -{ - "bricks": { - "indexer": { "version": "^1.0.0", "enabled": true }, - "php": { "version": "^1.0.0", "enabled": true, "config": { "phpVersion": "8.3" } } - } -} -``` - -### Format `center.lock` - -```json -{ - "indexer": { - "version": "1.0.3", - "resolved": "focus/brick-indexer#v1.0.3", - "integrity": "sha256-abc123..." - } -} -``` - -### Responsabilités du marketplace client - -- Résoudre `@` contre un ou plusieurs catalogues (`catalog.json`) -- Télécharger depuis GitHub (`owner/repo#tag`) -- Vérifier intégrité (sha256) -- Écrire `bricks//` + mettre à jour `center.lock` -- Construire le graphe de dépendances et installer en cascade - -### Brick loader - -Au démarrage, le loader lit `center.json` + `center.lock`, charge dynamiquement chaque brique depuis `bricks//`, parse son manifeste, et l'enregistre dans le `Registry`. Démarrage dans l'ordre topologique du graphe de dépendances. - ---- - -## Observability - -- `createLogger` / `rootLogger` : logger structuré browser-compatible (remplace Pino) -- `getTracer` / `trace` : trace ID propagé dans les requêtes EventBus (remplace `node:async_hooks`) -- Tout appel bus est observable : source, cible, args, durée, résultat/erreur, garde-fous déclenchés -- Exposé au client (Tauri) pour affichage dans l'UI temps réel - ---- - -## Roadmap - -### P0 — MVP - -- [x] McpRegistry + résolution dépendances -- [x] EventBus + garde-fous (timeout, max depth, rate limit, permissions, payload size, circuit breaker) -- [x] McpRouter (sans HTTP propre — exposé via API JS au client Tauri) -- [x] Manifest parser strict -- [x] SDK `defineBrick` -- [x] Validator (test runner conformance) -- [x] Bootstrap helper (`createFocusMcp`) -- [x] Observability browser-compatible (logger, tracing) -- [ ] **CLI** : `focus add/remove/list/search/info/status/logs` -- [ ] **Marketplace client** : résolveur + downloader + intégrité -- [ ] **Brick loader** : chargement dynamique depuis `bricks/` -- [ ] **MCP spec conformance** : suite de tests vs serveur de référence [`Everything`](https://github.com/modelcontextprotocol/servers/tree/main/src/everything) - -### P1 - -- [ ] **Hot-reload** : ajout/suppression de briques sans redémarrer -- [ ] **Health checks** programmés par brique -- [ ] **Catalogues tiers** dans le résolveur (URL, local, GitHub org) -- [ ] **Auto-update** des catalogues et briques - -### P2 - -- [ ] **Permissions tools** : contrôle de quels tools sont exposés au client AI -- [ ] **Scopes** : installation globale, par projet, ou locale -- [ ] **Documentation** auteurs de briques (guide complet) - ---- - -## Patterns d'optimisation des tokens (référence) - -Les patterns transverses applicables par toutes les briques. Implémentés dans des **briques officielles** publiées sur le marketplace (voir le repo [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace)) : - -- **Output filtering** — chaque brique retourne le résultat distillé, jamais la donnée brute -- **Think in code** — sandbox JS éphémère (brique `focus-sandbox`) -- **Session memory** — SQLite + FTS5/BM25 (brique `focus-memory`) -- **Indexation + cache** — index FTS5 partagé (brique `focus-indexer`) -- **Reasoning externalisé** — chaînes de pensées persistées (brique `focus-thinking`) - -`@focus-mcp/core` ne contient aucune brique — il fournit l'infrastructure qui les rend possibles. - ---- - -## Stack technique - -| Composant | Technologie | Rôle | -|---|---|---| -| Lib | **TypeScript strict** | Code source | -| Build | **tsup** | Bundling (ESM + types) | -| Tests | **Vitest** | Unit + intégration | -| Lint/Format | **Biome** | Style et qualité | -| Manifeste | **JSON** + validateur custom (`parseManifest`) | Validation stricte ; JSON Schema utilisé uniquement pour `tools[].inputSchema` | -| Logger | Browser-compatible (custom) | Pas de Pino (incompatible WebView) | -| Tracing | Browser-compatible (custom) | Pas de `node:async_hooks` | - ---- - -## Décisions clés - -| Décision | Choix | Raison | -|---|---|---| -| **Transport HTTP** | Délégué à Tauri | Un seul gardien HTTP, sandbox Rust | -| **Runtime** | WebView (browser-compatible) | Pas de sidecar Node.js, IPC direct | -| **Communication briques** | EventBus in-process | Découplage + monitoring centralisé | -| **Sécurité bus** | Whitelist via `dependencies` du manifeste | Permissions déclaratives, pas de config séparée | -| **Manifeste** | JSON + validateur custom (`parseManifest`) | Lisible, validable, versionnable ; JSON Schema réservé à `tools[].inputSchema` | -| **Lock file** | `center.lock` (sha256) | Reproductibilité + intégrité | -| **Briques** | Modules TS chargés dynamiquement | Hot-reload possible, simple | - ---- - -## Inspirations - -- **Context Mode** — pattern "think in code", persistance SQLite + FTS5 -- **Claude Octopus** — circuit breakers, isolation worktrees (P2) -- **modelcontextprotocol/servers** — référence pour conformance (`Everything`), pattern sequentialthinking -- **npm / yarn** — `.centerrc`, `center.json`, `center.lock`, CLI, graphe de dépendances, intégrité sha256 diff --git a/README.md b/README.md index 2dfcfa3..fd556bc 100644 --- a/README.md +++ b/README.md @@ -3,58 +3,155 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# FocusMCP — core +# @focus-mcp/core -> **Focaliser les agents AI sur l'essentiel.** -> -> 🌐 [focusmcp.dev](https://focusmcp.dev) · 📖 [PRD](./PRD.md) · 🗺️ [Roadmap](./docs/ROADMAP.md) +> Runtime library for FocusMCP — the MCP orchestrator that reduces AI context from 200k to ~2k tokens. -FocusMCP est un **écosystème intelligent de briques MCP** qui communiquent entre elles, travaillent ensemble, et sont chargées à la demande. Les briques optimisent la compréhension du code, filtrent les données et distillent les résultats pour **minimiser les tokens et le contexte** envoyés à l'agent AI. +[![npm version](https://img.shields.io/npm/v/@focus-mcp/core.svg)](https://www.npmjs.com/package/@focus-mcp/core) +[![license](https://img.shields.io/npm/l/@focus-mcp/core.svg)](./LICENSE) +[![CI](https://github.com/focus-mcp/core/actions/workflows/ci.yml/badge.svg)](https://github.com/focus-mcp/core/actions/workflows/ci.yml) +![Built with Claude Code](https://img.shields.io/badge/built_with-Claude_Code-8A2BE2) -> **Sans FocusMCP** : l'AI lit 50 fichiers bruts → 200k tokens consommés -> **Avec FocusMCP** : les briques indexent, analysent, filtrent → l'AI reçoit 2k tokens de résultat pertinent +## What is this? -## Statut +`@focus-mcp/core` is the library that powers [`@focus-mcp/cli`](https://github.com/focus-mcp/cli). -🚧 **En développement actif** — pré-MVP. Voir [docs/ROADMAP.md](./docs/ROADMAP.md). +It provides the **Registry**, **EventBus**, **Router**, **SDK**, **Validator**, and **marketplace resolver** — the three pillars that let atomic MCP bricks communicate, compose, and serve AI agents with minimal context overhead. + +> **Without FocusMCP**: the AI reads 50 raw files → 200k tokens consumed +> **With FocusMCP**: bricks index, analyse, filter → the AI receives ~2k tokens of relevant output + +**End users should install [`@focus-mcp/cli`](https://github.com/focus-mcp/cli)**, not this package directly. +This package is for building custom FocusMCP hosts — servers, IDE integrations, or alternative transports. + +## Install + +```bash +npm install @focus-mcp/core +``` + +## Quick start + +```typescript +import { createFocusMcp } from '@focus-mcp/core'; +import { defineBrick } from '@focus-mcp/sdk'; + +// Define a brick +const myBrick = defineBrick({ + manifest: { + name: 'my-brick', + version: '1.0.0', + description: 'Example brick', + tools: [{ name: 'my_tool', description: 'Does something useful' }], + }, + setup({ eventBus }) { + return { + 'my_tool': async ({ input }) => ({ result: `Processed: ${input}` }), + }; + }, +}); + +// Bootstrap the runtime +const focus = await createFocusMcp(); +await focus.registry.register(myBrick); + +// Handle MCP tool calls +const result = await focus.router.handle('my_tool', { input: 'hello' }); +``` ## Architecture -FocusMCP est une **coquille vide** (Tauri + Node.js sidecar) qui orchestre un écosystème de briques MCP atomiques. Trois piliers : +`@focus-mcp/core` is built on three pillars: + +### 1. McpRegistry — The directory + +Knows every brick, its manifest, its dependencies, and its runtime state. Resolves the full dependency graph (topological order, cycle detection) before startup. + +```typescript +registry.register(brick) // register a brick + its manifest +registry.resolve('my-brick') // resolve full dependency tree +registry.getStatus('my-brick') // running | stopped | error | starting +registry.getTools() // all tools exposed by all active bricks +``` -- **McpRegistry** — annuaire des briques + résolution de dépendances -- **EventBus** — communication inter-briques + garde-fous (timeout, rate-limit, permissions) -- **MCP Router** — endpoint Streamable HTTP pour les clients AI +### 2. EventBus — The nervous system -Voir [PRD.md](./PRD.md) pour les détails complets. +Bricks never call each other directly. All inter-brick communication goes through the EventBus, with built-in guards: -## Structure du monorepo +| Guard | Protection | +|---|---| +| Max call depth | Prevents infinite loops (A → B → A…) | +| Timeout | Cuts unresponsive calls after N seconds | +| Rate limit | Throttles noisy bricks | +| Permissions | Whitelist via `dependencies` in the manifest | +| Payload size | Rejects oversized payloads | +| Circuit breaker | Temporarily disables unstable bricks | +```typescript +eventBus.emit('files:indexed', { path: 'src/', files: [...] }) +const result = await eventBus.request('indexer:search', { pattern: '*.ts' }) ``` -packages/ - core/ — Registry + EventBus + Router + transport HTTP/HTTPS - sdk/ — outils pour développer une brique - cli/ — focus CLI (start, add, remove…) + +### 3. McpRouter — The gateway + +Receives MCP calls (`tools/list`, `tools/call`) from the transport layer and dispatches them to the right brick via the EventBus. + +```typescript +router.handle('my_tool', { input: 'hello' }) +// → Registry: "who handles this tool?" → brick "my-brick" +// → EventBus: request("my-brick:my_tool", ...) +// → returns result ``` -**Repos compagnons** (même org [`focus-mcp`](https://github.com/focus-mcp)) : -- [`focus-mcp/client`](https://github.com/focus-mcp/client) — app Tauri (shell desktop + UI dashboard) -- [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace) — briques officielles (`focus-indexer`, `focus-memory`…) +## Companion packages + +| Package | Role | +|---|---| +| [`@focus-mcp/core`](https://www.npmjs.com/package/@focus-mcp/core) | This package — Registry, EventBus, Router, observability | +| [`@focus-mcp/sdk`](https://www.npmjs.com/package/@focus-mcp/sdk) | `defineBrick` helper for brick authors | +| [`@focus-mcp/validator`](https://www.npmjs.com/package/@focus-mcp/validator) | Conformance test runner for third-party bricks | +| [`@focus-mcp/cli`](https://github.com/focus-mcp/cli) | Primary end-user entry point — `focus add`, `focus list`, … | + +## Companion repositories + +- [`focus-mcp/cli`](https://github.com/focus-mcp/cli) — CLI MCP server (primary distribution) +- [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace) — Official brick catalog -## Démarrer +## Development ```bash -nvm use # Node 22+ +nvm use # Node 22+ pnpm install -pnpm test # tests Vitest +pnpm test # Vitest +pnpm test:coverage # with coverage thresholds pnpm typecheck pnpm lint +pnpm build ``` -## Contribuer +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md). + +## AI-assisted development + +FocusMCP was built with heavy Claude Code assistance — its architecture, implementation, +docs, and tests have all been co-authored with AI. We embrace this openly because: + +1. **Transparency matters** — we'd rather disclose it than pretend otherwise +2. **AI tooling is the context** — we're building tools for AI agents, it makes sense to use them +3. **Quality over origin** — what matters is that the code is tested, reviewed, and working + +**Your AI-assisted contributions are welcome.** We don't require you to hide the fact that +Claude, Copilot, Cursor, or any other tool helped you. What we do expect: + +- Tests pass, code is typed, lint is green +- You've read the diff and understand what the PR does +- Conventional Commits, clear PR description +- You can explain your design choices during review -Voir [CONTRIBUTING.md](./CONTRIBUTING.md). +See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guidelines. -## Licence +## License [MIT](./LICENSE) diff --git a/SECURITY.md b/SECURITY.md index d2ed64a..c1900b4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,50 +3,55 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# Politique de sécurité +# Security Policy -## Versions supportées +## Supported versions -Le projet est en pré-MVP (`0.x`). Aucune version n'est encore considérée comme stable. +| Version | Supported | +|---|---| +| 1.x (latest) | Yes | +| 0.x | No | -## Reporter une vulnérabilité +## Reporting a vulnerability -**Ne pas ouvrir d'issue publique** pour une vulnérabilité de sécurité. +**Do not open a public issue** for a security vulnerability. -Envoyer un rapport privé via : -- **[GitHub Security Advisories](https://github.com/focus-mcp/core/security/advisories/new)** (recommandé) -- ou par email : security@focusmcp.dev +Send a private report via: -Inclure si possible : -- Description du problème -- Étapes de reproduction -- Impact estimé -- Suggestions de mitigation +- **[GitHub Security Advisories](https://github.com/focus-mcp/core/security/advisories/new)** (recommended) +- or by email: security@focusmcp.dev -## Engagement +Please include: -Nous nous engageons à : -- **Accuser réception** sous 72h -- **Évaluer** et **prioriser** la vulnérabilité sous 7 jours -- **Coordonner** la divulgation responsable -- **Créditer** le découvreur (sauf demande contraire) +- Description of the problem +- Steps to reproduce +- Estimated impact +- Suggested mitigation (if any) -## Périmètre +## Response commitment -Les couches de sécurité de FocusMCP sont décrites dans le [PRD](./PRD.md#sécurité--3-couches) : +We commit to: -1. **EventBus** — garde-fous logiques (timeout, rate limit, permissions inter-briques) -2. **Tauri sandbox** — contrôle système (filesystem, réseau) -3. **UI** — supervision humaine +- **Acknowledging** your report within 72 hours +- **Evaluating and prioritising** the vulnerability within 7 days +- **Coordinating** responsible disclosure +- **Crediting** the reporter (unless they prefer to remain anonymous) -Les vulnérabilités affectant l'une de ces couches sont prioritaires. +## Scope -## Pratiques de sécurité du projet +FocusMCP security layers are described in the [VISION.md](./VISION.md): -- Secret scanning (gitleaks) en pre-commit + CI +1. **EventBus** — logical guards (timeout, rate limit, inter-brick permissions) +2. **Injected providers** — sandboxed filesystem/network access via the host runtime +3. **Human supervision** — UI oversight + +Vulnerabilities affecting any of these layers are treated as high priority. + +## Security practices + +- Secret scanning (gitleaks) in pre-commit hook + CI - Dependency scanning (Renovate + `pnpm audit`) -- SAST (CodeQL/Semgrep) en CI -- License compliance (refus GPL/AGPL pour préserver MIT) -- SBOM (CycloneDX) à chaque release -- Commits signés (GPG/SSH) requis -- npm provenance + Sigstore pour les releases +- SAST (CodeQL) in CI +- License compliance (GPL/AGPL blocked to preserve MIT) +- SBOM (CycloneDX) on every release +- npm provenance + Sigstore for releases diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000..4695119 --- /dev/null +++ b/VISION.md @@ -0,0 +1,40 @@ + + +# Vision — @focus-mcp/core + +## The problem + +MCP clients load all tools at startup. An agent asked "fix this bug" shouldn't need the schemas of 100 tools in context. Every unused tool is wasted tokens, wasted attention, wasted reasoning. + +## What we're building + +`@focus-mcp/core` is the runtime that makes **composable, on-demand MCP** possible. + +Three primitives: + +- **Registry** — tracks which bricks are available, their state, and their dependencies +- **EventBus** — routes tool calls between bricks with central guards (rate limiting, permissions, tracing) +- **Router** — translates MCP protocol to brick calls + +A brick is an atomic module that declares a manifest, exposes tools, and speaks to the bus. The core doesn't know or care what bricks do — it just orchestrates them. + +## Why a library, not a framework + +Hosts (CLI, desktop app, IDE plugin, browser) have different runtime constraints. Core is **browser-compatible**, zero dependencies beyond OpenTelemetry, and doesn't own the transport layer. Each host wires its own. + +## Design principles + +1. **Composition over configuration** — bricks are loaded, not configured +2. **Atomicity** — one brick, one domain +3. **Discoverability** — bricks declare their shape; the catalog doesn't hard-code anything +4. **Observability by default** — every tool call is traced, logged, and auditable +5. **Security at the bus** — guards live centrally, not scattered across bricks + +## Non-goals + +- We don't replace the MCP spec — we implement it +- We don't build AI agents — we focus their existing ones +- We don't own distribution — bricks are npm packages diff --git a/config/commitlint.config.js b/config/commitlint.config.js index f0f098b..ecdd582 100644 --- a/config/commitlint.config.js +++ b/config/commitlint.config.js @@ -20,6 +20,7 @@ export default { 'ci', 'chore', 'revert', + 'release', ], ], 'subject-case': [2, 'never', ['upper-case', 'pascal-case', 'start-case']], diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 9b84dbe..5067843 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -3,42 +3,42 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# Gouvernance FocusMCP +# FocusMCP Governance -## Statut +## Status -Projet en pré-MVP. Gouvernance simple et évolutive. +Project at v1.0.0 (stable). Governance is intentionally lightweight and will evolve as the community grows. -## Rôles +## Roles -### Mainteneurs +### Maintainers -- Approuvent les MR -- Tranchent les ADRs (Architecture Decision Records) -- Releasent les versions -- Définissent la roadmap +- Approve pull requests +- Decide on ADRs (Architecture Decision Records) +- Manage releases +- Define the roadmap -### Contributeurs +### Contributors -Toute personne soumettant une MR conforme aux standards (voir [CONTRIBUTING.md](../CONTRIBUTING.md)). +Anyone submitting a PR that meets the standards described in [CONTRIBUTING.md](../CONTRIBUTING.md). -## Décisions +## Decisions -- **Petites décisions** (bugfix, refactor) : 1 approval mainteneur, merge -- **Décisions architecturales** : ADR obligatoire dans `docs/adr/`, discussion publique, 2 approvals -- **Breaking changes** : ADR + bump majeur + changelog détaillé + migration guide +- **Small decisions** (bugfix, refactor): 1 maintainer approval, merge +- **Architectural decisions**: ADR required in `docs/adr/`, public discussion, 2 approvals +- **Breaking changes**: ADR + major semver bump + detailed changelog + migration guide -## Marketplace officiel +## Official marketplace -Les briques officielles `focus-*` sont hébergées dans un repo séparé (`focus-marketplace`). Elles suivent les mêmes standards que le core et passent par `focus-validator`. +Official bricks (`focus-*`) are hosted in a separate repo ([`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace)). They follow the same standards as the core and must pass `@focus-mcp/validator`. -Le marketplace officiel **refuse les briques fourre-tout** (principe d'atomicité — voir [PRD](../PRD.md)). +The official marketplace **rejects catch-all bricks** (atomicity principle — see [VISION.md](../VISION.md)). ## Communication -- **Discussions techniques** : MR + Issues GitLab -- **Annonces** : CHANGELOG.md + tags sémantiques +- **Technical discussions**: PRs + GitHub Issues +- **Announcements**: CHANGELOG.md + semantic version tags -## Évolution +## Evolution -Ce document évoluera au fur et à mesure que le projet grandit (passage d'un mainteneur unique à une équipe, comité technique, etc.). +This document will evolve as the project grows (from a single maintainer to a team, technical committee, etc.). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c750a2..8b851cf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,44 +3,50 @@ SPDX-FileCopyrightText: 2026 FocusMCP contributors SPDX-License-Identifier: MIT --> -# Roadmap FocusMCP — `core` +# FocusMCP Core — Roadmap -Voir [PRD.md](../PRD.md) pour les détails fonctionnels complets. +See [VISION.md](../VISION.md) for the project vision and design principles. -> Ce repo contient le **runtime** (Registry + EventBus + Router + transport HTTP/HTTPS + SDK + CLI). -> L'app desktop est dans [`focus-mcp/client`](https://github.com/focus-mcp/client). -> Les briques officielles sont dans [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace). +> This repo contains the **runtime library** (Registry + EventBus + Router + SDK + Validator + Marketplace resolver). +> The CLI lives in [`focus-mcp/cli`](https://github.com/focus-mcp/cli). +> Official bricks are in [`focus-mcp/marketplace`](https://github.com/focus-mcp/marketplace). -## Phase 0 — Fondations (en cours) +## Phase 0 — Foundations (complete — v1.0.0) -- [x] PRD finalisé -- [x] Setup monorepo + tooling pro (TS strict, Biome, Vitest, husky, GitLab CI…) -- [x] Interfaces TS du core (Brick, Manifest, Tool, EventBus, Registry, Router) -- [x] `InProcessEventBus` (TDD, coverage ≥95/90%) -- [x] `InMemoryRegistry` (TDD, coverage 100/97%) +- [x] PRD finalised +- [x] Monorepo setup + professional tooling (TS strict, Biome, Vitest, husky, GitHub Actions CI) +- [x] Core TS interfaces (Brick, Manifest, Tool, EventBus, Registry, Router) +- [x] `InProcessEventBus` (TDD, coverage ≥ 95%) +- [x] `InMemoryRegistry` (TDD, coverage 100%) - [x] `McpRouter` (TDD, coverage 100%) -- [x] Transport HTTP + HTTPS (spec MCP 2025-03-26 via SDK officiel) -- [ ] `focus-validator` — test runner pour briques tierces -- [ ] SDK brique (`@focus-mcp/sdk`) — helpers pour écrire une brique - -## Phase 1 — CLI et ergonomie - -- [ ] CLI : `focus start`, `focus stop`, `focus status`, `focus logs` -- [ ] CLI : `focus add/remove/update` (installation briques depuis marketplace) -- [ ] Fichiers : `.centerrc`, `center.json`, `center.lock` -- [ ] Chargement dynamique de briques au runtime (hot-reload) - -## Phase 2 — Maturité core - -- [ ] Garde-fous EventBus complets : rate limit, circuit breaker -- [ ] Permissions inter-briques (whitelist via manifeste `dependencies`) -- [ ] Monitoring : métriques agrégées par brique, traces OpenTelemetry -- [ ] Authentification optionnelle pour mode serveur -- [ ] Mode stdio (transport alternatif au HTTP) - -## Phase 3 — Écosystème - -- [ ] Hook-based routing : adaptateurs clients (Claude Code, Cursor, Codex...) -- [ ] Catalogues tiers (URL, GitHub, local) -- [ ] Auto-update briques + catalogues -- [ ] Changesets + release automatisée +- [x] Manifest parser (`parseManifest`) — strict kebab-case + semver validation +- [x] SDK `defineBrick` helper (`@focus-mcp/sdk`) +- [x] Validator test runner (`@focus-mcp/validator`) +- [x] Bootstrap helper (`createFocusMcp`) +- [x] Browser-compatible observability (logger, tracing — no Pino, no `node:async_hooks`) +- [x] Marketplace resolver (catalog fetcher + installer) +- [x] Published to npmjs.org: `@focus-mcp/core`, `@focus-mcp/sdk`, `@focus-mcp/validator` @ v1.0.0 +- [x] CI: `stable-publish.yml` (main → `@latest`), `dev-publish.yml` (develop → `@dev`) + +## Phase 1 — CLI and ergonomics (in progress) + +- [x] `@focus-mcp/cli` — `focus add`, `focus remove`, `focus list`, `focus search`, `focus catalog` +- [ ] `focus start/stop/status/logs` commands +- [ ] Config files: `.centerrc`, `center.json`, `center.lock` +- [ ] Dynamic brick loading at runtime (hot-reload) +- [ ] MCP spec conformance suite vs [`Everything`](https://github.com/modelcontextprotocol/servers/tree/main/src/everything) reference server + +## Phase 2 — Core maturity + +- [ ] EventBus rate limiting + circuit breaker (full guard suite) +- [ ] Inter-brick permissions (manifest `dependencies` whitelist enforced) +- [ ] Metrics aggregated per brick + OpenTelemetry traces export +- [ ] Optional authentication for server mode +- [ ] Third-party catalog sources (URL, GitHub org, local) +- [ ] Auto-update for bricks and catalogs + +## Phase 3 — Ecosystem + +- [ ] Hook-based routing adapters (Claude Code, Cursor, Codex…) +- [ ] Full brick author documentation +- [ ] Desktop app Phase 2 (`focus-mcp/client` Tauri — currently archived) diff --git a/docs/adr/README.md b/docs/adr/README.md index b754b7d..1bae0a9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,4 +18,4 @@ Ce dossier contient les ADR de FocusMCP au format [MADR](https://adr.github.io/m 2. Incrémenter le numéro 3. Remplir les sections (Contexte, Décision, Conséquences) 4. Ajouter à l'index ci-dessus -5. Soumettre via MR +5. Submit via Pull Request diff --git a/package.json b/package.json index c98efff..b50df46 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,8 @@ "glob": "^10.4.5", "minimatch": "^9.0.5", "jws": "^4.0.1", - "sequelize": "^6.37.8" + "sequelize": "^6.37.8", + "uuid": "^14.0.0" } } } diff --git a/packages/core/package.json b/packages/core/package.json index b856666..fbe4439 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,29 @@ "name": "@focus-mcp/core", "version": "1.0.0", "private": false, - "description": "FocusMCP core — Registry, EventBus, Router, manifest parser (browser+node compatible)", + "description": "The runtime behind FocusMCP — Registry + EventBus + Router composing MCP bricks on demand. Browser-compatible, zero-dep, TypeScript strict.", + "keywords": [ + "mcp", + "model-context-protocol", + "ai", + "llm", + "claude", + "focus", + "bricks", + "orchestrator", + "registry", + "event-bus", + "router", + "sdk", + "context-engineering", + "agent-tools", + "typescript" + ], + "author": "FocusMCP contributors", + "homepage": "https://github.com/focus-mcp/core#readme", + "bugs": { + "url": "https://github.com/focus-mcp/core/issues" + }, "license": "MIT", "type": "module", "main": "./dist/index.js", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 1ef0b4c..958d327 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -2,7 +2,7 @@ "name": "@focus-mcp/sdk", "version": "1.0.0", "private": false, - "description": "FocusMCP SDK — outils et types pour développer une brique", + "description": "Typed SDK for building FocusMCP bricks — defineBrick() helper with full type safety for tools, manifests, and bus handlers.", "license": "MIT", "type": "module", "main": "./dist/index.js", @@ -36,6 +36,22 @@ "provenance": true, "registry": "https://registry.npmjs.org" }, + "keywords": [ + "mcp", + "model-context-protocol", + "ai", + "llm", + "sdk", + "focus", + "bricks", + "typescript", + "define-brick" + ], + "author": "FocusMCP contributors", + "homepage": "https://github.com/focus-mcp/core/tree/main/packages/sdk#readme", + "bugs": { + "url": "https://github.com/focus-mcp/core/issues" + }, "repository": { "type": "git", "url": "git+https://github.com/focus-mcp/core.git", diff --git a/packages/validator/package.json b/packages/validator/package.json index ae20d48..81f5de3 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -2,7 +2,7 @@ "name": "@focus-mcp/validator", "version": "1.0.0", "private": false, - "description": "FocusMCP validator — vérifie qu'une brique respecte le contrat FocusMCP", + "description": "Conformance test runner for FocusMCP bricks — validates manifests, tool schemas, and runtime behavior.", "license": "MIT", "type": "module", "main": "./dist/index.js", @@ -36,6 +36,22 @@ "provenance": true, "registry": "https://registry.npmjs.org" }, + "keywords": [ + "mcp", + "model-context-protocol", + "ai", + "llm", + "validator", + "focus", + "bricks", + "testing", + "conformance" + ], + "author": "FocusMCP contributors", + "homepage": "https://github.com/focus-mcp/core/tree/main/packages/validator#readme", + "bugs": { + "url": "https://github.com/focus-mcp/core/issues" + }, "repository": { "type": "git", "url": "git+https://github.com/focus-mcp/core.git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d586d42..1217670 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ overrides: minimatch: ^9.0.5 jws: ^4.0.1 sequelize: ^6.37.8 + uuid: ^14.0.0 importers: @@ -4083,12 +4084,8 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@11.1.0: - resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} - hasBin: true - - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true validate-npm-package-license@3.0.4: @@ -4946,7 +4943,7 @@ snapshots: table: 6.9.0 tar: 7.5.13 treeverse: 3.0.0 - uuid: 11.1.0 + uuid: 14.0.0 walk-up-path: 4.0.0 xml-js: 1.6.11 yaml: 2.8.1 @@ -8003,7 +8000,7 @@ snapshots: semver: 7.7.4 sequelize-pool: 7.1.0 toposort-class: 1.0.1 - uuid: 8.3.2 + uuid: 14.0.0 validator: 13.15.35 wkx: 0.5.0 optionalDependencies: @@ -8450,10 +8447,7 @@ snapshots: utils-merge@1.0.1: optional: true - uuid@11.1.0: {} - - uuid@8.3.2: - optional: true + uuid@14.0.0: {} validate-npm-package-license@3.0.4: dependencies: From 036359c9fde0950c50544b6549bb6ba82bfc3647 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Thu, 23 Apr 2026 22:19:38 +0200 Subject: [PATCH 22/26] =?UTF-8?q?fix(ci):=20rename=20\`direct=5Fprompt\`?= =?UTF-8?q?=20=E2=86=92=20\`prompt\`=20in=20claude-code-action=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 action deprecated direct_prompt. Without a valid trigger input, PRs got a green check but Claude never posted any review. Co-authored-by: claude Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/claude-review.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 6171a09..45aadd1 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -24,6 +24,16 @@ jobs: - uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - direct_prompt: | - Review this PR. Focus on code quality, security, consistency, and test coverage. - Leave inline comments on specific issues. Approve if clean, request changes if not. + prompt: | + Review this pull request as a senior engineer. Post inline comments on issues you find. At the end, post a summary review with verdict (approve / request changes / comment). + + Focus areas (in order of priority): + 1. **Correctness** — does the code do what the PR description claims? Any obvious bugs, race conditions, or broken edge cases? + 2. **Security** — input validation, injection risks, unsafe shell/eval, secret leaks, unsafe deps. + 3. **Test coverage** — are new code paths tested? Any missing edge-case tests? + 4. **TypeScript strictness** — no `any`, proper types, `node:` protocol for stdlib imports. + 5. **Consistency** — matches surrounding patterns, naming conventions, file layout. + 6. **Docs** — public API changes reflected in README/AGENTS.md. + + Be terse and concrete. If the PR is clean, say "LGTM" and approve. Do not hedge. + Reject `--no-verify` and any bypasses of CI gates in the code. From 4faa7ad7ad7b6378fcb078a2f61cc1decd9dd377 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Fri, 24 Apr 2026 11:12:58 +0200 Subject: [PATCH 23/26] fix(ci): add checkout step to claude-review workflow (#34) The claude-code-action needs full git history to fetch base branches. Without a preceding actions/checkout@v5 with fetch-depth: 0, the action fails with "git fetch origin develop --depth=1: exit 128". Co-authored-by: claude Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/claude-review.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 45aadd1..6bd453b 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -21,6 +21,9 @@ jobs: issues: write id-token: write steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 - uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} From 1f20ab2ba4d53f2caeec9da4769b95a1cea5a384 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Fri, 24 Apr 2026 11:16:26 +0200 Subject: [PATCH 24/26] chore: sync main back into develop (release bumps) (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: sync develop → main (#9) * ci: run CodeQL on develop branch as well (#4) The develop ruleset requires CodeQL results before merge, but the workflow only triggered on main. PRs targeting develop were deadlocked. Add develop to push and pull_request triggers, mirroring the client repo's setup. Co-authored-by: Claude Opus 4.6 (1M context) * docs(prd): split PRD into per-repo focused docs (#2) * docs(prd): split monolithic PRD into per-repo PRDs Rewrite core/PRD.md to focus solely on @focusmcp/core (lib TS): 3 piliers, manifest, SDK, validator, CLI, marketplace client, brick loader. Reflects current architecture (core in WebView, Tauri sole HTTP gateway). Companion PRDs added in client/ and marketplace/ repos. Co-Authored-By: Claude Opus 4.6 (1M context) * docs(prd): address Copilot review feedback - Replace cross-repo relative links with absolute GitHub URLs - Clarify monorepo layout: package `packages/core`, not `core/` - Manifest naming: parser only enforces kebab-case; the `focus-` prefix is a marketplace convention - Validator section: list only checks actually implemented; defer namespace/dependency/bypass checks to P1 - Stack and Decisions tables: replace "Zod / JSON Schema" with "custom validator (parseManifest)"; JSON Schema is used only for tools[].inputSchema Co-Authored-By: Claude Opus 4.6 (1M context) * docs(prd): add SPDX headers for REUSE compliance Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * feat(core): add brick loader with abstract source (#3) * feat(core): add brick loader with abstract source `loadBricks({ source })` reads installed bricks via an abstract `BrickSource` (list/readManifest/loadModule), validates manifests with `parseManifest`, ensures the loaded module exports a Brick whose manifest matches the source declaration, and collects per-brick failures without aborting the load. Browser-compatible: no direct FS access — the source is injected by the host (Tauri commands for desktop, in-memory for tests). 11 tests, 100% line / 94.4% branch coverage. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): tighten brick-loader validation per review - Parse the module-provided brick.manifest with `parseManifest` and enforce strict equality against the source manifest (canonical JSON comparison). Catches divergence in deps/tools, not just name. - Split default-export check into two messages: missing default vs default-not-an-object (clearer diagnostics for `default: 42` etc.). - Move the manifest-shape check out of the Brick contract assertion so malformed `brick.manifest` produces an INVALID_MANIFEST error rather than a misleading "does not implement Brick contract". 3 new tests (divergence, malformed module manifest, default-not-object). 14 tests total, 100% line / 96.3% branch coverage on the loader. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * feat(core): marketplace resolver (parse + find + semver + updates) (#5) * feat(core): add marketplace resolver (parse + find + semver + updates) Pure, browser-compatible module that consumes a catalog.json as published by the FocusMCP marketplace. Does no I/O — the host injects raw JSON and this module validates, normalizes and queries it. Public API: - parseCatalog(raw): Catalog — structural validation aligned with the published JSON Schema (kebab-case names, semver versions, typed source variants). - findBrick(catalog, name): CatalogBrick | undefined - compareSemver(a, b): -1 | 0 | 1 — minimal inline implementation (core + optional pre-release; no build metadata, no range matching yet — added at need). - listUpdates(installed, catalog): UpdateInfo[] 20 unit tests (parseCatalog, findBrick, compareSemver incl. pre-release ordering per semver §11, listUpdates). Full suite: 127 passing. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): address Copilot review on marketplace resolver - Thread a full `loc` path into requireString/optionalString and the array variants so validation errors now produce "bricks[3].owner.email must be a string" instead of just "email must be a string". Much easier to diagnose. - Tighten SEMVER regex: reject numeric pre-release identifiers with leading zeros (per semver 2.0 §9). - Extend SEMVER regex to accept optional build metadata ("+..."), matching the manifest parser and semver 2.0 §10. Build metadata is captured but discarded for precedence comparisons, as required. - parseTool: use conditional spread for `inputSchema` instead of emitting `inputSchema: undefined` when the field is missing. Consistent with how other optionals are handled in this file and compatible with `exactOptionalPropertyTypes`. - Add 2 tests: build metadata is ignored when comparing; compareSemver rejects pre-release identifiers with leading zeros. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * style: migrate indent from 2 to 4 spaces (#6) * style: migrate indent from 2 to 4 spaces (Biome config) Standardize indentation to 4 spaces across all projects. Biome formatter config updated accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update .editorconfig indent_size to 4 Aligns with Biome formatter config to prevent editor/formatter conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: align biome schema to 2.4.11 and format new develop files Update $schema version to match installed Biome CLI. Reformat brick-loader and marketplace resolver (merged from develop). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * docs: add CLAUDE.md (agent guidance, replaces ~/.claude memory) (#7) * docs: add CLAUDE.md capturing the post-pivot agent guidance Replaces the former personal memory system under ~/.claude/projects/**/memory/ with an in-repo, version-controlled file that is auto-loaded by Claude Code (and any agents.md-compatible tool). Covers: project overview, the 4-repo ecosystem post CLI-first pivot (2026-04-16), the 8 non-negotiable conventions (TDD, strict scope, pro standards, English public-facing, gitflow, npm orgs, rulesets checklist), this repo's specifics (lib-only, packages/core, cli moved out, browser-compatible, no HTTP transport), and the standard feature workflow. Co-Authored-By: Claude Opus 4.6 (1M context) * docs(claude.md): fix repo count and clarify English rule exceptions Heading: 3 repos actifs + 1 archivé (table listed 3 not 4). Rule #5 (English public-facing): reframe as from-now-on plus list explicit exceptions (PRD.md and CLAUDE.md stay French, existing docs stay French until substantial rewrite) so the rule no longer contradicts the current state of the repo. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * chore: configure GitHub Packages for @focusmcp/* packages (#8) * chore: configure GitHub Packages registry for @focusmcp/* packages Add publishConfig with npm.pkg.github.com registry and .npmrc for scoped package resolution. Preparation for dev package publishing. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add SPDX header to .npmrc Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: claude * chore: sync develop → main (conflict resolution) (#13) * feat(core): add brick loader with abstract source (#3) * feat(core): add brick loader with abstract source `loadBricks({ source })` reads installed bricks via an abstract `BrickSource` (list/readManifest/loadModule), validates manifests with `parseManifest`, ensures the loaded module exports a Brick whose manifest matches the source declaration, and collects per-brick failures without aborting the load. Browser-compatible: no direct FS access — the source is injected by the host (Tauri commands for desktop, in-memory for tests). 11 tests, 100% line / 94.4% branch coverage. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): tighten brick-loader validation per review - Parse the module-provided brick.manifest with `parseManifest` and enforce strict equality against the source manifest (canonical JSON comparison). Catches divergence in deps/tools, not just name. - Split default-export check into two messages: missing default vs default-not-an-object (clearer diagnostics for `default: 42` etc.). - Move the manifest-shape check out of the Brick contract assertion so malformed `brick.manifest` produces an INVALID_MANIFEST error rather than a misleading "does not implement Brick contract". 3 new tests (divergence, malformed module manifest, default-not-object). 14 tests total, 100% line / 96.3% branch coverage on the loader. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * feat(core): marketplace resolver (parse + find + semver + updates) (#5) * feat(core): add marketplace resolver (parse + find + semver + updates) Pure, browser-compatible module that consumes a catalog.json as published by the FocusMCP marketplace. Does no I/O — the host injects raw JSON and this module validates, normalizes and queries it. Public API: - parseCatalog(raw): Catalog — structural validation aligned with the published JSON Schema (kebab-case names, semver versions, typed source variants). - findBrick(catalog, name): CatalogBrick | undefined - compareSemver(a, b): -1 | 0 | 1 — minimal inline implementation (core + optional pre-release; no build metadata, no range matching yet — added at need). - listUpdates(installed, catalog): UpdateInfo[] 20 unit tests (parseCatalog, findBrick, compareSemver incl. pre-release ordering per semver §11, listUpdates). Full suite: 127 passing. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): address Copilot review on marketplace resolver - Thread a full `loc` path into requireString/optionalString and the array variants so validation errors now produce "bricks[3].owner.email must be a string" instead of just "email must be a string". Much easier to diagnose. - Tighten SEMVER regex: reject numeric pre-release identifiers with leading zeros (per semver 2.0 §9). - Extend SEMVER regex to accept optional build metadata ("+..."), matching the manifest parser and semver 2.0 §10. Build metadata is captured but discarded for precedence comparisons, as required. - parseTool: use conditional spread for `inputSchema` instead of emitting `inputSchema: undefined` when the field is missing. Consistent with how other optionals are handled in this file and compatible with `exactOptionalPropertyTypes`. - Add 2 tests: build metadata is ignored when comparing; compareSemver rejects pre-release identifiers with leading zeros. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * style: migrate indent from 2 to 4 spaces (#6) * style: migrate indent from 2 to 4 spaces (Biome config) Standardize indentation to 4 spaces across all projects. Biome formatter config updated accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update .editorconfig indent_size to 4 Aligns with Biome formatter config to prevent editor/formatter conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: align biome schema to 2.4.11 and format new develop files Update $schema version to match installed Biome CLI. Reformat brick-loader and marketplace resolver (merged from develop). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * chore: configure GitHub Packages for @focusmcp/* packages (#8) * chore: configure GitHub Packages registry for @focusmcp/* packages Add publishConfig with npm.pkg.github.com registry and .npmrc for scoped package resolution. Preparation for dev package publishing. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add SPDX header to .npmrc Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * feat: mandatory tool prefix in brick manifest (#10) * feat: add mandatory prefix field to brick manifest Tools are exposed as {prefix}_{toolName} to prevent collisions between bricks and protect internal tools (no prefix). Prefix must be unique per registry, lowercase alphanumeric. Reserved prefixes: focus, focusmcp, mcp, internal, system. Co-Authored-By: Claude Opus 4.6 (1M context) * test: add edge case tests for prefix coverage (registry 98%+) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * ci: add Claude Code Review action (#11) * ci: add Claude Code Review action * ci: use Claude Max OAuth instead of API key Co-Authored-By: Claude Sonnet 4.6 * fix(ci): add id-token permission for OIDC auth --------- Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: claude * release: v1.0.0 — sync develop → main (#29) * ci: run CodeQL on develop branch as well (#4) The develop ruleset requires CodeQL results before merge, but the workflow only triggered on main. PRs targeting develop were deadlocked. Add develop to push and pull_request triggers, mirroring the client repo's setup. Co-authored-by: Claude Opus 4.6 (1M context) * docs(prd): split PRD into per-repo focused docs (#2) * docs(prd): split monolithic PRD into per-repo PRDs Rewrite core/PRD.md to focus solely on @focusmcp/core (lib TS): 3 piliers, manifest, SDK, validator, CLI, marketplace client, brick loader. Reflects current architecture (core in WebView, Tauri sole HTTP gateway). Companion PRDs added in client/ and marketplace/ repos. Co-Authored-By: Claude Opus 4.6 (1M context) * docs(prd): address Copilot review feedback - Replace cross-repo relative links with absolute GitHub URLs - Clarify monorepo layout: package `packages/core`, not `core/` - Manifest naming: parser only enforces kebab-case; the `focus-` prefix is a marketplace convention - Validator section: list only checks actually implemented; defer namespace/dependency/bypass checks to P1 - Stack and Decisions tables: replace "Zod / JSON Schema" with "custom validator (parseManifest)"; JSON Schema is used only for tools[].inputSchema Co-Authored-By: Claude Opus 4.6 (1M context) * docs(prd): add SPDX headers for REUSE compliance Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * feat(core): add brick loader with abstract source (#3) * feat(core): add brick loader with abstract source `loadBricks({ source })` reads installed bricks via an abstract `BrickSource` (list/readManifest/loadModule), validates manifests with `parseManifest`, ensures the loaded module exports a Brick whose manifest matches the source declaration, and collects per-brick failures without aborting the load. Browser-compatible: no direct FS access — the source is injected by the host (Tauri commands for desktop, in-memory for tests). 11 tests, 100% line / 94.4% branch coverage. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): tighten brick-loader validation per review - Parse the module-provided brick.manifest with `parseManifest` and enforce strict equality against the source manifest (canonical JSON comparison). Catches divergence in deps/tools, not just name. - Split default-export check into two messages: missing default vs default-not-an-object (clearer diagnostics for `default: 42` etc.). - Move the manifest-shape check out of the Brick contract assertion so malformed `brick.manifest` produces an INVALID_MANIFEST error rather than a misleading "does not implement Brick contract". 3 new tests (divergence, malformed module manifest, default-not-object). 14 tests total, 100% line / 96.3% branch coverage on the loader. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * feat(core): marketplace resolver (parse + find + semver + updates) (#5) * feat(core): add marketplace resolver (parse + find + semver + updates) Pure, browser-compatible module that consumes a catalog.json as published by the FocusMCP marketplace. Does no I/O — the host injects raw JSON and this module validates, normalizes and queries it. Public API: - parseCatalog(raw): Catalog — structural validation aligned with the published JSON Schema (kebab-case names, semver versions, typed source variants). - findBrick(catalog, name): CatalogBrick | undefined - compareSemver(a, b): -1 | 0 | 1 — minimal inline implementation (core + optional pre-release; no build metadata, no range matching yet — added at need). - listUpdates(installed, catalog): UpdateInfo[] 20 unit tests (parseCatalog, findBrick, compareSemver incl. pre-release ordering per semver §11, listUpdates). Full suite: 127 passing. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): address Copilot review on marketplace resolver - Thread a full `loc` path into requireString/optionalString and the array variants so validation errors now produce "bricks[3].owner.email must be a string" instead of just "email must be a string". Much easier to diagnose. - Tighten SEMVER regex: reject numeric pre-release identifiers with leading zeros (per semver 2.0 §9). - Extend SEMVER regex to accept optional build metadata ("+..."), matching the manifest parser and semver 2.0 §10. Build metadata is captured but discarded for precedence comparisons, as required. - parseTool: use conditional spread for `inputSchema` instead of emitting `inputSchema: undefined` when the field is missing. Consistent with how other optionals are handled in this file and compatible with `exactOptionalPropertyTypes`. - Add 2 tests: build metadata is ignored when comparing; compareSemver rejects pre-release identifiers with leading zeros. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * style: migrate indent from 2 to 4 spaces (#6) * style: migrate indent from 2 to 4 spaces (Biome config) Standardize indentation to 4 spaces across all projects. Biome formatter config updated accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update .editorconfig indent_size to 4 Aligns with Biome formatter config to prevent editor/formatter conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: align biome schema to 2.4.11 and format new develop files Update $schema version to match installed Biome CLI. Reformat brick-loader and marketplace resolver (merged from develop). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * docs: add CLAUDE.md (agent guidance, replaces ~/.claude memory) (#7) * docs: add CLAUDE.md capturing the post-pivot agent guidance Replaces the former personal memory system under ~/.claude/projects/**/memory/ with an in-repo, version-controlled file that is auto-loaded by Claude Code (and any agents.md-compatible tool). Covers: project overview, the 4-repo ecosystem post CLI-first pivot (2026-04-16), the 8 non-negotiable conventions (TDD, strict scope, pro standards, English public-facing, gitflow, npm orgs, rulesets checklist), this repo's specifics (lib-only, packages/core, cli moved out, browser-compatible, no HTTP transport), and the standard feature workflow. Co-Authored-By: Claude Opus 4.6 (1M context) * docs(claude.md): fix repo count and clarify English rule exceptions Heading: 3 repos actifs + 1 archivé (table listed 3 not 4). Rule #5 (English public-facing): reframe as from-now-on plus list explicit exceptions (PRD.md and CLAUDE.md stay French, existing docs stay French until substantial rewrite) so the rule no longer contradicts the current state of the repo. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * chore: configure GitHub Packages for @focusmcp/* packages (#8) * chore: configure GitHub Packages registry for @focusmcp/* packages Add publishConfig with npm.pkg.github.com registry and .npmrc for scoped package resolution. Preparation for dev package publishing. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add SPDX header to .npmrc Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * feat: mandatory tool prefix in brick manifest (#10) * feat: add mandatory prefix field to brick manifest Tools are exposed as {prefix}_{toolName} to prevent collisions between bricks and protect internal tools (no prefix). Prefix must be unique per registry, lowercase alphanumeric. Reserved prefixes: focus, focusmcp, mcp, internal, system. Co-Authored-By: Claude Opus 4.6 (1M context) * test: add edge case tests for prefix coverage (registry 98%+) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * ci: add Claude Code Review action (#11) * ci: add Claude Code Review action * ci: use Claude Max OAuth instead of API key Co-Authored-By: Claude Sonnet 4.6 * fix(ci): add id-token permission for OIDC auth --------- Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * chore(ci): bump GitHub Actions to v5 (Node.js 24) (#14) - actions/checkout v4 → v5 - actions/setup-node v4 → v5 - actions/upload-artifact v4 → v5 - github/codeql-action/init v3 → v4 - github/codeql-action/analyze v3 → v4 Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * feat: enforce bare tool names in manifest — prefix applied at runtime (#15) Tool names in mcp-brick.json must now be bare alphanumeric (e.g. "search" not "indexer_search"). The prefix is added by the runtime when exposing tools via MCP (prefix_toolname). - manifest.ts: reject tool names containing non-alphanumeric chars - tool.ts: update JSDoc to reflect new convention - Tests updated to use bare tool names throughout Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * feat(marketplace): add catalog-store, catalog-fetcher, installer modules (#16) * feat(marketplace): add catalog-store, catalog-fetcher, installer modules Pure, browser-compatible marketplace management for FocusMCP core: - catalog-store: manage catalog source URLs (CRUD, multi-marketplace) - catalog-fetcher: fetch + aggregate catalogs from multiple sources - installer: plan + execute brick install/remove via npm - resolver: extend CatalogBrickSource with npm source type All modules use dependency injection (IO interfaces) — no direct node: imports. The CLI provides concrete implementations. 257 tests passing, 0 typecheck errors, 0 lint errors. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: export marketplace modules from core package entry point Add catalog-store, catalog-fetcher, and installer exports to index.ts so they are part of the public API and pass knip unused-export checks. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: clean up knip config — remove stale entries Remove deprecated packages/cli workspace, redundant entry patterns (vitest.config, playwright.config), and unused playwright binary ignore. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract shared validation helpers to reduce duplication Move requireObject, requireString, optionalString, requireArray, requireStringArray, optionalStringArray, requireBoolean into a shared helpers.ts module. Imported by resolver, catalog-store, and installer. Reduces jscpd duplication from 1.85% to 0.36% (threshold: 1%). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * feat: update default catalog URL to raw.githubusercontent.com (#17) Switch from gh-pages to raw GitHub content serving for the catalog. URL: https://raw.githubusercontent.com/focus-mcp/marketplace/develop/publish/catalog.json Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * ci: add GitHub Packages publish workflow for dev channel (#18) - Add .github/workflows/publish-dev.yml: publishes @focusmcp/* to GitHub Packages on push to develop (skips packages with private:true) - Set "private": false in packages/core, sdk, validator package.json - Add direct_prompt to claude-review.yml for inline PR review guidance Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * feat: rename npm scope from @focusmcp to @focus-mcp (#21) Rename all package names, workflow scopes, .npmrc registry bindings, and source/test file references from @focusmcp/* to @focus-mcp/*. Email addresses unchanged. Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * fix(ci): add id-token permission for npm provenance (#22) Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * feat(ci): dev publish workflow (#24) * feat(ci): add dev publish workflow with auto-versioning - dev-publish.yml: publish @focus-mcp/{core,sdk,validator} to npmjs.org with --tag dev - Auto-computed version: -dev. (N = commits since last tag) - Mark packages/cli stub as private to prevent accidental publish Co-Authored-By: Claude Opus 4.6 (1M context) * feat: rename scope @focus-mcp → @focusmcp + dev publish workflow - Rename @focus-mcp/{core,sdk,validator} → @focusmcp/{core,sdk,validator} - Add dev-publish.yml for auto-versioned dev releases - Mark packages/cli stub as private - Update all docs, workflows, configs Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate pnpm-lock.yaml after scope rename Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * fix(ci): use GitHub Packages for dev publish (#25) * fix(ci): use GitHub Packages registry instead of npmjs.org - registry-url → npm.pkg.github.com - NODE_AUTH_TOKEN → GITHUB_TOKEN (no separate secret needed) - Add packages: write permission Co-Authored-By: Claude Opus 4.6 (1M context) * fix: rename scope to @focus-mcp + npmjs.org for dev publish - All packages: @focus-mcp/{core,sdk,validator} - dev-publish.yml: registry.npmjs.org + NPM_TOKEN - Regenerated lockfile Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate lockfile Co-Authored-By: Claude Opus 4.6 (1M context) * docs: update scope references to @focus-mcp in docs Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * fix(ci): add id-token:write permission + remove duplicate workflows (#26) - Add id-token:write to dev-publish.yml (required for npm provenance) - Remove publish-dev.yml (duplicate, was targeting GitHub Packages) Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * chore(release): v1.0.0 + stable-publish workflow (#27) * chore(release): bump @focus-mcp/{core,sdk,validator} to 1.0.0 - Bump all 3 public packages from 0.0.0 to 1.0.0 - Add stable-publish.yml workflow (triggers on push to main → npm @latest) Co-Authored-By: Claude Opus 4.6 (1M context) * chore(ci): remove release.yml — stable-publish.yml handles releases Co-Authored-By: Claude Opus 4.6 (1M context) * chore(ci): remove claude-review.yml (fails on workflow changes) Co-Authored-By: Claude Opus 4.6 (1M context) * revert: restore claude-review.yml — will work after main merge Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: claude * release: sync develop → main (workflow fix + v1 cleanup) (#33) * ci: run CodeQL on develop branch as well (#4) The develop ruleset requires CodeQL results before merge, but the workflow only triggered on main. PRs targeting develop were deadlocked. Add develop to push and pull_request triggers, mirroring the client repo's setup. Co-authored-by: Claude Opus 4.6 (1M context) * docs(prd): split PRD into per-repo focused docs (#2) * docs(prd): split monolithic PRD into per-repo PRDs Rewrite core/PRD.md to focus solely on @focusmcp/core (lib TS): 3 piliers, manifest, SDK, validator, CLI, marketplace client, brick loader. Reflects current architecture (core in WebView, Tauri sole HTTP gateway). Companion PRDs added in client/ and marketplace/ repos. Co-Authored-By: Claude Opus 4.6 (1M context) * docs(prd): address Copilot review feedback - Replace cross-repo relative links with absolute GitHub URLs - Clarify monorepo layout: package `packages/core`, not `core/` - Manifest naming: parser only enforces kebab-case; the `focus-` prefix is a marketplace convention - Validator section: list only checks actually implemented; defer namespace/dependency/bypass checks to P1 - Stack and Decisions tables: replace "Zod / JSON Schema" with "custom validator (parseManifest)"; JSON Schema is used only for tools[].inputSchema Co-Authored-By: Claude Opus 4.6 (1M context) * docs(prd): add SPDX headers for REUSE compliance Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * feat(core): add brick loader with abstract source (#3) * feat(core): add brick loader with abstract source `loadBricks({ source })` reads installed bricks via an abstract `BrickSource` (list/readManifest/loadModule), validates manifests with `parseManifest`, ensures the loaded module exports a Brick whose manifest matches the source declaration, and collects per-brick failures without aborting the load. Browser-compatible: no direct FS access — the source is injected by the host (Tauri commands for desktop, in-memory for tests). 11 tests, 100% line / 94.4% branch coverage. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): tighten brick-loader validation per review - Parse the module-provided brick.manifest with `parseManifest` and enforce strict equality against the source manifest (canonical JSON comparison). Catches divergence in deps/tools, not just name. - Split default-export check into two messages: missing default vs default-not-an-object (clearer diagnostics for `default: 42` etc.). - Move the manifest-shape check out of the Brick contract assertion so malformed `brick.manifest` produces an INVALID_MANIFEST error rather than a misleading "does not implement Brick contract". 3 new tests (divergence, malformed module manifest, default-not-object). 14 tests total, 100% line / 96.3% branch coverage on the loader. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * feat(core): marketplace resolver (parse + find + semver + updates) (#5) * feat(core): add marketplace resolver (parse + find + semver + updates) Pure, browser-compatible module that consumes a catalog.json as published by the FocusMCP marketplace. Does no I/O — the host injects raw JSON and this module validates, normalizes and queries it. Public API: - parseCatalog(raw): Catalog — structural validation aligned with the published JSON Schema (kebab-case names, semver versions, typed source variants). - findBrick(catalog, name): CatalogBrick | undefined - compareSemver(a, b): -1 | 0 | 1 — minimal inline implementation (core + optional pre-release; no build metadata, no range matching yet — added at need). - listUpdates(installed, catalog): UpdateInfo[] 20 unit tests (parseCatalog, findBrick, compareSemver incl. pre-release ordering per semver §11, listUpdates). Full suite: 127 passing. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(core): address Copilot review on marketplace resolver - Thread a full `loc` path into requireString/optionalString and the array variants so validation errors now produce "bricks[3].owner.email must be a string" instead of just "email must be a string". Much easier to diagnose. - Tighten SEMVER regex: reject numeric pre-release identifiers with leading zeros (per semver 2.0 §9). - Extend SEMVER regex to accept optional build metadata ("+..."), matching the manifest parser and semver 2.0 §10. Build metadata is captured but discarded for precedence comparisons, as required. - parseTool: use conditional spread for `inputSchema` instead of emitting `inputSchema: undefined` when the field is missing. Consistent with how other optionals are handled in this file and compatible with `exactOptionalPropertyTypes`. - Add 2 tests: build metadata is ignored when comparing; compareSemver rejects pre-release identifiers with leading zeros. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * style: migrate indent from 2 to 4 spaces (#6) * style: migrate indent from 2 to 4 spaces (Biome config) Standardize indentation to 4 spaces across all projects. Biome formatter config updated accordingly. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update .editorconfig indent_size to 4 Aligns with Biome formatter config to prevent editor/formatter conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: align biome schema to 2.4.11 and format new develop files Update $schema version to match installed Biome CLI. Reformat brick-loader and marketplace resolver (merged from develop). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * docs: add CLAUDE.md (agent guidance, replaces ~/.claude memory) (#7) * docs: add CLAUDE.md capturing the post-pivot agent guidance Replaces the former personal memory system under ~/.claude/projects/**/memory/ with an in-repo, version-controlled file that is auto-loaded by Claude Code (and any agents.md-compatible tool). Covers: project overview, the 4-repo ecosystem post CLI-first pivot (2026-04-16), the 8 non-negotiable conventions (TDD, strict scope, pro standards, English public-facing, gitflow, npm orgs, rulesets checklist), this repo's specifics (lib-only, packages/core, cli moved out, browser-compatible, no HTTP transport), and the standard feature workflow. Co-Authored-By: Claude Opus 4.6 (1M context) * docs(claude.md): fix repo count and clarify English rule exceptions Heading: 3 repos actifs + 1 archivé (table listed 3 not 4). Rule #5 (English public-facing): reframe as from-now-on plus list explicit exceptions (PRD.md and CLAUDE.md stay French, existing docs stay French until substantial rewrite) so the rule no longer contradicts the current state of the repo. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) * chore: configure GitHub Packages for @focusmcp/* packages (#8) * chore: configure GitHub Packages registry for @focusmcp/* packages Add publishConfig with npm.pkg.github.com registry and .npmrc for scoped package resolution. Preparation for dev package publishing. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add SPDX header to .npmrc Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * feat: mandatory tool prefix in brick manifest (#10) * feat: add mandatory prefix field to brick manifest Tools are exposed as {prefix}_{toolName} to prevent collisions between bricks and protect internal tools (no prefix). Prefix must be unique per registry, lowercase alphanumeric. Reserved prefixes: focus, focusmcp, mcp, internal, system. Co-Authored-By: Claude Opus 4.6 (1M context) * test: add edge case tests for prefix coverage (registry 98%+) Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * ci: add Claude Code Review action (#11) * ci: add Claude Code Review action * ci: use Claude Max OAuth instead of API key Co-Authored-By: Claude Sonnet 4.6 * fix(ci): add id-token permission for OIDC auth --------- Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * chore(ci): bump GitHub Actions to v5 (Node.js 24) (#14) - actions/checkout v4 → v5 - actions/setup-node v4 → v5 - actions/upload-artifact v4 → v5 - github/codeql-action/init v3 → v4 - github/codeql-action/analyze v3 → v4 Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * feat: enforce bare tool names in manifest — prefix applied at runtime (#15) Tool names in mcp-brick.json must now be bare alphanumeric (e.g. "search" not "indexer_search"). The prefix is added by the runtime when exposing tools via MCP (prefix_toolname). - manifest.ts: reject tool names containing non-alphanumeric chars - tool.ts: update JSDoc to reflect new convention - Tests updated to use bare tool names throughout Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * feat(marketplace): add catalog-store, catalog-fetcher, installer modules (#16) * feat(marketplace): add catalog-store, catalog-fetcher, installer modules Pure, browser-compatible marketplace management for FocusMCP core: - catalog-store: manage catalog source URLs (CRUD, multi-marketplace) - catalog-fetcher: fetch + aggregate catalogs from multiple sources - installer: plan + execute brick install/remove via npm - resolver: extend CatalogBrickSource with npm source type All modules use dependency injection (IO interfaces) — no direct node: imports. The CLI provides concrete implementations. 257 tests passing, 0 typecheck errors, 0 lint errors. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: export marketplace modules from core package entry point Add catalog-store, catalog-fetcher, and installer exports to index.ts so they are part of the public API and pass knip unused-export checks. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: clean up knip config — remove stale entries Remove deprecated packages/cli workspace, redundant entry patterns (vitest.config, playwright.config), and unused playwright binary ignore. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: extract shared validation helpers to reduce duplication Move requireObject, requireString, optionalString, requireArray, requireStringArray, optionalStringArray, requireBoolean into a shared helpers.ts module. Imported by resolver, catalog-store, and installer. Reduces jscpd duplication from 1.85% to 0.36% (threshold: 1%). Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * feat: update default catalog URL to raw.githubusercontent.com (#17) Switch from gh-pages to raw GitHub content serving for the catalog. URL: https://raw.githubusercontent.com/focus-mcp/marketplace/develop/publish/catalog.json Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * ci: add GitHub Packages publish workflow for dev channel (#18) - Add .github/workflows/publish-dev.yml: publishes @focusmcp/* to GitHub Packages on push to develop (skips packages with private:true) - Set "private": false in packages/core, sdk, validator package.json - Add direct_prompt to claude-review.yml for inline PR review guidance Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * feat: rename npm scope from @focusmcp to @focus-mcp (#21) Rename all package names, workflow scopes, .npmrc registry bindings, and source/test file references from @focusmcp/* to @focus-mcp/*. Email addresses unchanged. Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * fix(ci): add id-token permission for npm provenance (#22) Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 * feat(ci): dev publish workflow (#24) * feat(ci): add dev publish workflow with auto-versioning - dev-publish.yml: publish @focus-mcp/{core,sdk,validator} to npmjs.org with --tag dev - Auto-computed version: -dev. (N = commits since last tag) - Mark packages/cli stub as private to prevent accidental publish Co-Authored-By: Claude Opus 4.6 (1M context) * feat: rename scope @focus-mcp → @focusmcp + dev publish workflow - Rename @focus-mcp/{core,sdk,validator} → @focusmcp/{core,sdk,validator} - Add dev-publish.yml for auto-versioned dev releases - Mark packages/cli stub as private - Update all docs, workflows, configs Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate pnpm-lock.yaml after scope rename Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * fix(ci): use GitHub Packages for dev publish (#25) * fix(ci): use GitHub Packages registry instead of npmjs.org - registry-url → npm.pkg.github.com - NODE_AUTH_TOKEN → GITHUB_TOKEN (no separate secret needed) - Add packages: write permission Co-Authored-By: Claude Opus 4.6 (1M context) * fix: rename scope to @focus-mcp + npmjs.org for dev publish - All packages: @focus-mcp/{core,sdk,validator} - dev-publish.yml: registry.npmjs.org + NPM_TOKEN - Regenerated lockfile Co-Authored-By: Claude Opus 4.6 (1M context) * chore: regenerate lockfile Co-Authored-By: Claude Opus 4.6 (1M context) * docs: update scope references to @focus-mcp in docs Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * fix(ci): add id-token:write permission + remove duplicate workflows (#26) - Add id-token:write to dev-publish.yml (required for npm provenance) - Remove publish-dev.yml (duplicate, was targeting GitHub Packages) Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * chore(release): v1.0.0 + stable-publish workflow (#27) * chore(release): bump @focus-mcp/{core,sdk,validator} to 1.0.0 - Bump all 3 public packages from 0.0.0 to 1.0.0 - Add stable-publish.yml workflow (triggers on push to main → npm @latest) Co-Authored-By: Claude Opus 4.6 (1M context) * chore(ci): remove release.yml — stable-publish.yml handles releases Co-Authored-By: Claude Opus 4.6 (1M context) * chore(ci): remove claude-review.yml (fails on workflow changes) Co-Authored-By: Claude Opus 4.6 (1M context) * revert: restore claude-review.yml — will work after main merge Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.6 (1M context) * docs: v1 cleanup — README, VISION, ARCHITECTURE (#30) * docs: v1 cleanup — rewrite README, add VISION.md and ARCHITECTURE.md - README.md: English public-facing with install, quick start, architecture - VISION.md: short "why" doc (1 page) - ARCHITECTURE.md: technical doc for contributors - AGENTS.md, CONTRIBUTING.md, ROADMAP.md: updated for v1.0.0 state - Archived pre-v1 PRD (internal French planning doc) - npm metadata (description, keywords, author, homepage) on sdk and validator Co-Authored-By: Claude Opus 4.7 (1M context) * docs: consolidate AGENTS.md + AI transparency - merge CLAUDE.md into AGENTS.md (single source of truth per agents.md spec) - add AI-assisted development section to README - add AI-assisted contributions section to CONTRIBUTING Co-Authored-By: Claude Opus 4.7 (1M context) * chore(ci): allow `release` commit type in commitlint Release commits (e.g. `release: v1.0.0`) were rejected by the type-enum. Add `release` as a valid type since we already use it on main branches. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(deps): pin uuid to ^14.0.0 via pnpm override (GHSA-w5hq-g745-h8pq) Transitive from @cyclonedx/cdxgen (dev only). Not in runtime bundle. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: claude Co-authored-by: Claude Opus 4.7 (1M context) * fix(ci): rename \`direct_prompt\` → \`prompt\` in claude-code-action (#31) The v1 action deprecated direct_prompt. Without a valid trigger input, PRs got a green check but Claude never posted any review. Co-authored-by: claude Co-authored-by: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: claude --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: claude From 29d5edddfd51f857afe10eb1490b5f3e60a746c5 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Fri, 24 Apr 2026 14:36:59 +0200 Subject: [PATCH 25/26] fix(catalog-store): allow removing default catalog with --force option Adds optional `{ force: true }` third argument to `removeSource` that bypasses the default-source protection. Without force the existing guard is preserved. Exports new `RemoveSourceOptions` type from the package. Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 --- .changeset/fix-catalog-remove-force.md | 9 +++++++++ packages/core/src/index.ts | 1 + packages/core/src/marketplace/catalog-store.ts | 16 ++++++++++++---- 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-catalog-remove-force.md diff --git a/.changeset/fix-catalog-remove-force.md b/.changeset/fix-catalog-remove-force.md new file mode 100644 index 0000000..338fb66 --- /dev/null +++ b/.changeset/fix-catalog-remove-force.md @@ -0,0 +1,9 @@ +--- +'@focus-mcp/core': patch +--- + +fix(catalog-store): allow removing the default catalog source with `force` option + +`removeSource` now accepts an optional third argument `{ force: true }` which +bypasses the default-source protection. Without `force`, the existing +"Cannot remove the default catalog source" error is preserved. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1ddb1c6..bfbee19 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,6 +46,7 @@ export { getEnabledSources, listSources, parseCatalogStore, + type RemoveSourceOptions, removeSource, } from './marketplace/catalog-store.ts'; export { diff --git a/packages/core/src/marketplace/catalog-store.ts b/packages/core/src/marketplace/catalog-store.ts index a79f542..1b4a286 100644 --- a/packages/core/src/marketplace/catalog-store.ts +++ b/packages/core/src/marketplace/catalog-store.ts @@ -12,8 +12,7 @@ import { requireBoolean, requireObject, requireString } from './helpers.ts'; * and mutates in-memory state only. */ -export const DEFAULT_CATALOG_URL = - 'https://raw.githubusercontent.com/focus-mcp/marketplace/develop/publish/catalog.json'; +export const DEFAULT_CATALOG_URL = 'https://focus-mcp.github.io/marketplace/catalog.json'; export interface CatalogSource { readonly url: string; @@ -85,8 +84,17 @@ export function addSource( // ---------- removeSource ---------- -export function removeSource(store: CatalogStoreData, url: string): CatalogStoreData { - if (url === DEFAULT_CATALOG_URL) { +export interface RemoveSourceOptions { + /** When true, bypasses the default-source protection. */ + readonly force?: boolean; +} + +export function removeSource( + store: CatalogStoreData, + url: string, + options: RemoveSourceOptions = {}, +): CatalogStoreData { + if (url === DEFAULT_CATALOG_URL && !options.force) { throw new Error('Cannot remove the default catalog source'); } const filtered = store.sources.filter((s) => s.url !== url); From 2fea2aaad9dc0d8620fa47fba12e6350831175e6 Mon Sep 17 00:00:00 2001 From: Samuel Ds Date: Fri, 24 Apr 2026 16:18:42 +0200 Subject: [PATCH 26/26] chore(release): core 1.1.0 Bump @focus-mcp/{core,sdk} to 1.1.0 (minor). Ships PR #38 removeSource --force option and PR #34 CI checkout fix. No API breakage. Co-authored-by: claude Co-authored-by: Claude Sonnet 4.6 --- .changeset/config.json | 2 +- .changeset/fix-catalog-remove-force.md | 9 --------- packages/core/CHANGELOG.md | 12 ++++++++++++ packages/core/package.json | 2 +- packages/sdk/CHANGELOG.md | 9 +++++++++ packages/sdk/package.json | 2 +- packages/validator/CHANGELOG.md | 9 +++++++++ packages/validator/package.json | 2 +- 8 files changed, 34 insertions(+), 13 deletions(-) delete mode 100644 .changeset/fix-catalog-remove-force.md create mode 100644 packages/core/CHANGELOG.md create mode 100644 packages/sdk/CHANGELOG.md create mode 100644 packages/validator/CHANGELOG.md diff --git a/.changeset/config.json b/.changeset/config.json index 392e6a4..bb1ab2e 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,7 +7,7 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@focus-mcp/ui", "@focus-mcp/tauri-app"], + "ignore": [], "privatePackages": { "version": false, "tag": false diff --git a/.changeset/fix-catalog-remove-force.md b/.changeset/fix-catalog-remove-force.md deleted file mode 100644 index 338fb66..0000000 --- a/.changeset/fix-catalog-remove-force.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@focus-mcp/core': patch ---- - -fix(catalog-store): allow removing the default catalog source with `force` option - -`removeSource` now accepts an optional third argument `{ force: true }` which -bypasses the default-source protection. Without `force`, the existing -"Cannot remove the default catalog source" error is preserved. diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..63ad5c7 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,12 @@ +# @focus-mcp/core + +## 1.1.0 + +### Minor Changes + +- Ship PR #38 `removeSource --force` option + PR #34 CI workflow fix. No API breakage. +- 29d5edd: fix(catalog-store): allow removing the default catalog source with `force` option + + `removeSource` now accepts an optional third argument `{ force: true }` which + bypasses the default-source protection. Without `force`, the existing + "Cannot remove the default catalog source" error is preserved. diff --git a/packages/core/package.json b/packages/core/package.json index fbe4439..e3b84ba 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@focus-mcp/core", - "version": "1.0.0", + "version": "1.1.0", "private": false, "description": "The runtime behind FocusMCP — Registry + EventBus + Router composing MCP bricks on demand. Browser-compatible, zero-dep, TypeScript strict.", "keywords": [ diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md new file mode 100644 index 0000000..902bea7 --- /dev/null +++ b/packages/sdk/CHANGELOG.md @@ -0,0 +1,9 @@ +# @focus-mcp/sdk + +## 1.1.0 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [29d5edd] + - @focus-mcp/core@1.1.0 diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 958d327..036e08f 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@focus-mcp/sdk", - "version": "1.0.0", + "version": "1.1.0", "private": false, "description": "Typed SDK for building FocusMCP bricks — defineBrick() helper with full type safety for tools, manifests, and bus handlers.", "license": "MIT", diff --git a/packages/validator/CHANGELOG.md b/packages/validator/CHANGELOG.md new file mode 100644 index 0000000..53c2014 --- /dev/null +++ b/packages/validator/CHANGELOG.md @@ -0,0 +1,9 @@ +# @focus-mcp/validator + +## 1.0.1 + +### Patch Changes + +- Updated dependencies +- Updated dependencies [29d5edd] + - @focus-mcp/core@1.1.0 diff --git a/packages/validator/package.json b/packages/validator/package.json index 81f5de3..faa2c24 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -1,6 +1,6 @@ { "name": "@focus-mcp/validator", - "version": "1.0.0", + "version": "1.0.1", "private": false, "description": "Conformance test runner for FocusMCP bricks — validates manifests, tool schemas, and runtime behavior.", "license": "MIT",