diff --git a/.ralphai b/.ralphai new file mode 120000 index 0000000..59c0b0a --- /dev/null +++ b/.ralphai @@ -0,0 +1 @@ +/home/mfaux/work/dotai/.ralphai \ No newline at end of file diff --git a/README.md b/README.md index 23e5d37..83fbc54 100644 --- a/README.md +++ b/README.md @@ -85,17 +85,17 @@ npx dotai add ./my-local-context # local path ## Commands -| Command | Description | -| --------------- | ------------------------------------------------------ | -| `add ` | Discover, select, transpile, and install context | -| `remove [name]` | Remove installed context | -| `list` | List installed items | -| `find [query]` | Search for skills & preview all context in a repo | +| Command | Description | +| --------------- | --------------------------------------------------------- | +| `add ` | Discover, select, transpile, and install context | +| `remove [name]` | Remove installed context | +| `list` | List installed items | +| `find [query]` | Search for skills & preview all context in a repo | | `import` | Convert native agent rules to canonical `RULES.md` format | -| `check` | Check for available updates | -| `update` | Update installed items to latest versions | -| `init [name]` | Create a context template (skill, rule, prompt, agent) | -| `restore` | Restore from lock files | +| `check` | Check for available updates | +| `update` | Update installed items to latest versions | +| `init [name]` | Create a context template (skill, rule, prompt, agent) | +| `restore` | Restore from lock files | ## Supported Targets @@ -106,6 +106,7 @@ npx dotai add ./my-local-context # local path | -------------- | ------ | ----- | ----------------------- | ------------------------- | | GitHub Copilot | ✅ | ✅ | ✅ | ✅ | | Claude Code | ✅ | ✅ | ✅ | ✅ | +| OpenCode | ✅ | ✅ | ✅ | ✅ | | Cursor | ✅ | ✅ | ⚠️ (native/compat only) | ⚠️ (via `.github/agents`) | | Windsurf | ✅ | ✅ | ⚠️ (native passthrough) | — | | Cline | ✅ | ✅ | — | — | @@ -113,6 +114,7 @@ npx dotai add ./my-local-context # local path - **Cursor prompts:** Cursor reads Copilot's `.github/prompts/` path. Canonical `PROMPT.md` is not transpiled to a Cursor-specific format. - **Cursor agents:** Cursor reads `.github/agents/` from the Copilot path. Canonical `AGENT.md` transpiles to Copilot format, which Cursor picks up. - **Windsurf prompts:** Windsurf workflows use a native format. Only passthrough is supported. +- **OpenCode rules:** OpenCode rules are plain markdown (no frontmatter). After installing, add the output paths to the `instructions` array in `opencode.json`. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 354a264..d0c90f0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -22,7 +22,7 @@ Full option tables, examples, and authoring format for `dotai`. For a quick over | `-y, --yes` | Skip confirmation prompts | | `--all` | Shorthand for `--skill '*' --agents '*' -y` | -> **`--agents`:** A single flag for both skill install targets and transpilation targets. For skills, any of the 41 supported agents (e.g., `--agents cursor,claude-code`). For rules, prompts, and agents, the 5 transpilation targets: copilot, claude, cursor, windsurf, cline. When omitted, all detected agents are used for skills and all 5 transpilation targets for rules/prompts/agents. +> **`--agents`:** A single flag for both skill install targets and transpilation targets. For skills, any of the 41 supported agents (e.g., `--agents cursor,claude-code`). For rules, prompts, and agents, the 6 transpilation targets: copilot, claude, cursor, windsurf, cline, opencode. When omitted, all detected agents are used for skills and all 6 transpilation targets for rules/prompts/agents. > **Zero-flag mode:** Running `dotai add owner/repo` with no type-specific flags discovers all content types (skills, rules, prompts, agents) and presents an interactive grouped selection. Use `dotai find owner/repo` for a non-interactive preview. @@ -108,6 +108,7 @@ The following native directories are scanned (derived from the [target-agents re | Cursor | `.cursor/rules/*.mdc` | — | — | | Claude Code | `.claude/rules/*.md` | `.claude/commands/*.md` | `.claude/agents/*.md` | | GitHub Copilot | `.github/instructions/*.instructions.md` | `.github/prompts/*.prompt.md` | `.github/agents/*.agent.md` | +| OpenCode | `.opencode/rules/*.md` | `.opencode/commands/*.md` | `.opencode/agents/*.md` | | Windsurf | `.windsurf/rules/*.md` | `.windsurf/workflows/*.md` | — | | Cline | `.clinerules/*.md` | — | — | @@ -197,7 +198,7 @@ npx dotai add owner/repo --rule code-style --append npx dotai add owner/repo --rule code-style --gitignore # CI-friendly non-interactive install -npx dotai add owner/repo --all --agents copilot,claude,cursor,windsurf,cline -y +npx dotai add owner/repo --all --agents copilot,claude,cursor,windsurf,cline,opencode -y ``` ## Team Sharing @@ -236,6 +237,7 @@ dotai also discovers **native agent-specific files** in source repos and passes | Cursor | `.cursor/rules/*.mdc` | — | — | | GitHub Copilot | `.github/instructions/*.instructions.md` | `.github/prompts/*.prompt.md` | `.github/agents/*.agent.md` | | Claude Code | `.claude/rules/*.md` | `.claude/commands/*.md` | `.claude/agents/*.md` | +| OpenCode | `.opencode/rules/*.md` | `.opencode/commands/*.md` | `.opencode/agents/*.md` | | Windsurf | `.windsurf/rules/*.md` | `.windsurf/workflows/*.md` | — | | Cline | `.clinerules/*.md` | — | — | @@ -284,7 +286,7 @@ Override blocks work on all three canonical types: Identity fields (`name`, `schema-version`) and structural fields (`body`) cannot be overridden. -Override keys must match a valid target agent (`github-copilot`, `claude-code`, `cursor`, `windsurf`, `cline`). Unrecognized keys produce a parser warning. +Override keys must match a valid target agent (`github-copilot`, `claude-code`, `cursor`, `windsurf`, `cline`, `opencode`). Unrecognized keys produce a parser warning. Agent-exclusive fields like `disallowed-tools`, `max-turns`, and `background` can appear in any agent's override block. The transpiler for agents that do not support those fields ignores them, just as it does for base fields. @@ -395,14 +397,14 @@ Supported fields: `name` (required), `description` (required), `globs`, `activat The `activation` field controls how each target agent decides when to apply the rule: -| `activation` | Cursor | Windsurf | Copilot | Claude Code | Cline | -| ------------ | ------------------- | ------------------------- | ------------------ | ------------------- | ------------------------- | -| `always` | `alwaysApply: true` | `trigger: always_on` | `applyTo: "**"` | always applies | always applies | -| `auto` | agent decides | `trigger: model_decision` | `applyTo: "**"` | agent decides | always applies | -| `manual` | manual inclusion | `trigger: manual` | `applyTo: "**"` | manual | always applies | -| `glob` | `globs: ` | `trigger: glob` | `applyTo: ` | `globs: ` | `**Applies to:** ` | +| `activation` | Cursor | Windsurf | Copilot | Claude Code | Cline | OpenCode | +| ------------ | ------------------- | ------------------------- | ------------------ | ------------------- | ------------------------- | -------------- | +| `always` | `alwaysApply: true` | `trigger: always_on` | `applyTo: "**"` | always applies | always applies | plain markdown | +| `auto` | agent decides | `trigger: model_decision` | `applyTo: "**"` | agent decides | always applies | plain markdown | +| `manual` | manual inclusion | `trigger: manual` | `applyTo: "**"` | manual | always applies | plain markdown | +| `glob` | `globs: ` | `trigger: glob` | `applyTo: ` | `globs: ` | `**Applies to:** ` | plain markdown | -> **Note:** Cline uses plain markdown with no structured metadata, so all activation modes result in a rule that is always visible to the agent. Claude Code treats `globs` as independent file scoping — globs are emitted whenever present, regardless of activation mode. +> **Note:** Cline uses plain markdown with no structured metadata, so all activation modes result in a rule that is always visible to the agent. Claude Code treats `globs` as independent file scoping — globs are emitted whenever present, regardless of activation mode. OpenCode rules are plain markdown with no frontmatter; users add the file path to the `instructions` array in `opencode.json` to activate them. ### Prompt (`PROMPT.md`) diff --git a/docs/supported-agents.md b/docs/supported-agents.md index ef5b083..46ab3eb 100644 --- a/docs/supported-agents.md +++ b/docs/supported-agents.md @@ -2,52 +2,54 @@ dotai installs `SKILL.md` files into the config directories of 41 agents. **GitHub Copilot**, **Claude Code**, and **OpenCode** are actively tested; other agents follow the [Agent Skills specification](https://agentskills.io) but are not individually verified. -Rules, prompts, and agent definitions use a separate set of [transpilation targets](../README.md#supported-targets) (Copilot, Claude Code, Cursor, Windsurf, Cline). +Rules, prompts, and agent definitions use a separate set of [transpilation targets](../README.md#supported-targets) (Copilot, Claude Code, Cursor, Windsurf, Cline, OpenCode). OpenCode is both a skill install target and a transpilation target.
Full agent table -| Agent | `--agents` | Project Path | Global Path | -|-------|-----------|--------------|-------------| -| Amp, Kimi Code CLI, Replit, Universal | `amp`, `kimi-cli`, `replit`, `universal` | `.agents/skills/` | `~/.config/agents/skills/` | -| Antigravity | `antigravity` | `.agent/skills/` | `~/.gemini/antigravity/skills/` | -| Augment | `augment` | `.augment/skills/` | `~/.augment/skills/` | -| Claude Code | `claude-code` | `.claude/skills/` | `~/.claude/skills/` | -| OpenClaw | `openclaw` | `skills/` | `~/.clawdbot/skills/` | -| Cline | `cline` | `.agents/skills/` | `~/.agents/skills/` | -| CodeBuddy | `codebuddy` | `.codebuddy/skills/` | `~/.codebuddy/skills/` | -| Codex | `codex` | `.agents/skills/` | `~/.codex/skills/` | -| Command Code | `command-code` | `.commandcode/skills/` | `~/.commandcode/skills/` | -| Continue | `continue` | `.continue/skills/` | `~/.continue/skills/` | -| Cortex Code | `cortex` | `.cortex/skills/` | `~/.snowflake/cortex/skills/` | -| Crush | `crush` | `.crush/skills/` | `~/.config/crush/skills/` | -| Cursor | `cursor` | `.agents/skills/` | `~/.cursor/skills/` | -| Droid | `droid` | `.factory/skills/` | `~/.factory/skills/` | -| Gemini CLI | `gemini-cli` | `.agents/skills/` | `~/.gemini/skills/` | -| GitHub Copilot | `github-copilot` | `.agents/skills/` | `~/.copilot/skills/` | -| Goose | `goose` | `.goose/skills/` | `~/.config/goose/skills/` | -| Junie | `junie` | `.junie/skills/` | `~/.junie/skills/` | -| iFlow CLI | `iflow-cli` | `.iflow/skills/` | `~/.iflow/skills/` | -| Kilo Code | `kilo` | `.kilocode/skills/` | `~/.kilocode/skills/` | -| Kiro CLI | `kiro-cli` | `.kiro/skills/` | `~/.kiro/skills/` | -| Kode | `kode` | `.kode/skills/` | `~/.kode/skills/` | -| MCPJam | `mcpjam` | `.mcpjam/skills/` | `~/.mcpjam/skills/` | -| Mistral Vibe | `mistral-vibe` | `.vibe/skills/` | `~/.vibe/skills/` | -| Mux | `mux` | `.mux/skills/` | `~/.mux/skills/` | -| OpenCode | `opencode` | `.agents/skills/` | `~/.config/opencode/skills/` | -| OpenHands | `openhands` | `.openhands/skills/` | `~/.openhands/skills/` | -| Pi | `pi` | `.pi/skills/` | `~/.pi/agent/skills/` | -| Qoder | `qoder` | `.qoder/skills/` | `~/.qoder/skills/` | -| Qwen Code | `qwen-code` | `.qwen/skills/` | `~/.qwen/skills/` | -| Roo Code | `roo` | `.roo/skills/` | `~/.roo/skills/` | -| Trae | `trae` | `.trae/skills/` | `~/.trae/skills/` | -| Trae CN | `trae-cn` | `.trae/skills/` | `~/.trae-cn/skills/` | -| Windsurf | `windsurf` | `.windsurf/skills/` | `~/.codeium/windsurf/skills/` | -| Zencoder | `zencoder` | `.zencoder/skills/` | `~/.zencoder/skills/` | -| Neovate | `neovate` | `.neovate/skills/` | `~/.neovate/skills/` | -| Pochi | `pochi` | `.pochi/skills/` | `~/.pochi/skills/` | -| AdaL | `adal` | `.adal/skills/` | `~/.adal/skills/` | + +| Agent | `--agents` | Project Path | Global Path | +| ------------------------------------- | ---------------------------------------- | ---------------------- | ------------------------------- | +| Amp, Kimi Code CLI, Replit, Universal | `amp`, `kimi-cli`, `replit`, `universal` | `.agents/skills/` | `~/.config/agents/skills/` | +| Antigravity | `antigravity` | `.agent/skills/` | `~/.gemini/antigravity/skills/` | +| Augment | `augment` | `.augment/skills/` | `~/.augment/skills/` | +| Claude Code | `claude-code` | `.claude/skills/` | `~/.claude/skills/` | +| OpenClaw | `openclaw` | `skills/` | `~/.clawdbot/skills/` | +| Cline | `cline` | `.agents/skills/` | `~/.agents/skills/` | +| CodeBuddy | `codebuddy` | `.codebuddy/skills/` | `~/.codebuddy/skills/` | +| Codex | `codex` | `.agents/skills/` | `~/.codex/skills/` | +| Command Code | `command-code` | `.commandcode/skills/` | `~/.commandcode/skills/` | +| Continue | `continue` | `.continue/skills/` | `~/.continue/skills/` | +| Cortex Code | `cortex` | `.cortex/skills/` | `~/.snowflake/cortex/skills/` | +| Crush | `crush` | `.crush/skills/` | `~/.config/crush/skills/` | +| Cursor | `cursor` | `.agents/skills/` | `~/.cursor/skills/` | +| Droid | `droid` | `.factory/skills/` | `~/.factory/skills/` | +| Gemini CLI | `gemini-cli` | `.agents/skills/` | `~/.gemini/skills/` | +| GitHub Copilot | `github-copilot` | `.agents/skills/` | `~/.copilot/skills/` | +| Goose | `goose` | `.goose/skills/` | `~/.config/goose/skills/` | +| Junie | `junie` | `.junie/skills/` | `~/.junie/skills/` | +| iFlow CLI | `iflow-cli` | `.iflow/skills/` | `~/.iflow/skills/` | +| Kilo Code | `kilo` | `.kilocode/skills/` | `~/.kilocode/skills/` | +| Kiro CLI | `kiro-cli` | `.kiro/skills/` | `~/.kiro/skills/` | +| Kode | `kode` | `.kode/skills/` | `~/.kode/skills/` | +| MCPJam | `mcpjam` | `.mcpjam/skills/` | `~/.mcpjam/skills/` | +| Mistral Vibe | `mistral-vibe` | `.vibe/skills/` | `~/.vibe/skills/` | +| Mux | `mux` | `.mux/skills/` | `~/.mux/skills/` | +| OpenCode | `opencode` | `.agents/skills/` | `~/.config/opencode/skills/` | +| OpenHands | `openhands` | `.openhands/skills/` | `~/.openhands/skills/` | +| Pi | `pi` | `.pi/skills/` | `~/.pi/agent/skills/` | +| Qoder | `qoder` | `.qoder/skills/` | `~/.qoder/skills/` | +| Qwen Code | `qwen-code` | `.qwen/skills/` | `~/.qwen/skills/` | +| Roo Code | `roo` | `.roo/skills/` | `~/.roo/skills/` | +| Trae | `trae` | `.trae/skills/` | `~/.trae/skills/` | +| Trae CN | `trae-cn` | `.trae/skills/` | `~/.trae-cn/skills/` | +| Windsurf | `windsurf` | `.windsurf/skills/` | `~/.codeium/windsurf/skills/` | +| Zencoder | `zencoder` | `.zencoder/skills/` | `~/.zencoder/skills/` | +| Neovate | `neovate` | `.neovate/skills/` | `~/.neovate/skills/` | +| Pochi | `pochi` | `.pochi/skills/` | `~/.pochi/skills/` | +| AdaL | `adal` | `.adal/skills/` | `~/.adal/skills/` | +
@@ -60,6 +62,7 @@ Rules, prompts, and agent definitions use a separate set of [transpilation targe The CLI searches for skills in these locations within a repository: + - Root directory (if it contains `SKILL.md`) - `skills/` - `skills/.curated/` diff --git a/src/agent-transpilers.test.ts b/src/agent-transpilers.test.ts index 21139ae..5533183 100644 --- a/src/agent-transpilers.test.ts +++ b/src/agent-transpilers.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { copilotAgentTranspiler, claudeCodeAgentTranspiler, + opencodeAgentTranspiler, nativeAgentPassthrough, agentTranspilers, transpileAgent, @@ -61,6 +62,7 @@ describe('canTranspile', () => { it.each([ ['copilot', copilotAgentTranspiler], ['claude-code', claudeCodeAgentTranspiler], + ['opencode', opencodeAgentTranspiler], ] as const)('%s accepts canonical agents', (_name, transpiler) => { expect(transpiler.canTranspile(canonicalAgent)).toBe(true); }); @@ -68,6 +70,7 @@ describe('canTranspile', () => { it.each([ ['copilot', copilotAgentTranspiler], ['claude-code', claudeCodeAgentTranspiler], + ['opencode', opencodeAgentTranspiler], ] as const)('%s rejects native agents', (_name, transpiler) => { expect(transpiler.canTranspile(nativeAgent)).toBe(false); }); @@ -75,6 +78,7 @@ describe('canTranspile', () => { it.each([ ['copilot', copilotAgentTranspiler], ['claude-code', claudeCodeAgentTranspiler], + ['opencode', opencodeAgentTranspiler], ] as const)('%s rejects skill items', (_name, transpiler) => { expect(transpiler.canTranspile(skillItem)).toBe(false); }); @@ -82,6 +86,7 @@ describe('canTranspile', () => { it.each([ ['copilot', copilotAgentTranspiler], ['claude-code', claudeCodeAgentTranspiler], + ['opencode', opencodeAgentTranspiler], ] as const)('%s rejects rule items', (_name, transpiler) => { expect(transpiler.canTranspile(ruleItem)).toBe(false); }); @@ -89,6 +94,7 @@ describe('canTranspile', () => { it.each([ ['copilot', copilotAgentTranspiler], ['claude-code', claudeCodeAgentTranspiler], + ['opencode', opencodeAgentTranspiler], ] as const)('%s rejects prompt items', (_name, transpiler) => { expect(transpiler.canTranspile(promptItem)).toBe(false); }); @@ -420,6 +426,7 @@ describe('nativeAgentPassthrough', () => { const agents: Array<[TargetAgent, string, string]> = [ ['github-copilot', '.agent.md', '.github/agents'], ['claude-code', '.md', '.claude/agents'], + ['opencode', '.md', '.opencode/agents'], ]; for (const [agent, expectedExt, expectedDir] of agents) { @@ -518,11 +525,11 @@ describe('transpileAgentForAllAgents', () => { const item = makeDiscoveredAgentItem(); const outputs = transpileAgentForAllAgents(item, TARGET_AGENTS); - // Only Copilot and Claude Code support canonical agent transpilation - expect(outputs).toHaveLength(2); + // Copilot, Claude Code, and OpenCode support canonical agent transpilation + expect(outputs).toHaveLength(3); const dirs = outputs.map((o) => o.outputDir).sort(); - expect(dirs).toEqual(['.claude/agents', '.github/agents']); + expect(dirs).toEqual(['.claude/agents', '.github/agents', '.opencode/agents']); }); it('produces output for only matching agent from native agent', () => { @@ -572,8 +579,12 @@ describe('transpileAgentForAllAgents', () => { // --------------------------------------------------------------------------- describe('agentTranspilers registry', () => { - it('has entries for Copilot and Claude Code only', () => { - expect(Object.keys(agentTranspilers).sort()).toEqual(['claude-code', 'github-copilot']); + it('has entries for Copilot, Claude Code, and OpenCode only', () => { + expect(Object.keys(agentTranspilers).sort()).toEqual([ + 'claude-code', + 'github-copilot', + 'opencode', + ]); }); it('does not have entries for cursor, windsurf, or cline', () => { diff --git a/src/agent-transpilers.ts b/src/agent-transpilers.ts index 1f47f44..5e51953 100644 --- a/src/agent-transpilers.ts +++ b/src/agent-transpilers.ts @@ -130,6 +130,136 @@ export const claudeCodeAgentTranspiler: Transpiler = { }, }; +// --------------------------------------------------------------------------- +// OpenCode agent transpiler (.opencode/agents/*.md) +// +// OpenCode agent files use YAML frontmatter with: +// - description: string +// - mode: "subagent" (safe default for canonical agents) +// - model: string (optional, in provider/model-id format) +// - tools: boolean map (write, edit, bash — true/false) +// - permission: map with deny values for disallowed tools +// Claude-specific fields (max-turns, background) are omitted. +// --------------------------------------------------------------------------- + +/** + * Map canonical tool names to OpenCode tool boolean keys. + */ +const TOOL_NAME_TO_OPENCODE: Record = { + write_file: 'write', + create_file: 'write', + edit_file: 'edit', + run_command: 'bash', + bash: 'bash', +}; + +/** + * Map canonical disallowed-tool names to OpenCode permission keys. + */ +const DISALLOWED_TOOL_TO_OPENCODE: Record = { + write_file: 'write', + create_file: 'write', + edit_file: 'edit', + run_command: 'bash', + bash: 'bash', +}; + +/** + * Build the OpenCode `tools` boolean map from a canonical tools list. + * When a tools list is specified, only listed tools are enabled (true); + * unlisted tools default to false. + * When no tools list is provided, all tools default to true (OpenCode default). + */ +function buildToolsMap(tools?: string[]): Record | undefined { + if (!tools) { + // No tools specified — all default to true (OpenCode default behavior) + return undefined; + } + + // Start with all tools disabled + const map: Record = { + write: false, + edit: false, + bash: false, + }; + + // Enable tools that are listed + for (const tool of tools) { + const opencodeKey = TOOL_NAME_TO_OPENCODE[tool]; + if (opencodeKey) { + map[opencodeKey] = true; + } + } + + return map; +} + +/** + * Build the OpenCode `permission` map from canonical disallowed-tools. + */ +function buildPermissionMap(disallowedTools?: string[]): Record | undefined { + if (!disallowedTools || disallowedTools.length === 0) { + return undefined; + } + + const map: Record = {}; + for (const tool of disallowedTools) { + const opencodeKey = DISALLOWED_TOOL_TO_OPENCODE[tool]; + if (opencodeKey) { + map[opencodeKey] = 'deny'; + } + } + + return Object.keys(map).length > 0 ? map : undefined; +} + +export const opencodeAgentTranspiler: Transpiler = { + canTranspile(item: DiscoveredItem): boolean { + return item.type === 'agent' && item.format === 'canonical'; + }, + + transform(agent: CanonicalAgent, _targetAgent: TargetAgent): TranspiledOutput { + const config = getTargetAgentConfig('opencode'); + const lines: string[] = ['---']; + lines.push(`description: ${quoteYaml(agent.description)}`); + lines.push('mode: subagent'); + + if (agent.model) { + lines.push(`model: ${quoteYaml(agent.model)}`); + } + + const toolsMap = buildToolsMap(agent.tools); + if (toolsMap) { + lines.push('tools:'); + for (const [key, value] of Object.entries(toolsMap)) { + lines.push(` ${key}: ${value}`); + } + } + + const permissionMap = buildPermissionMap(agent.disallowedTools); + if (permissionMap) { + lines.push('permission:'); + for (const [key, value] of Object.entries(permissionMap)) { + lines.push(` ${key}: ${value}`); + } + } + + // max-turns and background are not supported by OpenCode — omitted + + lines.push('---'); + lines.push(''); + lines.push(agent.body); + lines.push(''); + + return { + filename: `${agent.name}${config.agentsConfig!.extension}`, + content: lines.join('\n'), + outputDir: config.agentsConfig!.outputDir, + mode: 'write', + }; + }, +}; + // --------------------------------------------------------------------------- // Native passthrough handler // @@ -184,6 +314,7 @@ export function nativeAgentPassthrough( export const agentTranspilers: Partial>> = { 'github-copilot': copilotAgentTranspiler, 'claude-code': claudeCodeAgentTranspiler, + opencode: opencodeAgentTranspiler, // cursor: not supported — no agent system // windsurf: not supported — no agent system // cline: not supported — no agent system diff --git a/src/dotai-lock.ts b/src/dotai-lock.ts index 2421a09..65f84fc 100644 --- a/src/dotai-lock.ts +++ b/src/dotai-lock.ts @@ -300,6 +300,7 @@ const VALID_AGENTS: ReadonlySet = new Set([ 'cursor', 'windsurf', 'cline', + 'opencode', ]); function isPlainObject(value: unknown): value is Record { diff --git a/src/model-aliases.ts b/src/model-aliases.ts index 765f710..67fd726 100644 --- a/src/model-aliases.ts +++ b/src/model-aliases.ts @@ -53,6 +53,7 @@ const BUILT_IN_ALIASES: Record { const item = makeDiscoveredPromptItem(); const outputs = transpilePromptForAllAgents(item, TARGET_AGENTS); - // Only Copilot and Claude Code support canonical prompt transpilation - expect(outputs).toHaveLength(2); + // Copilot, Claude Code, and OpenCode support canonical prompt transpilation + expect(outputs).toHaveLength(3); const dirs = outputs.map((o) => o.outputDir).sort(); - expect(dirs).toEqual(['.claude/commands', '.github/prompts']); + expect(dirs).toEqual(['.claude/commands', '.github/prompts', '.opencode/commands']); }); it('produces output for only matching agent from native prompt', () => { @@ -525,8 +525,12 @@ describe('transpilePromptForAllAgents', () => { // --------------------------------------------------------------------------- describe('promptTranspilers registry', () => { - it('has entries for Copilot and Claude Code only', () => { - expect(Object.keys(promptTranspilers).sort()).toEqual(['claude-code', 'github-copilot']); + it('has entries for Copilot, Claude Code, and OpenCode', () => { + expect(Object.keys(promptTranspilers).sort()).toEqual([ + 'claude-code', + 'github-copilot', + 'opencode', + ]); }); it('does not have entries for cursor, windsurf, or cline', () => { diff --git a/src/prompt-transpilers.ts b/src/prompt-transpilers.ts index 0bb3568..81b2a54 100644 --- a/src/prompt-transpilers.ts +++ b/src/prompt-transpilers.ts @@ -114,6 +114,47 @@ export const claudeCodePromptTranspiler: Transpiler = { }, }; +// --------------------------------------------------------------------------- +// OpenCode command transpiler (.opencode/commands/*.md) +// +// OpenCode commands use YAML frontmatter with: +// - description: string +// - model: string (optional, in provider/model-id format) +// - agent: string (optional, references a named agent) +// Tools are omitted — commands inherit tools from the agent they invoke. +// --------------------------------------------------------------------------- + +export const opencodeCommandTranspiler: Transpiler = { + canTranspile(item: DiscoveredItem): boolean { + return item.type === 'prompt' && item.format === 'canonical'; + }, + + transform(prompt: CanonicalPrompt, _targetAgent: TargetAgent): TranspiledOutput { + const config = getTargetAgentConfig('opencode'); + const lines: string[] = ['---']; + lines.push(`description: ${quoteYaml(prompt.description)}`); + + if (prompt.model) { + lines.push(`model: ${quoteYaml(prompt.model)}`); + } + if (prompt.agent) { + lines.push(`agent: ${quoteYaml(prompt.agent)}`); + } + + lines.push('---'); + lines.push(''); + lines.push(prompt.body); + lines.push(''); + + return { + filename: `${prompt.name}${config.promptsConfig!.extension}`, + content: lines.join('\n'), + outputDir: config.promptsConfig!.outputDir, + mode: 'write', + }; + }, +}; + // --------------------------------------------------------------------------- // Native passthrough handler // @@ -172,6 +213,7 @@ export function nativePromptPassthrough( export const promptTranspilers: Partial>> = { 'github-copilot': copilotPromptTranspiler, 'claude-code': claudeCodePromptTranspiler, + opencode: opencodeCommandTranspiler, // cursor: not supported — no prompt/command system // windsurf: native passthrough only — no canonical transpilation // cline: not supported — no prompt/command system diff --git a/src/reverse-transpiler.test.ts b/src/reverse-transpiler.test.ts index 8ec0ecd..c2f45bd 100644 --- a/src/reverse-transpiler.test.ts +++ b/src/reverse-transpiler.test.ts @@ -693,12 +693,13 @@ describe('round-trip: forward → reverse', () => { // --------------------------------------------------------------------------- describe('reverseTranspilers registry', () => { - it('has entries for all 5 target agents', () => { + it('has entries for all 6 target agents', () => { expect(Object.keys(reverseTranspilers).sort()).toEqual([ 'claude-code', 'cline', 'cursor', 'github-copilot', + 'opencode', 'windsurf', ]); }); diff --git a/src/reverse-transpiler.ts b/src/reverse-transpiler.ts index 6408b5e..46ffa2b 100644 --- a/src/reverse-transpiler.ts +++ b/src/reverse-transpiler.ts @@ -421,6 +421,39 @@ const clineReverseTranspiler: ReverseTranspiler = { }, }; +// --------------------------------------------------------------------------- +// OpenCode reverse parser (.opencode/rules/*.md) +// +// OpenCode rule files are plain markdown with no YAML frontmatter. +// The entire content is treated as the body. Name is derived from +// the filename. +// --------------------------------------------------------------------------- + +const opencodeReverseTranspiler: ReverseTranspiler = { + agent: 'opencode', + + parse(content: string, filename: string): ReverseParseResult { + const name = toKebabCase(filename, '.md'); + if (!name) { + return { ok: false, error: 'could not derive name from file' }; + } + + const body = content.trim(); + + return { + ok: true, + rule: { + name, + description: 'Imported from OpenCode', + globs: [], + activation: 'always', + schemaVersion: 1, + body, + }, + }; + }, +}; + // --------------------------------------------------------------------------- // Registry // --------------------------------------------------------------------------- @@ -431,4 +464,5 @@ export const reverseTranspilers: Record = { 'github-copilot': copilotReverseTranspiler, windsurf: windsurfReverseTranspiler, cline: clineReverseTranspiler, + opencode: opencodeReverseTranspiler, }; diff --git a/src/rule-add.ts b/src/rule-add.ts index 6042cf1..d6914c0 100644 --- a/src/rule-add.ts +++ b/src/rule-add.ts @@ -73,6 +73,7 @@ const AGENT_ALIASES: Record = { cursor: 'cursor', windsurf: 'windsurf', cline: 'cline', + opencode: 'opencode', }; /** diff --git a/src/rule-discovery.test.ts b/src/rule-discovery.test.ts index 81a6594..3f05700 100644 --- a/src/rule-discovery.test.ts +++ b/src/rule-discovery.test.ts @@ -78,13 +78,14 @@ const VALID_RULE = { // --------------------------------------------------------------------------- describe('target-agents registry', () => { - it('has exactly 5 target agents', () => { - expect(TARGET_AGENTS).toHaveLength(5); + it('has exactly 6 target agents', () => { + expect(TARGET_AGENTS).toHaveLength(6); expect(TARGET_AGENTS).toContain('github-copilot'); expect(TARGET_AGENTS).toContain('claude-code'); expect(TARGET_AGENTS).toContain('cursor'); expect(TARGET_AGENTS).toContain('windsurf'); expect(TARGET_AGENTS).toContain('cline'); + expect(TARGET_AGENTS).toContain('opencode'); }); it('each agent has required config fields', () => { diff --git a/src/rule-installer.test.ts b/src/rule-installer.test.ts index 047900f..5ace3d7 100644 --- a/src/rule-installer.test.ts +++ b/src/rule-installer.test.ts @@ -244,14 +244,14 @@ describe('install-pipeline', () => { // ------------------------------------------------------------------------- describe('planRuleWrites', () => { - it('transpiles a canonical rule to all 5 agents', () => { + it('transpiles a canonical rule to all 6 agents', () => { const items = [canonicalRule('code-style')]; const opts = baseOptions(tmpDir); const { writes, skipped } = planRuleWrites(items, opts); expect(skipped).toHaveLength(0); - expect(writes).toHaveLength(5); + expect(writes).toHaveLength(6); const agents = writes.map((w) => w.agent); expect(agents).toContain('github-copilot'); @@ -259,6 +259,7 @@ describe('install-pipeline', () => { expect(agents).toContain('cursor'); expect(agents).toContain('windsurf'); expect(agents).toContain('cline'); + expect(agents).toContain('opencode'); }); it('respects agent subset filter', () => { @@ -345,8 +346,8 @@ describe('install-pipeline', () => { const { writes } = planRuleWrites(items, opts); - // 2 rules × 5 agents = 10 writes - expect(writes).toHaveLength(10); + // 2 rules × 6 agents = 12 writes + expect(writes).toHaveLength(12); }); it('handles mixed canonical + native rules', () => { @@ -355,8 +356,8 @@ describe('install-pipeline', () => { const { writes } = planRuleWrites(items, opts); - // 1 canonical × 5 agents + 1 native × 1 agent = 6 - expect(writes).toHaveLength(6); + // 1 canonical × 6 agents + 1 native × 1 agent = 7 + expect(writes).toHaveLength(7); }); it('skips items with invalid content', () => { @@ -397,7 +398,7 @@ describe('install-pipeline', () => { const result = await executeInstallPipeline(items, opts); expect(result.success).toBe(true); - expect(result.writes).toHaveLength(5); + expect(result.writes).toHaveLength(6); expect(result.written).toHaveLength(0); // No files should exist on disk @@ -427,14 +428,14 @@ describe('install-pipeline', () => { // ------------------------------------------------------------------------- describe('executeInstallPipeline — writes', () => { - it('writes transpiled rules to all 5 agent directories', async () => { + it('writes transpiled rules to all 6 agent directories', async () => { const items = [canonicalRule('code-style')]; const opts = baseOptions(tmpDir); const result = await executeInstallPipeline(items, opts); expect(result.success).toBe(true); - expect(result.written).toHaveLength(5); + expect(result.written).toHaveLength(6); // Verify files exist on disk expect(existsSync(join(tmpDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); @@ -444,6 +445,7 @@ describe('install-pipeline', () => { existsSync(join(tmpDir, '.github', 'instructions', 'code-style.instructions.md')) ).toBe(true); expect(existsSync(join(tmpDir, '.claude', 'rules', 'code-style.md'))).toBe(true); + expect(existsSync(join(tmpDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); }); it('creates target directories that do not exist', async () => { @@ -535,7 +537,7 @@ describe('install-pipeline', () => { expect(result.success).toBe(true); expect(result.collisions.length).toBeGreaterThan(0); - expect(result.written).toHaveLength(5); + expect(result.written).toHaveLength(6); // File should be overwritten with transpiled content const content = readFileSync(join(conflictDir, 'code-style.mdc'), 'utf-8'); @@ -703,19 +705,20 @@ describe('install-pipeline', () => { // ------------------------------------------------------------------------- describe('planRuleWrites — prompts', () => { - it('transpiles a canonical prompt to Copilot and Claude Code', () => { + it('transpiles a canonical prompt to Copilot, Claude Code, and OpenCode', () => { const items = [canonicalPrompt('review-code')]; const opts = baseOptions(tmpDir); const { writes, skipped } = planRuleWrites(items, opts); expect(skipped).toHaveLength(0); - // Only Copilot and Claude Code support canonical prompt transpilation - expect(writes).toHaveLength(2); + // Copilot, Claude Code, and OpenCode support canonical prompt transpilation + expect(writes).toHaveLength(3); const agents = writes.map((w) => w.agent); expect(agents).toContain('github-copilot'); expect(agents).toContain('claude-code'); + expect(agents).toContain('opencode'); }); it('respects agent subset filter for prompts', () => { @@ -778,8 +781,8 @@ describe('install-pipeline', () => { const { writes } = planRuleWrites(items, opts); - // 2 prompts × 2 supported agents = 4 writes - expect(writes).toHaveLength(4); + // 2 prompts × 3 supported agents = 6 writes + expect(writes).toHaveLength(6); }); it('handles native prompt passthrough — only targets matching agent', () => { @@ -814,10 +817,10 @@ describe('install-pipeline', () => { const { writes, skipped } = planRuleWrites(items, opts); - // canonical rule: 5 agents - // canonical prompt: 2 agents (copilot + claude-code) + // canonical rule: 6 agents + // canonical prompt: 3 agents (copilot + claude-code + opencode) // skill: silently skipped - expect(writes).toHaveLength(7); + expect(writes).toHaveLength(9); expect(skipped).toHaveLength(0); }); }); @@ -827,18 +830,19 @@ describe('install-pipeline', () => { // ------------------------------------------------------------------------- describe('executeInstallPipeline — prompt writes', () => { - it('writes transpiled prompts to Copilot and Claude Code directories', async () => { + it('writes transpiled prompts to Copilot, Claude Code, and OpenCode directories', async () => { const items = [canonicalPrompt('review-code')]; const opts = baseOptions(tmpDir); const result = await executeInstallPipeline(items, opts); expect(result.success).toBe(true); - expect(result.written).toHaveLength(2); + expect(result.written).toHaveLength(3); // Verify files exist on disk expect(existsSync(join(tmpDir, '.github', 'prompts', 'review-code.prompt.md'))).toBe(true); expect(existsSync(join(tmpDir, '.claude', 'commands', 'review-code.md'))).toBe(true); + expect(existsSync(join(tmpDir, '.opencode', 'commands', 'review-code.md'))).toBe(true); }); it('written Copilot prompt has correct content', async () => { @@ -876,7 +880,7 @@ describe('install-pipeline', () => { const result = await executeInstallPipeline(items, opts); expect(result.success).toBe(true); - expect(result.writes).toHaveLength(2); + expect(result.writes).toHaveLength(3); expect(result.written).toHaveLength(0); expect(existsSync(join(tmpDir, '.github', 'prompts', 'review-code.prompt.md'))).toBe(false); }); @@ -920,19 +924,20 @@ describe('install-pipeline', () => { // ------------------------------------------------------------------------- describe('planRuleWrites — agents', () => { - it('transpiles a canonical agent to Copilot and Claude Code', () => { + it('transpiles a canonical agent to Copilot, Claude Code, and OpenCode', () => { const items = [canonicalAgent('architect')]; const opts = baseOptions(tmpDir); const { writes, skipped } = planRuleWrites(items, opts); expect(skipped).toHaveLength(0); - // Only Copilot and Claude Code support agent transpilation - expect(writes).toHaveLength(2); + // Copilot, Claude Code, and OpenCode support agent transpilation + expect(writes).toHaveLength(3); const agents = writes.map((w) => w.agent); expect(agents).toContain('github-copilot'); expect(agents).toContain('claude-code'); + expect(agents).toContain('opencode'); }); it('respects agent subset filter for agents', () => { @@ -995,8 +1000,8 @@ describe('install-pipeline', () => { const { writes } = planRuleWrites(items, opts); - // 2 agents × 2 supported target agents = 4 writes - expect(writes).toHaveLength(4); + // 2 agents × 3 supported target agents = 6 writes + expect(writes).toHaveLength(6); }); it('handles native agent passthrough — only targets matching agent', () => { @@ -1032,11 +1037,11 @@ describe('install-pipeline', () => { const { writes, skipped } = planRuleWrites(items, opts); - // canonical rule: 5 agents - // canonical prompt: 2 agents (copilot + claude-code) - // canonical agent: 2 agents (copilot + claude-code) + // canonical rule: 6 agents + // canonical prompt: 3 agents (copilot + claude-code + opencode) + // canonical agent: 3 agents (copilot + claude-code + opencode) // skill: silently skipped - expect(writes).toHaveLength(9); + expect(writes).toHaveLength(12); expect(skipped).toHaveLength(0); }); }); @@ -1046,18 +1051,19 @@ describe('install-pipeline', () => { // ------------------------------------------------------------------------- describe('executeInstallPipeline — agent writes', () => { - it('writes transpiled agents to Copilot and Claude Code directories', async () => { + it('writes transpiled agents to Copilot, Claude Code, and OpenCode directories', async () => { const items = [canonicalAgent('architect')]; const opts = baseOptions(tmpDir); const result = await executeInstallPipeline(items, opts); expect(result.success).toBe(true); - expect(result.written).toHaveLength(2); + expect(result.written).toHaveLength(3); // Verify files exist on disk expect(existsSync(join(tmpDir, '.github', 'agents', 'architect.agent.md'))).toBe(true); expect(existsSync(join(tmpDir, '.claude', 'agents', 'architect.md'))).toBe(true); + expect(existsSync(join(tmpDir, '.opencode', 'agents', 'architect.md'))).toBe(true); }); it('written Copilot agent has correct content', async () => { @@ -1113,7 +1119,7 @@ describe('install-pipeline', () => { const result = await executeInstallPipeline(items, opts); expect(result.success).toBe(true); - expect(result.writes).toHaveLength(2); + expect(result.writes).toHaveLength(3); expect(result.written).toHaveLength(0); expect(existsSync(join(tmpDir, '.github', 'agents', 'architect.agent.md'))).toBe(false); }); @@ -1175,8 +1181,8 @@ describe('install-pipeline', () => { const { writes, skipped } = planRuleWrites(items, opts); expect(skipped).toHaveLength(0); - // 5 agents: copilot (AGENTS.md), claude-code (CLAUDE.md), cursor, windsurf, cline - expect(writes).toHaveLength(5); + // 6 agents: copilot (AGENTS.md), claude-code (CLAUDE.md), cursor, windsurf, cline, opencode + expect(writes).toHaveLength(6); const copilotWrite = writes.find((w) => w.agent === 'github-copilot'); expect(copilotWrite).toBeDefined(); @@ -1188,7 +1194,7 @@ describe('install-pipeline', () => { expect(claudeWrite!.planned.output.mode).toBe('append'); expect(claudeWrite!.planned.output.filename).toBe('CLAUDE.md'); - // Cursor, Windsurf, Cline remain per-rule file mode + // Cursor, Windsurf, Cline, OpenCode remain per-rule file mode const cursorWrite = writes.find((w) => w.agent === 'cursor'); expect(cursorWrite!.planned.output.mode).toBe('write'); }); @@ -1223,8 +1229,8 @@ describe('install-pipeline', () => { const { writes } = planRuleWrites(items, opts); - // prompt: 2 agents, agent: 2 agents = 4 - expect(writes).toHaveLength(4); + // prompt: 3 agents, agent: 3 agents = 6 + expect(writes).toHaveLength(6); for (const w of writes) { expect(w.planned.output.mode).toBe('write'); } @@ -1338,8 +1344,8 @@ describe('install-pipeline', () => { const result = await executeInstallPipeline(items, opts); expect(result.success).toBe(true); - // 5 writes: AGENTS.md, CLAUDE.md, .cursor/rules, .windsurf/rules, .clinerules - expect(result.written).toHaveLength(5); + // 6 writes: AGENTS.md, CLAUDE.md, .cursor/rules, .windsurf/rules, .clinerules, .opencode/rules + expect(result.written).toHaveLength(6); // Append targets expect(existsSync(join(tmpDir, 'AGENTS.md'))).toBe(true); @@ -1349,6 +1355,7 @@ describe('install-pipeline', () => { expect(existsSync(join(tmpDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); expect(existsSync(join(tmpDir, '.windsurf', 'rules', 'code-style.md'))).toBe(true); expect(existsSync(join(tmpDir, '.clinerules', 'code-style.md'))).toBe(true); + expect(existsSync(join(tmpDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); }); }); }); diff --git a/src/rule-installer.ts b/src/rule-installer.ts index 2753171..fa7640d 100644 --- a/src/rule-installer.ts +++ b/src/rule-installer.ts @@ -80,13 +80,14 @@ export interface InstallPipelineResult { error?: string; } -/** All five target agents. */ +/** All six target agents. */ const ALL_AGENTS: readonly TargetAgent[] = [ 'github-copilot', 'claude-code', 'cursor', 'windsurf', 'cline', + 'opencode', ] as const; /** @@ -343,13 +344,16 @@ const OUTPUT_DIR_TO_AGENT: ReadonlyArray<{ prefix: string; agent: TargetAgent }> { prefix: '.cursor/rules', agent: 'cursor' }, { prefix: '.windsurf/rules', agent: 'windsurf' }, { prefix: '.clinerules', agent: 'cline' }, + { prefix: '.opencode/rules', agent: 'opencode' }, // Prompts { prefix: '.github/prompts', agent: 'github-copilot' }, { prefix: '.claude/commands', agent: 'claude-code' }, { prefix: '.windsurf/workflows', agent: 'windsurf' }, + { prefix: '.opencode/commands', agent: 'opencode' }, // Agents { prefix: '.github/agents', agent: 'github-copilot' }, { prefix: '.claude/agents', agent: 'claude-code' }, + { prefix: '.opencode/agents', agent: 'opencode' }, ]; /** diff --git a/src/rule-transpilers.test.ts b/src/rule-transpilers.test.ts index c390f06..e8a4b38 100644 --- a/src/rule-transpilers.test.ts +++ b/src/rule-transpilers.test.ts @@ -636,7 +636,7 @@ describe('transpileRule', () => { expect(output!.content).toContain('alwaysApply: false'); }); - it('transpiles canonical rule for all 5 agents', () => { + it('transpiles canonical rule for all 6 agents', () => { const item = makeDiscoveredItem(); for (const agent of TARGET_AGENTS) { @@ -738,11 +738,11 @@ describe('transpileRule', () => { // --------------------------------------------------------------------------- describe('transpileRuleForAllAgents', () => { - it('produces outputs for all 5 agents from canonical rule', () => { + it('produces outputs for all 6 agents from canonical rule', () => { const item = makeDiscoveredItem(); const outputs = transpileRuleForAllAgents(item, TARGET_AGENTS); - expect(outputs).toHaveLength(5); + expect(outputs).toHaveLength(6); const dirs = outputs.map((o) => o.outputDir).sort(); expect(dirs).toEqual([ @@ -750,6 +750,7 @@ describe('transpileRuleForAllAgents', () => { '.clinerules', '.cursor/rules', '.github/instructions', + '.opencode/rules', '.windsurf/rules', ]); }); @@ -790,7 +791,7 @@ describe('transpileRuleForAllAgents', () => { const item = makeDiscoveredItem(); const outputs = transpileRuleForAllAgents(item, TARGET_AGENTS, true); - expect(outputs).toHaveLength(5); + expect(outputs).toHaveLength(6); const appendOutputs = outputs.filter((o) => o.mode === 'append'); expect(appendOutputs).toHaveLength(2); @@ -799,10 +800,15 @@ describe('transpileRuleForAllAgents', () => { expect(appendFiles).toEqual(['AGENTS.md', 'CLAUDE.md']); const writeOutputs = outputs.filter((o) => o.mode === 'write'); - expect(writeOutputs).toHaveLength(3); + expect(writeOutputs).toHaveLength(4); const writeDirs = writeOutputs.map((o) => o.outputDir).sort(); - expect(writeDirs).toEqual(['.clinerules', '.cursor/rules', '.windsurf/rules']); + expect(writeDirs).toEqual([ + '.clinerules', + '.cursor/rules', + '.opencode/rules', + '.windsurf/rules', + ]); }); it('mixes per-rule and append outputs correctly', () => { @@ -823,7 +829,7 @@ describe('transpileRuleForAllAgents', () => { // --------------------------------------------------------------------------- describe('ruleTranspilers registry', () => { - it('has entries for all 5 target agents', () => { + it('has entries for all 6 target agents', () => { expect(Object.keys(ruleTranspilers).sort()).toEqual([...TARGET_AGENTS].sort()); }); diff --git a/src/rule-transpilers.ts b/src/rule-transpilers.ts index 76d94be..b1258ad 100644 --- a/src/rule-transpilers.ts +++ b/src/rule-transpilers.ts @@ -254,6 +254,30 @@ export const claudeCodeRuleTranspiler: Transpiler = { }, }; +// --------------------------------------------------------------------------- +// OpenCode transpiler (.opencode/rules/*.md) +// +// OpenCode rule files are plain markdown — no YAML frontmatter. The body +// is written directly without any wrapper. +// --------------------------------------------------------------------------- + +export const opencodeRuleTranspiler: Transpiler = { + canTranspile(item: DiscoveredItem): boolean { + return item.type === 'rule' && item.format === 'canonical'; + }, + + transform(rule: CanonicalRule, _targetAgent: TargetAgent): TranspiledOutput { + const config = getTargetAgentConfig('opencode'); + + return { + filename: `${rule.name}${config.rulesConfig.extension}`, + content: rule.body + '\n', + outputDir: config.rulesConfig.outputDir, + mode: 'write', + }; + }, +}; + // --------------------------------------------------------------------------- // Copilot append transpiler (→ AGENTS.md with markers) // @@ -373,6 +397,7 @@ export const ruleTranspilers: Record> = { cline: clineRuleTranspiler, 'github-copilot': copilotRuleTranspiler, 'claude-code': claudeCodeRuleTranspiler, + opencode: opencodeRuleTranspiler, }; /** diff --git a/src/target-agents.ts b/src/target-agents.ts index 7139f79..56d6012 100644 --- a/src/target-agents.ts +++ b/src/target-agents.ts @@ -191,6 +191,35 @@ export const targetAgents: Record = { }, // Cline has no prompt/command system }, + opencode: { + name: 'opencode', + displayName: 'OpenCode', + skillsDir: '.opencode/skills', + rulesConfig: { + outputDir: '.opencode/rules', + extension: '.md', + }, + nativeRuleDiscovery: { + sourceDir: '.opencode/rules', + pattern: '*.md', + }, + promptsConfig: { + outputDir: '.opencode/commands', + extension: '.md', + }, + nativePromptDiscovery: { + sourceDir: '.opencode/commands', + pattern: '*.md', + }, + agentsConfig: { + outputDir: '.opencode/agents', + extension: '.md', + }, + nativeAgentDiscovery: { + sourceDir: '.opencode/agents', + pattern: '*.md', + }, + }, }; /** All target agent identifiers. */ diff --git a/src/types.ts b/src/types.ts index 00ce6ba..d863eb9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,8 +99,14 @@ export interface RemoteSkill { // Canonical context types (dotai extensions) // --------------------------------------------------------------------------- -/** The five target agents for dotai transpilation (MVP scope). */ -export type TargetAgent = 'github-copilot' | 'claude-code' | 'cursor' | 'windsurf' | 'cline'; +/** The six target agents for dotai transpilation. */ +export type TargetAgent = + | 'github-copilot' + | 'claude-code' + | 'cursor' + | 'windsurf' + | 'cline' + | 'opencode'; /** Context item types supported by dotai. */ export type ContextType = 'skill' | 'rule' | 'prompt' | 'agent'; diff --git a/tests/append-integration.test.ts b/tests/append-integration.test.ts index 8b7f467..b0ea728 100644 --- a/tests/append-integration.test.ts +++ b/tests/append-integration.test.ts @@ -57,10 +57,11 @@ describe('addRules --append integration', () => { expect(claudeMd).toContain(''); expect(claudeMd).toContain('Use const over let'); - // Per-rule files should still be written for cursor, windsurf, cline + // Per-rule files should still be written for cursor, windsurf, cline, opencode expect(existsSync(join(projectDir, '.cursor', 'rules', 'code-style.mdc'))).toBe(true); expect(existsSync(join(projectDir, '.windsurf', 'rules', 'code-style.md'))).toBe(true); expect(existsSync(join(projectDir, '.clinerules', 'code-style.md'))).toBe(true); + expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); // Per-rule files should NOT be written for copilot/claude (append mode instead) expect( diff --git a/tests/cli-subprocess.test.ts b/tests/cli-subprocess.test.ts index 712e12d..0d32386 100644 --- a/tests/cli-subprocess.test.ts +++ b/tests/cli-subprocess.test.ts @@ -55,6 +55,7 @@ describe('CLI --rule subprocess tests', () => { ).toBe(true); expect(existsSync(join(projectDir, '.windsurf', 'rules', 'code-style.md'))).toBe(true); expect(existsSync(join(projectDir, '.clinerules', 'code-style.md'))).toBe(true); + expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); }); it('add --rule --dry-run does not create files', async () => { @@ -97,6 +98,7 @@ describe('CLI --rule subprocess tests', () => { existsSync(join(projectDir, '.github', 'instructions', 'code-style.instructions.md')) ).toBe(false); expect(existsSync(join(projectDir, '.windsurf', 'rules', 'code-style.md'))).toBe(false); + expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(false); // Lock file should only list targeted agents const lock = await readLockFileFromDisk(projectDir); @@ -183,9 +185,10 @@ describe('CLI --custom-agent subprocess tests', () => { expect(agentEntries[0]!.name).toBe('architect'); expect(agentEntries[0]!.type).toBe('agent'); - // Verify transpiled files exist for Copilot and Claude Code only + // Verify transpiled files exist for Copilot, Claude Code, and OpenCode expect(existsSync(join(projectDir, '.github', 'agents', 'architect.agent.md'))).toBe(true); expect(existsSync(join(projectDir, '.claude', 'agents', 'architect.md'))).toBe(true); + expect(existsSync(join(projectDir, '.opencode', 'agents', 'architect.md'))).toBe(true); // No agent files for Cursor, Windsurf, Cline (no agent support) expect(existsSync(join(projectDir, '.cursor', 'agents'))).toBe(false); diff --git a/tests/debug-addRules.test.ts b/tests/debug-addRules.test.ts new file mode 100644 index 0000000..c69d52f --- /dev/null +++ b/tests/debug-addRules.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs'; +import { execSync } from 'child_process'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { addRules } from '../src/rule-add.ts'; +import { executeInstallPipeline } from '../src/rule-installer.ts'; +import { discover, filterByType } from '../src/rule-discovery.ts'; + +describe('debug addRules', () => { + it('shows full pipeline result', async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'dbg-proj-')); + execSync('git init --initial-branch=main', { cwd: projectRoot, stdio: 'ignore' }); + + const sourceRepo = mkdtempSync(join(tmpdir(), 'dbg-src-')); + const ruleDir = join(sourceRepo, 'rules', 'code-style'); + mkdirSync(ruleDir, { recursive: true }); + writeFileSync( + join(ruleDir, 'RULES.md'), + `--- +name: code-style +description: Code style guidelines +activation: always +--- + +Use consistent formatting. +` + ); + + // Step 1: Check discovery + const { items, warnings } = await discover(sourceRepo, { types: ['rule'] }); + console.log( + 'DISCOVERY items:', + items.length, + items.map((i) => `${i.type}:${i.name}:${i.format}`) + ); + console.log('DISCOVERY warnings:', warnings); + + const allRules = filterByType(items, 'rule'); + console.log('FILTERED rules:', allRules.length); + + // Step 2: Run install pipeline directly + const pipelineResult = await executeInstallPipeline(allRules, { + projectRoot, + source: 'test/e2e-repo', + }); + + console.log('PIPELINE success:', pipelineResult.success); + console.log( + 'PIPELINE writes:', + pipelineResult.writes.length, + pipelineResult.writes.map((w) => `${w.agent}:${w.planned.absolutePath}`) + ); + console.log('PIPELINE written:', pipelineResult.written.length, pipelineResult.written); + console.log('PIPELINE skipped:', pipelineResult.skipped); + console.log('PIPELINE collisions:', pipelineResult.collisions); + console.log('PIPELINE error:', pipelineResult.error); + + // Step 3: Check if files actually exist + const opencodePath = join(projectRoot, '.opencode', 'rules'); + console.log('.opencode/rules exists:', existsSync(opencodePath)); + if (existsSync(opencodePath)) { + console.log('.opencode/rules contents:', readdirSync(opencodePath)); + } + + // Step 4: Now run addRules + const result = await addRules({ + source: 'test/e2e-repo', + sourcePath: sourceRepo, + projectRoot, + ruleNames: ['*'], + force: true, // force to overwrite from pipeline above + }); + + console.log('ADDRULES success:', result.success); + console.log('ADDRULES rulesInstalled:', result.rulesInstalled); + console.log('ADDRULES writtenPaths:', result.writtenPaths.length, result.writtenPaths); + console.log('ADDRULES error:', result.error); + console.log('ADDRULES messages:', result.messages); + + // Check lock file + const lockPath = join(projectRoot, '.dotai-lock.json'); + console.log('LOCK EXISTS:', existsSync(lockPath)); + + expect(true).toBe(true); + }); +}); diff --git a/tests/e2e-canonical-install.test.ts b/tests/e2e-canonical-install.test.ts index 9174c53..2641ad2 100644 --- a/tests/e2e-canonical-install.test.ts +++ b/tests/e2e-canonical-install.test.ts @@ -47,11 +47,11 @@ describe('E2E canonical install', () => { }); // ------------------------------------------------------------------------- - // Canonical rule → all 5 agents → lock updated + // Canonical rule → all 6 agents → lock updated // ------------------------------------------------------------------------- describe('canonical rule install', () => { - it('installs a canonical rule to all 5 agents and updates lock', async () => { + it('installs a canonical rule to all 6 agents and updates lock', async () => { // Create a canonical rule in the source repo const ruleContent = makeRuleContent('code-style', { description: 'Code style guidelines', @@ -71,9 +71,9 @@ describe('E2E canonical install', () => { // Verify success expect(result.success).toBe(true); expect(result.rulesInstalled).toBe(1); - expect(result.writtenPaths).toHaveLength(5); + expect(result.writtenPaths).toHaveLength(6); - // Verify output files exist for all 5 agents + // Verify output files exist for all 6 agents for (const agent of ALL_AGENTS) { const outputPath = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); assertFileExists(outputPath); @@ -107,9 +107,9 @@ describe('E2E canonical install', () => { const lockEntry = await assertLockEntry(projectRoot, 'rule', 'code-style', { source: 'test/e2e-repo', format: 'canonical', - outputCount: 5, + outputCount: 6, }); - expect(lockEntry.agents).toHaveLength(5); + expect(lockEntry.agents).toHaveLength(6); expect(lockEntry.hash).toBeTruthy(); await assertLockEntryCount(projectRoot, 1); }); @@ -137,8 +137,8 @@ describe('E2E canonical install', () => { expect(result.success).toBe(true); expect(result.rulesInstalled).toBe(2); - // 2 rules x 5 agents = 10 output files - expect(result.writtenPaths).toHaveLength(10); + // 2 rules x 6 agents = 12 output files + expect(result.writtenPaths).toHaveLength(12); // Both rules should have output files for all agents for (const agent of ALL_AGENTS) { @@ -262,11 +262,11 @@ describe('E2E canonical install', () => { }); // ------------------------------------------------------------------------- - // Canonical prompt → Copilot + Claude → lock updated + // Canonical prompt → Copilot + Claude + OpenCode → lock updated // ------------------------------------------------------------------------- describe('canonical prompt install', () => { - it('installs a canonical prompt to Copilot and Claude Code and updates lock', async () => { + it('installs a canonical prompt to Copilot, Claude Code, and OpenCode and updates lock', async () => { const promptContent = makePromptContent('review-code', { description: 'Review code for issues', body: 'Review the code and identify potential issues.', @@ -282,7 +282,7 @@ describe('E2E canonical install', () => { expect(result.success).toBe(true); expect(result.promptsInstalled).toBe(1); - expect(result.writtenPaths).toHaveLength(2); + expect(result.writtenPaths).toHaveLength(3); // Verify output files for supported agents for (const agent of PROMPT_AGENTS) { @@ -314,7 +314,7 @@ describe('E2E canonical install', () => { const lockEntry = await assertLockEntry(projectRoot, 'prompt', 'review-code', { source: 'test/e2e-repo', format: 'canonical', - outputCount: 2, + outputCount: 3, }); expect(lockEntry.hash).toBeTruthy(); await assertLockEntryCount(projectRoot, 1); @@ -370,8 +370,8 @@ describe('E2E canonical install', () => { expect(result.success).toBe(true); expect(result.promptsInstalled).toBe(2); - // 2 prompts x 2 agents = 4 output files - expect(result.writtenPaths).toHaveLength(4); + // 2 prompts x 3 agents = 6 output files + expect(result.writtenPaths).toHaveLength(6); await assertLockEntryCount(projectRoot, 2); await assertLockEntry(projectRoot, 'prompt', 'review-code'); @@ -380,11 +380,11 @@ describe('E2E canonical install', () => { }); // ------------------------------------------------------------------------- - // Canonical agent → Copilot + Claude → lock updated + // Canonical agent → Copilot + Claude + OpenCode → lock updated // ------------------------------------------------------------------------- describe('canonical agent install', () => { - it('installs a canonical agent to Copilot and Claude Code and updates lock', async () => { + it('installs a canonical agent to Copilot, Claude Code, and OpenCode and updates lock', async () => { const agentContent = makeAgentContent('architect', { description: 'Architecture planning agent', model: 'claude-sonnet-4', @@ -402,7 +402,7 @@ describe('E2E canonical install', () => { expect(result.success).toBe(true); expect(result.agentsInstalled).toBe(1); - expect(result.writtenPaths).toHaveLength(2); + expect(result.writtenPaths).toHaveLength(3); // Verify output files for supported agents for (const agent of AGENT_AGENTS) { @@ -434,7 +434,7 @@ describe('E2E canonical install', () => { const lockEntry = await assertLockEntry(projectRoot, 'agent', 'architect', { source: 'test/e2e-repo', format: 'canonical', - outputCount: 2, + outputCount: 3, }); expect(lockEntry.hash).toBeTruthy(); await assertLockEntryCount(projectRoot, 1); @@ -494,8 +494,8 @@ describe('E2E canonical install', () => { expect(result.success).toBe(true); expect(result.agentsInstalled).toBe(2); - // 2 agents x 2 target agents = 4 output files - expect(result.writtenPaths).toHaveLength(4); + // 2 agents x 3 target agents = 6 output files + expect(result.writtenPaths).toHaveLength(6); await assertLockEntryCount(projectRoot, 2); await assertLockEntry(projectRoot, 'agent', 'architect'); @@ -555,15 +555,15 @@ describe('E2E canonical install', () => { expect(agentResult.success).toBe(true); // Verify all output files exist - // Rule: 5 agents + // Rule: 6 agents for (const agent of ALL_AGENTS) { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style')); } - // Prompt: 2 agents + // Prompt: 3 agents for (const agent of PROMPT_AGENTS) { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'review-code')); } - // Agent: 2 agents + // Agent: 3 agents for (const agent of AGENT_AGENTS) { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'agent', 'architect')); } diff --git a/tests/e2e-cli-matrix.test.ts b/tests/e2e-cli-matrix.test.ts index 31f1361..e23adbb 100644 --- a/tests/e2e-cli-matrix.test.ts +++ b/tests/e2e-cli-matrix.test.ts @@ -94,7 +94,7 @@ describe('E2E CLI matrix: four-type flows', () => { expect(result.success).toBe(true); expect(result.rulesInstalled).toBe(1); - // Rule files exist for all 5 agents + // Rule files exist for all 6 agents for (const agent of ALL_AGENTS) { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'rule', 'style-guide')); } @@ -836,7 +836,7 @@ description: A test skill expect(result.success).toBe(true); expect(result.rulesInstalled).toBe(1); - // 4. Verify: output files exist for all 5 target agents + // 4. Verify: output files exist for all 6 target agents for (const agent of ALL_AGENTS) { const outputPath = getExpectedOutputPath(projectRoot, agent, 'rule', 'code-style'); assertFileExists(outputPath, 'Use 2-space indentation'); @@ -846,7 +846,7 @@ description: A test skill const lockEntry = await assertLockEntry(projectRoot, 'rule', 'code-style', { source: 'team/shared-rules', format: 'canonical', - outputCount: 5, + outputCount: 6, }); expect(lockEntry.hash).toMatch(/^[a-f0-9]{64}$/); await assertLockEntryCount(projectRoot, 1); diff --git a/tests/e2e-collision.test.ts b/tests/e2e-collision.test.ts index 438b172..a87322b 100644 --- a/tests/e2e-collision.test.ts +++ b/tests/e2e-collision.test.ts @@ -358,7 +358,7 @@ describe('E2E collision tests', () => { expect(result.success).toBe(true); expect(result.rulesInstalled).toBe(1); - expect(result.writtenPaths).toHaveLength(5); + expect(result.writtenPaths).toHaveLength(6); // All files now contain dotai-managed content for (const agent of ALL_AGENTS) { @@ -370,7 +370,7 @@ describe('E2E collision tests', () => { await assertLockEntry(projectRoot, 'rule', 'code-style', { source: 'test/force-repo', format: 'canonical', - outputCount: 5, + outputCount: 6, }); }); diff --git a/tests/e2e-native-passthrough.test.ts b/tests/e2e-native-passthrough.test.ts index 0f6ec13..7511e20 100644 --- a/tests/e2e-native-passthrough.test.ts +++ b/tests/e2e-native-passthrough.test.ts @@ -364,8 +364,8 @@ describe('E2E native passthrough', () => { expect(result.success).toBe(true); expect(result.rulesInstalled).toBe(2); - // canonical: 5 files + native: 1 file = 6 total - expect(result.writtenPaths).toHaveLength(6); + // canonical: 6 files + native: 1 file = 7 total + expect(result.writtenPaths).toHaveLength(7); // Canonical rule should be in all agents for (const agent of ALL_AGENTS) { @@ -382,7 +382,7 @@ describe('E2E native passthrough', () => { await assertLockEntryCount(projectRoot, 2); await assertLockEntry(projectRoot, 'rule', 'formatting', { format: 'canonical', - outputCount: 5, + outputCount: 6, }); await assertLockEntry(projectRoot, 'rule', 'cursor-only', { format: 'native:cursor', @@ -417,10 +417,10 @@ describe('E2E native passthrough', () => { expect(result.success).toBe(true); expect(result.promptsInstalled).toBe(2); - // canonical: 2 files + native: 1 file = 3 total - expect(result.writtenPaths).toHaveLength(3); + // canonical: 3 files + native: 1 file = 4 total + expect(result.writtenPaths).toHaveLength(4); - // Canonical prompt in copilot + claude + // Canonical prompt in copilot + claude + opencode for (const agent of PROMPT_AGENTS) { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'prompt', 'review-code')); } @@ -463,10 +463,10 @@ describe('E2E native passthrough', () => { expect(result.success).toBe(true); expect(result.agentsInstalled).toBe(2); - // canonical: 2 files + native: 1 file = 3 total - expect(result.writtenPaths).toHaveLength(3); + // canonical: 3 files + native: 1 file = 4 total + expect(result.writtenPaths).toHaveLength(4); - // Canonical agent in copilot + claude + // Canonical agent in copilot + claude + opencode for (const agent of AGENT_AGENTS) { assertFileExists(getExpectedOutputPath(projectRoot, agent, 'agent', 'architect')); } diff --git a/tests/e2e-update-flow.test.ts b/tests/e2e-update-flow.test.ts index 6b4e49b..20fa8ca 100644 --- a/tests/e2e-update-flow.test.ts +++ b/tests/e2e-update-flow.test.ts @@ -661,7 +661,7 @@ describe('E2E update flow', () => { await assertLockEntryCount(projectRoot, 1); }); - it('update outputs all 5 agent files for a rule (same as initial install)', async () => { + it('update outputs all 6 agent files for a rule (same as initial install)', async () => { writeCanonicalFile( sourceRepo, 'rule', @@ -686,11 +686,11 @@ describe('E2E update flow', () => { await updateRules(projectRoot); - // All 5 agents should have output files with new content + // All 6 agents should have output files with new content const updatedEntry = await assertLockEntry(projectRoot, 'rule', 'code-style', { - outputCount: 5, + outputCount: 6, }); - expect(updatedEntry.agents).toHaveLength(5); + expect(updatedEntry.agents).toHaveLength(6); for (const agent of ALL_AGENTS) { assertFileExists( diff --git a/tests/e2e-utils.ts b/tests/e2e-utils.ts index 6e09dda..8e8e745 100644 --- a/tests/e2e-utils.ts +++ b/tests/e2e-utils.ts @@ -429,20 +429,29 @@ export async function assertLockEntryCount( // All-agents helpers // --------------------------------------------------------------------------- -/** All five target agents. */ +/** All six target agents. */ export const ALL_AGENTS: readonly TargetAgent[] = [ 'github-copilot', 'claude-code', 'cursor', 'windsurf', 'cline', + 'opencode', ] as const; /** Target agents that support canonical prompt transpilation. */ -export const PROMPT_AGENTS: readonly TargetAgent[] = ['github-copilot', 'claude-code'] as const; +export const PROMPT_AGENTS: readonly TargetAgent[] = [ + 'github-copilot', + 'claude-code', + 'opencode', +] as const; /** Target agents that support canonical agent transpilation. */ -export const AGENT_AGENTS: readonly TargetAgent[] = ['github-copilot', 'claude-code'] as const; +export const AGENT_AGENTS: readonly TargetAgent[] = [ + 'github-copilot', + 'claude-code', + 'opencode', +] as const; /** * Write a pre-existing file at a target output path. diff --git a/tests/install-integration.test.ts b/tests/install-integration.test.ts index 759588c..f452863 100644 --- a/tests/install-integration.test.ts +++ b/tests/install-integration.test.ts @@ -22,13 +22,14 @@ import type { LockEntry, TargetAgent } from '../src/types.ts'; // Helpers // --------------------------------------------------------------------------- -/** All five target agents. */ +/** All six target agents. */ const ALL_AGENTS: readonly TargetAgent[] = [ 'github-copilot', 'claude-code', 'cursor', 'windsurf', 'cline', + 'opencode', ] as const; /** Create a canonical RULES.md file with valid frontmatter. */ @@ -93,10 +94,10 @@ describe('integration: discover → transpile → install', () => { }); // ------------------------------------------------------------------------- - // Happy path: single rule → all 5 agents + // Happy path: single rule → all 6 agents // ------------------------------------------------------------------------- - it('discovers and installs a single canonical rule to all 5 agents', async () => { + it('discovers and installs a single canonical rule to all 6 agents', async () => { // Source repo has one canonical rule const ruleDir = join(sourceDir, 'rules', 'code-style'); mkdirSync(ruleDir, { recursive: true }); @@ -120,7 +121,7 @@ describe('integration: discover → transpile → install', () => { }); expect(result.success).toBe(true); - expect(result.written).toHaveLength(5); + expect(result.written).toHaveLength(6); expect(result.collisions).toHaveLength(0); // Verify each agent got the file @@ -131,6 +132,7 @@ describe('integration: discover → transpile → install', () => { existsSync(join(projectDir, '.github', 'instructions', 'code-style.instructions.md')) ).toBe(true); expect(existsSync(join(projectDir, '.claude', 'rules', 'code-style.md'))).toBe(true); + expect(existsSync(join(projectDir, '.opencode', 'rules', 'code-style.md'))).toBe(true); // Verify content is correctly transpiled const cursorContent = readFileSync( @@ -326,7 +328,7 @@ describe('integration: discover → transpile → install', () => { }); expect(result.success).toBe(true); - expect(result.written).toHaveLength(5); + expect(result.written).toHaveLength(6); // Cursor: globs as comma-separated string const cursor = readFileSync(join(projectDir, '.cursor', 'rules', 'ts-style.mdc'), 'utf-8'); @@ -372,7 +374,7 @@ describe('integration: discover → transpile → install', () => { }); expect(result.success).toBe(true); - expect(result.writes).toHaveLength(5); + expect(result.writes).toHaveLength(6); expect(result.written).toHaveLength(0); // No files should have been created @@ -381,6 +383,7 @@ describe('integration: discover → transpile → install', () => { expect(existsSync(join(projectDir, '.clinerules'))).toBe(false); expect(existsSync(join(projectDir, '.github'))).toBe(false); expect(existsSync(join(projectDir, '.claude'))).toBe(false); + expect(existsSync(join(projectDir, '.opencode'))).toBe(false); }); // ------------------------------------------------------------------------- @@ -409,6 +412,7 @@ describe('integration: discover → transpile → install', () => { expect(existsSync(join(projectDir, '.windsurf'))).toBe(false); expect(existsSync(join(projectDir, '.github'))).toBe(false); expect(existsSync(join(projectDir, '.claude'))).toBe(false); + expect(existsSync(join(projectDir, '.opencode'))).toBe(false); }); // ------------------------------------------------------------------------- @@ -466,7 +470,7 @@ describe('integration: discover → transpile → install', () => { expect(result.success).toBe(true); expect(result.collisions.length).toBeGreaterThan(0); // collisions detected but forced - expect(result.written).toHaveLength(5); + expect(result.written).toHaveLength(6); // File should now have transpiled content, not user content const content = readFileSync(join(conflictDir, 'code-style.mdc'), 'utf-8'); diff --git a/tests/lock-integration.test.ts b/tests/lock-integration.test.ts index d5d96b9..8b9d05d 100644 --- a/tests/lock-integration.test.ts +++ b/tests/lock-integration.test.ts @@ -57,10 +57,10 @@ describe('addRules → lock file integration', () => { expect(entry.name).toBe('code-style'); expect(entry.source).toBe('test/repo'); expect(entry.format).toBe('canonical'); - expect(entry.agents).toHaveLength(5); + expect(entry.agents).toHaveLength(6); expect(entry.hash).toBeTruthy(); expect(entry.installedAt).toBeTruthy(); - expect(entry.outputs).toHaveLength(5); + expect(entry.outputs).toHaveLength(6); }); it('writes correct content hash in lock entry', async () => {