--- 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: ```typescript 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**: ```bash npm run build --workspace=@pr-pm/types ``` ## Step 2: Converters Package - Schema (`packages/converters/schemas/`) Create JSON schema file: `{format}.schema.json` Example structure: ```json { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://registry.prpm.dev/api/v1/schemas/opencode.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 }, "additionalProperties": false }, "content": { "type": "string", "description": "Body content as markdown" } } } ``` **CRITICAL Schema Requirements:** - `$id` must use new URL pattern: `https://registry.prpm.dev/api/v1/schemas/{format}.json` for base schemas - For subtypes: `https://registry.prpm.dev/api/v1/schemas/{format}/{subtype}.json` - Add `"additionalProperties": false` to frontmatter object to catch invalid fields - String fields requiring slugs (like `name`) should use pattern: `"pattern": "^[a-z0-9-]+$"` If the format has subtypes (like Claude with agents/skills/commands), create separate schema files: - `{format}-agent.schema.json` - `{format}-skill.schema.json` - `{format}-slash-command.schema.json` - etc. **IMPORTANT**: When creating subtype schemas, you MUST update the validation logic to map them. ## Step 3: Converters Package - Format Documentation (`packages/converters/docs/`) **CRITICAL**: Create comprehensive format documentation file: `{format}.md` This documentation serves as the source of truth for: - Package authors creating packages in this format - PRPM contributors implementing converters - Users understanding format capabilities and limitations **Required sections**: ```markdown # {Format Name} Format Specification **File Locations:** - {Type 1}: `{path}` - {Type 2}: `{path}` **Format:** {Markdown/JSON/etc.} with {YAML frontmatter/etc.} **Official Docs:** {link to official documentation} ## Overview Brief description of the format and its purpose. ## Frontmatter Fields ### Required Fields - **`field-name`** (type): Description ### Optional Fields - **`field-name`** (type): Description ## Content Format Describe the body/content structure. ## Best Practices 1. Practice 1 2. Practice 2 ## Conversion Notes ### From {Format} to Canonical How the converter parses this format. ### From Canonical to {Format} How the converter generates this format. ## Limitations - Limitation 1 - Limitation 2 ## Examples ### Example 1 ```markdown {example content} ``` ## Related Documentation - [Official Docs]({url}) - [PRPM Format Guide](../../docs/formats.mdx) ## Changelog - **{Date}**: Initial format support ``` **Add to README.md**: 1. **Format Matrix table**: Add row(s) with subtypes, official docs, and OpenCode docs links 2. **Available Formats table**: Add row with link to your new `.md` file 3. **Schema Validation section**: Add schema filename(s) to appropriate list 4. **Frontmatter Support table**: Add row with frontmatter requirements 5. **File Organization table**: Add row with file paths and structure See `packages/converters/docs/README.md` for examples of how other formats are documented. ## Step 4: Converters Package - Canonical Types **File**: `packages/converters/src/types/canonical.ts` ### 3a. Add format to CanonicalPackage.format union: ```typescript format: 'cursor' | 'claude' | ... | 'opencode' | 'ruler' | 'generic' | 'mcp'; ``` ### 3b. Add format-specific metadata (if needed): ```typescript // In CanonicalPackage.metadata metadata?: { // ... existing configs opencode?: { mode?: 'subagent' | 'primary' | 'all'; model?: string; temperature?: number; permission?: Record; disable?: boolean; }; }; ``` ### 3c. Add to MetadataSection.data (if storing format-specific data): ```typescript export interface MetadataSection { type: 'metadata'; data: { title: string; description: string; // ... existing fields opencode?: { // Same structure as above }; }; } ``` ### 3d. Add to formatScores and sourceFormat: ```typescript formatScores?: { cursor?: number; // ... others opencode?: number; }; sourceFormat?: 'cursor' | 'claude' | ... | 'opencode' | ... | 'generic'; ``` ## Step 5: Converters Package - From Converter **File**: `packages/converters/src/from-{format}.ts` Create converter that parses format → canonical: ```typescript 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; 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; const body = match[2]; return { frontmatter, body }; } export function fromFormat( content: string, metadata: Partial & Pick ): 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'` and `version: '1.0'` in content - InstructionsSection requires `title` field - Call `setTaxonomy()` before returning ## Step 6: Converters Package - To Converter **File**: `packages/converters/src/to-{format}.ts` Create converter that converts canonical → format: ```typescript 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 = {}; 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` (NOT `section.content`) - **RulesSection**: `section.items` (NOT `section.rules`), each item has `rule.content` - **InstructionsSection**: `section.content` and `section.title` - **ExamplesSection**: `section.examples` array with `description` and `code` ## Step 7: Converters Package - Exports and Validation **File**: `packages/converters/src/index.ts` Add to exports: ```typescript // From converters export { fromFormat } from './from-format.js'; // To converters export { toFormat } from './to-format.js'; ``` **File**: `packages/converters/src/validation.ts` ### 7a. Add to FormatType: ```typescript export type FormatType = | 'cursor' | 'claude' // ... others | 'opencode' | 'canonical'; ``` ### 7b. Add to base schema map: ```typescript const schemaMap: Record = { 'cursor': 'cursor.schema.json', // ... others 'opencode': 'opencode.schema.json', 'canonical': 'canonical.schema.json', }; ``` ### 7c. **CRITICAL**: Add subtype schemas to subtypeSchemaMap: ```typescript const subtypeSchemaMap: Record = { 'claude:agent': 'claude-agent.schema.json', 'claude:skill': 'claude-skill.schema.json', 'claude:slash-command': 'claude-slash-command.schema.json', 'claude:hook': 'claude-hook.schema.json', 'cursor:slash-command': 'cursor-command.schema.json', 'kiro:hook': 'kiro-hooks.schema.json', 'kiro:agent': 'kiro-agent.schema.json', 'droid:skill': 'droid-skill.schema.json', 'droid:slash-command': 'droid-slash-command.schema.json', 'droid:hook': 'droid-hook.schema.json', 'opencode:slash-command': 'opencode-slash-command.schema.json', // Add your subtypes here }; ``` **Why this matters**: Without adding subtypes to `subtypeSchemaMap`, validation will fall back to the base format schema and won't validate subtype-specific fields. This causes validation to fail or pass incorrectly. **File**: `packages/converters/src/taxonomy-utils.ts` Add to Format type: ```typescript export type Format = 'cursor' | 'claude' | ... | 'opencode' | ... | 'mcp'; ``` Add to normalizeFormat: ```typescript 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**: ```bash npm run build --workspace=@pr-pm/converters ``` ## Step 8: CLI Package - Filesystem **File**: `packages/cli/src/core/filesystem.ts` ### 7a. Add to getDestinationDir: ```typescript 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: ```typescript const formatDirs: Array<{ format: Format; dir: string }> = [ { format: 'cursor', dir: '.cursor' }, // ... others { format: 'opencode', dir: '.opencode' }, { format: 'agents.md', dir: '.agents' }, ]; ``` ## Step 9: CLI Package - Format Mappings **Files**: `packages/cli/src/commands/search.ts` and `packages/cli/src/commands/install.ts` Add to both files: ### 8a. formatIcons: ```typescript const formatIcons: Record = { 'claude': '🤖', 'cursor': '📋', // ... others 'opencode': '⚡', // Choose appropriate emoji 'gemini.md': '✨', // Don't forget format aliases 'claude.md': '🤖', 'ruler': '📏', 'generic': '📦', }; ``` ### 8b. formatLabels: ```typescript const formatLabels: Record = { 'claude': 'Claude', 'cursor': 'Cursor', // ... others 'opencode': 'OpenCode', 'gemini.md': 'Gemini', // Format aliases 'claude.md': 'Claude', 'ruler': 'Ruler', 'generic': '', }; ``` ## Step 10: Webapp - Format Subtypes and Filter Dropdown **File**: `packages/webapp/src/app/(app)/search/SearchClient.tsx` ### 9a. Add to FORMAT_SUBTYPES: ```typescript const FORMAT_SUBTYPES: Record = { '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): ```typescript ``` ### 9c. Add compatibility info section (after the dropdown): ```typescript {selectedFormat === 'opencode' && (

Tool-specific format for OpenCode AI

)} ``` ## Step 11: 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): ```typescript // 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): ```typescript 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: ```typescript // 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 12: Testing and Validation ### 11a. Build types package first: ```bash npm run build --workspace=@pr-pm/types ``` This is critical because other packages depend on the updated Format type. ### 11b. Build registry and webapp: ```bash npm run build --workspace=@pr-pm/registry npm run build --workspace=@pr-pm/webapp ``` ### 11c. Run typecheck: ```bash 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: ```bash npm run build ``` ### 11e. Run converter tests: ```bash npm test --workspace=@pr-pm/converters ``` ### 11f. Create test fixtures (recommended): ```typescript // packages/converters/src/__tests__/to-opencode.test.ts import { describe, it, expect } from 'vitest'; import { toOpencode } from '../to-opencode.js'; import { validateMarkdown } from '../validation.js'; import type { CanonicalPackage } from '../types/canonical.js'; 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('---'); }); // CRITICAL: Add schema validation tests! describe('JSON Schema Validation', () => { it('should generate schema-compliant agent output', () => { const agentPackage: CanonicalPackage = { // ... build agent test package with subtype: 'agent' }; const result = toOpencode(agentPackage); const validation = validateMarkdown('opencode', result.content, 'agent'); if (!validation.valid) { console.error('Validation errors:', validation.errors); } expect(validation.valid).toBe(true); expect(validation.errors).toHaveLength(0); }); }); }); ``` **Why Schema Validation Tests Matter:** - Catch mismatches between converter implementation and schema - Ensure converters generate compliant output - Reveal missing required fields or incorrect field names - Example: We discovered Claude agent schema was missing required `mode` field via validation tests ## Step 13: Additional Documentation Beyond the format documentation created in Step 3: - **User-facing**: Add to Mintlify docs if the format needs special installation instructions - **Internal**: Add notes to `docs/development/` if there are special considerations - **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`, not `content` - RulesSection uses `items`, not `rules` - InstructionsSection requires `title` field - Each Rule has `content`, not `description` ### 3. CanonicalContent Requirements Must always include: ```typescript { format: 'canonical', version: '1.0', sections: [...] } ``` ### 4. setTaxonomy Signature ```typescript setTaxonomy(pkg, 'formatname', 'subtype'); // Returns void return pkg; // Return the package separately ``` ### 5. Tool Name Normalization Map format-specific tool names to canonical: - `write` → `Write` - `edit` → `Edit` - `bash` → `Bash` ### 6. YAML Import If using YAML frontmatter: ```typescript 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(s) in converters/schemas/ - [ ] If format has subtypes, created separate schema files for each subtype (e.g., {format}-agent.schema.json, {format}-slash-command.schema.json) - [ ] Created format documentation in converters/docs/{format}.md - [ ] Updated converters/docs/README.md (Format Matrix, Available Formats, Schema Validation, Frontmatter Support, File Organization tables) - [ ] 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, schemaMap, and **CRITICAL**: subtypeSchemaMap for each subtype) - [ ] Updated converters/src/taxonomy-utils.ts (Format type and normalizeFormat) - [ ] Copied all schemas to packages/cli/dist/schemas/ for runtime use **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.ts` - `packages/converters/src/to-opencode.ts` - `packages/converters/schemas/opencode.schema.json` - Git commit history for the OpenCode integration PR ## Summary Adding a new format requires changes across 6 packages: 1. **types** - Add to Format type (build first!) 2. **converters** - Schema, from/to converters, canonical types, validation, taxonomy 3. **cli** - Filesystem and format mappings 4. **webapp** - Format subtypes, filter dropdown, compatibility info 5. **registry** - Fastify route schemas (download, search, analytics) 6. **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.