| 166 | |
| 167 | |
| 168 | class OpenCodeAdapter(HarnessAdapter): |
| 169 | harness_id = "opencode" |
| 170 | |
| 171 | def __init__(self, output_root: Path | None = None) -> None: |
| 172 | super().__init__(output_root=output_root) |
| 173 | self._seen_skill_ids: dict[str, str] = {} |
| 174 | |
| 175 | def emit_plugin(self, plugin: PluginSource) -> EmitResult: |
| 176 | result = EmitResult() |
| 177 | for skill in plugin.skills: |
| 178 | self._emit_skill(plugin, skill, result) |
| 179 | for agent in plugin.agents: |
| 180 | self._emit_agent(plugin, agent, result) |
| 181 | for cmd in plugin.commands: |
| 182 | self._emit_command(plugin, cmd, result) |
| 183 | return result |
| 184 | |
| 185 | def emit_global(self, plugins: list[PluginSource]) -> EmitResult: |
| 186 | result = EmitResult() |
| 187 | # Minimal opencode.json pointing at .opencode/ |
| 188 | # NOTE: only `$schema` is accepted as an extension key — OpenCode rejects others. |
| 189 | config = { |
| 190 | "$schema": "https://opencode.ai/config.json", |
| 191 | } |
| 192 | result.written.append(self.write("opencode.json", json.dumps(config, indent=2) + "\n")) |
| 193 | return result |
| 194 | |
| 195 | # ── Internals ────────────────────────────────────────────────────────── |
| 196 | |
| 197 | def _emit_skill(self, plugin: PluginSource, skill: SkillSource, result: EmitResult) -> None: |
| 198 | skill_id = _opencode_skill_id(plugin, skill) |
| 199 | source_id = f"{plugin.name}/{skill.name}" |
| 200 | existing_source = self._seen_skill_ids.get(skill_id) |
| 201 | if existing_source and existing_source != source_id: |
| 202 | raise ValueError( |
| 203 | f"OpenCode skill id collision for `{skill_id}`: {existing_source} and {source_id}" |
| 204 | ) |
| 205 | self._seen_skill_ids[skill_id] = source_id |
| 206 | |
| 207 | skill_dir = Path(".opencode") / "skills" / skill_id |
| 208 | |
| 209 | fm = dict(skill.frontmatter) |
| 210 | fm["name"] = skill_id |
| 211 | |
| 212 | body = _rewrite_body_lowercase_tools(skill.body).rstrip() + "\n" |
| 213 | content = _opencode_frontmatter(fm) + "\n\n" + body |
| 214 | result.written.append(self.write(skill_dir / "SKILL.md", content)) |
| 215 | |
| 216 | # Mirror all support files (references/, assets/, scripts/, examples/, etc.) |
| 217 | # without decoding so binary assets keep working. |
| 218 | for src in sorted(skill.dir.rglob("*")): |
| 219 | if not src.is_file() or src.name == "SKILL.md": |
| 220 | continue |
| 221 | rel = src.relative_to(skill.dir) |
| 222 | result.written.append(self.mirror_file(src, skill_dir / rel)) |
| 223 | |
| 224 | def _emit_agent(self, plugin: PluginSource, agent: AgentSource, result: EmitResult) -> None: |
| 225 | agent_id = f"{plugin.name}__{agent.name}" |
no outgoing calls