CtrlK
BlogDocsLog inGet started
Tessl Logo

punkdev/cc2oc

Add and ship OpenCode support for one Claude Code plugin at a time. Includes core migration to a reviewable `opencode-plugin/` adapter, maintainer-facing docs, and follow-up CI/versioning setup for package publishing or skill-copy drift checks.

92

1.25x
Quality

92%

Does it follow best practices?

Impact

97%

1.25x

Average score across 2 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

inspect_repo.pyskills/migrate-plugin/scripts/

#!/usr/bin/env python3
"""Inspect a Claude plugin or skill repo before adding OpenCode support."""

from __future__ import annotations

import argparse
from pathlib import Path

from _common import iter_files, iter_skill_files, print_result, rel


HOOK_EXTENSIONS = {".py", ".sh", ".bash", ".js", ".mjs", ".cjs", ".ts"}


def classify(root: Path, assets: list[dict[str, str]]) -> str:
    has_marketplace = any(asset["kind"] == "claude-marketplace" for asset in assets)
    has_plugin = any(asset["kind"] == "claude-plugin" for asset in assets)
    skill_count = sum(1 for asset in assets if asset["kind"] == "skill")
    hook_count = sum(1 for asset in assets if asset["kind"] == "hook-candidate")

    if has_marketplace:
        return "marketplace"
    if hook_count >= 4:
        return "hook-heavy"
    if has_plugin:
        return "single-plugin"
    if skill_count > 0:
        return "skill-only"
    return "unknown"


def inspect_repo(root: Path) -> dict:
    root = root.resolve()
    assets: list[dict[str, str]] = []
    warnings: list[str] = []

    for plugin_json in sorted(root.rglob(".claude-plugin/plugin.json")):
        location = "root" if plugin_json.parent.parent == root else "nested"
        assets.append({"path": rel(plugin_json, root), "kind": "claude-plugin", "verdict": "claude-only", "notes": f"{location} Claude plugin metadata"})

    for marketplace_json in sorted(root.rglob(".claude-plugin/marketplace.json")):
        assets.append({"path": rel(marketplace_json, root), "kind": "claude-marketplace", "verdict": "scope-decision", "notes": "Decide one package per plugin or scoped family"})

    for mcp_json in sorted(root.rglob(".mcp.json")):
        assets.append({"path": rel(mcp_json, root), "kind": "claude-mcp", "verdict": "config-migration", "notes": "Convert mcpServers to OpenCode mcp config"})

    for hooks_json in sorted(root.rglob("hooks/hooks.json")):
        assets.append({"path": rel(hooks_json, root), "kind": "claude-hooks", "verdict": "wrapped-or-rewritten", "notes": "Create per-hook OpenCode parity records"})

    for skill_file in iter_skill_files(root):
        assets.append({"path": rel(skill_file, root), "kind": "skill", "verdict": "shared", "notes": "Agent Skill source"})

    for path in iter_files(root):
        relative = rel(path, root)
        parts = set(Path(relative).parts)
        if path.suffix in HOOK_EXTENSIONS and ("hooks" in parts or ".claude" in parts or ".claude-plugin" in parts):
            assets.append({"path": relative, "kind": "hook-candidate", "verdict": "wrapped-or-rewritten", "notes": "Review for OpenCode event parity"})

    opencode_package = root / "opencode-plugin" / "package.json"
    if opencode_package.exists():
        assets.append({"path": rel(opencode_package, root), "kind": "opencode-package", "verdict": "generated-or-existing", "notes": "Validate before reusing"})

    opencode_dir = root / ".opencode"
    if opencode_dir.exists():
        warnings.append(".opencode/ exists; treat it as active OpenCode config, not a neutral generated-files directory")

    repo_shape = classify(root, assets)
    if repo_shape == "unknown":
        warnings.append("No Claude plugin metadata, skills, or hook candidates found; ask the user where the Claude plugin lives")

    return {
        "title": "OpenCode Support Repo Inspection",
        "summary": {
            "target": str(root),
            "repo_shape": repo_shape,
            "asset_count": len(assets),
        },
        "assets": assets,
        "warnings": warnings,
        "errors": [],
    }


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("target", nargs="?", default=".", help="Repo to inspect")
    parser.add_argument("--format", choices=("json", "markdown"), default="json")
    args = parser.parse_args()

    print_result(inspect_repo(Path(args.target)), args.format)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

README.md

tile.json