mirror of
https://github.com/wshobson/agents
synced 2026-06-21 14:13:58 +00:00
Add OpenCode global install support (#555)
* Add OpenCode global install support * Format OpenCode PR updates * Address OpenCode install review feedback * Cover OpenCode skill name length validation * Guard OpenCode stale artifact scan
This commit is contained in:
@@ -43,9 +43,10 @@ CI (`.github/workflows/validate.yml`) runs all four on every PR plus installs Op
|
||||
```bash
|
||||
make generate HARNESS=codex # emits .codex/skills, .codex/agents
|
||||
make generate HARNESS=cursor # emits .cursor-plugin/, .cursor/rules/
|
||||
make generate HARNESS=opencode # emits .opencode/agents/, .opencode/commands/
|
||||
make generate HARNESS=opencode # emits .opencode/agents/, .opencode/commands/, .opencode/skills/
|
||||
make generate HARNESS=gemini # emits skills/, agents/, commands/ at extension root
|
||||
make generate-all # all four
|
||||
make install-opencode # symlink generated OpenCode artifacts into global config
|
||||
```
|
||||
|
||||
Source-of-truth lives only under `plugins/`. Generated artifacts are gitignored — never hand-edit them.
|
||||
@@ -56,10 +57,12 @@ Source-of-truth lives only under `plugins/`. Generated artifacts are gitignored
|
||||
|
||||
- **Claude Code**: auto-discovery via Anthropic's SKILL.md spec
|
||||
- **Codex CLI**: mirrored to `.codex/skills/<plugin>__<skill>/` (8 KB body cap; detail in `references/details.md`)
|
||||
- **OpenCode**: reads `.claude/skills/` directly (no re-emit)
|
||||
- **OpenCode**: mirrored to `.opencode/skills/<plugin>-<skill>/` using hyphenated names for global install
|
||||
- **Cursor**: reads `.claude/skills/` directly (no re-emit)
|
||||
- **Gemini CLI**: native skills at `skills/<plugin>__<skill>/SKILL.md`
|
||||
|
||||
Top-level `skills/` is Gemini output; do not use it for OpenCode installs.
|
||||
|
||||
## Subagents (cross-harness)
|
||||
|
||||
191 subagents under `plugins/*/agents/<name>.md`. Per-harness transpilation:
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ Each adapter consumes the canonical `plugins/` source and emits harness-native a
|
||||
|---|---|---|
|
||||
| `codex.py` | `.codex/skills/`, `.codex/agents/*.toml` | Markdown → TOML transform, 8 KB body cap with `references/` overflow, sandbox_mode heuristic, collision detection |
|
||||
| `cursor.py` | `.cursor-plugin/`, `.cursor/rules/*.mdc` | Marketplace manifests + hand-curated rules. Cursor reads `.claude/` directly for skills/agents |
|
||||
| `opencode.py` | `.opencode/agents/`, `.opencode/commands/` | Permission block from `tools:` allowlist (locked agents preserve intent); strict lowercase tool names |
|
||||
| `opencode.py` | `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/` | Permission block from `tools:` allowlist (locked agents preserve intent); strict lowercase tool names; OpenCode-safe skill names |
|
||||
| `gemini.py` | `skills/`, `agents/`, `commands/*.toml` at extension root | Native skills + April-2026 subagents; `@{path}` injection for large command bodies |
|
||||
|
||||
Detail in [`docs/harnesses.md`](docs/harnesses.md) (capability matrix per harness) and [`docs/architecture.md`](docs/architecture.md) (full design rationale).
|
||||
|
||||
@@ -16,7 +16,7 @@ YTX_SCRIPT := yt-design-extractor.py
|
||||
# `uv run` against the plugin-eval venv — has pyyaml + extra-paths to tools/adapters/
|
||||
UV_TOOLS := uv run $(EVAL_PROJECT) python
|
||||
|
||||
.PHONY: help install install-ocr install-easyocr deps check run run-full run-ocr run-transcript clean generate generate-all clean-generated validate garden test smoke-test generate-plugin sync-commands generate-all-commands clean-commands
|
||||
.PHONY: help install install-ocr install-easyocr deps check run run-full run-ocr run-transcript clean generate generate-all clean-generated install-opencode uninstall-opencode validate garden test smoke-test generate-plugin sync-commands generate-all-commands clean-commands
|
||||
|
||||
help:
|
||||
@echo "claude-agents — multi-harness plugin marketplace"
|
||||
@@ -26,6 +26,8 @@ help:
|
||||
@echo " make generate HARNESS=<h> [PLUGIN=<p>] Generate per-harness artifacts (defaults to all plugins)"
|
||||
@echo " make generate-all Generate for ALL harnesses + ALL plugins"
|
||||
@echo " make clean-generated [HARNESS=<h>] Remove generated artifacts"
|
||||
@echo " make install-opencode [FORCE=1] Symlink OpenCode artifacts into global config"
|
||||
@echo " make uninstall-opencode Remove repo-owned OpenCode symlinks"
|
||||
@echo " make validate [HARNESS=<h>] [STRICT=1] Structural validation of generated artifacts"
|
||||
@echo " make garden [STRICT=1] Run doc-gardener (drift detection)"
|
||||
@echo " make test Full pytest suite (plugin-eval + tools)"
|
||||
@@ -213,6 +215,13 @@ else
|
||||
done
|
||||
endif
|
||||
|
||||
install-opencode:
|
||||
$(UV_TOOLS) $(GENERATE) --harness opencode --all --prune
|
||||
$(UV_TOOLS) tools/install_opencode.py install $(if $(filter 1 true TRUE yes YES,$(FORCE)),--force)
|
||||
|
||||
uninstall-opencode:
|
||||
$(UV_TOOLS) tools/install_opencode.py uninstall
|
||||
|
||||
# Legacy Gemini wrappers (delegate to the unified CLI)
|
||||
generate-plugin:
|
||||
ifndef PLUGIN
|
||||
|
||||
@@ -81,7 +81,7 @@ emits harness-native artifacts (not lowest-common-denominator translations):
|
||||
| **Claude Code** | (source-of-truth) | Native `marketplace.json` + `plugins/` |
|
||||
| **Codex CLI** | `.codex/skills/`, `.codex/agents/`, `AGENTS.md` | 8 KB skill cap respected; commands → skills |
|
||||
| **Cursor** | `.cursor-plugin/`, `.cursor/rules/` | Thin marketplace + curated rules; reuses `.claude/` |
|
||||
| **OpenCode** | `.opencode/agents/`, `.opencode/commands/` | `permission:` block from `tools:` allowlist |
|
||||
| **OpenCode** | `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/` | `permission:` block from `tools:` allowlist; OpenCode-safe skill names |
|
||||
| **Gemini CLI** | `skills/`, `agents/`, `commands/` (TOML) | Native skills + subagents (April 2026 spec) |
|
||||
|
||||
```bash
|
||||
|
||||
+8
-4
@@ -13,14 +13,14 @@ as Claude Code markdown. Per-harness artifacts are generated by adapters under `
|
||||
| **Claude Code** | source-of-truth | `plugins/`, `.claude-plugin/marketplace.json` |
|
||||
| **OpenAI Codex CLI** | supported | `.codex/skills/`, `.codex/agents/`, `AGENTS.md` |
|
||||
| **Cursor** (2.5+) | supported | `.cursor-plugin/`, `.cursor/rules/` (curated) |
|
||||
| **OpenCode** (`sst/opencode`) | supported | `.opencode/agents/`, `.opencode/commands/`, `opencode.json` |
|
||||
| **OpenCode** (`sst/opencode`) | supported | `.opencode/agents/`, `.opencode/commands/`, `.opencode/skills/`, `opencode.json` |
|
||||
| **Gemini CLI** | supported | `skills/`, `agents/`, `commands/` (at extension root) |
|
||||
|
||||
## Capability matrix
|
||||
|
||||
| Capability | Claude Code | Codex | Cursor | OpenCode | Gemini |
|
||||
|---|---|---|---|---|---|
|
||||
| Skills (SKILL.md native) | ✅ | ✅ | ✅ via `.claude/` | ✅ via `.claude/` | ✅ (auto-discovered) |
|
||||
| Skills (SKILL.md native) | ✅ | ✅ | ✅ via `.claude/` | ✅ via `.opencode/skills/` | ✅ (auto-discovered) |
|
||||
| Subagents (markdown native) | ✅ | TOML format | ✅ via `.claude/` | ✅ (different frontmatter) | ✅ (April 2026 spec) |
|
||||
| Slash commands | ✅ | converted to skills | ✅ | ✅ | TOML at `commands/` |
|
||||
| Plugin marketplace | ✅ | — | ✅ (2.5+) | — | — (direct URL install) |
|
||||
@@ -59,8 +59,8 @@ plugins/ # SOURCE OF TRUTH (committed)
|
||||
GEMINI.md, AGENTS.md* # context files (committed/generated)
|
||||
.codex/skills/, .codex/agents/ # generated by Codex adapter
|
||||
.cursor-plugin/, .cursor/rules/ # generated by Cursor adapter
|
||||
.opencode/agents/, .opencode/commands/, opencode.json # generated by OpenCode adapter
|
||||
skills/, agents/, commands/ # generated by Gemini adapter (at extension root)
|
||||
.opencode/agents/, .opencode/commands/, .opencode/skills/, opencode.json # generated by OpenCode adapter
|
||||
skills/, agents/, commands/ # generated by Gemini adapter (at extension root; not for OpenCode install)
|
||||
```
|
||||
|
||||
## Regenerating
|
||||
@@ -72,6 +72,10 @@ make generate HARNESS=opencode
|
||||
make generate HARNESS=gemini
|
||||
# Or all at once:
|
||||
make generate-all
|
||||
|
||||
# Optional global OpenCode install:
|
||||
make install-opencode
|
||||
make uninstall-opencode
|
||||
```
|
||||
|
||||
## External Pensyve integrations
|
||||
|
||||
@@ -127,7 +127,7 @@ CAPABILITIES: dict[str, Capability] = {
|
||||
skill_body_max_bytes=_NO_CAP,
|
||||
tool_name_case="lowercase",
|
||||
bare_model_aliases=False, # use full provider/model-id
|
||||
notes="Reads .claude/skills/ verbatim (toggle via OPENCODE_DISABLE_CLAUDE_CODE_SKILLS). Agent frontmatter uses permission: block (not tools:). Tool names are strictly lowercase.",
|
||||
notes="Emits .opencode/skills/ with OpenCode-safe names for install. Agent frontmatter uses permission: block (not tools:). Tool names are strictly lowercase.",
|
||||
),
|
||||
"gemini": Capability(
|
||||
harness_id="gemini",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""OpenCode adapter.
|
||||
|
||||
OpenCode reads `.claude/skills/` directly, so the adapter focuses on the parts that
|
||||
need real transpilation:
|
||||
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.json` at root with `"$schema": "https://opencode.ai/config.json"`.
|
||||
3. `.opencode/skills/<plugin>-<skill>/SKILL.md` — skills with OpenCode-valid names.
|
||||
4. `opencode.json` at root with `"$schema": "https://opencode.ai/config.json"`.
|
||||
|
||||
Sources: research summary by `a8a6c57414dc1ba23` synthesized into the plan.
|
||||
"""
|
||||
@@ -23,12 +24,15 @@ from tools.adapters.base import (
|
||||
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 = [
|
||||
@@ -145,16 +149,35 @@ def _opencode_frontmatter(fm: dict) -> str:
|
||||
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)
|
||||
# Skills are read directly from .claude/skills/ — no emission.
|
||||
return result
|
||||
|
||||
def emit_global(self, plugins: list[PluginSource]) -> EmitResult:
|
||||
@@ -169,6 +192,33 @@ class OpenCodeAdapter(HarnessAdapter):
|
||||
|
||||
# ── 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"
|
||||
|
||||
@@ -153,6 +153,35 @@ def check_stale_artifacts(report: Report) -> None:
|
||||
if src.is_file():
|
||||
pairs.append((src, md))
|
||||
|
||||
# OpenCode skills (.opencode/skills/<plugin>-<skill>/SKILL.md)
|
||||
opencode_skills = WORKTREE / ".opencode" / "skills"
|
||||
if opencode_skills.is_dir():
|
||||
skill_sources: dict[str, Path] = {}
|
||||
if PLUGINS_DIR.is_dir():
|
||||
for plugin_dir in sorted(PLUGINS_DIR.iterdir()):
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
for skill_file in sorted((plugin_dir / "skills").glob("*/SKILL.md")):
|
||||
skill_id = f"{plugin_dir.name}-{skill_file.parent.name}"
|
||||
existing = skill_sources.get(skill_id)
|
||||
if existing and existing != skill_file:
|
||||
report.add(
|
||||
kind="opencode-skill-id-collision",
|
||||
severity="error",
|
||||
path=skill_file,
|
||||
message=(
|
||||
f"OpenCode skill id `{skill_id}` also maps to "
|
||||
f"{existing.relative_to(WORKTREE)}"
|
||||
),
|
||||
fix="Rename the plugin or skill so OpenCode's hyphenated skill id is unique.",
|
||||
)
|
||||
continue
|
||||
skill_sources[skill_id] = skill_file
|
||||
for skill_md in opencode_skills.glob("*/SKILL.md"):
|
||||
src = skill_sources.get(skill_md.parent.name)
|
||||
if src and src.is_file():
|
||||
pairs.append((src, skill_md))
|
||||
|
||||
# Gemini skills and agents at root
|
||||
for top_dir in ("skills", "agents"):
|
||||
root = WORKTREE / top_dir
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install generated OpenCode artifacts into the user's OpenCode config directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
GENERATED_ROOT = REPO_ROOT / ".opencode"
|
||||
ARTIFACT_GLOBS = {
|
||||
"agents": "*.md",
|
||||
"commands": "*.md",
|
||||
"skills": "*",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallReport:
|
||||
linked: int = 0
|
||||
unchanged: int = 0
|
||||
removed: int = 0
|
||||
skipped: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
|
||||
def default_config_dir(env: dict[str, str] | None = None) -> Path:
|
||||
env = env or os.environ
|
||||
if env.get("OPENCODE_CONFIG_DIR"):
|
||||
return Path(env["OPENCODE_CONFIG_DIR"]).expanduser()
|
||||
if env.get("XDG_CONFIG_HOME"):
|
||||
return Path(env["XDG_CONFIG_HOME"]).expanduser() / "opencode"
|
||||
return Path.home() / ".config" / "opencode"
|
||||
|
||||
|
||||
def _is_relative_to(child: Path, parent: Path) -> bool:
|
||||
try:
|
||||
child.resolve(strict=False).relative_to(parent.resolve(strict=False))
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _generated_artifacts(repo_root: Path) -> list[tuple[str, Path]]:
|
||||
generated_root = repo_root / ".opencode"
|
||||
artifacts: list[tuple[str, Path]] = []
|
||||
for subdir, pattern in ARTIFACT_GLOBS.items():
|
||||
source_dir = generated_root / subdir
|
||||
if not source_dir.is_dir():
|
||||
raise FileNotFoundError(
|
||||
f"{source_dir} does not exist; run `make generate HARNESS=opencode` first"
|
||||
)
|
||||
for src in sorted(source_dir.glob(pattern)):
|
||||
if subdir == "skills" and not src.is_dir():
|
||||
continue
|
||||
if subdir != "skills" and not src.is_file():
|
||||
continue
|
||||
artifacts.append((subdir, src.resolve()))
|
||||
return artifacts
|
||||
|
||||
|
||||
def _link_one(src: Path, dst: Path, *, force: bool, report: InstallReport) -> None:
|
||||
if dst.is_symlink():
|
||||
if dst.resolve(strict=False) == src:
|
||||
report.unchanged += 1
|
||||
return
|
||||
if not force:
|
||||
report.errors.append(
|
||||
f"{dst} already exists as a symlink to {dst.resolve(strict=False)}; "
|
||||
"rerun with FORCE=1 to replace symlink conflicts"
|
||||
)
|
||||
return
|
||||
dst.unlink()
|
||||
elif dst.exists():
|
||||
report.errors.append(f"{dst} already exists and is not a symlink; refusing to overwrite")
|
||||
return
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.symlink_to(src, target_is_directory=src.is_dir())
|
||||
report.linked += 1
|
||||
|
||||
|
||||
def install(
|
||||
*,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
config_dir: Path | None = None,
|
||||
force: bool = False,
|
||||
) -> InstallReport:
|
||||
config_dir = (config_dir or default_config_dir()).expanduser()
|
||||
report = InstallReport()
|
||||
try:
|
||||
artifacts = _generated_artifacts(repo_root)
|
||||
except FileNotFoundError as exc:
|
||||
report.errors.append(str(exc))
|
||||
return report
|
||||
|
||||
for subdir, src in artifacts:
|
||||
dst = config_dir / subdir / src.name
|
||||
_link_one(src, dst, force=force, report=report)
|
||||
return report
|
||||
|
||||
|
||||
def uninstall(
|
||||
*,
|
||||
repo_root: Path = REPO_ROOT,
|
||||
config_dir: Path | None = None,
|
||||
) -> InstallReport:
|
||||
config_dir = (config_dir or default_config_dir()).expanduser()
|
||||
generated_root = (repo_root / ".opencode").resolve(strict=False)
|
||||
report = InstallReport()
|
||||
|
||||
for subdir in ARTIFACT_GLOBS:
|
||||
target_dir = config_dir / subdir
|
||||
if not target_dir.is_dir():
|
||||
continue
|
||||
for dst in sorted(target_dir.iterdir()):
|
||||
if not dst.is_symlink():
|
||||
report.skipped += 1
|
||||
continue
|
||||
target = dst.resolve(strict=False)
|
||||
if _is_relative_to(target, generated_root):
|
||||
dst.unlink()
|
||||
report.removed += 1
|
||||
else:
|
||||
report.skipped += 1
|
||||
return report
|
||||
|
||||
|
||||
def _print_report(action: str, config_dir: Path, report: InstallReport) -> None:
|
||||
print(
|
||||
f"{action}: config={config_dir} linked={report.linked} unchanged={report.unchanged} "
|
||||
f"removed={report.removed} skipped={report.skipped}"
|
||||
)
|
||||
for error in report.errors:
|
||||
print(f"error: {error}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=("install", "uninstall"))
|
||||
parser.add_argument("--config-dir", type=Path, default=None)
|
||||
parser.add_argument("--repo-root", type=Path, default=REPO_ROOT)
|
||||
parser.add_argument("--force", action="store_true", help="Replace conflicting symlinks only")
|
||||
args = parser.parse_args()
|
||||
|
||||
config_dir = (args.config_dir or default_config_dir()).expanduser()
|
||||
if args.action == "install":
|
||||
report = install(repo_root=args.repo_root, config_dir=config_dir, force=args.force)
|
||||
else:
|
||||
report = uninstall(repo_root=args.repo_root, config_dir=config_dir)
|
||||
_print_report(args.action, config_dir, report)
|
||||
return 0 if report.ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -16,7 +16,7 @@ from tools.adapters.base import PluginSource, parse_frontmatter
|
||||
from tools.adapters.codex import CodexAdapter, _split_body_if_oversized
|
||||
from tools.adapters.cursor import CursorAdapter
|
||||
from tools.adapters.gemini import _INLINE_BODY_THRESHOLD, GeminiAdapter
|
||||
from tools.adapters.opencode import OpenCodeAdapter
|
||||
from tools.adapters.opencode import OpenCodeAdapter, _opencode_skill_id
|
||||
|
||||
# ── Codex ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -590,8 +590,7 @@ class TestOpenCodeAdapter:
|
||||
|
||||
def test_lowercases_tool_refs_in_body(self, synthetic_plugin: PluginSource, output_root: Path):
|
||||
OpenCodeAdapter(output_root=output_root).emit_plugin(synthetic_plugin)
|
||||
# Body of the skill references `Read` and `Bash` — but OpenCode doesn't emit
|
||||
# skills (it reads .claude/). So check the command body instead:
|
||||
# Commands and agents need OpenCode's lowercase tool vocabulary.
|
||||
cmd_md = output_root / ".opencode" / "commands" / "demo__say-hi.md"
|
||||
assert cmd_md.is_file()
|
||||
|
||||
@@ -604,11 +603,145 @@ class TestOpenCodeAdapter:
|
||||
data = json.loads(cfg.read_text())
|
||||
assert data["$schema"] == "https://opencode.ai/config.json"
|
||||
|
||||
def test_no_skills_emitted(self, synthetic_plugin: PluginSource, output_root: Path):
|
||||
"""OpenCode reads .claude/skills/ directly — adapter should NOT mirror skills."""
|
||||
def test_emits_opencode_skill_with_hyphenated_name(
|
||||
self, synthetic_plugin: PluginSource, output_root: Path
|
||||
):
|
||||
OpenCodeAdapter(output_root=output_root).emit_plugin(synthetic_plugin)
|
||||
skills_dir = output_root / ".opencode" / "skills"
|
||||
assert not skills_dir.exists() or not any(skills_dir.iterdir())
|
||||
skill_md = output_root / ".opencode" / "skills" / "demo-hello" / "SKILL.md"
|
||||
assert skill_md.is_file()
|
||||
fm, body = parse_frontmatter(skill_md.read_text())
|
||||
assert fm["name"] == "demo-hello"
|
||||
assert fm["description"] == "Use when greeting users."
|
||||
assert "# Hello" in body
|
||||
assert "`read`" in body
|
||||
assert "`bash`" in body
|
||||
assert "`Read`" not in body
|
||||
assert "`Bash`" not in body
|
||||
|
||||
def test_emits_opencode_skill_support_files(self, tmp_path: Path, output_root: Path):
|
||||
from tools.tests.conftest import _make_skill
|
||||
|
||||
plugin_dir = tmp_path / "demo"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / ".claude-plugin").mkdir()
|
||||
(plugin_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "demo"}')
|
||||
skill = _make_skill(
|
||||
plugin_dir,
|
||||
"with-assets",
|
||||
"name: with-assets\ndescription: Use when testing assets.",
|
||||
"# With Assets\n\nBody.\n",
|
||||
)
|
||||
(skill.dir / "references").mkdir()
|
||||
(skill.dir / "references" / "details.md").write_text("More detail.\n")
|
||||
(skill.dir / "assets").mkdir()
|
||||
(skill.dir / "assets" / "icon.bin").write_bytes(b"\x00\x01")
|
||||
plugin = PluginSource(
|
||||
name="demo", dir=plugin_dir, plugin_json={"name": "demo"}, skills=[skill]
|
||||
)
|
||||
|
||||
OpenCodeAdapter(output_root=output_root).emit_plugin(plugin)
|
||||
|
||||
skill_dir = output_root / ".opencode" / "skills" / "demo-with-assets"
|
||||
assert (skill_dir / "references" / "details.md").read_text() == "More detail.\n"
|
||||
assert (skill_dir / "assets" / "icon.bin").read_bytes() == b"\x00\x01"
|
||||
|
||||
def test_rejects_invalid_opencode_skill_id(self, tmp_path: Path):
|
||||
from tools.tests.conftest import _make_skill
|
||||
|
||||
plugin_dir = tmp_path / "bad_plugin"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / ".claude-plugin").mkdir()
|
||||
(plugin_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "bad_plugin"}')
|
||||
skill = _make_skill(
|
||||
plugin_dir,
|
||||
"hello",
|
||||
"name: hello\ndescription: Use when testing.",
|
||||
"# Hello\n\nBody.\n",
|
||||
)
|
||||
plugin = PluginSource(
|
||||
name="bad_plugin", dir=plugin_dir, plugin_json={"name": "bad_plugin"}, skills=[skill]
|
||||
)
|
||||
|
||||
try:
|
||||
_opencode_skill_id(plugin, skill)
|
||||
except ValueError as exc:
|
||||
assert "must match" in str(exc)
|
||||
else:
|
||||
raise AssertionError("invalid OpenCode skill id was accepted")
|
||||
|
||||
def test_rejects_too_long_opencode_skill_id(self, tmp_path: Path):
|
||||
from tools.tests.conftest import _make_skill
|
||||
|
||||
plugin_dir = tmp_path / "demo"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / ".claude-plugin").mkdir()
|
||||
(plugin_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "demo"}')
|
||||
skill = _make_skill(
|
||||
plugin_dir,
|
||||
"x" * 80,
|
||||
"name: long\ndescription: Use when testing.",
|
||||
"# Long\n\nBody.\n",
|
||||
)
|
||||
plugin = PluginSource(
|
||||
name="demo", dir=plugin_dir, plugin_json={"name": "demo"}, skills=[skill]
|
||||
)
|
||||
|
||||
try:
|
||||
_opencode_skill_id(plugin, skill)
|
||||
except ValueError as exc:
|
||||
assert "limit" in str(exc)
|
||||
else:
|
||||
raise AssertionError("too-long OpenCode skill id was accepted")
|
||||
|
||||
def test_rejects_ambiguous_opencode_skill_id_collision(self, tmp_path: Path, output_root: Path):
|
||||
from tools.tests.conftest import _make_skill
|
||||
|
||||
first_dir = tmp_path / "data-analysis"
|
||||
second_dir = tmp_path / "data"
|
||||
first_dir.mkdir()
|
||||
second_dir.mkdir()
|
||||
for plugin_dir in (first_dir, second_dir):
|
||||
(plugin_dir / ".claude-plugin").mkdir()
|
||||
(plugin_dir / ".claude-plugin" / "plugin.json").write_text(
|
||||
f'{{"name": "{plugin_dir.name}"}}'
|
||||
)
|
||||
|
||||
first_skill = _make_skill(
|
||||
first_dir,
|
||||
"report",
|
||||
"name: report\ndescription: Use when testing.",
|
||||
"# Report\n\nBody.\n",
|
||||
)
|
||||
second_skill = _make_skill(
|
||||
second_dir,
|
||||
"analysis-report",
|
||||
"name: analysis-report\ndescription: Use when testing.",
|
||||
"# Analysis Report\n\nBody.\n",
|
||||
)
|
||||
first = PluginSource(
|
||||
name="data-analysis",
|
||||
dir=first_dir,
|
||||
plugin_json={"name": "data-analysis"},
|
||||
skills=[first_skill],
|
||||
)
|
||||
second = PluginSource(
|
||||
name="data",
|
||||
dir=second_dir,
|
||||
plugin_json={"name": "data"},
|
||||
skills=[second_skill],
|
||||
)
|
||||
|
||||
adapter = OpenCodeAdapter(output_root=output_root)
|
||||
adapter.emit_plugin(first)
|
||||
|
||||
try:
|
||||
adapter.emit_plugin(second)
|
||||
except ValueError as exc:
|
||||
assert "collision" in str(exc)
|
||||
assert "data-analysis/report" in str(exc)
|
||||
assert "data/analysis-report" in str(exc)
|
||||
else:
|
||||
raise AssertionError("ambiguous OpenCode skill id collision was accepted")
|
||||
|
||||
def test_explicit_empty_tools_yields_locked_permission_block(
|
||||
self, tmp_path: Path, output_root: Path
|
||||
|
||||
@@ -77,6 +77,42 @@ class TestStaleArtifacts:
|
||||
check_stale_artifacts(report)
|
||||
assert [f for f in report.findings if f.kind == "STALE_ARTIFACT"]
|
||||
|
||||
def test_opencode_skill_id_collision_errors(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_patch_paths(monkeypatch, tmp_path)
|
||||
first = tmp_path / "plugins" / "data-analysis" / "skills" / "report"
|
||||
second = tmp_path / "plugins" / "data" / "skills" / "analysis-report"
|
||||
first.mkdir(parents=True)
|
||||
second.mkdir(parents=True)
|
||||
for skill in (first, second):
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: test\ndescription: Use when testing.\n---\n\nBody.\n"
|
||||
)
|
||||
(tmp_path / ".opencode" / "skills" / "data-analysis-report").mkdir(parents=True)
|
||||
|
||||
report = Report()
|
||||
check_stale_artifacts(report)
|
||||
|
||||
findings = [f for f in report.findings if f.kind == "opencode-skill-id-collision"]
|
||||
assert findings
|
||||
assert "data-analysis-report" in findings[0].message
|
||||
|
||||
def test_missing_plugins_dir_does_not_crash_for_opencode_skills(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
_patch_paths(monkeypatch, tmp_path)
|
||||
skill = tmp_path / ".opencode" / "skills" / "demo-greeter"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: demo-greeter\ndescription: Use when greeting.\n---\n\nBody.\n"
|
||||
)
|
||||
|
||||
report = Report()
|
||||
check_stale_artifacts(report)
|
||||
|
||||
assert [f for f in report.findings if f.kind == "opencode-skill-id-collision"] == []
|
||||
|
||||
|
||||
# ── Context file size ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for safe OpenCode global install/uninstall helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from tools.install_opencode import default_config_dir, install, uninstall
|
||||
|
||||
|
||||
def _write_generated_opencode(repo_root: Path) -> None:
|
||||
agents = repo_root / ".opencode" / "agents"
|
||||
commands = repo_root / ".opencode" / "commands"
|
||||
skills = repo_root / ".opencode" / "skills" / "demo-hello"
|
||||
agents.mkdir(parents=True)
|
||||
commands.mkdir(parents=True)
|
||||
skills.mkdir(parents=True)
|
||||
(agents / "demo__agent.md").write_text("agent\n")
|
||||
(commands / "demo__command.md").write_text("command\n")
|
||||
(skills / "SKILL.md").write_text("---\nname: demo-hello\n---\n\nBody.\n")
|
||||
|
||||
|
||||
def test_default_config_dir_prefers_opencode_config_dir(tmp_path: Path):
|
||||
env = {
|
||||
"OPENCODE_CONFIG_DIR": str(tmp_path / "custom"),
|
||||
"XDG_CONFIG_HOME": str(tmp_path / "xdg"),
|
||||
}
|
||||
assert default_config_dir(env) == tmp_path / "custom"
|
||||
|
||||
|
||||
def test_default_config_dir_uses_xdg_config_home(tmp_path: Path):
|
||||
assert default_config_dir({"XDG_CONFIG_HOME": str(tmp_path / "xdg")}) == (
|
||||
tmp_path / "xdg" / "opencode"
|
||||
)
|
||||
|
||||
|
||||
def test_install_creates_idempotent_symlinks(tmp_path: Path):
|
||||
repo_root = tmp_path / "repo"
|
||||
config_dir = tmp_path / "config"
|
||||
_write_generated_opencode(repo_root)
|
||||
|
||||
first = install(repo_root=repo_root, config_dir=config_dir)
|
||||
second = install(repo_root=repo_root, config_dir=config_dir)
|
||||
|
||||
assert first.ok
|
||||
assert first.linked == 3
|
||||
assert second.ok
|
||||
assert second.unchanged == 3
|
||||
assert (config_dir / "agents" / "demo__agent.md").is_symlink()
|
||||
assert (config_dir / "commands" / "demo__command.md").is_symlink()
|
||||
assert (config_dir / "skills" / "demo-hello").is_symlink()
|
||||
|
||||
|
||||
def test_install_refuses_to_overwrite_real_files(tmp_path: Path):
|
||||
repo_root = tmp_path / "repo"
|
||||
config_dir = tmp_path / "config"
|
||||
_write_generated_opencode(repo_root)
|
||||
target = config_dir / "agents" / "demo__agent.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("user file\n")
|
||||
|
||||
report = install(repo_root=repo_root, config_dir=config_dir)
|
||||
|
||||
assert not report.ok
|
||||
assert "not a symlink" in report.errors[0]
|
||||
assert target.read_text() == "user file\n"
|
||||
|
||||
|
||||
def test_force_replaces_conflicting_symlink_only(tmp_path: Path):
|
||||
repo_root = tmp_path / "repo"
|
||||
config_dir = tmp_path / "config"
|
||||
other = tmp_path / "other.md"
|
||||
other.write_text("other\n")
|
||||
_write_generated_opencode(repo_root)
|
||||
target = config_dir / "agents" / "demo__agent.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.symlink_to(other)
|
||||
|
||||
blocked = install(repo_root=repo_root, config_dir=config_dir)
|
||||
forced = install(repo_root=repo_root, config_dir=config_dir, force=True)
|
||||
|
||||
assert not blocked.ok
|
||||
assert forced.ok
|
||||
assert target.resolve() == (repo_root / ".opencode" / "agents" / "demo__agent.md").resolve()
|
||||
|
||||
|
||||
def test_uninstall_removes_only_repo_owned_symlinks(tmp_path: Path):
|
||||
repo_root = tmp_path / "repo"
|
||||
config_dir = tmp_path / "config"
|
||||
_write_generated_opencode(repo_root)
|
||||
assert install(repo_root=repo_root, config_dir=config_dir).ok
|
||||
|
||||
unrelated_target = tmp_path / "unrelated.md"
|
||||
unrelated_target.write_text("unrelated\n")
|
||||
unrelated = config_dir / "agents" / "unrelated.md"
|
||||
unrelated.symlink_to(unrelated_target)
|
||||
real_file = config_dir / "commands" / "user.md"
|
||||
real_file.write_text("user\n")
|
||||
|
||||
report = uninstall(repo_root=repo_root, config_dir=config_dir)
|
||||
|
||||
assert report.ok
|
||||
assert report.removed == 3
|
||||
assert not (config_dir / "agents" / "demo__agent.md").exists()
|
||||
assert unrelated.is_symlink()
|
||||
assert real_file.read_text() == "user\n"
|
||||
@@ -124,6 +124,12 @@ class TestOpenCodeRoundTrip:
|
||||
f"command count mismatch: source={_source_command_count()} opencode={n}"
|
||||
)
|
||||
|
||||
def test_opencode_skill_count_matches_source(self):
|
||||
n = len(list((WORKTREE / ".opencode" / "skills").glob("*/SKILL.md")))
|
||||
assert n == _source_skill_count(), (
|
||||
f"skill count mismatch: source={_source_skill_count()} opencode={n}"
|
||||
)
|
||||
|
||||
def test_every_opencode_agent_has_required_frontmatter(self):
|
||||
modes = {"primary", "subagent", "all"}
|
||||
problems = []
|
||||
@@ -171,6 +177,22 @@ class TestOpenCodeRoundTrip:
|
||||
problems.append(f"{agent_id}: read should be denied for locked agent")
|
||||
assert not problems, "Locked-agent permission regressions:\n " + "\n ".join(problems)
|
||||
|
||||
def test_every_opencode_skill_has_valid_frontmatter(self):
|
||||
name_re = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
problems = []
|
||||
for skill_md in (WORKTREE / ".opencode" / "skills").glob("*/SKILL.md"):
|
||||
fm, _ = parse_frontmatter(skill_md.read_text())
|
||||
name = str(fm.get("name") or "")
|
||||
if name != skill_md.parent.name:
|
||||
problems.append(f"{skill_md}: name {name!r} != directory {skill_md.parent.name!r}")
|
||||
if not name_re.fullmatch(name):
|
||||
problems.append(f"{skill_md}: invalid OpenCode skill name {name!r}")
|
||||
if len(name) > 64:
|
||||
problems.append(f"{skill_md}: OpenCode skill name exceeds 64 chars")
|
||||
if not str(fm.get("description") or "").strip():
|
||||
problems.append(f"{skill_md}: missing description")
|
||||
assert not problems, "OpenCode skill issues:\n " + "\n ".join(problems[:20])
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not (WORKTREE / ".cursor-plugin").is_dir(),
|
||||
|
||||
@@ -222,6 +222,53 @@ class TestOpenCodeValidator:
|
||||
validate_opencode(report)
|
||||
assert any("permission.read" in f.message and "maybe" in f.message for f in report.errors())
|
||||
|
||||
def test_skill_name_mismatch_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_worktree(monkeypatch, tmp_path)
|
||||
skill = tmp_path / ".opencode" / "skills" / "demo-hello"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: wrong-name\ndescription: Use when testing.\n---\n\nBody.\n"
|
||||
)
|
||||
|
||||
report = Report()
|
||||
validate_opencode(report)
|
||||
assert any("directory" in f.message for f in report.errors())
|
||||
|
||||
def test_invalid_skill_name_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_worktree(monkeypatch, tmp_path)
|
||||
skill = tmp_path / ".opencode" / "skills" / "demo__hello"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
"---\nname: demo__hello\ndescription: Use when testing.\n---\n\nBody.\n"
|
||||
)
|
||||
|
||||
report = Report()
|
||||
validate_opencode(report)
|
||||
assert any("OpenCode-safe" in f.message for f in report.errors())
|
||||
|
||||
def test_empty_skill_description_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_worktree(monkeypatch, tmp_path)
|
||||
skill = tmp_path / ".opencode" / "skills" / "demo-hello"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text("---\nname: demo-hello\n---\n\nBody.\n")
|
||||
|
||||
report = Report()
|
||||
validate_opencode(report)
|
||||
assert any("empty description" in f.message for f in report.errors())
|
||||
|
||||
def test_too_long_skill_name_errors(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_worktree(monkeypatch, tmp_path)
|
||||
name = "x" * 65
|
||||
skill = tmp_path / ".opencode" / "skills" / name
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: Use when testing.\n---\n\nBody.\n"
|
||||
)
|
||||
|
||||
report = Report()
|
||||
validate_opencode(report)
|
||||
assert any("64" in f.message for f in report.errors())
|
||||
|
||||
|
||||
# ── Gemini ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
@@ -290,6 +291,8 @@ _OPENCODE_PERMISSION_KEYS = {
|
||||
}
|
||||
_OPENCODE_PERMISSION_VALUES = {"allow", "ask", "deny"}
|
||||
_OPENCODE_MODES = {"primary", "subagent", "all"}
|
||||
_OPENCODE_SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
_OPENCODE_SKILL_NAME_MAX = 64
|
||||
|
||||
|
||||
def _extract_permission_block(raw: str) -> dict | None:
|
||||
@@ -416,6 +419,59 @@ def validate_opencode(report: Report) -> None:
|
||||
remediation="Values must be allow/ask/deny.",
|
||||
)
|
||||
|
||||
# 3. Every skill has OpenCode-valid frontmatter with name matching directory.
|
||||
skills_dir = root / "skills"
|
||||
if skills_dir.is_dir():
|
||||
for skill_md in skills_dir.glob("*/SKILL.md"):
|
||||
content = skill_md.read_text()
|
||||
fm, _ = parse_frontmatter(content)
|
||||
if not fm:
|
||||
report.add(
|
||||
severity="error",
|
||||
harness="opencode",
|
||||
path=skill_md,
|
||||
message="missing or invalid frontmatter",
|
||||
remediation="Regenerate via `make generate HARNESS=opencode`.",
|
||||
)
|
||||
continue
|
||||
|
||||
name = str(fm.get("name") or "")
|
||||
if name != skill_md.parent.name:
|
||||
report.add(
|
||||
severity="error",
|
||||
harness="opencode",
|
||||
path=skill_md,
|
||||
message=f"frontmatter name {name!r} != directory {skill_md.parent.name!r}",
|
||||
remediation="OpenCode skill names must match their directory.",
|
||||
)
|
||||
if not _OPENCODE_SKILL_NAME_RE.fullmatch(name):
|
||||
report.add(
|
||||
severity="error",
|
||||
harness="opencode",
|
||||
path=skill_md,
|
||||
message=f"skill name {name!r} is not OpenCode-safe",
|
||||
remediation="Use lowercase alphanumeric names with single hyphen separators.",
|
||||
)
|
||||
if len(name) > _OPENCODE_SKILL_NAME_MAX:
|
||||
report.add(
|
||||
severity="error",
|
||||
harness="opencode",
|
||||
path=skill_md,
|
||||
message=(
|
||||
f"skill name {name!r} is {len(name)} chars; "
|
||||
f"limit is {_OPENCODE_SKILL_NAME_MAX}"
|
||||
),
|
||||
remediation="Shorten the plugin or skill name before generating OpenCode skills.",
|
||||
)
|
||||
if not str(fm.get("description") or "").strip():
|
||||
report.add(
|
||||
severity="error",
|
||||
harness="opencode",
|
||||
path=skill_md,
|
||||
message="empty description in frontmatter",
|
||||
remediation="OpenCode skills require a description.",
|
||||
)
|
||||
|
||||
|
||||
# ── Gemini validators ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user