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'
| 'ruler'
| 'generic'
| 'mcp' ;
export const FORMATS : readonly Format [] = [
'cursor' ,
'claude' ,
'opencode' ,
'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://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" } ,
} ,
"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 :
# {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
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):
metadata ?: {
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 ;
opencode ?: {
};
};
}
3d. Add to formatScores and sourceFormat:
formatScores ?: {
cursor ?: number ;
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:
import type {
CanonicalPackage ,
PackageMetadata ,
Section ,
MetadataSection ,
ToolsSection ,
} from './types/canonical.js' ;
import { setTaxonomy } from './taxonomy-utils.js' ;
import yaml from 'js-yaml' ;
interface FormatFrontmatter {
}
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 [] = [];
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 ,
},
};
if () {
metadataSection.data .formatName = {
};
}
sections.push (metadataSection);
if (fm.tools ) {
const enabledTools = Object .entries (fm.tools )
.filter (([_, enabled] ) => enabled === true )
.map (([tool, _] ) => {
return normalizeToolName (tool);
});
if (enabledTools.length > 0 ) {
sections.push ({
type : 'tools' ,
tools : enabledTools,
});
}
}
if (body.trim ()) {
sections.push ({
type : 'instructions' ,
title : 'Instructions' ,
content : body.trim (),
});
}
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' ,
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:
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 [] = [];
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' );
const frontmatter : Record <string , any > = {};
if (metadata?.type === 'metadata' ) {
frontmatter.description = metadata.data .description ;
}
const formatData = metadata?.type === 'metadata' ? metadata.data .formatName : undefined ;
if (formatData) {
Object .assign (frontmatter, formatData);
}
if (tools?.type === 'tools' && tools.tools .length > 0 ) {
frontmatter.tools = convertToolsToFormatStructure (tools.tools );
}
lines.push ('---' );
lines.push (yaml.dump (frontmatter, { indent : 2 , lineWidth : -1 }).trim ());
lines.push ('---' );
lines.push ('' );
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:
export { fromFormat } from './from-format.js' ;
export { toFormat } from './to-format.js' ;
File : packages/converters/src/validation.ts
7a. Add to FormatType:
export type FormatType =
| 'cursor'
| 'claude'
| 'opencode'
| 'canonical' ;
7b. Add to base schema map:
const schemaMap : Record <FormatType , string > = {
'cursor' : 'cursor.schema.json' ,
'opencode' : 'opencode.schema.json' ,
'canonical' : 'canonical.schema.json' ,
};
7c. CRITICAL : Add subtype schemas to subtypeSchemaMap:
const subtypeSchemaMap : Record <string , string > = {
'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' ,
};
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:
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' ;
if (normalized.includes ('opencode' )) return 'opencode' ;
return 'generic' ;
}
Build converters :
npm run build --workspace=@pr-pm/converters
Step 8: 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) {
case 'opencode' :
if (subtype === 'agent' ) return '.opencode/agent' ;
if (subtype === 'slash-command' ) return '.opencode/command' ;
if (subtype === 'tool' ) return '.opencode/tool' ;
return '.opencode/agent' ;
}
}
7b. Add to autoDetectFormat:
const formatDirs : Array <{ format : Format ; dir : string }> = [
{ format : 'cursor' , dir : '.cursor' },
{ 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:
const formatIcons : Record <Format , string > = {
'claude' : '๐ค' ,
'cursor' : '๐' ,
'opencode' : 'โก' ,
'gemini.md' : 'โจ' ,
'claude.md' : '๐ค' ,
'ruler' : '๐' ,
'generic' : '๐ฆ' ,
};
8b. formatLabels:
const formatLabels : Record <Format , string > = {
'claude' : 'Claude' ,
'cursor' : 'Cursor' ,
'opencode' : 'OpenCode' ,
'gemini.md' : 'Gemini' ,
'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:
const FORMAT_SUBTYPES : Record <Format , Subtype []> = {
'cursor' : ['rule' , 'agent' , 'slash-command' , 'tool' ],
'claude' : ['skill' , 'agent' , 'slash-command' , 'tool' , 'hook' ],
'claude.md' : ['agent' ],
'gemini.md' : ['slash-command' ],
'opencode' : ['agent' , 'slash-command' , 'tool' ],
'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 > {}
<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 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):
format : {
type : 'string' ,
enum : ['cursor' , 'claude' , 'continue' , 'windsurf' , 'copilot' , 'kiro' , 'ruler' , 'agents.md' , 'gemini' , 'droid' , 'opencode' , 'generic' ],
description : 'Target format for conversion (optional)' ,
},
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:
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 (),
});
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:
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):
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 = {
};
const result = toOpencode (canonical);
expect (result.format ).toBe ('opencode' );
expect (result.content ).toContain ('---' );
});
describe ('JSON Schema Validation' , () => {
it ('should generate schema-compliant agent output' , () => {
const agentPackage : CanonicalPackage = {
};
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:
{
format : 'canonical' ,
version : '1.0' ,
sections : [...]
}
4. setTaxonomy Signature
setTaxonomy (pkg, 'formatname' , 'subtype' );
return pkg;
5. Tool Name Normalization
Map format-specific tool names to canonical:
write โ Write
edit โ Edit
bash โ Bash
6. YAML Import
If using YAML frontmatter:
import yaml from 'js-yaml' ;
Checklist
Before submitting:
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:
Webapp Package:
Registry Package:
Testing:
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:
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.