Écrire un addon arka-deck
Ce tutoriel explique comment créer un addon de zéro en suivant les patterns des addons existants (cortex-actions, memory-local, gouvernance-lite).
Pour la référence formelle des types et contrats, voir contrat-addon.
Pré-requis
Section intitulée « Pré-requis »- Comprendre l’architecture hexagonale
- Comprendre le bus d’événements
- Avoir lu un addon existant —
addons/memory-local/src/est le plus simple
1. Structure de dossiers
Section intitulée « 1. Structure de dossiers »Créez votre addon dans addons/<votre-addon>/ :
addons/mon-addon/├── manifest.json└── src/ ├── index.ts # entry point : register function + re-exports ├── domain/ │ └── mon-concept.ts # types domain locaux à l'addon ├── ports/ │ ├── for-mon-addon.ts # port inbound (use-cases exposés) │ └── mon-store.ts # port outbound (stockage) ├── adapters/ │ └── fs-mon-store.ts # implémentation du store via Filesystem └── use-cases/ ├── build-for-mon-addon.ts # assembleur des use-cases └── event-subscriber.ts # abonnements au bus2. manifest.json
Section intitulée « 2. manifest.json »{ "name": "mon-addon", "version": "1.0.0", "kind": "capability", "description": "Ce que fait mon addon en une phrase.", "status": "active", "workers": [], "dependencies": [], "entrypoint": "src/index.ts"}3. Types domain
Section intitulée « 3. Types domain »Définissez les types propres à votre addon dans src/domain/. Ces types ne doivent pas dépendre d’adapters/ ni d’autres addons.
export interface MonConcept { readonly id: string; readonly projectPath: string; readonly content: string; readonly createdAt: string;}4. Port inbound (use-cases)
Section intitulée « 4. Port inbound (use-cases) »Le port inbound définit ce que votre addon expose aux routes HTTP et à la composition.
import type { MonConcept } from '../domain/mon-concept.js';
export interface SaveConceptInput { readonly projectPath: string; readonly content: string;}
export interface ForMonAddon { saveConcept(input: SaveConceptInput): Promise<MonConcept>; getConcepts(projectPath: string): Promise<readonly MonConcept[]>;}5. Port outbound (store)
Section intitulée « 5. Port outbound (store) »import type { MonConcept } from '../domain/mon-concept.js';
export interface MonStore { save(concept: MonConcept): Promise<void>; findAll(projectPath: string): Promise<readonly MonConcept[]>;}6. Adapter filesystem
Section intitulée « 6. Adapter filesystem »Implémentez le store via le port Filesystem fourni par le core (jamais fs directement).
import type { Filesystem } from '../../../../core/ports/outbound/filesystem.js';import type { MonStore } from '../ports/mon-store.js';import type { MonConcept } from '../domain/mon-concept.js';
const STORE_PATH = (projectPath: string) => `${projectPath}/.arka-deck/addons/mon-addon/concepts.json`;
export class FsMonStore implements MonStore { constructor(private readonly fs: Filesystem) {}
async save(concept: MonConcept): Promise<void> { const path = STORE_PATH(concept.projectPath); const existing = await this.findAll(concept.projectPath); const updated = [...existing, concept]; await this.fs.writeJson(path, updated); }
async findAll(projectPath: string): Promise<readonly MonConcept[]> { const path = STORE_PATH(projectPath); const raw = await this.fs.readJson(path).catch(() => null); if (!Array.isArray(raw)) return []; return raw as MonConcept[]; }}Chemin de stockage : toujours sous
<projectPath>/.arka-deck/addons/<votre-addon>/. N’écrivez jamais en dehors.
7. Use-cases
Section intitulée « 7. Use-cases »import type { Clock } from '../../../../core/ports/outbound/clock.js';import type { IdGenerator } from '../../../../core/ports/outbound/id-generator.js';import type { MonStore } from '../ports/mon-store.js';import type { ForMonAddon, SaveConceptInput } from '../ports/for-mon-addon.js';
export function buildForMonAddon(deps: { clock: Clock; idGenerator: IdGenerator; store: MonStore;}): ForMonAddon { return { async saveConcept(input: SaveConceptInput) { const concept = { id: deps.idGenerator.next(), projectPath: input.projectPath, content: input.content, createdAt: deps.clock.now().toISOString(), }; await deps.store.save(concept); return concept; },
async getConcepts(projectPath: string) { return deps.store.findAll(projectPath); }, };}8. Subscriber bus
Section intitulée « 8. Subscriber bus »L’addon s’abonne aux events du bus pour réagir aux événements système.
import type { EventBus } from '../../../../core/ports/outbound/event-bus.js';import type { ForMonAddon } from '../ports/for-mon-addon.js';
export interface SubscriberHandle { readonly unsubscribe: () => void;}
export interface SubscriberDeps { readonly eventBus: EventBus; readonly forMonAddon: ForMonAddon; readonly resolveProjectPath: (sessionId: string) => Promise<string | null>;}
export function subscribeMonAddonToEventBus(deps: SubscriberDeps): SubscriberHandle { const unsubEnded = deps.eventBus.subscribe('chat.session.ended', async (ev) => { const projectPath = await deps.resolveProjectPath(ev.sessionId).catch(() => null); if (projectPath === null) return;
await deps.forMonAddon.saveConcept({ projectPath, content: `Session ${ev.sessionId} terminée (${ev.reason})`, }).catch(() => undefined); });
return { unsubscribe: () => { unsubEnded(); }, };}Règle importante : ne jamais lancer d’exception dans un handler bus. Utilisez .catch(() => undefined) pour les opérations best-effort. Une erreur dans votre handler n’impacte pas les autres subscribers.
9. BeforeTurnAugmenter (optionnel)
Section intitulée « 9. BeforeTurnAugmenter (optionnel) »Si votre addon doit injecter du contexte avant un tour de chat, implémentez BeforeTurnAugmenter :
import type { BeforeTurnAugmenter, BeforeTurnAugmentInput } from '../../../../core/ports/outbound/before-turn-augmenter.js';import type { ForMonAddon } from '../ports/for-mon-addon.js';
export function buildMonAddonAugmenter(deps: { forMonAddon: ForMonAddon; resolveProjectPath: (sessionId: string) => Promise<string | null>;}): BeforeTurnAugmenter { return { async augmentPrompt(input: BeforeTurnAugmentInput): Promise<string | null> { const projectPath = await deps.resolveProjectPath(input.sessionId).catch(() => null); if (projectPath === null) return null;
const concepts = await deps.forMonAddon.getConcepts(projectPath).catch(() => []); if (concepts.length === 0) return null;
return `[Contexte mon-addon]\n${concepts.map((c) => `- ${c.content}`).join('\n')}\n`; }, };}10. Entry point (register function)
Section intitulée « 10. Entry point (register function) »import type { Clock } from '../../../core/ports/outbound/clock.js';import type { EventBus } from '../../../core/ports/outbound/event-bus.js';import type { Filesystem } from '../../../core/ports/outbound/filesystem.js';import type { IdGenerator } from '../../../core/ports/outbound/id-generator.js';import type { BeforeTurnAugmenter } from '../../../core/ports/outbound/before-turn-augmenter.js';
import { FsMonStore } from './adapters/fs-mon-store.js';import type { ForMonAddon } from './ports/for-mon-addon.js';import { buildForMonAddon } from './use-cases/build-for-mon-addon.js';import { buildMonAddonAugmenter } from './use-cases/build-augmenter.js';import { subscribeMonAddonToEventBus } from './use-cases/event-subscriber.js';
export type { ForMonAddon, SaveConceptInput } from './ports/for-mon-addon.js';export type { MonConcept } from './domain/mon-concept.js';
export interface MonAddonDeps { readonly clock: Clock; readonly idGenerator: IdGenerator; readonly filesystem: Filesystem; readonly eventBus: EventBus; readonly resolveProjectPath: (sessionId: string) => Promise<string | null>;}
export interface MonAddonRuntime { readonly forMonAddon: ForMonAddon; readonly beforeTurnAugmenter: BeforeTurnAugmenter; readonly unsubscribe: () => void;}
export function registerMonAddon(deps: MonAddonDeps): MonAddonRuntime { const store = new FsMonStore(deps.filesystem);
const forMonAddon = buildForMonAddon({ clock: deps.clock, idGenerator: deps.idGenerator, store, });
const { unsubscribe } = subscribeMonAddonToEventBus({ eventBus: deps.eventBus, forMonAddon, resolveProjectPath: deps.resolveProjectPath, });
const beforeTurnAugmenter = buildMonAddonAugmenter({ forMonAddon, resolveProjectPath: deps.resolveProjectPath, });
return { forMonAddon, beforeTurnAugmenter, unsubscribe };}11. Enregistrement en composition
Section intitulée « 11. Enregistrement en composition »Ouvrez composition/core-container.ts et ajoutez votre addon dans la section addons :
import { registerMonAddon } from '../addons/mon-addon/src/index.js';
const monAddonRuntime = registerMonAddon({ clock, idGenerator, filesystem, eventBus, resolveProjectPath: (sessionId) => chatSessionStore.getSession(sessionId) .then((s) => s?.projectPath ?? null),});Si votre addon expose des routes HTTP, câblez-les dans composition/addons/ et enregistrez-les dans web-container.ts.
12. Tests
Section intitulée « 12. Tests »Créez des fakes pour vos ports outbound et testez les use-cases en isolation :
import { describe, it, expect } from 'vitest';import { buildForMonAddon } from './build-for-mon-addon.js';
const fakeClock = { now: () => new Date('2026-01-01T00:00:00Z') };const fakeIdGenerator = { next: () => 'test-id-1' };const fakeStore = { concepts: [] as MonConcept[], async save(c: MonConcept) { this.concepts.push(c); }, async findAll() { return this.concepts; },};
describe('buildForMonAddon', () => { it('saves and retrieves a concept', async () => { const forMonAddon = buildForMonAddon({ clock: fakeClock, idGenerator: fakeIdGenerator, store: fakeStore, });
await forMonAddon.saveConcept({ projectPath: '/tmp/proj', content: 'test' }); const concepts = await forMonAddon.getConcepts('/tmp/proj');
expect(concepts).toHaveLength(1); expect(concepts[0].content).toBe('test'); });});Checklist avant de contribuer
Section intitulée « Checklist avant de contribuer »-
manifest.jsoncomplet et valide -
registerMonAddonexporteunsubscribe - Handlers bus avec
.catch(() => undefined)(best-effort) - Écriture fichiers uniquement sous
.arka-deck/addons/<mon-addon>/ - Pas d’import vers
adapters/,composition/ou d’autres addons - Tests unitaires sur les use-cases
-
npm run lintetnpm testverts