Claude Code Plugins

Community-maintained marketplace

Feedback

adding-new-ai-format

@pr-pm/prpm
45
0

Step-by-step guide for adding support for a new AI editor format to PRPM - covers types, converters, schemas, CLI, webapp, and testing

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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' and version: '1.0' in content
  • InstructionsSection requires title field
  • 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 (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 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, 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');  // Returns void
return pkg;  // Return the package separately

5. Tool Name Normalization

Map format-specific tool names to canonical:

  • writeWrite
  • editEdit
  • bashBash

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