| name | adding-new-ai-format |
| description | Step-by-step guide for adding support for a new AI editor format to PRPM - covers types, converters, schemas, CLI, webapp, and testing |
Adding a New AI Format to PRPM
Complete process for adding support for a new AI editor format (like OpenCode, Cursor, Claude, etc.) to PRPM.
Overview
This skill documents the systematic process for adding a new AI format to PRPM, based on the OpenCode integration. Follow these steps in order to ensure complete integration across all packages.
Prerequisites
- Format documentation (understand file structure, frontmatter, directory conventions)
- Example files from the format
- Understanding of format-specific features (tools, agents, commands, etc.)
Step 1: Types Package (packages/types/)
File: src/package.ts
Add the format to the Format type and FORMATS array:
export type Format =
| 'cursor'
| 'claude'
| 'continue'
| 'windsurf'
| 'copilot'
| 'kiro'
| 'agents.md'
| 'gemini.md'
| 'claude.md'
| 'gemini'
| 'opencode' // Add new format here
| 'ruler'
| 'generic'
| 'mcp';
export const FORMATS: readonly Format[] = [
'cursor',
'claude',
// ... other formats
'opencode', // Add here too
'ruler',
'generic',
'mcp',
] as const;
Build and verify:
npm run build --workspace=@pr-pm/types
Step 2: Converters Package - Schema (packages/converters/schemas/)
Create JSON schema file: {format}.schema.json
Example structure:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://prpm.dev/schemas/opencode.schema.json",
"title": "OpenCode Agent Format",
"description": "JSON Schema for OpenCode Agents",
"type": "object",
"required": ["frontmatter", "content"],
"properties": {
"frontmatter": {
"type": "object",
"required": ["description"],
"properties": {
"description": { "type": "string" },
// Format-specific fields
}
},
"content": {
"type": "string",
"description": "Body content as markdown"
}
}
}
Step 3: Converters Package - Canonical Types
File: packages/converters/src/types/canonical.ts
3a. Add format to CanonicalPackage.format union:
format: 'cursor' | 'claude' | ... | 'opencode' | 'ruler' | 'generic' | 'mcp';
3b. Add format-specific metadata (if needed):
// In CanonicalPackage.metadata
metadata?: {
// ... existing configs
opencode?: {
mode?: 'subagent' | 'primary' | 'all';
model?: string;
temperature?: number;
permission?: Record<string, any>;
disable?: boolean;
};
};
3c. Add to MetadataSection.data (if storing format-specific data):
export interface MetadataSection {
type: 'metadata';
data: {
title: string;
description: string;
// ... existing fields
opencode?: {
// Same structure as above
};
};
}
3d. Add to formatScores and sourceFormat:
formatScores?: {
cursor?: number;
// ... others
opencode?: number;
};
sourceFormat?: 'cursor' | 'claude' | ... | 'opencode' | ... | 'generic';
Step 4: Converters Package - From Converter
File: packages/converters/src/from-{format}.ts
Create converter that parses format → canonical:
import type {
CanonicalPackage,
PackageMetadata,
Section,
MetadataSection,
ToolsSection,
} from './types/canonical.js';
import { setTaxonomy } from './taxonomy-utils.js';
import yaml from 'js-yaml'; // If using YAML frontmatter
// Define format-specific interfaces
interface FormatFrontmatter {
// Format-specific frontmatter structure
}
// Parse frontmatter if needed
function parseFrontmatter(content: string): {
frontmatter: Record<string, any>;
body: string
} {
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) {
return { frontmatter: {}, body: content };
}
const frontmatter = yaml.load(match[1]) as Record<string, any>;
const body = match[2];
return { frontmatter, body };
}
export function fromFormat(
content: string,
metadata: Partial<PackageMetadata> & Pick<PackageMetadata, 'id' | 'name' | 'version' | 'author'>
): CanonicalPackage {
const { frontmatter, body } = parseFrontmatter(content);
const fm = frontmatter as FormatFrontmatter;
const sections: Section[] = [];
// 1. Create metadata section
const metadataSection: MetadataSection = {
type: 'metadata',
data: {
title: metadata.name || metadata.id,
description: fm.description || metadata.description || '',
version: metadata.version || '1.0.0',
author: metadata.author,
},
};
// Store format-specific data for roundtrip
if (/* has format-specific fields */) {
metadataSection.data.formatName = {
// Format-specific data
};
}
sections.push(metadataSection);
// 2. Extract tools (if applicable)
if (fm.tools) {
const enabledTools = Object.entries(fm.tools)
.filter(([_, enabled]) => enabled === true)
.map(([tool, _]) => {
// Normalize tool names to canonical format
return normalizeToolName(tool);
});
if (enabledTools.length > 0) {
sections.push({
type: 'tools',
tools: enabledTools,
});
}
}
// 3. Add body as instructions
if (body.trim()) {
sections.push({
type: 'instructions',
title: 'Instructions',
content: body.trim(),
});
}
// 4. Build canonical package
const canonicalContent: CanonicalPackage['content'] = {
format: 'canonical',
version: '1.0',
sections
};
const pkg: CanonicalPackage = {
...metadata,
id: metadata.id,
name: metadata.name || metadata.id,
version: metadata.version,
author: metadata.author,
description: metadata.description || fm.description || '',
tags: metadata.tags || [],
format: 'formatname',
subtype: 'agent', // Or detect from content
content: canonicalContent,
};
setTaxonomy(pkg, 'formatname', 'agent');
return pkg;
}
Key points:
- Import yaml if format uses YAML frontmatter
- Extract all format-specific metadata for roundtrip conversion
- Normalize tool names to canonical format (Write, Edit, Bash, etc.)
- Always include
format: 'canonical'andversion: '1.0'in content - InstructionsSection requires
titlefield - Call
setTaxonomy()before returning
Step 5: Converters Package - To Converter
File: packages/converters/src/to-{format}.ts
Create converter that converts canonical → format:
import type {
CanonicalPackage,
ConversionResult,
} from './types/canonical.js';
import yaml from 'js-yaml';
export function toFormat(pkg: CanonicalPackage): ConversionResult {
const warnings: string[] = [];
let qualityScore = 100;
try {
const content = convertContent(pkg, warnings);
const lossyConversion = warnings.some(w =>
w.includes('not supported') || w.includes('skipped')
);
if (lossyConversion) {
qualityScore -= 10;
}
return {
content,
format: 'formatname',
warnings: warnings.length > 0 ? warnings : undefined,
lossyConversion,
qualityScore,
};
} catch (error) {
warnings.push(`Conversion error: ${error instanceof Error ? error.message : String(error)}`);
return {
content: '',
format: 'formatname',
warnings,
lossyConversion: true,
qualityScore: 0,
};
}
}
function convertContent(pkg: CanonicalPackage, warnings: string[]): string {
const lines: string[] = [];
// Extract sections
const metadata = pkg.content.sections.find(s => s.type === 'metadata');
const tools = pkg.content.sections.find(s => s.type === 'tools');
const instructions = pkg.content.sections.find(s => s.type === 'instructions');
// Build frontmatter
const frontmatter: Record<string, any> = {};
if (metadata?.type === 'metadata') {
frontmatter.description = metadata.data.description;
}
// Restore format-specific metadata (for roundtrip)
const formatData = metadata?.type === 'metadata' ? metadata.data.formatName : undefined;
if (formatData) {
Object.assign(frontmatter, formatData);
}
// Convert tools
if (tools?.type === 'tools' && tools.tools.length > 0) {
frontmatter.tools = convertToolsToFormatStructure(tools.tools);
}
// Generate YAML frontmatter (if applicable)
lines.push('---');
lines.push(yaml.dump(frontmatter, { indent: 2, lineWidth: -1 }).trim());
lines.push('---');
lines.push('');
// Add body content
if (instructions?.type === 'instructions') {
lines.push(instructions.content);
}
return lines.join('\n').trim() + '\n';
}
Section type handling:
- PersonaSection:
section.data.role(NOTsection.content) - RulesSection:
section.items(NOTsection.rules), each item hasrule.content - InstructionsSection:
section.contentandsection.title - ExamplesSection:
section.examplesarray withdescriptionandcode
Step 6: Converters Package - Exports and Validation
File: packages/converters/src/index.ts
Add to exports:
// From converters
export { fromFormat } from './from-format.js';
// To converters
export { toFormat } from './to-format.js';
File: packages/converters/src/validation.ts
Add to FormatType:
export type FormatType =
| 'cursor'
| 'claude'
// ... others
| 'opencode'
| 'canonical';
Add to schema map:
const schemaMap: Record<FormatType, string> = {
'cursor': 'cursor.schema.json',
// ... others
'opencode': 'opencode.schema.json',
'canonical': 'canonical.schema.json',
};
File: packages/converters/src/taxonomy-utils.ts
Add to Format type:
export type Format = 'cursor' | 'claude' | ... | 'opencode' | ... | 'mcp';
Add to normalizeFormat:
export function normalizeFormat(sourceFormat: string): Format {
const normalized = sourceFormat.toLowerCase();
if (normalized.includes('cursor')) return 'cursor';
// ... others
if (normalized.includes('opencode')) return 'opencode';
return 'generic';
}
Build converters:
npm run build --workspace=@pr-pm/converters
Step 7: CLI Package - Filesystem
File: packages/cli/src/core/filesystem.ts
7a. Add to getDestinationDir:
export function getDestinationDir(format: Format, subtype: Subtype, name?: string): string {
const packageName = stripAuthorNamespace(name);
switch (format) {
// ... existing cases
case 'opencode':
// OpenCode supports agents, slash commands, and custom tools
// Agents: .opencode/agent/*.md
// Commands: .opencode/command/*.md
// Tools: .opencode/tool/*.ts or *.js
if (subtype === 'agent') return '.opencode/agent';
if (subtype === 'slash-command') return '.opencode/command';
if (subtype === 'tool') return '.opencode/tool';
return '.opencode/agent'; // Default
// ... rest
}
}
7b. Add to autoDetectFormat:
const formatDirs: Array<{ format: Format; dir: string }> = [
{ format: 'cursor', dir: '.cursor' },
// ... others
{ format: 'opencode', dir: '.opencode' },
{ format: 'agents.md', dir: '.agents' },
];
Step 8: CLI Package - Format Mappings
Files: packages/cli/src/commands/search.ts and packages/cli/src/commands/install.ts
Add to both files:
8a. formatIcons:
const formatIcons: Record<Format, string> = {
'claude': '🤖',
'cursor': '📋',
// ... others
'opencode': '⚡', // Choose appropriate emoji
'gemini.md': '✨', // Don't forget format aliases
'claude.md': '🤖',
'ruler': '📏',
'generic': '📦',
};
8b. formatLabels:
const formatLabels: Record<Format, string> = {
'claude': 'Claude',
'cursor': 'Cursor',
// ... others
'opencode': 'OpenCode',
'gemini.md': 'Gemini', // Format aliases
'claude.md': 'Claude',
'ruler': 'Ruler',
'generic': '',
};
Step 9: Webapp - Format Subtypes and Filter Dropdown
File: packages/webapp/src/app/(app)/search/SearchClient.tsx
9a. Add to FORMAT_SUBTYPES:
const FORMAT_SUBTYPES: Record<Format, Subtype[]> = {
'cursor': ['rule', 'agent', 'slash-command', 'tool'],
'claude': ['skill', 'agent', 'slash-command', 'tool', 'hook'],
'claude.md': ['agent'], // Format aliases
'gemini.md': ['slash-command'],
// ... others
'opencode': ['agent', 'slash-command', 'tool'], // List all supported subtypes
'ruler': ['rule', 'agent', 'tool'],
'generic': ['rule', 'agent', 'skill', 'slash-command', 'tool', 'chatmode', 'hook'],
};
9b. Add to format filter dropdown (around line 1195):
<select
value={selectedFormat}
onChange={(e) => setSelectedFormat(e.target.value as Format | '')}
className="w-full px-3 py-2 bg-prpm-dark border border-prpm-border rounded text-white focus:outline-none focus:border-prpm-accent"
>
<option value="">All Formats</option>
<option value="cursor">Cursor</option>
<option value="claude">Claude</option>
<option value="continue">Continue</option>
<option value="windsurf">Windsurf</option>
<option value="copilot">GitHub Copilot</option>
<option value="kiro">Kiro</option>
<option value="gemini">Gemini CLI</option>
<option value="droid">Droid</option>
<option value="opencode">OpenCode</option> {/* Add your format here */}
<option value="mcp">MCP</option>
<option value="agents.md">Agents.md</option>
<option value="generic">Generic</option>
</select>
9c. Add compatibility info section (after the dropdown):
{selectedFormat === 'opencode' && (
<div className="mt-3 p-3 bg-gray-500/10 border border-gray-500/30 rounded-lg">
<p className="text-xs text-gray-400">
Tool-specific format for <strong>OpenCode AI</strong>
</p>
</div>
)}
Step 10: Registry - Fastify Route Schemas
CRITICAL: Add the format to all Fastify route validation schemas to prevent 400 errors.
10a. File: packages/registry/src/routes/download.ts
Add to format enum in schema (2-3 places):
// Download route schema (line ~46)
format: {
type: 'string',
enum: ['cursor', 'claude', 'continue', 'windsurf', 'copilot', 'kiro', 'ruler', 'agents.md', 'gemini', 'droid', 'opencode', 'generic'],
description: 'Target format for conversion (optional)',
},
// Compatibility check route schema (lines ~201, 205)
from: {
type: 'string',
enum: ['cursor', 'claude', 'continue', 'windsurf', 'copilot', 'kiro', 'ruler', 'agents.md', 'gemini', 'droid', 'opencode', 'generic'],
},
to: {
type: 'string',
enum: ['cursor', 'claude', 'continue', 'windsurf', 'copilot', 'kiro', 'ruler', 'agents.md', 'gemini', 'droid', 'opencode', 'generic'],
},
10b. File: packages/registry/src/routes/search.ts
Add to FORMAT_ENUM constant (line ~12):
const FORMAT_ENUM = ['cursor', 'claude', 'continue', 'windsurf', 'copilot', 'kiro', 'agents.md', 'gemini', 'ruler', 'droid', 'opencode', 'generic', 'mcp'] as const;
10c. File: packages/registry/src/routes/analytics.ts
Add to both Zod schema and Fastify schema:
// Zod schema (line ~15)
const TrackDownloadSchema = z.object({
packageId: z.string(),
version: z.string().optional(),
format: z.enum(['cursor', 'claude', 'continue', 'windsurf', 'copilot', 'kiro', 'agents.md', 'gemini', 'ruler', 'droid', 'opencode', 'generic', 'mcp']).optional(),
client: z.enum(['cli', 'web', 'api']).optional(),
});
// Fastify schema (line ~45)
format: {
type: 'string',
enum: ['cursor', 'claude', 'continue', 'windsurf', 'copilot', 'kiro', 'agents.md', 'gemini', 'ruler', 'droid', 'opencode', 'generic', 'mcp'],
description: 'Download format'
},
Why this matters: Without these additions, the registry will reject API requests with 400 validation errors when users try to download or filter by the new format.
Step 11: Testing and Validation
11a. Build types package first:
npm run build --workspace=@pr-pm/types
This is critical because other packages depend on the updated Format type.
11b. Build registry and webapp:
npm run build --workspace=@pr-pm/registry
npm run build --workspace=@pr-pm/webapp
11c. Run typecheck:
npm run typecheck
Fix any TypeScript errors:
- Missing format in type unions
- Format aliases ('gemini.md', 'claude.md')
- Section structure (use correct field names)
11d. Build all packages:
npm run build
11e. Run converter tests:
npm test --workspace=@pr-pm/converters
11f. Create test fixtures (recommended):
// packages/converters/src/__tests__/opencode.test.ts
describe('OpenCode Format', () => {
it('should convert from OpenCode to canonical', () => {
const opencodeContent = `---
description: Test agent
mode: subagent
---
Test instructions`;
const result = fromOpencode(opencodeContent, {
id: 'test',
name: 'test',
version: '1.0.0',
author: 'test',
});
expect(result.format).toBe('opencode');
expect(result.subtype).toBe('agent');
});
it('should convert canonical to OpenCode', () => {
const canonical: CanonicalPackage = {
// ... build test package
};
const result = toOpencode(canonical);
expect(result.format).toBe('opencode');
expect(result.content).toContain('---');
});
});
Step 12: Documentation
Create documentation at appropriate location:
- User-facing: Add to Mintlify docs or README
- Internal: Add notes to
docs/development/if needed - Decision logs: Document any architectural decisions in
docs/decisions/
Common Pitfalls
1. Missing Format Aliases
Formats like 'gemini.md' and 'claude.md' are aliases that MUST be included in all format mappings.
2. Incorrect Section Structure
- PersonaSection uses
data.role, notcontent - RulesSection uses
items, notrules - InstructionsSection requires
titlefield - Each Rule has
content, notdescription
3. CanonicalContent Requirements
Must always include:
{
format: 'canonical',
version: '1.0',
sections: [...]
}
4. setTaxonomy Signature
setTaxonomy(pkg, 'formatname', 'subtype'); // Returns void
return pkg; // Return the package separately
5. Tool Name Normalization
Map format-specific tool names to canonical:
write→Writeedit→Editbash→Bash
6. YAML Import
If using YAML frontmatter:
import yaml from 'js-yaml'; // Top-level import
// NOT: const yaml = await import('js-yaml');
Checklist
Before submitting:
Types Package:
- Added format to types/src/package.ts (Format type and FORMATS array)
- Built types package
Converters Package:
- Created schema file in converters/schemas/
- Updated converters/src/types/canonical.ts (all 4 places: format union, metadata, MetadataSection.data, formatScores, sourceFormat)
- Created from-{format}.ts converter
- Created to-{format}.ts converter
- Updated converters/src/index.ts exports
- Updated converters/src/validation.ts (FormatType and schemaMap)
- Updated converters/src/taxonomy-utils.ts (Format type and normalizeFormat)
CLI Package:
- Updated cli/src/core/filesystem.ts (getDestinationDir and autoDetectFormat)
- Updated cli/src/commands/search.ts (formatIcons and formatLabels, including aliases)
- Updated cli/src/commands/install.ts (formatIcons and formatLabels, including aliases)
Webapp Package:
- Updated webapp SearchClient.tsx (FORMAT_SUBTYPES, including aliases)
- Added to format filter dropdown
- Added compatibility info section
Registry Package:
- Updated registry/src/routes/download.ts (format enum in 2-3 places)
- Updated registry/src/routes/search.ts (FORMAT_ENUM constant)
- Updated registry/src/routes/analytics.ts (Zod schema and Fastify schema)
- Built registry package
Testing:
- Ran typecheck successfully
- Built all packages successfully
- Wrote tests for converters
- Documented the integration
Example: OpenCode Integration
See the following files for reference:
packages/converters/src/from-opencode.tspackages/converters/src/to-opencode.tspackages/converters/schemas/opencode.schema.json- Git commit history for the OpenCode integration PR
Summary
Adding a new format requires changes across 6 packages:
- types - Add to Format type (build first!)
- converters - Schema, from/to converters, canonical types, validation, taxonomy
- cli - Filesystem and format mappings
- webapp - Format subtypes, filter dropdown, compatibility info
- registry - Fastify route schemas (download, search, analytics)
- tests - Verify everything works
Build order matters: types → converters → cli → webapp → registry
Follow the steps systematically, use existing format implementations as reference, and always run typecheck and tests before submitting.