Files
2026-07-13 12:36:35 +08:00

266 lines
10 KiB
Python

"""OpenCode adapter.
OpenCode can read Claude-compatible skills, but global installs need OpenCode-safe
skill names, so this adapter emits native OpenCode artifacts:
1. `.opencode/agents/<plugin>__<agent>.md` — agents with `mode: subagent` + `permission:`
block (replacing Claude Code's `tools:` allowlist), full provider-prefixed model IDs.
2. `.opencode/commands/<plugin>__<command>.md` — commands with lowercased tool refs.
3. `.opencode/skills/<plugin>-<skill>/SKILL.md` — skills with OpenCode-valid names.
4. `opencode.json` at root with `"$schema": "https://opencode.ai/config.json"`.
Install globally with `make install-opencode` (symlinks `.opencode/` -> ~/.config/opencode/).
Sources: research summary by `a8a6c57414dc1ba23` synthesized into the plan.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from tools.adapters.base import (
AgentSource,
CommandSource,
EmitResult,
HarnessAdapter,
PluginSource,
SkillSource,
)
from tools.adapters.capabilities import TOOL_NAME_MAPS, resolve_model
# Detects orchestration intent in command bodies. Word-boundary match so identifiers
# like `PerformanceReviewAgent` or `useragent` don't trip it.
_SUBAGENT_KEYWORD_RE = re.compile(r"\b(?:agent|subagent)s?\b", re.IGNORECASE)
_OPENCODE_SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
_OPENCODE_SKILL_NAME_MAX = 64
_OPENCODE_PERMISSIONS = [
"read",
"edit",
"write",
"bash",
"grep",
"glob",
"list",
"task",
"skill",
"lsp",
"webfetch",
"websearch",
"external_directory",
"todowrite",
"question",
"doom_loop",
]
# Map Claude Code tool name -> OpenCode permission key
_TOOL_TO_PERMISSION = {
"Read": "read",
"Edit": "edit",
"Write": "write",
"Bash": "bash",
"Grep": "grep",
"Glob": "glob",
"LS": "list",
"Agent": "task",
"Task": "task",
"Skill": "skill",
"LSP": "lsp",
"WebFetch": "webfetch",
"WebSearch": "websearch",
"TodoWrite": "todowrite",
"AskUserQuestion": "question",
}
def _rewrite_body_lowercase_tools(body: str) -> str:
"""Lowercase the Claude tool names that appear as backticked identifiers."""
out = body
for camel, replacement in TOOL_NAME_MAPS["opencode"].items():
out = out.replace(f"`{camel}`", f"`{replacement}`")
return out
def _build_permission_block(tools: list[str], *, has_tools_field: bool = True) -> dict:
"""Convert source `tools:` allowlist to OpenCode permission block.
`has_tools_field` lets the caller distinguish "tools: key missing entirely" from
"tools: present with an empty list". The two carry opposite semantics in Claude
Code and we must preserve that distinction or we leak privilege:
| source frontmatter | meaning in Claude | what we emit |
|----------------------------|----------------------------|--------------------------|
| (no tools: key) | unrestricted (default) | no permission block |
| `tools: []` (explicit) | NO tools allowed (locked) | deny-everything block |
| `tools: Read, Grep` | only those tools | allow those, deny others |
| `tools: [mcp__x]` | MCP only, no Claude tools | no permission block (MCP via MCP config) |
Base capabilities `skill` and `task` are ALWAYS allowed even on locked agents —
Claude Code authors don't list these in `tools:` (Skill isn't a tool name, Task is
the spawn tool implicit to every agent). Denying them would silently strip subagent
delegation and skill invocation from every restricted agent.
"""
if not has_tools_field:
# Source author didn't set `tools:` at all — Claude default is unrestricted.
return {}
base_capabilities = {"skill", "task"}
if not tools:
# Explicit `tools: []` — the author wants the agent locked down.
# Allow only base capabilities (skill, task); deny all Claude tools.
block = {}
for perm in _OPENCODE_PERMISSIONS:
block[perm] = "allow" if perm in base_capabilities else "deny"
return block
granted = {_TOOL_TO_PERMISSION[t] for t in tools if t in _TOOL_TO_PERMISSION}
if not granted:
# All tools are MCP / unmappable — MCP runs through its own server config,
# not the permission block. Leave permissive so the agent functions.
return {}
granted.update(base_capabilities)
block = {}
for perm in _OPENCODE_PERMISSIONS:
block[perm] = "allow" if perm in granted else "deny"
return block
def _opencode_frontmatter(fm: dict) -> str:
lines = ["---"]
for k, v in fm.items():
if isinstance(v, dict):
lines.append(f"{k}:")
for sk, sv in v.items():
lines.append(f" {sk}: {sv}")
elif isinstance(v, list):
lines.append(f"{k}:")
for item in v:
lines.append(f" - {item}")
elif isinstance(v, bool):
lines.append(f"{k}: {'true' if v else 'false'}")
elif v is None:
continue
else:
value = str(v).replace("\n", " ").strip()
lines.append(f"{k}: {value}")
lines.append("---")
return "\n".join(lines)
def _opencode_skill_id(plugin: PluginSource, skill: SkillSource) -> str:
skill_id = f"{plugin.name}-{skill.name}"
if len(skill_id) > _OPENCODE_SKILL_NAME_MAX:
raise ValueError(
f"OpenCode skill id `{skill_id}` is {len(skill_id)} chars; "
f"limit is {_OPENCODE_SKILL_NAME_MAX}"
)
if not _OPENCODE_SKILL_NAME_RE.fullmatch(skill_id):
raise ValueError(
f"OpenCode skill id `{skill_id}` must match {_OPENCODE_SKILL_NAME_RE.pattern}"
)
return skill_id
class OpenCodeAdapter(HarnessAdapter):
harness_id = "opencode"
def __init__(self, output_root: Path | None = None) -> None:
super().__init__(output_root=output_root)
self._seen_skill_ids: dict[str, str] = {}
def emit_plugin(self, plugin: PluginSource) -> EmitResult:
result = EmitResult()
for skill in plugin.skills:
self._emit_skill(plugin, skill, result)
for agent in plugin.agents:
self._emit_agent(plugin, agent, result)
for cmd in plugin.commands:
self._emit_command(plugin, cmd, result)
return result
def emit_global(self, plugins: list[PluginSource]) -> EmitResult:
result = EmitResult()
# Minimal opencode.json pointing at .opencode/
# NOTE: only `$schema` is accepted as an extension key — OpenCode rejects others.
config = {
"$schema": "https://opencode.ai/config.json",
}
result.written.append(self.write("opencode.json", json.dumps(config, indent=2) + "\n"))
return result
# ── Internals ──────────────────────────────────────────────────────────
def _emit_skill(self, plugin: PluginSource, skill: SkillSource, result: EmitResult) -> None:
skill_id = _opencode_skill_id(plugin, skill)
source_id = f"{plugin.name}/{skill.name}"
existing_source = self._seen_skill_ids.get(skill_id)
if existing_source and existing_source != source_id:
raise ValueError(
f"OpenCode skill id collision for `{skill_id}`: {existing_source} and {source_id}"
)
self._seen_skill_ids[skill_id] = source_id
skill_dir = Path(".opencode") / "skills" / skill_id
fm = dict(skill.frontmatter)
fm["name"] = skill_id
body = _rewrite_body_lowercase_tools(skill.body).rstrip() + "\n"
content = _opencode_frontmatter(fm) + "\n\n" + body
result.written.append(self.write(skill_dir / "SKILL.md", content))
# Mirror all support files (references/, assets/, scripts/, examples/, etc.)
# without decoding so binary assets keep working.
for src in sorted(skill.dir.rglob("*")):
if not src.is_file() or src.name == "SKILL.md":
continue
rel = src.relative_to(skill.dir)
result.written.append(self.mirror_file(src, skill_dir / rel))
def _emit_agent(self, plugin: PluginSource, agent: AgentSource, result: EmitResult) -> None:
agent_id = f"{plugin.name}__{agent.name}"
rel = Path(".opencode") / "agents" / f"{agent_id}.md"
model, warning = resolve_model("opencode", agent.model)
if warning:
result.warnings.append(f"agent `{agent_id}`: {warning}")
fm: dict = {
"name": agent_id,
"description": agent.description or f"{agent.name} (from {plugin.name})",
"mode": "subagent",
"model": model,
}
has_tools_field = "tools" in agent.frontmatter
permission = _build_permission_block(agent.tools, has_tools_field=has_tools_field)
if permission:
fm["permission"] = permission
body = _rewrite_body_lowercase_tools(agent.body).rstrip() + "\n"
content = _opencode_frontmatter(fm) + "\n\n" + body
result.written.append(self.write(rel, content))
def _emit_command(self, plugin: PluginSource, cmd: CommandSource, result: EmitResult) -> None:
cmd_id = f"{plugin.name}__{cmd.name}"
rel = Path(".opencode") / "commands" / f"{cmd_id}.md"
fm: dict = {
"description": cmd.description or f"{cmd.name} (from {plugin.name})",
}
if cmd.argument_hint:
fm["argument-hint"] = cmd.argument_hint
# Heuristic: if command orchestrates subagents, force isolation.
# Word-boundary match avoids false positives on substrings like
# `PerformanceReviewAgent` (class name in a code snippet) or `useragent`.
if _SUBAGENT_KEYWORD_RE.search(cmd.body):
fm["subtask"] = True
body = _rewrite_body_lowercase_tools(cmd.body).rstrip() + "\n"
content = _opencode_frontmatter(fm) + "\n\n" + body
result.written.append(self.write(rel, content))