Aller au contenu

É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.



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 bus

{
"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"
}

Définissez les types propres à votre addon dans src/domain/. Ces types ne doivent pas dépendre d’adapters/ ni d’autres addons.

src/domain/mon-concept.ts
export interface MonConcept {
readonly id: string;
readonly projectPath: string;
readonly content: string;
readonly createdAt: string;
}

Le port inbound définit ce que votre addon expose aux routes HTTP et à la composition.

src/ports/for-mon-addon.ts
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[]>;
}

src/ports/mon-store.ts
import type { MonConcept } from '../domain/mon-concept.js';
export interface MonStore {
save(concept: MonConcept): Promise<void>;
findAll(projectPath: string): Promise<readonly MonConcept[]>;
}

Implémentez le store via le port Filesystem fourni par le core (jamais fs directement).

src/adapters/fs-mon-store.ts
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.


src/use-cases/build-for-mon-addon.ts
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);
},
};
}

L’addon s’abonne aux events du bus pour réagir aux événements système.

src/use-cases/event-subscriber.ts
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.


Si votre addon doit injecter du contexte avant un tour de chat, implémentez BeforeTurnAugmenter :

src/use-cases/build-augmenter.ts
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`;
},
};
}

src/index.ts
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 };
}

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.


Créez des fakes pour vos ports outbound et testez les use-cases en isolation :

src/use-cases/build-for-mon-addon.test.ts
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');
});
});

  • manifest.json complet et valide
  • registerMonAddon exporte unsubscribe
  • 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 lint et npm test verts