diff --git a/docs/CONVERSION-GUIDE.md b/docs/CONVERSION-GUIDE.md new file mode 100644 index 00000000..165d08fd --- /dev/null +++ b/docs/CONVERSION-GUIDE.md @@ -0,0 +1,522 @@ +# Claude Code to OpenCode Conversion Guide + +**Purpose:** Guide for converting Claude Code (CC) definitions (skills, commands, agents) to OpenCode (OC) format for use in the Systematic plugin. + +**Audience:** Humans and AI agents lifting definitions from CEP or other CC plugins/configurations. + +--- + +## Quick Reference Table + +| Aspect | Claude Code | OpenCode / Systematic | +|--------|-------------|----------------------| +| **Skills directory** | `.claude/skills//SKILL.md` | `.opencode/skills//SKILL.md` | +| **Commands directory** | `.claude/commands/.md` | `.opencode/commands/.md` | +| **Agents directory** | `.claude/agents/.md` | `.opencode/agents/.md` | +| **Global skills** | `~/.claude/skills/` | `~/.config/opencode/skills/` | +| **Global commands** | N/A | `~/.config/opencode/commands/` | +| **Global agents** | N/A | `~/.config/opencode/agents/` | +| **Config file** | `.claude/settings.json` | `opencode.json` or `opencode.jsonc` | +| **Skill tool** | `Skill` | `skill` (native) or `systematic_skill` (bundled) | +| **Task/subagent tool** | `Task` | `@mention` or `delegate_task` | +| **Todo tool** | `TodoWrite` | `todowrite` | +| **AskUserQuestion tool** | `AskUserQuestion` | `question` | +| **Command prefix** | `/compound-engineering:` | `/systematic:` | +| **Plugin prefix** | `compound-engineering:` | `systematic:` | + +--- + +## Frontmatter Conversion + +### Skills + +#### CC Fields → OC Handling + +| CC Field | OC Field | Conversion Action | +|----------|----------|-------------------| +| `name` | `name` | **Keep** - lowercase, hyphens, max 64 chars | +| `description` | `description` | **Keep** - critical for trigger matching | +| `model` | ❌ | **REMOVE** - OC uses global/agent-level model selection | +| `allowed-tools` | ❌ | **REMOVE** - OC handles permissions via config | +| `argument-hint` | `argument-hint` | **Keep** (used for autocomplete hints) | +| `disable-model-invocation` | ❌ | **REMOVE** - not supported in OC | +| `user-invocable` | ❌ | **REMOVE** - not supported in OC | +| `context: fork` | ❌ | **REMOVE** - subagent execution not automatic | +| `agent` | ❌ | **REMOVE** - use explicit delegation in content | +| `license` | `license` | **Keep** (optional) | +| `compatibility` | `compatibility` | **Keep** (optional) | +| `metadata` | `metadata` | **Keep** (optional) | + +#### Example Transformation + +**Before (Claude Code):** +```yaml +--- +name: brainstorming +description: Collaborative design workflow for exploring ideas +model: sonnet +allowed-tools: Read, Grep, WebSearch +disable-model-invocation: false +--- + +# Brainstorming + +When brainstorming, use the Task tool to spawn research agents... +``` + +**After (OpenCode):** +```yaml +--- +name: brainstorming +description: This skill should be used before implementing features, building components, or making changes. It guides exploring user intent, approaches, and design decisions before planning. Triggers on "let's brainstorm", "help me think through", "what should we build", "explore approaches", ambiguous feature requests, or when the user's request has multiple valid interpretations that need clarification. +--- + +# Brainstorming + +When brainstorming, use delegate_task to spawn research agents... +``` + +**Key changes:** +1. Removed `model`, `allowed-tools`, `disable-model-invocation` +2. Enhanced `description` to include **trigger conditions** (critical for auto-invocation) +3. Updated tool references in content + +### Commands + +#### CC Fields → OC Handling + +| CC Field | OC Field | Conversion Action | +|----------|----------|-------------------| +| `name` | `name` | **Keep** or derive from filename | +| `description` | `description` | **Keep** | +| `argument-hint` | ❌ | **REMOVE** - use `$ARGUMENTS` in template | +| `model` | `model` | **Keep** if provider-qualified (e.g., `anthropic/claude-3-5-sonnet`) | +| `agent` | `agent` | **Keep** - specifies which agent executes | +| `subtask` | `subtask` | **Keep** - forces subagent invocation | + +#### Template Syntax + +| CC Syntax | OC Syntax | Notes | +|-----------|-----------|-------| +| `$ARGUMENTS` | `$ARGUMENTS` | Same - all args as string | +| `$0`, `$1`, `$2` | `$1`, `$2`, `$3` | **Shift by 1** - OC is 1-indexed | +| `$ARGUMENTS[0]` | `$1` | Use positional instead | +| `` !`command` `` | `` !`command` `` | Same - shell output injection | +| `@filename` | `@filename` | Same - file content injection | + +### Agents + +#### CC Fields → OC Handling + +| CC Field | OC Field | Conversion Action | +|----------|----------|-------------------| +| `name` | ❌ | **REMOVE** - derived from filename | +| `description` | `description` | **Keep** - required | +| `model: inherit` | ❌ | **REMOVE** - OC inherits by default | +| `model: sonnet` | `model: anthropic/claude-3-5-sonnet` | **Normalize** - add provider prefix | +| `model: opus` | `model: anthropic/claude-3-opus` | **Normalize** | +| `model: haiku` | `model: anthropic/claude-3-haiku` | **Normalize** | +| `tools` | `tools` | **Keep** - array of allowed tools | +| `disallowedTools` | ❌ | **REMOVE** - use OC permission system | +| `permissionMode` | ❌ | **REMOVE** - use OC permission config | +| `skills` | ❌ | **REMOVE** - skills load via tool calls | +| `hooks` | ❌ | **REMOVE** - hooks configured separately | +| N/A | `mode` | **ADD** - `primary`, `subagent`, or `all` | +| N/A | `temperature` | **ADD** - inferred from agent purpose | + +#### Model Normalization Rules + +``` +claude-* → anthropic/claude-* +gpt-* → openai/gpt-* +o1-* → openai/o1-* +o3-* → openai/o3-* +gemini-* → google/gemini-* +inherit → (remove field) + → anthropic/ +``` + +#### Temperature Inference + +| Agent Keywords | Temperature | +|----------------|-------------| +| review, audit, security, sentinel, oracle, lint, verification | 0.1 | +| plan, planning, architecture, strategist, analysis, research | 0.2 | +| doc, readme, changelog, editor, writer | 0.3 | +| brainstorm, creative, ideate, design, concept | 0.6 | +| (default) | 0.3 | + +#### Example Transformation + +**Before (Claude Code):** +```yaml +--- +name: code-simplicity-reviewer +description: Reviews code for unnecessary complexity and suggests simplifications +model: inherit +tools: Read, Grep, Glob +--- + +You are a code simplicity expert... +``` + +**After (OpenCode):** +```yaml +--- +description: Reviews code for unnecessary complexity and suggests simplifications +mode: subagent +temperature: 0.1 +--- + +You are a code simplicity expert... +``` + +--- + +## Content Transformations + +### Tool Name Mappings + +Update references in markdown content: + +| CC Tool | OC Tool | Notes | +|---------|---------|-------| +| `Task` | `delegate_task` / `@mention` | Use delegate_task for programmatic, @agent for inline | +| `Skill` | `skill` (native) or `systematic_skill` | Systematic bundled skills use `systematic_skill` | +| `TodoWrite` | `update_plan` or `todowrite` | Depends on environment | +| `Read` | `read` | Lowercase | +| `Write` | `write` | Lowercase | +| `Edit` | `edit` | Lowercase | +| `Bash` | `bash` | Lowercase | +| `Grep` | `grep` | Lowercase | +| `Glob` | `glob` | Lowercase | +| `WebFetch` | `webfetch` | Lowercase | +| `WebSearch` | `google_search` | Different name - depends on environment | + +### Prefix Conversions + +| CC Pattern | OC Pattern | +|------------|------------| +| `/compound-engineering:` | `/systematic:` | +| `/workflows:` | `/workflows:` | (keep if present) | +| `compound-engineering:` | `systematic:` | + +### CC-Specific Syntax Removal + +Remove or adapt these CC-specific patterns: + +| Pattern | Action | +|---------|--------| +| `context: fork` in frontmatter | Remove - use explicit delegation | +| `${CLAUDE_SESSION_ID}` | Remove - not available in OC | +| `CLAUDE.md` references | Convert to `AGENTS.md` or skill references | + +### Reference Updates + +| CC Reference | OC Reference | +|--------------|--------------| +| `.claude/skills/` | `.opencode/skills/` | +| `.claude/commands/` | `.opencode/commands/` | +| `.claude/agents/` | `.opencode/agents/` | +| `~/.claude/` | `~/.config/opencode/` | +| `CLAUDE.md` | `AGENTS.md` | + +--- + +## Systematic Plugin Specifics + +### Bundled Content Organization + +``` +systematic/ +├── skills/ # Bundled skills (SKILL.md format) +│ └── / +│ └── SKILL.md +├── agents/ # Bundled agents (Markdown format) +│ └── .md +└── commands/ # Bundled commands (Markdown format) + └── .md +``` + +### Skill Resolution Priority + +1. **Project skills**: `.opencode/skills/` in current project +2. **User skills**: `~/.config/opencode/skills/` +3. **Bundled skills**: Provided by systematic plugin + +### Tool Mapping Instruction + +Include this instruction in skills that reference tools: + +```markdown +**Tool Mapping for OpenCode:** +When skills reference tools you don't have, substitute OpenCode equivalents: +- `TodoWrite` → `update_plan` +- `Task` tool with subagents → Use OpenCode's subagent system (@mention) +- `Skill` tool → OpenCode's native `skill` tool +- `SystematicSkill` tool → `systematic_skill` (Systematic plugin skills) +- `Read`, `Write`, `Edit`, `Bash` → Your native tools +- `AskUserQuestion` tool → Use OpenCode's native `question` tool +``` + +### Bootstrap Skill Pattern + +The `using-systematic` skill is injected into the system prompt and teaches the agent: +- How to discover available skills +- When to use `systematic_skill` vs native `skill` tool +- Skill invocation discipline (invoke BEFORE any response) + +--- + +## Conversion Checklist + +### Skills + +- [ ] Remove `model` field from frontmatter +- [ ] Remove `allowed-tools` field from frontmatter +- [ ] Remove `disable-model-invocation` field +- [ ] Remove `user-invocable` field +- [ ] Remove `context: fork` field +- [ ] Remove `agent` field (handle delegation in content) +- [ ] Enhance `description` with trigger conditions +- [ ] Update tool references in content (Task → delegate_task, etc.) +- [ ] Update directory references (.claude → .opencode) +- [ ] Convert dynamic injection syntax if present +- [ ] Add tool mapping instruction if skill references CC tools + +### Commands + +- [ ] Remove unsupported frontmatter fields +- [ ] Normalize model field if present (add provider prefix) +- [ ] Shift positional argument indices ($0 → $1) +- [ ] Convert bash injection syntax +- [ ] Update prefix from `/compound-engineering:` to `/systematic:` + +### Agents + +- [ ] Remove `name` field (derived from filename) +- [ ] Normalize `model` field (add provider prefix) or remove if `inherit` +- [ ] Remove `permissionMode` field +- [ ] Remove `skills` field (load via tool calls instead) +- [ ] Remove `hooks` field (configure separately) +- [ ] Add `mode` field (`primary`, `subagent`, or `all`) +- [ ] Add `temperature` field (infer from purpose) +- [ ] Update tool references in content + +--- + +## Features Not Supported in OpenCode + +These CC features have no direct OC equivalent: + +| CC Feature | Workaround | +|------------|------------| +| `model` in skill frontmatter | Use agent-level model selection | +| `allowed-tools` in skills | Configure via OC permissions | +| `disable-model-invocation` | No equivalent - all skills can be auto-invoked | +| `user-invocable: false` | No equivalent - use naming conventions | +| `context: fork` | Explicitly use subagent delegation | +| Dynamic injection `!`command`` | Pre-compute or use tool calls | +| `${CLAUDE_SESSION_ID}` | Not available | +| `permissionMode` in agents | Use OC permission config | +| Hooks in frontmatter | Configure in OC hooks system | +| `skills` preloading in agents | Load skills via tool calls | + +--- + +## OpenCode-Only Features to Leverage + +When converting, consider using these OC-specific capabilities: + +| Feature | Usage | +|---------|-------| +| `mode: all` in agents | Single definition for both primary and subagent use | +| `temperature` per agent | Fine-tune creativity (0.1 for review, 0.6 for brainstorm) | +| Per-agent model selection | Cost optimization (cheap for tests, premium for architecture) | +| `hidden: true` in agents | Internal-only subagents | +| JSONC config | Add comments to configuration | +| Granular bash permissions | Glob patterns for command-level control | + +--- + +## Example: Full Skill Conversion + +### Original (CEP/Claude Code) + +**File:** `compound-engineering/skills/brainstorming/SKILL.md` + +```yaml +--- +name: brainstorming +description: Collaborative design workflow for exploring ideas +model: sonnet +allowed-tools: Read, Grep, WebSearch, Task +--- + +# Brainstorming + +This skill provides a systematic approach to brainstorming. + +## Usage +When invoked, use the Task tool to spawn research agents... + +## Process +1. Understand requirements +2. Explore approaches +3. Document decisions + +Reference `.claude/skills/` for related skills. +``` + +### Converted (Systematic/OpenCode) + +**File:** `systematic/skills/brainstorming/SKILL.md` + +```yaml +--- +name: brainstorming +description: This skill should be used before implementing features, building components, or making changes. It guides exploring user intent, approaches, and design decisions before planning. Triggers on "let's brainstorm", "help me think through", "what should we build", "explore approaches", ambiguous feature requests, or when the user's request has multiple valid interpretations that need clarification. +--- + +# Brainstorming + +This skill provides a systematic approach to brainstorming. + +## Usage +When invoked, use delegate_task or @mention to spawn research agents... + +## Process +1. Understand requirements +2. Explore approaches +3. Document decisions + +Reference `.opencode/skills/` or use `systematic_skill` for bundled skills. +``` + +**Changes made:** +1. ✅ Removed `model: sonnet` +2. ✅ Removed `allowed-tools` +3. ✅ Enhanced `description` with trigger conditions +4. ✅ Changed `Task tool` → `delegate_task or @mention` +5. ✅ Changed `.claude/skills/` → `.opencode/skills/` + systematic_skill reference + +--- + +## Automated Conversion Pipeline + +The Systematic plugin includes a converter at `src/lib/converter.ts`: + +### Current Capabilities + +| Capability | Details | +|------------|---------| +| **Agent frontmatter transformation** | Normalizes model, infers temperature, adds mode, removes `name` | +| **Skill frontmatter transformation** | Removes CC-only fields (`model`, `allowed-tools`, `disable-model-invocation`, `user-invocable`, `context`, `agent`) | +| **Command frontmatter transformation** | Normalizes model (adds provider prefix), removes `inherit` models, removes `argument-hint` | +| **Body content transformation** | Tool name mappings, path replacements, prefix conversions | +| **Model normalization** | Adds provider prefix (claude-*→ anthropic/claude-*) | +| **Caching** | Avoids redundant processing via file mtime checks | + +### Tool Name Transformations (Automated) + +| CC Tool | OC Tool | Pattern | +|---------|---------|---------| +| `Task` | `delegate_task` | Context-aware (avoids "Task tool" false positives) | +| `TodoWrite` | `todowrite` | Direct replacement | +| `AskUserQuestion` | `question` | Direct replacement | +| `WebSearch` | `google_search` | Direct replacement | +| `WebFetch` | `webfetch` | Direct replacement | +| `Read`, `Write`, `Edit`, `Bash`, `Grep`, `Glob` | lowercase | Context-aware (requires "tool" or "to" context) | +| `Skill` | `skill` | Only when followed by "tool" | + +### Path Transformations (Automated) + +| CC Path | OC Path | +|---------|---------| +| `.claude/skills/` | `.opencode/skills/` | +| `.claude/commands/` | `.opencode/commands/` | +| `.claude/agents/` | `.opencode/agents/` | +| `~/.claude/` | `~/.config/opencode/` | +| `CLAUDE.md` | `AGENTS.md` | +| `/compound-engineering:` | `/systematic:` | +| `compound-engineering:` | `systematic:` | + +### Remaining Gaps (Manual Attention Required) + +| Gap | Workaround | +|-----|------------| +| `${CLAUDE_SESSION_ID}` | Remove - not available in OC | +| Description enhancement with trigger conditions | Manual - requires understanding of skill purpose | +| Complex tool contexts | May require manual review for false positives/negatives | + +### Usage Options + +The converter supports a `skipBodyTransform` option to disable body content transformations: + +```typescript +import { convertContent } from './lib/converter.js' + +// Full transformation (default) +const converted = convertContent(content, 'skill') + +// Skip body transformations (frontmatter only) +const convertedFrontmatterOnly = convertContent(content, 'skill', { + skipBodyTransform: true +}) +``` + +### Extending the Converter + +To add new transformations, modify the constants in `src/lib/converter.ts`: + +```typescript +// Add new tool mappings (regex pattern → replacement string) +const TOOL_MAPPINGS: ReadonlyArray = [ + [/\bNewTool\b/g, 'new_tool'], + // ...existing mappings +] + +// Add new path replacements +const PATH_REPLACEMENTS: ReadonlyArray = [ + [/new-path/g, 'replacement-path'], + // ...existing replacements +] + +// Add CC-only fields to strip from skills +const CC_ONLY_SKILL_FIELDS = [ + 'new-cc-field', + // ...existing fields +] +``` + +--- + +## Testing Conversions + +After converting, verify: + +1. **Skill loads correctly**: `systematic_skill` or `skill` tool returns content +2. **Frontmatter parses**: No YAML errors +3. **Description triggers**: Model auto-invokes skill when appropriate +4. **Tool references work**: Referenced tools exist in environment +5. **Directory references valid**: Paths point to correct locations +6. **Body transformations applied**: Tool names and paths converted correctly + +Run the converter tests to validate: + +```bash +bun test tests/unit/converter.test.ts +``` + +--- + +## Related Resources + +- [CEP Source](https://github.com/EveryInc/compound-engineering-plugin) +- [Oh My OpenCode Loader](https://github.com/code-yeongyu/oh-my-opencode) +- [Superpowers Plugin](https://github.com/obra/superpowers) +- [OpenCode Docs - Skills](https://opencode.ai/docs/skills/) +- [OpenCode Docs - Commands](https://opencode.ai/docs/commands/) +- [OpenCode Docs - Agents](https://opencode.ai/docs/agents/) +- [Claude Code Docs - Skills](https://code.claude.com/docs/en/skills) +- [Migration Article](https://www.devashish.me/p/migrating-from-claude-code-to-opencode) diff --git a/src/lib/converter.ts b/src/lib/converter.ts index e4cc31d7..46d822f9 100644 --- a/src/lib/converter.ts +++ b/src/lib/converter.ts @@ -8,6 +8,8 @@ export type AgentMode = 'primary' | 'subagent' export interface ConvertOptions { source?: SourceType agentMode?: AgentMode + /** Skip body content transformations (tool names, paths, etc.) */ + skipBodyTransform?: boolean } interface CacheEntry { @@ -17,6 +19,73 @@ interface CacheEntry { const cache = new Map() +/** + * Claude Code tool names mapped to OpenCode equivalents. + * Only includes tools that need transformation (case changes or renames). + * + * Task transformation strategy: + * - Match Task followed by parentheses or agent-name pattern (tool invocation) + * - Match "Task tool" explicitly + * - Avoid standalone "Task" as noun (e.g., "Complete the Task") + */ +const TOOL_MAPPINGS: ReadonlyArray = [ + // Semantic tool renames (different names in OC) + // Task tool explicit reference + [/\bTask\s+tool\b/gi, 'delegate_task tool'], + // Task followed by agent name + parens: Task agent-name(args) + [/\bTask\s+([\w-]+)\s*\(/g, 'delegate_task $1('], + // Task with immediate parens: Task(args) or Task (args) + [/\bTask\s*\(/g, 'delegate_task('], + // Task followed by "to" verb pattern: use Task to spawn + [/\bTask\b(?=\s+to\s+\w)/g, 'delegate_task'], + [/\bTodoWrite\b/g, 'todowrite'], + [/\bAskUserQuestion\b/g, 'question'], + [/\bWebSearch\b/g, 'google_search'], + // Case normalization (CC uses PascalCase, OC uses lowercase) + [/\bRead\b(?=\s+tool|\s+to\s+|\()/g, 'read'], + [/\bWrite\b(?=\s+tool|\s+to\s+|\()/g, 'write'], + [/\bEdit\b(?=\s+tool|\s+to\s+|\()/g, 'edit'], + [/\bBash\b(?=\s+tool|\s+to\s+|\()/g, 'bash'], + [/\bGrep\b(?=\s+tool|\s+to\s+|\()/g, 'grep'], + [/\bGlob\b(?=\s+tool|\s+to\s+|\()/g, 'glob'], + [/\bWebFetch\b/g, 'webfetch'], + // Skill tool (context-aware to avoid false positives) + [/\bSkill\b(?=\s+tool)/g, 'skill'], +] as const + +/** + * Path and reference replacements for CC → OC migration. + */ +const PATH_REPLACEMENTS: ReadonlyArray = [ + [/\.claude\/skills\//g, '.opencode/skills/'], + [/\.claude\/commands\//g, '.opencode/commands/'], + [/\.claude\/agents\//g, '.opencode/agents/'], + [/~\/\.claude\//g, '~/.config/opencode/'], + [/CLAUDE\.md/g, 'AGENTS.md'], + [/\/compound-engineering:/g, '/systematic:'], + [/compound-engineering:/g, 'systematic:'], +] as const + +/** + * CC-only frontmatter fields that should be removed from skills. + */ +const CC_ONLY_SKILL_FIELDS = [ + 'model', + 'allowed-tools', + 'allowedTools', + 'disable-model-invocation', + 'disableModelInvocation', + 'user-invocable', + 'userInvocable', + 'context', + 'agent', +] as const + +/** + * CC-only frontmatter fields that should be removed from commands. + */ +const CC_ONLY_COMMAND_FIELDS = ['argument-hint', 'argumentHint'] as const + function inferTemperature(name: string, description?: string): number { const sample = `${name} ${description ?? ''}`.toLowerCase() if ( @@ -40,6 +109,53 @@ function inferTemperature(name: string, description?: string): number { return 0.3 } +function transformBody(body: string): string { + let result = body + + for (const [pattern, replacement] of TOOL_MAPPINGS) { + result = result.replace(pattern, replacement) + } + + for (const [pattern, replacement] of PATH_REPLACEMENTS) { + result = result.replace(pattern, replacement) + } + + return result +} + +function removeFields( + data: Record, + fieldsToRemove: readonly string[], +): Record { + const result: Record = {} + for (const [key, value] of Object.entries(data)) { + if (!fieldsToRemove.includes(key)) { + result[key] = value + } + } + return result +} + +function transformSkillFrontmatter( + data: Record, +): Record { + return removeFields(data, CC_ONLY_SKILL_FIELDS) +} + +function transformCommandFrontmatter( + data: Record, +): Record { + const cleaned = removeFields(data, CC_ONLY_COMMAND_FIELDS) + + if (typeof cleaned.model === 'string' && cleaned.model !== 'inherit') { + cleaned.model = normalizeModel(cleaned.model) + } else if (cleaned.model === 'inherit') { + delete cleaned.model + } + + return cleaned +} + function normalizeModel(model: string): string { if (model.includes('/')) return model if (model === 'inherit') return model @@ -50,14 +166,14 @@ function normalizeModel(model: string): string { } function transformAgentFrontmatter( - data: Record, + data: Record, agentMode: AgentMode, -): Record { +): Record { const name = typeof data.name === 'string' ? data.name : '' const description = typeof data.description === 'string' ? data.description : '' - const newData: Record = { + const newData: Record = { description: description || `${name} agent`, mode: agentMode, } @@ -83,16 +199,29 @@ export function convertContent( if (content === '') return '' const { data, body, hadFrontmatter } = - parseFrontmatter>(content) + parseFrontmatter>(content) if (!hadFrontmatter) { - return content + return options.skipBodyTransform ? content : transformBody(content) } + const shouldTransformBody = !options.skipBodyTransform + const transformedBody = shouldTransformBody ? transformBody(body) : body + if (type === 'agent') { const agentMode = options.agentMode ?? 'subagent' const transformedData = transformAgentFrontmatter(data, agentMode) - return `${formatFrontmatter(transformedData)}\n${body}` + return `${formatFrontmatter(transformedData)}\n${transformedBody}` + } + + if (type === 'skill') { + const transformedData = transformSkillFrontmatter(data) + return `${formatFrontmatter(transformedData)}\n${transformedBody}` + } + + if (type === 'command') { + const transformedData = transformCommandFrontmatter(data) + return `${formatFrontmatter(transformedData)}\n${transformedBody}` } return content @@ -106,7 +235,7 @@ export function convertFileWithCache( const fd = fs.openSync(filePath, 'r') try { const stats = fs.fstatSync(fd) - const cacheKey = `${filePath}:${type}:${options.source ?? 'bundled'}:${options.agentMode ?? 'subagent'}` + const cacheKey = `${filePath}:${type}:${options.source ?? 'bundled'}:${options.agentMode ?? 'subagent'}:${options.skipBodyTransform ?? false}` const cached = cache.get(cacheKey) if (cached != null && cached.mtimeMs === stats.mtimeMs) { diff --git a/src/lib/frontmatter.ts b/src/lib/frontmatter.ts index 7392297e..6d22d428 100644 --- a/src/lib/frontmatter.ts +++ b/src/lib/frontmatter.ts @@ -43,15 +43,20 @@ export function parseFrontmatter>( } } -export function formatFrontmatter( - data: Record, -): string { - const lines: string[] = ['---'] - for (const [key, value] of Object.entries(data)) { - lines.push(`${key}: ${value}`) +export function formatFrontmatter(data: Record): string { + if (Object.keys(data).length === 0) { + return ['---', '---'].join('\n') } - lines.push('---') - return lines.join('\n') + + const yamlContent = yaml + .dump(data, { + schema: yaml.JSON_SCHEMA, + lineWidth: -1, + noRefs: true, + }) + .trimEnd() + + return ['---', yamlContent, '---'].join('\n') } export function stripFrontmatter(content: string): string { diff --git a/tests/unit/converter.test.ts b/tests/unit/converter.test.ts index 1e080ed3..26d4d2cc 100644 --- a/tests/unit/converter.test.ts +++ b/tests/unit/converter.test.ts @@ -154,39 +154,181 @@ Content` }) }) - describe('Skills and commands - no transformation', () => { - test('returns skill content unchanged', () => { + describe('Skills and commands transformation', () => { + test('removes CC-only fields from skill frontmatter', () => { const input = `--- name: my-skill description: A skill +model: sonnet +allowed-tools: Read, Grep +disable-model-invocation: false --- Skill content` const result = convertContent(input, 'skill') - expect(result).toBe(input) + expect(result).toContain('name: my-skill') + expect(result).toContain('description: A skill') + expect(result).not.toContain('model:') + expect(result).not.toContain('allowed-tools:') + expect(result).not.toContain('disable-model-invocation:') }) - test('returns command content unchanged', () => { + test('removes camelCase CC-only fields from skill frontmatter', () => { + const input = `--- +name: my-skill +description: A skill +allowedTools: Read, Grep +disableModelInvocation: false +userInvocable: true +--- +Skill content` + const result = convertContent(input, 'skill') + expect(result).not.toContain('allowedTools:') + expect(result).not.toContain('disableModelInvocation:') + expect(result).not.toContain('userInvocable:') + }) + + test('removes context and agent fields from skill frontmatter', () => { + const input = `--- +name: my-skill +description: A skill +context: fork +agent: oracle +--- +Skill content` + const result = convertContent(input, 'skill') + expect(result).not.toContain('context:') + expect(result).not.toContain('agent:') + }) + + test('removes argument-hint from command frontmatter', () => { const input = `--- name: my-command description: A command +argument-hint: --- Command content` const result = convertContent(input, 'command') - expect(result).toBe(input) + expect(result).not.toContain('argument-hint:') + expect(result).toContain('name: my-command') + }) + + test('normalizes model in command frontmatter', () => { + const input = `--- +name: my-command +description: A command +model: claude-sonnet-4-20250514 +--- +Command content` + const result = convertContent(input, 'command') + expect(result).toContain('model: anthropic/claude-sonnet-4-20250514') + }) + + test('removes inherit model from command frontmatter', () => { + const input = `--- +name: my-command +description: A command +model: inherit +--- +Command content` + const result = convertContent(input, 'command') + expect(result).not.toContain('model:') }) }) - describe('Body content - no transformation', () => { - test('agent body is not transformed', () => { + describe('Body content transformations', () => { + test('transforms tool names in body', () => { + const input = `--- +name: my-skill +description: A skill +--- +Use TodoWrite to track progress. +Then use Task to spawn agents. +Use AskUserQuestion when unclear.` + const result = convertContent(input, 'skill') + expect(result).toContain('todowrite') + expect(result).toContain('delegate_task') + expect(result).toContain('question') + }) + + test('transforms path references in body', () => { + const input = `--- +name: my-skill +description: A skill +--- +Check .claude/skills/ for other skills. +Also look in ~/.claude/ for user config. +Reference CLAUDE.md for setup.` + const result = convertContent(input, 'skill') + expect(result).toContain('.opencode/skills/') + expect(result).toContain('~/.config/opencode/') + expect(result).toContain('AGENTS.md') + }) + + test('transforms prefix references', () => { + const input = `--- +name: my-skill +description: A skill +--- +Use /compound-engineering:skill-name to invoke. +The compound-engineering:brainstorming skill is useful.` + const result = convertContent(input, 'skill') + expect(result).toContain('/systematic:skill-name') + expect(result).toContain('systematic:brainstorming') + }) + + test('transforms WebSearch and WebFetch', () => { + const input = `--- +name: my-skill +description: A skill +--- +Use WebSearch to find info. +Use WebFetch to get page content.` + const result = convertContent(input, 'skill') + expect(result).toContain('google_search') + expect(result).toContain('webfetch') + }) + + test('preserves Skill in non-tool contexts', () => { + const input = `--- +name: my-skill +description: A skill +--- +This skill is powerful. Use the Skill tool to load skills.` + const result = convertContent(input, 'skill') + expect(result).toContain('This skill is powerful') + expect(result).toContain('skill tool') + }) + + test('skips body transformation when option set', () => { + const input = `--- +name: my-skill +description: A skill +--- +Use TodoWrite to track. Check .claude/skills/ for more.` + const result = convertContent(input, 'skill', { + skipBodyTransform: true, + }) + expect(result).toContain('TodoWrite') + expect(result).toContain('.claude/skills/') + }) + + test('transforms body for content without frontmatter', () => { + const input = `Use TodoWrite to track. Check .claude/skills/ for more.` + const result = convertContent(input, 'skill') + expect(result).toContain('todowrite') + expect(result).toContain('.opencode/skills/') + }) + + test('agent body is also transformed', () => { const input = `--- name: test-agent description: Test --- -Use TodoWrite to track. Task explorer(find files). Use Skill tool.` +Use TodoWrite to track. Task explorer(find files). Check .claude/skills/.` const result = convertContent(input, 'agent') - expect(result).toContain('Use TodoWrite to track') - expect(result).toContain('Task explorer(find files)') - expect(result).toContain('Use Skill tool') + expect(result).toContain('todowrite') + expect(result).toContain('delegate_task') + expect(result).toContain('.opencode/skills/') }) }) @@ -205,7 +347,7 @@ Just plain content` }) describe('Combined transformations', () => { - test('produces correct output format for agent', () => { + test('produces correct output format for agent with transformed body', () => { const input = `--- name: review-agent description: Code review agent @@ -222,7 +364,7 @@ Use TodoWrite to track.` expect(result).toContain('temperature: 0.1') expect(result).not.toContain('name:') expect(result).toContain('# Review Agent') - expect(result).toContain('Use TodoWrite to track') + expect(result).toContain('Use todowrite to track') }) }) }) diff --git a/tests/unit/skill-loader.test.ts b/tests/unit/skill-loader.test.ts index bf89bac9..c89120db 100644 --- a/tests/unit/skill-loader.test.ts +++ b/tests/unit/skill-loader.test.ts @@ -156,11 +156,15 @@ description: A test skill const loaded = loadSkill(skillInfo) expect(loaded).not.toBeNull() - expect(loaded!.name).toBe('test-skill') - expect(loaded!.prefixedName).toBe('systematic:test-skill') - expect(loaded!.description).toBe('(systematic - Skill) A test skill') - expect(loaded!.wrappedTemplate).toContain('') - expect(loaded!.wrappedTemplate).toContain('# Test Content') + if (loaded == null) { + throw new Error('Expected skill to load') + } + + expect(loaded.name).toBe('test-skill') + expect(loaded.prefixedName).toBe('systematic:test-skill') + expect(loaded.description).toBe('(systematic - Skill) A test skill') + expect(loaded.wrappedTemplate).toContain('') + expect(loaded.wrappedTemplate).toContain('# Test Content') }) test('returns null for non-existent file', () => { @@ -195,7 +199,11 @@ Content here.`, } const loaded = loadSkill(skillInfo) - const extracted = extractSkillBody(loaded!.wrappedTemplate) + if (loaded == null) { + throw new Error('Expected skill to load') + } + + const extracted = extractSkillBody(loaded.wrappedTemplate) expect(extracted).toContain('# Original Body') expect(extracted).toContain('Content here.')