Skip to main content

Plugin Development

a16n uses a plugin architecture to support different AI coding tools. Each plugin handles:

  1. Discovery - Finding agent customization files in a project
  2. Emission - Writing converted files to the target format

Plugin Interface

Plugins implement the A16nPlugin interface from @a16njs/models; see the Models API Reference for complete interface documentation.

The key methods are:

  • discover(rootOrWorkspace) - Scan a directory and return found customizations
  • emit(models, rootOrWorkspace, options) - Write customizations to disk in the plugin's format

Both methods accept string | Workspace — a plain directory path string or a Workspace instance. When a string is passed, plugins should wrap it using toWorkspace() from @a16njs/models.

import type { A16nPlugin, Workspace } from '@a16njs/models';
import { CustomizationType, toWorkspace } from '@a16njs/models';

const myPlugin: A16nPlugin = {
id: 'my-agent',
name: 'My Agent',
supports: [CustomizationType.GlobalPrompt, CustomizationType.FileRule],

async discover(rootOrWorkspace: string | Workspace) {
const ws = toWorkspace(rootOrWorkspace, 'my-agent-discover');
// Return { items: [...], warnings: [...] }
},

async emit(models, rootOrWorkspace, options) {
const ws = toWorkspace(rootOrWorkspace, 'my-agent-emit');
// Return { written: [...], warnings: [...], unsupported: [...] }
}
};

export default myPlugin;

Key Concepts

Customization Types

Plugins declare which types they support via the supports array. Entries are instances of CustomizationType from @a16njs/models.

Workspace Interface

The Workspace interface abstracts filesystem operations so plugins can work with real directories or in-memory workspaces (useful for testing). All paths passed to workspace methods are relative to the workspace root.

import type { Workspace } from '@a16njs/models';

// Available methods:
await ws.exists('path/to/file'); // boolean
await ws.read('path/to/file'); // string (UTF-8)
await ws.write('path/to/file', content); // void
await ws.readdir('path/to/dir'); // WorkspaceEntry[]
await ws.mkdir('path/to/dir'); // void (always recursive)
ws.resolve('path/to/file'); // absolute path string

WorkspaceEntry items returned by readdir() have boolean properties isFile and isDirectory (not methods).

Use toWorkspace() to normalize the string | Workspace parameter at the top of your functions:

import { toWorkspace } from '@a16njs/models';

const ws = toWorkspace(rootOrWorkspace, 'my-plugin-discover');
// ws is always a Workspace — strings are auto-wrapped in LocalWorkspace

Discovery

Discovery scans a project directory and returns customizations in the intermediate representation:

async discover(rootOrWorkspace: string | Workspace): Promise<DiscoveryResult> {
const ws = toWorkspace(rootOrWorkspace, 'my-plugin-discover');
const items: AgentCustomization[] = [];
const warnings: Warning[] = [];

// Use ws.readdir(), ws.read(), ws.exists() to scan for config files
// Parse and convert to AgentCustomization items

return { items, warnings };
}

Emission

Emission converts intermediate representation back to the plugin's native format:

async emit(
items: AgentCustomization[],
rootOrWorkspace: string | Workspace,
options?: EmitOptions
): Promise<EmitResult> {
const ws = toWorkspace(rootOrWorkspace, 'my-plugin-emit');
const written: WrittenFile[] = [];
const warnings: Warning[] = [];
const unsupported: AgentCustomization[] = [];

for (const item of items) {
if (this.supports.includes(item.type)) {
// Use ws.mkdir(), ws.write() to write files
// Use ws.resolve(relPath) for written[].path
} else {
unsupported.push(item);
}
}

return { written, warnings, unsupported };
}

Project Structure

Recommended plugin structure:

a16n-plugin-example/
├── src/
│ ├── index.ts # Plugin entry point & default export
│ ├── discover.ts # Discovery logic
│ └── emit.ts # Emission logic
├── test/
│ ├── fixtures/ # Test fixtures
│ ├── discover-*.test.ts # discovery tests (split by domain as the suite grows)
│ └── emit-*.test.ts # emission tests (split by domain as the suite grows)
├── package.json
├── tsconfig.json
└── README.md

package.json Requirements

Your package.json must:

  • Use the a16n-plugin- prefix in the package name (required for auto-discovery)
  • Set main to point to your built entry file
  • Declare @a16njs/models as a peerDependency
{
"name": "a16n-plugin-example",
"type": "module",
"main": "./dist/index.js",
"peerDependencies": {
"@a16njs/models": "^0.9.0"
},
"devDependencies": {
"@a16njs/models": "^0.9.0"
}
}

Testing Your Plugin

File Organization

Use one test file per behavior domain. Grow the suite as vertical slices, not as rows in a monolith:

test/
├── fixtures/ # Shared fixture directories
├── test-support/
│ ├── emit-helpers.ts # suiteTempDir() — per-suite temp isolation for emit tests
│ └── discover-helpers.ts # discoverFixturesDir() — fixture path resolution
├── discover-cursor-plugin.test.ts
├── discover-mdc-parsing.test.ts
├── emit-global-prompt.test.ts
└── emit-file-rule.test.ts

Each discover-*.test.ts covers one top-level discovery concern; each emit-*.test.ts covers one top-level emission concern. One file per root describe block — do not let a single file accumulate multiple unrelated domains.

Per-Suite Temp Directory Isolation (emit tests)

Vitest runs test files in parallel by default. Emit tests write to disk, so each suite must use a unique temp root. The recommended pattern:

// test/test-support/emit-helpers.ts
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';

export function suiteTempDir(importMetaUrl: string, slug: string): string {
const dir = path.join(
path.dirname(fileURLToPath(importMetaUrl)),
'.temp-emit',
slug
);
return dir;
}

// In each emit-*.test.ts:
import { suiteTempDir } from './test-support/emit-helpers.js';

const tempDir = suiteTempDir(import.meta.url, 'my-domain');

beforeEach(async () => { await fs.promises.mkdir(tempDir, { recursive: true }); });
afterEach(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });

Fixture Path Resolution (discover tests)

Discovery tests are read-only. Resolve fixtures relative to the test file so they work correctly regardless of Vitest's working directory:

// test/test-support/discover-helpers.ts
import { fileURLToPath } from 'node:url';
import path from 'node:path';

export function discoverFixturesDir(importMetaUrl: string): string {
return path.join(path.dirname(fileURLToPath(importMetaUrl)), '..', 'fixtures');
}

// In each discover-*.test.ts:
import { discoverFixturesDir } from './test-support/discover-helpers.js';

const fixturesDir = discoverFixturesDir(import.meta.url);

Learning from Existing Plugins

The best way to understand plugin development is to study the existing implementations:

@a16njs/plugin-cursor

The Cursor plugin demonstrates:

  • MDC file parsing (YAML frontmatter + markdown body)
  • Multiple file types (rules, commands, ignore files)
  • Frontmatter-based type classification

@a16njs/plugin-claude

The Claude plugin demonstrates:

  • Settings JSON handling

a16n-plugin-cursorrules

The cursorrules plugin demonstrates:

  • Community plugin naming format (a16n-plugin-*)
  • Discovery-only support (no emission)

Publishing

Community plugins should be published to npm with the a16n-plugin- prefix:

a16n-plugin-<name>

a16n automatically discovers installed packages matching the a16n-plugin-* naming convention by scanning node_modules directories. No registration or configuration is needed - just npm install the plugin alongside a16n.

note

The @a16njs/plugin-* scoped packages are reserved for bundled plugins maintained in the a16n monorepo. Community plugins should use the unscoped a16n-plugin-* convention.

See Also