Skip to content

Instantly share code, notes, and snippets.

@0xqtpie
Created December 27, 2025 06:45
Show Gist options
  • Select an option

  • Save 0xqtpie/2ee1e6a01b373f5d7300a9b1018f7a7c to your computer and use it in GitHub Desktop.

Select an option

Save 0xqtpie/2ee1e6a01b373f5d7300a9b1018f7a7c to your computer and use it in GitHub Desktop.
claude code <--> opencode migrator

Claude Code ↔ OpenCode Migration Script

A self-contained Python CLI that migrates configurations between Claude Code and OpenCode formats. Supports bidirectional migration. Uses uv with PEP 723 inline dependencies for zero-setup execution.

Quick Start

# Forward: Claude Code → OpenCode
uv run migrate_claude_to_opencode.py --dry-run        # Preview changes
uv run migrate_claude_to_opencode.py --all            # Migrate everything

# Reverse: OpenCode → Claude Code
uv run migrate_claude_to_opencode.py --reverse --dry-run
uv run migrate_claude_to_opencode.py --reverse --all

# Migrate specific components
uv run migrate_claude_to_opencode.py --agents
uv run migrate_claude_to_opencode.py --commands
uv run migrate_claude_to_opencode.py --skills
uv run migrate_claude_to_opencode.py --permissions
uv run migrate_claude_to_opencode.py --mcp

What It Migrates

Forward Migration (Claude Code → OpenCode)

Source (Claude Code) Target (OpenCode)
.claude/agents/*.md .opencode/agent/*.md
.claude/commands/*.md .opencode/command/*.md
.claude/skills/<name>/SKILL.md .opencode/skill/<name>/SKILL.md
.claude/settings.json opencode.json (permission/tools)
~/.claude.json / .mcp.json opencode.json (mcp)

Reverse Migration (OpenCode → Claude Code)

Source (OpenCode) Target (Claude Code)
.opencode/agent/*.md .claude/agents/*.md
.opencode/command/*.md .claude/commands/*.md
.opencode/skill/<name>/SKILL.md .claude/skills/<name>/SKILL.md
opencode.json (permission) .claude/settings.json
opencode.json (mcp) .mcp.json or ~/.claude.json

Transformations

Agents

Forward (Claude Code → OpenCode):

  • Removes name field, adds mode: subagent
  • Converts tools string to tools object with "*": false pattern
  • Maps model aliases: sonnetanthropic/claude-sonnet-4-5
  • Maps colors to hex: yellow#EAB308
  • Normalizes tool names: mcp__tools__lstools_ls

Reverse (OpenCode → Claude Code):

  • Extracts name from filename, removes mode field
  • Converts tools object back to string list
  • Maps models back: anthropic/claude-sonnet-4-5sonnet
  • Maps hex colors back to names: #EAB308yellow
  • Denormalizes tool names: tools_lsmcp__tools__ls

Commands

Forward (Claude Code → OpenCode):

  • Adds description from first heading or filename
  • Maps model if present
  • Drops allowed-tools with warning (not supported in OpenCode)

Reverse (OpenCode → Claude Code):

  • Preserves description and model
  • Preserves allowed-tools if present

Skills

Forward (Claude Code → OpenCode):

  • Keeps name and description
  • Drops allowed-tools and model with warning (not supported in OpenCode)
  • Copies additional files in skill directory

Reverse (OpenCode → Claude Code):

  • Keeps name and description
  • Drops license, compatibility, metadata with warning (not supported in Claude Code)
  • Copies additional files in skill directory

Permissions

Forward:

  • Bash(git log:*)permission.bash["git log *"] = "allow"
  • mcp__server__* patterns → tools["server_*"] = true
  • Safe defaults: bash["*"] = "ask", core tools enabled

Reverse:

  • permission.bash["git log *"] = "allow"Bash(git log:*)
  • tools["server_*"] = truemcp__server__*

MCP Servers

Forward:

  • stdiolocal with command array
  • sse/httpremote with url
  • envenvironment
  • Adds enabled: true

Reverse:

  • localstdio with command string/array
  • remotesse with url
  • environmentenv

CLI Options

--root ROOT           Project root (default: .)
--reverse             Reverse migration: OpenCode → Claude Code
--agents              Migrate agents
--commands            Migrate commands
--skills              Migrate skills
--permissions         Migrate permissions
--mcp                 Migrate MCP servers
--all                 Migrate all (default if no specific flags)
--include-local       Include .claude/settings.local.json (forward only)
--mcp-target          Where to write MCP config: project|global (default: project)
--dry-run             Show diffs without writing
--conflict            Conflict handling: skip|overwrite|prompt (default: skip)
--no-color            Disable colored output
-v, --verbose         Verbose output

Safety Features

  • Dry-run mode: Preview all changes with unified diffs
  • Timestamped backups: Stored in .opencode-migrate-backup/YYYYMMDD-HHMMSS/
  • Conflict strategies: Skip (default), overwrite, or prompt
  • Warnings: Unsupported fields and unknown values logged

Running Tests

# Run all tests
uv run --with pytest --with PyYAML --with rich python -m pytest tests/test_migration.py -v

# Run forward migration tests only
uv run --with pytest --with PyYAML --with rich python -m pytest tests/test_migration.py -v -k "not Reverse"

# Run reverse migration tests only
uv run --with pytest --with PyYAML --with rich python -m pytest tests/test_migration.py -v -k "Reverse"

# Run integration tests only
uv run --with pytest --with PyYAML --with rich python -m pytest tests/test_migration.py -v -k "Integration"

Directory Structure

agent-tools/
├── migrate_claude_to_opencode.py  # Main script
├── README.md                       # This file
├── CHANGELOG.md                    # Version history
└── tests/
    ├── test_migration.py          # Test suite (82 tests)
    └── fixtures/
        └── claude_sample/         # Sample Claude config for testing
            └── .claude/
                ├── agents/
                ├── commands/
                └── settings.json

Field Compatibility Reference

Command Fields

Field Claude Code OpenCode Notes
description Preserved in both directions
model Mapped between formats
allowed-tools Dropped going to OpenCode

Skill Fields

Field Claude Code OpenCode Notes
name Preserved in both directions
description Preserved in both directions
allowed-tools Dropped going to OpenCode
model Dropped going to OpenCode
license Dropped going to Claude Code
compatibility Dropped going to Claude Code
metadata Dropped going to Claude Code

Unsupported Tools

The following tools are dropped during forward migration (with warnings):

  • WebSearch - No OpenCode equivalent
  • Task - Use @mention or subagent instead

Known Limitations

  • Hook migration is not supported
  • No JSON schema validation against OpenCode schema
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "PyYAML>=6.0.2",
# "rich>=13.7.0",
# ]
# ///
"""
Claude Code <-> OpenCode Migration Script
Run:
uv run tools/migrate_claude_to_opencode.py --dry-run
uv run tools/migrate_claude_to_opencode.py --agents --commands --permissions --mcp
"""
from __future__ import annotations
import argparse
import datetime as _dt
import difflib
import json
import os
import re
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
import yaml
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
# ---------------------------
# Constants and mappings
# ---------------------------
COLOR_MAP = {
"blue": "#3B82F6",
"cyan": "#06B6D4",
"green": "#22C55E",
"yellow": "#EAB308",
"magenta": "#D946EF",
"red": "#EF4444",
}
MODEL_MAP = {
"sonnet": "anthropic/claude-sonnet-4-5",
"opus": "anthropic/claude-opus-4-5",
"haiku": "anthropic/claude-haiku-4-5",
"sonnet-4.5": "anthropic/claude-sonnet-4-5",
"opus-4.5": "anthropic/claude-opus-4-5",
"haiku-4.5": "anthropic/claude-haiku-4-5",
}
UNSUPPORTED_TOOLS = {
"websearch", # No OpenCode equivalent
"task", # Use @mention or subagent instead
}
# Reverse mappings for OpenCode → Claude Code migration
REVERSE_COLOR_MAP = {v.lower(): k for k, v in COLOR_MAP.items()}
REVERSE_MODEL_MAP = {v: k.split("-")[0] for k, v in MODEL_MAP.items() if "-" not in k}
# More specific reverse mappings
REVERSE_MODEL_MAP.update(
{
"anthropic/claude-sonnet-4-5": "sonnet",
"anthropic/claude-opus-4-5": "opus",
"anthropic/claude-haiku-4-5": "haiku",
}
)
# Tool name mappings for PascalCase conversion
TOOL_PASCAL_CASE = {
"read": "Read",
"write": "Write",
"edit": "Edit",
"bash": "Bash",
"grep": "Grep",
"glob": "Glob",
"webfetch": "WebFetch",
"websearch": "WebSearch",
"task": "Task",
"notebookedit": "NotebookEdit",
"todowrite": "TodoWrite",
"skill": "Skill",
}
# OpenCode agent fields that have no Claude Code equivalent
OPENCODE_AGENT_UNSUPPORTED = {"temperature", "maxSteps", "disable"}
# OpenCode skill fields that have no Claude Code equivalent
OPENCODE_SKILL_UNSUPPORTED = {"license", "compatibility", "metadata"}
# Claude Code skill fields that have no OpenCode equivalent
CLAUDE_SKILL_UNSUPPORTED = {"allowed-tools", "model"}
# ---------------------------
# Utilities
# ---------------------------
def _now_ts() -> str:
return _dt.datetime.now().strftime("%Y%m%d-%H%M%S")
def read_text(path: Path) -> Optional[str]:
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
def write_text(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def load_json(path: Path) -> Dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return {}
except json.JSONDecodeError as e:
raise RuntimeError(f"Invalid JSON at {path}: {e}")
def dump_json(data: Dict[str, Any]) -> str:
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
def unified_diff(old: str, new: str, path: str) -> str:
a = old.splitlines(keepends=True)
b = new.splitlines(keepends=True)
diff = difflib.unified_diff(a, b, fromfile=f"a/{path}", tofile=f"b/{path}")
return "".join(diff)
def ensure_color_hex(value: Optional[str], console: Console, ctx: str) -> Optional[str]:
if not value:
return None
v = value.strip().lower()
if v in COLOR_MAP:
return COLOR_MAP[v]
if re.fullmatch(r"#?[0-9a-f]{6}", v, flags=re.IGNORECASE):
return v if v.startswith("#") else f"#{v}"
console.print(
f"[yellow]Warning:[/yellow] Unknown color '{value}' at {ctx}; keeping as-is"
)
return value
def map_model(value: Optional[str]) -> Optional[str]:
if not value:
return None
v = value.strip()
low = v.lower()
if "/" in v:
return v # Already namespaced, trust user
return MODEL_MAP.get(low, v) # fallback to original if unmapped
MCP_PAT = re.compile(r"^mcp__([A-Za-z0-9_\-]+)__([A-Za-z0-9_\-]+)$")
def normalize_tool_name(
token: str, *, console: Optional[Console] = None
) -> Optional[str]:
token_lower = token.lower()
# Drop unsupported
if token_lower in UNSUPPORTED_TOOLS:
if console:
console.print(
f"[yellow]Warning:[/yellow] Dropping unsupported tool '{token}'"
)
return None
m = MCP_PAT.match(token)
if m:
server, tool = m.group(1), m.group(2)
return f"{server.lower()}_{tool.lower()}"
# PascalCase or other → lowercase
return token_lower
def tools_list_to_mapping(
tools_list: Iterable[str], console: Console
) -> Dict[str, bool]:
out: Dict[str, bool] = {"*": False}
for raw in tools_list:
t = normalize_tool_name(raw.strip(), console=console)
if not t:
continue
out[t] = True
return out
# ---------------------------
# Reverse Migration Utilities
# ---------------------------
def reverse_map_model(value: Optional[str]) -> Optional[str]:
"""Map OpenCode model (anthropic/claude-sonnet-4-5) → Claude Code alias (sonnet)"""
if not value:
return None
v = value.strip()
if v in REVERSE_MODEL_MAP:
return REVERSE_MODEL_MAP[v]
# If already an alias or unknown, return as-is
return v
def reverse_ensure_color(
value: Optional[str], console: Console, ctx: str
) -> Optional[str]:
"""Map OpenCode hex color (#EAB308) → Claude Code name (yellow)"""
if not value:
return None
v = value.strip().lower()
if v in REVERSE_COLOR_MAP:
return REVERSE_COLOR_MAP[v]
# Check if it's already a color name
if v in COLOR_MAP:
return v
console.print(
f"[yellow]Warning:[/yellow] Unknown color '{value}' at {ctx}; keeping as-is"
)
return value
def denormalize_tool_name(token: str, *, console: Optional[Console] = None) -> str:
"""
Convert OpenCode tool name to Claude Code format.
tools_ls → mcp__tools__ls
read → Read
"""
# Check for MCP-style tool (contains underscore, not a known built-in)
if "_" in token and token.lower() not in TOOL_PASCAL_CASE:
# Split on first underscore: server_tool → mcp__server__tool
parts = token.split("_", 1)
if len(parts) == 2:
server, tool = parts
return f"mcp__{server}__{tool}"
# Convert to PascalCase if known
low = token.lower()
if low in TOOL_PASCAL_CASE:
return TOOL_PASCAL_CASE[low]
# Capitalize first letter as fallback
return token.capitalize() if token else token
def tools_mapping_to_list(tools: Dict[str, bool], console: Console) -> List[str]:
"""
Convert OpenCode tools mapping to Claude Code list.
{"*": False, "read": True, "grep": True} → ["Read", "Grep"]
"""
result = []
for name, enabled in tools.items():
if name == "*":
continue
if enabled:
denorm = denormalize_tool_name(name, console=console)
result.append(denorm)
return sorted(result)
def parse_yaml_frontmatter(md: str) -> Tuple[Optional[Dict[str, Any]], str]:
"""Return (frontmatter_dict_or_None, body)"""
if not md.startswith("---"):
return None, md
lines = md.splitlines()
if not lines or lines[0].strip() != "---":
return None, md
# find closing '---' on a line by itself
end_idx = None
for i in range(1, min(len(lines), 2000)): # safety bound
if lines[i].strip() == "---":
end_idx = i
break
if end_idx is None:
return None, md
fm_str = "\n".join(lines[1:end_idx]) + "\n"
body = "\n".join(lines[end_idx + 1 :]) + ("\n" if md.endswith("\n") else "")
try:
fm = yaml.safe_load(fm_str) or {}
if not isinstance(fm, dict):
fm = {}
except Exception:
fm = {}
return fm, body
def make_yaml_frontmatter(data: Dict[str, Any]) -> str:
dumped = yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
return f"---\n{dumped}---\n"
def extract_title_for_description(md_body: str, fallback_name: str) -> str:
for line in md_body.splitlines():
m = re.match(r"^\s*#\s+(.+)$", line.strip())
if m:
return m.group(1).strip()
# fallback from filename
name = Path(fallback_name).stem
name = re.sub(r"[_\-]+", " ", name).strip().title()
return name or "Command"
def parse_bash_pattern(item: str) -> Optional[str]:
"""
Convert Bash(git log:*) -> "git log *"
Bash(pwd) -> "pwd"
Bash(env | grep:*) -> "env | grep *"
"""
if not item.startswith("Bash(") or not item.endswith(")"):
return None
inner = item[len("Bash(") : -1].strip()
inner = inner.replace(":*", " *")
return inner
# ---------------------------
# Migration Plan Engine
# ---------------------------
@dataclass
class Action:
kind: str # "mkdir", "write_text", "update_json"
path: Path
description: str
content: Optional[str] = None
update_fn: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None
@dataclass
class MigrationPlan:
root: Path
dry_run: bool
conflict: str # "skip" | "overwrite" | "prompt"
console: Console
actions: List[Action] = field(default_factory=list)
_backup_dir: Optional[Path] = None
def backup_dir(self) -> Path:
if self._backup_dir is None:
ts = _now_ts()
self._backup_dir = self.root / f".opencode-migrate-backup/{ts}"
return self._backup_dir
def add_mkdir(self, path: Path, description: str = "Create directory") -> None:
self.actions.append(Action(kind="mkdir", path=path, description=description))
def add_write_text(self, path: Path, content: str, description: str) -> None:
self.actions.append(
Action(
kind="write_text", path=path, description=description, content=content
)
)
def add_update_json(
self,
path: Path,
update_fn: Callable[[Dict[str, Any]], Dict[str, Any]],
description: str,
) -> None:
self.actions.append(
Action(
kind="update_json",
path=path,
description=description,
update_fn=update_fn,
)
)
def _backup_if_exists(self, path: Path) -> None:
if not path.exists():
return
backup_root = self.backup_dir()
try:
rel = path.resolve().relative_to(self.root.resolve())
dest = backup_root / rel
except Exception:
safe_abs = str(path.resolve()).replace(":", "").replace("/", "_")
dest = backup_root / "external" / safe_abs
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, dest)
def _maybe_prompt_overwrite(self, path: Path) -> bool:
if self.conflict == "overwrite":
return True
if self.conflict == "skip":
return False
self.console.print(f"[yellow]File exists:[/yellow] {path}")
resp = input("Overwrite? [y/N]: ").strip().lower()
return resp in {"y", "yes"}
def execute(self) -> int:
created = 0
updated = 0
skipped = 0
errored = 0
for act in self.actions:
try:
if act.kind == "mkdir":
if self.dry_run:
exists = act.path.exists()
self.console.print(
f"[cyan]mkdir[/cyan] {act.path} {'(exists)' if exists else ''}"
)
else:
act.path.mkdir(parents=True, exist_ok=True)
self.console.print(f"[green]mkdir[/green] {act.path}")
continue
if act.kind == "write_text":
current = read_text(act.path)
new = act.content or ""
if current == new:
skipped += 1
self.console.print(f"[blue]up-to-date[/blue] {act.path}")
continue
if act.path.exists():
if self.dry_run:
diff = unified_diff(current or "", new, str(act.path))
self.console.print(
Panel(
diff or "(no diff?)",
title=f"diff: {act.path}",
border_style="yellow",
)
)
skipped += 1
continue
else:
if self.conflict == "skip":
skipped += 1
self.console.print(
f"[yellow]skip (conflict)[/yellow] {act.path}"
)
continue
if self.conflict == "prompt":
if not self._maybe_prompt_overwrite(act.path):
skipped += 1
self.console.print(
f"[yellow]skip[/yellow] {act.path}"
)
continue
self._backup_if_exists(act.path)
write_text(act.path, new)
updated += 1
self.console.print(f"[green]updated[/green] {act.path}")
else:
if self.dry_run:
self.console.print(f"[cyan]create[/cyan] {act.path}")
diff = unified_diff("", new, str(act.path))
self.console.print(
Panel(
diff or new,
title=f"new: {act.path}",
border_style="green",
)
)
created += 1
else:
write_text(act.path, new)
created += 1
self.console.print(f"[green]created[/green] {act.path}")
continue
if act.kind == "update_json":
current_obj = load_json(act.path)
new_obj = act.update_fn(
current_obj if isinstance(current_obj, dict) else {}
)
new_json = dump_json(new_obj)
current_json = dump_json(
current_obj if isinstance(current_obj, dict) else {}
)
if current_json == new_json:
skipped += 1
self.console.print(f"[blue]up-to-date[/blue] {act.path}")
continue
if self.dry_run:
diff = unified_diff(current_json, new_json, str(act.path))
self.console.print(
Panel(
diff or "(no diff?)",
title=f"json diff: {act.path}",
border_style="yellow",
)
)
skipped += 1
else:
if act.path.exists():
if self.conflict == "skip":
skipped += 1
self.console.print(
f"[yellow]skip (conflict)[/yellow] {act.path}"
)
continue
if self.conflict == "prompt":
if not self._maybe_prompt_overwrite(act.path):
skipped += 1
self.console.print(
f"[yellow]skip[/yellow] {act.path}"
)
continue
self._backup_if_exists(act.path)
self.console.print(f"[green]updated[/green] {act.path}")
act.path.parent.mkdir(parents=True, exist_ok=True)
act.path.write_text(new_json, encoding="utf-8")
updated += 1
else:
self.console.print(f"[green]created[/green] {act.path}")
act.path.parent.mkdir(parents=True, exist_ok=True)
act.path.write_text(new_json, encoding="utf-8")
created += 1
continue
self.console.print(f"[red]Unknown action kind[/red]: {act.kind}")
errored += 1
except Exception as e:
errored += 1
self.console.print(f"[red]error[/red] {act.path}: {e}")
summary = Table(title="Migration Summary", show_header=False)
summary.add_row("Created", str(created))
summary.add_row("Updated", str(updated))
summary.add_row("Skipped", str(skipped))
summary.add_row("Errored", str(errored))
if not self.dry_run and (created or updated):
self.console.print(f"Backups stored under: {self.backup_dir()}")
self.console.print(summary)
return 0 if errored == 0 else 1
# ---------------------------
# Discovery and transforms
# ---------------------------
def discover_agent_files(root: Path) -> List[Path]:
return sorted((root / ".claude" / "agents").glob("*.md"))
def discover_command_files(root: Path) -> List[Path]:
return sorted((root / ".claude" / "commands").glob("*.md"))
def discover_skill_dirs(root: Path) -> List[Path]:
"""Find Claude Code skill directories containing SKILL.md."""
skill_dir = root / ".claude" / "skills"
if not skill_dir.exists():
return []
return sorted(
[d for d in skill_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()]
)
def discover_settings(root: Path, include_local: bool) -> Dict[str, Any]:
merged: Dict[str, Any] = {}
settings_path = root / ".claude" / "settings.json"
base = load_json(settings_path)
merged.update(base)
if include_local:
local_path = root / ".claude" / "settings.local.json"
local = load_json(local_path)
if "permissions" in local:
mp = local["permissions"]
bp = merged.setdefault("permissions", {})
for k in ("allow", "deny"):
ba = set(bp.get(k, []))
la = set(mp.get(k, []))
bp[k] = sorted(ba.union(la))
return merged
def load_mcp_sources(root: Path) -> Dict[str, Any]:
local = load_json(root / ".mcp.json")
global_path = Path(os.path.expanduser("~")) / ".claude.json"
glob = load_json(global_path)
res: Dict[str, Any] = {}
def extract(obj: Dict[str, Any]):
if not obj:
return
servers = obj.get("mcpServers") or obj.get("mcp") or {}
for name, cfg in servers.items():
res[name] = cfg
extract(glob)
extract(local) # override global
return res
def transform_agent_markdown(md: str, filename: str, console: Console) -> str:
fm, body = parse_yaml_frontmatter(md)
fm = fm or {}
description = fm.get("description") or ""
model = map_model(fm.get("model"))
color = ensure_color_hex(fm.get("color"), console, f"agent {filename}")
original_tools = fm.get("tools")
tools_map: Dict[str, bool] = {"*": False}
if isinstance(original_tools, str):
parts = [p.strip() for p in original_tools.split(",") if p.strip()]
tools_map = tools_list_to_mapping(parts, console)
elif isinstance(original_tools, list):
tools_map = tools_list_to_mapping(original_tools, console)
elif isinstance(original_tools, dict):
base = {"*": bool(original_tools.get("*", False))}
for k, v in original_tools.items():
if k == "*":
continue
nk = normalize_tool_name(k, console=console)
if nk:
base[nk] = bool(v)
tools_map = base
else:
tools_map = {"*": False}
new_fm: Dict[str, Any] = {
"mode": "subagent",
"description": description,
"tools": tools_map,
}
if model:
new_fm["model"] = model
if color:
new_fm["color"] = color
if body and not body.endswith("\n"):
body = body + "\n"
return make_yaml_frontmatter(new_fm) + body
def transform_command_markdown(md: str, filename: str, console: Console) -> str:
fm, body = parse_yaml_frontmatter(md)
body = body or ""
new_fm: Dict[str, Any] = {}
if fm and "model" in fm:
mapped = map_model(fm.get("model"))
if mapped:
new_fm["model"] = mapped
# Warn about allowed-tools being dropped (not supported in OpenCode)
if fm and "allowed-tools" in fm:
console.print(
f"[yellow]Warning:[/yellow] Command '{filename}' has 'allowed-tools' which is not supported in OpenCode (will be dropped)"
)
desc = fm.get("description") if fm else None
if not desc:
desc = extract_title_for_description(body, filename)
new_fm["description"] = desc
return make_yaml_frontmatter(new_fm) + body
def transform_skill_markdown(md: str, skill_name: str, console: Console) -> str:
"""Transform Claude Code skill markdown to OpenCode format."""
fm, body = parse_yaml_frontmatter(md)
fm = fm or {}
# Warn about unsupported fields
unsupported = [k for k in CLAUDE_SKILL_UNSUPPORTED if k in fm]
if unsupported:
console.print(
f"[yellow]Warning:[/yellow] Dropping unsupported fields in skill '{skill_name}': {unsupported}"
)
# Build OpenCode frontmatter - only name and description are common
new_fm: Dict[str, Any] = {}
name = fm.get("name", skill_name)
if name:
new_fm["name"] = name
description = fm.get("description")
if description:
new_fm["description"] = description
if body and not body.endswith("\n"):
body = body + "\n"
return make_yaml_frontmatter(new_fm) + body
def build_permissions_from_settings(
settings: Dict[str, Any], console: Console
) -> Tuple[Dict[str, Any], Dict[str, bool]]:
"""Returns (permission_obj, tools_obj)"""
p = settings.get("permissions", {}) or {}
allow: List[str] = p.get("allow", []) or []
deny: List[str] = p.get("deny", []) or []
permission: Dict[str, Any] = {}
tools: Dict[str, bool] = {}
# Safe defaults
permission["bash"] = {"*": "ask"}
permission["edit"] = permission.get("edit", "ask")
permission["write"] = permission.get("write", "ask")
tools["*"] = tools.get("*", False)
tools["bash"] = tools.get("bash", True)
tools["edit"] = tools.get("edit", True)
tools["write"] = tools.get("write", True)
def handle_entry(lst: List[str], mode: str):
for raw in lst:
raw = raw.strip()
if not raw:
continue
bp = parse_bash_pattern(raw)
if bp:
permission.setdefault("bash", {}).setdefault(bp, mode)
permission["bash"][bp] = mode
continue
if raw.startswith("mcp__"):
m = MCP_PAT.match(raw)
if m:
server = m.group(1).lower()
tools[f"{server}_*"] = mode == "allow"
else:
console.print(
f"[yellow]Warning:[/yellow] Unknown MCP pattern: {raw}"
)
continue
tool = normalize_tool_name(raw, console=console)
if not tool:
continue
tools[tool] = mode == "allow"
if mode in ("allow", "deny"):
permission[tool] = mode
handle_entry(allow, "allow")
handle_entry(deny, "deny")
return permission, tools
def update_opencode_json_factory(
add_permission: Dict[str, Any],
add_tools: Dict[str, bool],
add_mcp: Dict[str, Any],
) -> Callable[[Dict[str, Any]], Dict[str, Any]]:
def updater(current: Dict[str, Any]) -> Dict[str, Any]:
new = dict(current) if current else {}
perm = dict(new.get("permission", {}))
tools = dict(new.get("tools", {}))
mcp = dict(new.get("mcp", {}))
# Merge bash permissions
bash_cur = dict(perm.get("bash", {}))
bash_add = dict(add_permission.get("bash", {}))
for k, v in bash_add.items():
if k not in bash_cur:
bash_cur[k] = v
perm["bash"] = {"*": "ask", **bash_cur} if "*" not in bash_cur else bash_cur
for k, v in add_permission.items():
if k == "bash":
continue
if k not in perm:
perm[k] = v
tools.setdefault("*", False)
for k, v in add_tools.items():
tools.setdefault(k, v)
for name, cfg in add_mcp.items():
if name not in mcp:
mcp[name] = cfg
new["permission"] = perm
new["tools"] = tools
if add_mcp:
new["mcp"] = mcp
return new
return updater
def transform_mcp_servers(mcp_src: Dict[str, Any], console: Console) -> Dict[str, Any]:
"""Transform Claude MCP server entries to OpenCode format."""
out: Dict[str, Any] = {}
for name, cfg in (mcp_src or {}).items():
if not isinstance(cfg, dict):
continue
typ = cfg.get("type") or cfg.get("transport") or "stdio"
typ_low = str(typ).lower()
if typ_low == "stdio":
dest_type = "local"
elif typ_low in ("sse", "http", "https"):
dest_type = "remote"
else:
dest_type = "local"
command = cfg.get("command")
args = cfg.get("args", [])
env = cfg.get("env", {}) or cfg.get("environment", {})
command_arr: Optional[List[str]] = None
if isinstance(command, str):
if isinstance(args, list) and args:
command_arr = [command, *[str(a) for a in args]]
else:
command_arr = [command]
elif isinstance(command, list):
command_arr = [str(x) for x in command]
dest = {"type": dest_type, "enabled": True}
if command_arr and dest_type == "local":
dest["command"] = command_arr
if isinstance(env, dict) and env:
dest["environment"] = env
for key in ("url", "baseUrl", "endpoint"):
if key in cfg:
dest["url"] = cfg[key]
break
out[name] = dest
return out
# ---------------------------
# Reverse Migration: OpenCode → Claude Code
# ---------------------------
def discover_opencode_agent_files(root: Path) -> List[Path]:
"""Find OpenCode agent files."""
return sorted((root / ".opencode" / "agent").glob("*.md"))
def discover_opencode_command_files(root: Path) -> List[Path]:
"""Find OpenCode command files."""
return sorted((root / ".opencode" / "command").glob("*.md"))
def discover_opencode_skill_dirs(root: Path) -> List[Path]:
"""Find OpenCode skill directories containing SKILL.md."""
skill_dir = root / ".opencode" / "skill"
if not skill_dir.exists():
return []
return sorted(
[d for d in skill_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()]
)
def load_opencode_config(root: Path) -> Dict[str, Any]:
"""Load opencode.json configuration."""
return load_json(root / "opencode.json")
def reverse_transform_agent_markdown(md: str, filename: str, console: Console) -> str:
"""Transform OpenCode agent markdown to Claude Code format."""
fm, body = parse_yaml_frontmatter(md)
fm = fm or {}
# Warn about unsupported fields
unsupported = [k for k in OPENCODE_AGENT_UNSUPPORTED if k in fm]
if unsupported:
console.print(
f"[yellow]Warning:[/yellow] Dropping unsupported fields in {filename}: {unsupported}"
)
# Check for primary mode (not supported in Claude Code the same way)
mode = fm.get("mode", "").lower()
if mode == "primary":
console.print(
f"[yellow]Warning:[/yellow] Agent '{filename}' has mode=primary which has no Claude Code equivalent; converting to subagent"
)
# Build Claude Code frontmatter
name = Path(filename).stem
description = fm.get("description", "")
# Reverse map model
model = reverse_map_model(fm.get("model"))
# Reverse map color
color = reverse_ensure_color(fm.get("color"), console, f"agent {filename}")
# Convert tools mapping to list
original_tools = fm.get("tools")
tools_list: Optional[List[str]] = None
if isinstance(original_tools, dict):
tools_list = tools_mapping_to_list(original_tools, console)
elif isinstance(original_tools, list):
tools_list = [denormalize_tool_name(t, console=console) for t in original_tools]
elif isinstance(original_tools, str):
parts = [p.strip() for p in original_tools.split(",") if p.strip()]
tools_list = [denormalize_tool_name(t, console=console) for t in parts]
# Build new frontmatter
new_fm: Dict[str, Any] = {"name": name}
if description:
new_fm["description"] = description
if tools_list:
new_fm["tools"] = ", ".join(tools_list)
if model:
new_fm["model"] = model
if color:
new_fm["color"] = color
# Handle permissionMode if present
permission = fm.get("permission")
if isinstance(permission, dict):
# Try to map to permissionMode
edit_perm = permission.get("edit", "").lower()
bash_perm = permission.get("bash", {})
if edit_perm == "deny" or (
isinstance(bash_perm, dict) and bash_perm.get("*") == "deny"
):
new_fm["permissionMode"] = "plan"
elif edit_perm == "allow" and (
isinstance(bash_perm, str) and bash_perm == "allow"
):
new_fm["permissionMode"] = "bypassPermissions"
if body and not body.endswith("\n"):
body = body + "\n"
return make_yaml_frontmatter(new_fm) + body
def reverse_transform_command_markdown(md: str, filename: str, console: Console) -> str:
"""Transform OpenCode command markdown to Claude Code format."""
fm, body = parse_yaml_frontmatter(md)
fm = fm or {}
new_fm: Dict[str, Any] = {}
# Keep description if present
desc = fm.get("description")
if desc:
new_fm["description"] = desc
# Reverse map model
model = reverse_map_model(fm.get("model"))
if model:
new_fm["model"] = model
# Preserve allowed-tools - supported by both OpenCode and Claude Code
allowed_tools = fm.get("allowed-tools")
if allowed_tools:
new_fm["allowed-tools"] = allowed_tools
# Note: agent and subtask fields don't have direct Claude Code equivalents
# but we can keep them as they might be partially compatible
agent = fm.get("agent")
if agent:
console.print(
f"[yellow]Warning:[/yellow] Command '{filename}' has 'agent' field which may not work in Claude Code"
)
subtask = fm.get("subtask")
if subtask:
console.print(
f"[yellow]Warning:[/yellow] Command '{filename}' has 'subtask' field which is not supported in Claude Code"
)
# If no frontmatter needed, just return body
if not new_fm:
return body
return make_yaml_frontmatter(new_fm) + body
def reverse_transform_skill_markdown(md: str, skill_name: str, console: Console) -> str:
"""Transform OpenCode skill markdown to Claude Code format."""
fm, body = parse_yaml_frontmatter(md)
fm = fm or {}
# Warn about unsupported fields
unsupported = [k for k in OPENCODE_SKILL_UNSUPPORTED if k in fm]
if unsupported:
console.print(
f"[yellow]Warning:[/yellow] Dropping unsupported fields in skill '{skill_name}': {unsupported}"
)
# Build Claude Code frontmatter - only name and description are common
new_fm: Dict[str, Any] = {}
name = fm.get("name", skill_name)
if name:
new_fm["name"] = name
description = fm.get("description")
if description:
new_fm["description"] = description
if body and not body.endswith("\n"):
body = body + "\n"
return make_yaml_frontmatter(new_fm) + body
def reverse_build_bash_pattern(pattern: str) -> str:
"""
Convert OpenCode bash pattern to Claude Code format.
"git log *" → "Bash(git log:*)"
"pwd" → "Bash(pwd)"
"""
pattern = pattern.strip()
if pattern.endswith(" *"):
# Has wildcard
base = pattern[:-2]
return f"Bash({base}:*)"
else:
return f"Bash({pattern})"
def reverse_build_permissions(
permission: Dict[str, Any], tools: Dict[str, bool], console: Console
) -> Dict[str, Any]:
"""
Convert OpenCode permission/tools config to Claude Code settings.json format.
Returns {"permissions": {"allow": [...], "deny": [...]}}
"""
allow: List[str] = []
deny: List[str] = []
# Process bash permissions
bash_perms = permission.get("bash", {})
if isinstance(bash_perms, dict):
for pattern, mode in bash_perms.items():
if pattern == "*":
continue # Default, don't need to specify
mode_low = str(mode).lower()
bash_rule = reverse_build_bash_pattern(pattern)
if mode_low == "allow":
allow.append(bash_rule)
elif mode_low == "deny":
deny.append(bash_rule)
# Process tools
for tool_name, enabled in tools.items():
if tool_name == "*":
continue
# Check if it's an MCP wildcard pattern (e.g., "server_*")
if tool_name.endswith("_*"):
server = tool_name[:-2]
mcp_pattern = f"mcp__{server}__*"
if enabled:
allow.append(mcp_pattern)
else:
deny.append(mcp_pattern)
continue
# Regular tool
denorm = denormalize_tool_name(tool_name, console=console)
if enabled:
allow.append(denorm)
else:
deny.append(denorm)
# Process other permission types (edit, write, webfetch)
for perm_type in ("edit", "write", "webfetch"):
perm_val = permission.get(perm_type)
if isinstance(perm_val, str):
tool_name = denormalize_tool_name(perm_type, console=console)
if perm_val.lower() == "allow":
if tool_name not in allow:
allow.append(tool_name)
elif perm_val.lower() == "deny":
if tool_name not in deny:
deny.append(tool_name)
result: Dict[str, Any] = {"permissions": {}}
if allow:
result["permissions"]["allow"] = sorted(allow)
if deny:
result["permissions"]["deny"] = sorted(deny)
return result
def reverse_transform_mcp_servers(
mcp_src: Dict[str, Any], console: Console
) -> Dict[str, Any]:
"""Transform OpenCode MCP server entries to Claude Code format."""
out: Dict[str, Any] = {}
for name, cfg in (mcp_src or {}).items():
if not isinstance(cfg, dict):
continue
typ = cfg.get("type", "local")
typ_low = str(typ).lower()
# Map type
if typ_low == "local":
dest_type = "stdio"
elif typ_low == "remote":
dest_type = "sse"
else:
dest_type = "stdio"
dest: Dict[str, Any] = {"type": dest_type}
# Handle command array → command + args
command = cfg.get("command")
if isinstance(command, list) and command:
dest["command"] = command[0]
if len(command) > 1:
dest["args"] = command[1:]
elif isinstance(command, str):
dest["command"] = command
# Handle environment → env
env = cfg.get("environment") or cfg.get("env")
if isinstance(env, dict) and env:
dest["env"] = env
# Handle URL for remote type
url = cfg.get("url")
if url and dest_type in ("sse", "http"):
dest["url"] = url
# Note: enabled field is dropped (implicit in Claude Code)
out[name] = dest
return out
def update_claude_settings_factory(
permissions_update: Dict[str, Any],
) -> Callable[[Dict[str, Any]], Dict[str, Any]]:
"""Create updater function for .claude/settings.json"""
def updater(current: Dict[str, Any]) -> Dict[str, Any]:
new = dict(current) if current else {}
perms = dict(new.get("permissions", {}))
new_perms = permissions_update.get("permissions", {})
# Merge allow lists
existing_allow = set(perms.get("allow", []))
new_allow = set(new_perms.get("allow", []))
merged_allow = sorted(existing_allow.union(new_allow))
# Merge deny lists
existing_deny = set(perms.get("deny", []))
new_deny = set(new_perms.get("deny", []))
merged_deny = sorted(existing_deny.union(new_deny))
if merged_allow:
perms["allow"] = merged_allow
if merged_deny:
perms["deny"] = merged_deny
if perms:
new["permissions"] = perms
return new
return updater
def run_reverse_migration(
root: Path,
*,
migrate_agents: bool,
migrate_commands: bool,
migrate_skills: bool,
migrate_permissions: bool,
migrate_mcp: bool,
mcp_target: str,
dry_run: bool,
conflict: str,
console: Console,
) -> int:
"""Run OpenCode → Claude Code migration."""
plan = MigrationPlan(root=root, dry_run=dry_run, conflict=conflict, console=console)
claude_agent_dir = root / ".claude" / "agents"
claude_command_dir = root / ".claude" / "commands"
claude_skill_dir = root / ".claude" / "skills"
claude_settings = root / ".claude" / "settings.json"
mcp_json = root / ".mcp.json"
global_mcp = Path(os.path.expanduser("~")) / ".claude.json"
if migrate_agents:
plan.add_mkdir(claude_agent_dir, "Ensure .claude/agents directory")
for src in discover_opencode_agent_files(root):
try:
md = read_text(src)
if md is None:
continue
new_md = reverse_transform_agent_markdown(md, src.name, console)
dest = claude_agent_dir / src.name
plan.add_write_text(dest, new_md, f"Migrate agent {src.name}")
except Exception as e:
console.print(f"[red]Agent error[/red] {src}: {e}")
if migrate_commands:
plan.add_mkdir(claude_command_dir, "Ensure .claude/commands directory")
for src in discover_opencode_command_files(root):
try:
md = read_text(src)
if md is None:
continue
new_md = reverse_transform_command_markdown(md, src.name, console)
dest = claude_command_dir / src.name
plan.add_write_text(dest, new_md, f"Migrate command {src.name}")
except Exception as e:
console.print(f"[red]Command error[/red] {src}: {e}")
if migrate_skills:
plan.add_mkdir(claude_skill_dir, "Ensure .claude/skills directory")
for skill_dir in discover_opencode_skill_dirs(root):
try:
skill_md = skill_dir / "SKILL.md"
md = read_text(skill_md)
if md is None:
continue
skill_name = skill_dir.name
new_md = reverse_transform_skill_markdown(md, skill_name, console)
# Create skill directory and SKILL.md
dest_dir = claude_skill_dir / skill_name
plan.add_mkdir(dest_dir, f"Ensure skill directory {skill_name}")
plan.add_write_text(
dest_dir / "SKILL.md", new_md, f"Migrate skill {skill_name}"
)
# Copy any additional files in the skill directory
for extra_file in skill_dir.iterdir():
if extra_file.name != "SKILL.md" and extra_file.is_file():
content = read_text(extra_file)
if content:
plan.add_write_text(
dest_dir / extra_file.name,
content,
f"Copy {extra_file.name}",
)
except Exception as e:
console.print(f"[red]Skill error[/red] {skill_dir}: {e}")
# Load OpenCode config for permissions and MCP
opencode_config = load_opencode_config(root)
if migrate_permissions:
try:
permission = opencode_config.get("permission", {})
tools = opencode_config.get("tools", {})
if permission or tools:
perms_update = reverse_build_permissions(permission, tools, console)
updater = update_claude_settings_factory(perms_update)
plan.add_update_json(
claude_settings, updater, "Update .claude/settings.json"
)
except Exception as e:
console.print(f"[red]Permission migration error:[/red] {e}")
if migrate_mcp:
try:
mcp_src = opencode_config.get("mcp", {})
if mcp_src:
mcp_dest = reverse_transform_mcp_servers(mcp_src, console)
def mcp_updater(current: Dict[str, Any]) -> Dict[str, Any]:
new = dict(current) if current else {}
servers = dict(new.get("mcpServers", {}))
for name, cfg in mcp_dest.items():
if name not in servers:
servers[name] = cfg
new["mcpServers"] = servers
return new
target_file = mcp_json if mcp_target == "project" else global_mcp
plan.add_update_json(
target_file, mcp_updater, f"Update MCP config ({mcp_target})"
)
except Exception as e:
console.print(f"[red]MCP migration error:[/red] {e}")
return plan.execute()
# ---------------------------
# Orchestration
# ---------------------------
def run_migration(
root: Path,
*,
migrate_agents: bool,
migrate_commands: bool,
migrate_skills: bool,
migrate_permissions: bool,
migrate_mcp: bool,
include_local_settings: bool,
mcp_target: str,
dry_run: bool,
conflict: str,
console: Console,
) -> int:
plan = MigrationPlan(root=root, dry_run=dry_run, conflict=conflict, console=console)
op_agent_dir = root / ".opencode" / "agent"
op_command_dir = root / ".opencode" / "command"
op_skill_dir = root / ".opencode" / "skill"
op_project_config = root / "opencode.json"
op_global_config = (
Path(os.path.expanduser("~")) / ".config" / "opencode" / "opencode.json"
)
if migrate_agents:
plan.add_mkdir(op_agent_dir, "Ensure .opencode/agent directory")
for src in discover_agent_files(root):
try:
md = read_text(src)
if md is None:
continue
new_md = transform_agent_markdown(md, src.name, console)
dest = op_agent_dir / src.name
plan.add_write_text(dest, new_md, f"Migrate agent {src.name}")
except Exception as e:
console.print(f"[red]Agent error[/red] {src}: {e}")
if migrate_commands:
plan.add_mkdir(op_command_dir, "Ensure .opencode/command directory")
for src in discover_command_files(root):
try:
md = read_text(src)
if md is None:
continue
new_md = transform_command_markdown(md, src.name, console)
dest = op_command_dir / src.name
plan.add_write_text(dest, new_md, f"Migrate command {src.name}")
except Exception as e:
console.print(f"[red]Command error[/red] {src}: {e}")
if migrate_skills:
plan.add_mkdir(op_skill_dir, "Ensure .opencode/skill directory")
for skill_dir in discover_skill_dirs(root):
try:
skill_md = skill_dir / "SKILL.md"
md = read_text(skill_md)
if md is None:
continue
skill_name = skill_dir.name
new_md = transform_skill_markdown(md, skill_name, console)
# Create skill directory and SKILL.md
dest_dir = op_skill_dir / skill_name
plan.add_mkdir(dest_dir, f"Ensure skill directory {skill_name}")
plan.add_write_text(
dest_dir / "SKILL.md", new_md, f"Migrate skill {skill_name}"
)
# Copy any additional files in the skill directory
for extra_file in skill_dir.iterdir():
if extra_file.name != "SKILL.md" and extra_file.is_file():
content = read_text(extra_file)
if content is not None:
plan.add_write_text(
dest_dir / extra_file.name,
content,
f"Copy {extra_file.name} for skill {skill_name}",
)
except Exception as e:
console.print(f"[red]Skill error[/red] {skill_dir}: {e}")
add_permission: Dict[str, Any] = {}
add_tools: Dict[str, bool] = {}
add_mcp: Dict[str, Any] = {}
if migrate_permissions:
try:
settings = discover_settings(root, include_local=include_local_settings)
perm, tools = build_permissions_from_settings(settings, console)
add_permission.update(perm)
add_tools.update(tools)
except Exception as e:
console.print(f"[red]Permission migration error:[/red] {e}")
if migrate_mcp:
try:
mcp_src = load_mcp_sources(root)
add_mcp = transform_mcp_servers(mcp_src, console)
for server in add_mcp.keys():
add_tools.setdefault(f"{server}_*".lower(), True)
except Exception as e:
console.print(f"[red]MCP migration error:[/red] {e}")
if migrate_permissions or migrate_mcp:
updater = update_opencode_json_factory(add_permission, add_tools, add_mcp)
if mcp_target == "project":
plan.add_update_json(
op_project_config, updater, "Update project opencode.json"
)
elif mcp_target == "global":
plan.add_update_json(
op_global_config, updater, "Update global opencode.json"
)
return plan.execute()
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Migrate between Claude Code and OpenCode configuration formats.",
epilog="By default, migrates Claude Code → OpenCode. Use --reverse for OpenCode → Claude Code.",
)
p.add_argument("--root", default=".", help="Project root (default: .)")
# Direction
p.add_argument(
"--reverse",
action="store_true",
help="Reverse migration: OpenCode → Claude Code (default is Claude Code → OpenCode)",
)
scope = p.add_argument_group("Scope selection")
scope.add_argument(
"--agents", dest="agents", action="store_true", help="Migrate agents"
)
scope.add_argument(
"--commands", dest="commands", action="store_true", help="Migrate commands"
)
scope.add_argument(
"--skills",
dest="skills",
action="store_true",
help="Migrate skills",
)
scope.add_argument(
"--permissions",
dest="permissions",
action="store_true",
help="Migrate permissions",
)
scope.add_argument(
"--mcp", dest="mcp", action="store_true", help="Migrate MCP servers"
)
scope.add_argument(
"--all",
dest="all",
action="store_true",
help="Migrate all (default if no specific flags)",
)
p.add_argument(
"--include-local",
action="store_true",
help="Include .claude/settings.local.json (forward migration only)",
)
p.add_argument(
"--mcp-target",
choices=["project", "global"],
default="project",
help="Where to write MCP config",
)
p.add_argument("--dry-run", action="store_true", help="Show diffs without writing")
p.add_argument(
"--conflict",
choices=["skip", "overwrite", "prompt"],
default="skip",
help="Conflict handling",
)
p.add_argument("--no-color", action="store_true", help="Disable colored output")
p.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
return p
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
console = Console(no_color=args.no_color)
root = Path(args.root).resolve()
# Handle reverse migration (OpenCode → Claude Code)
if args.reverse:
if args.all or not (
args.agents or args.commands or args.skills or args.permissions or args.mcp
):
migrate_agents = migrate_commands = migrate_skills = migrate_permissions = (
migrate_mcp
) = True
else:
migrate_agents = args.agents
migrate_commands = args.commands
migrate_skills = args.skills
migrate_permissions = args.permissions
migrate_mcp = args.mcp
console.print(
Panel(
Text("OpenCode → Claude Code Migration", style="bold"),
subtitle=f"root={root} dry_run={args.dry_run} conflict={args.conflict} mcp_target={args.mcp_target}",
border_style="magenta",
)
)
if not (root / ".opencode").exists() and not (root / "opencode.json").exists():
console.print(
"[yellow]Note:[/yellow] .opencode directory and opencode.json not found. Nothing to migrate."
)
return run_reverse_migration(
root=root,
migrate_agents=migrate_agents,
migrate_commands=migrate_commands,
migrate_skills=migrate_skills,
migrate_permissions=migrate_permissions,
migrate_mcp=migrate_mcp,
mcp_target=args.mcp_target,
dry_run=args.dry_run,
conflict=args.conflict,
console=console,
)
# Forward migration (Claude Code → OpenCode)
if args.all or not (
args.agents or args.commands or args.skills or args.permissions or args.mcp
):
migrate_agents = migrate_commands = migrate_skills = migrate_permissions = (
migrate_mcp
) = True
else:
migrate_agents = args.agents
migrate_commands = args.commands
migrate_skills = args.skills
migrate_permissions = args.permissions
migrate_mcp = args.mcp
console.print(
Panel(
Text("Claude Code → OpenCode Migration", style="bold"),
subtitle=f"root={root} dry_run={args.dry_run} conflict={args.conflict} mcp_target={args.mcp_target}",
border_style="cyan",
)
)
if not (root / ".claude").exists():
console.print(
"[yellow]Note:[/yellow] .claude directory not found. Continuing for MCP/global config if applicable."
)
return run_migration(
root=root,
migrate_agents=migrate_agents,
migrate_commands=migrate_commands,
migrate_skills=migrate_skills,
migrate_permissions=migrate_permissions,
migrate_mcp=migrate_mcp,
include_local_settings=args.include_local,
mcp_target=args.mcp_target,
dry_run=args.dry_run,
conflict=args.conflict,
console=console,
)
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "pytest>=8.0.0",
# "PyYAML>=6.0.2",
# "rich>=13.7.0",
# ]
# ///
"""
Tests for Claude Code → OpenCode Migration Script
Run with: uv run pytest claude_to_opencode_migration/tests/test_migration.py -v
"""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any, Dict
import pytest
from rich.console import Console
# Import from sibling module
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from migrate_claude_to_opencode import (
# Forward migration (Claude Code → OpenCode)
normalize_tool_name,
tools_list_to_mapping,
map_model,
ensure_color_hex,
parse_bash_pattern,
parse_yaml_frontmatter,
make_yaml_frontmatter,
extract_title_for_description,
transform_agent_markdown,
transform_command_markdown,
transform_skill_markdown,
discover_skill_dirs,
build_permissions_from_settings,
transform_mcp_servers,
run_migration,
# Reverse migration (OpenCode → Claude Code)
reverse_map_model,
reverse_ensure_color,
denormalize_tool_name,
tools_mapping_to_list,
reverse_transform_agent_markdown,
reverse_transform_command_markdown,
reverse_transform_skill_markdown,
reverse_build_bash_pattern,
reverse_build_permissions,
reverse_transform_mcp_servers,
run_reverse_migration,
)
# ---------------------------
# Fixtures
# ---------------------------
@pytest.fixture
def console() -> Console:
"""A console that captures output."""
return Console(force_terminal=False, no_color=True)
@pytest.fixture
def temp_project(tmp_path: Path) -> Path:
"""Create a temporary project with Claude config."""
fixtures = Path(__file__).parent / "fixtures" / "claude_sample"
if fixtures.exists():
shutil.copytree(fixtures, tmp_path / "project")
return tmp_path / "project"
# Fallback: create minimal structure
project = tmp_path / "project"
claude_dir = project / ".claude"
(claude_dir / "agents").mkdir(parents=True)
(claude_dir / "commands").mkdir(parents=True)
# Create sample agent
(claude_dir / "agents" / "test-agent.md").write_text("""---
name: test-agent
description: A test agent
tools: Read, Grep, mcp__tools__ls
color: blue
model: sonnet
---
Test agent prompt content.
""")
# Create sample command
(claude_dir / "commands" / "test-command.md").write_text("""# Test Command
Do the thing.
""")
# Create settings
(claude_dir / "settings.json").write_text(
json.dumps(
{
"permissions": {
"allow": ["Bash(git status)", "WebFetch", "mcp__tools__ls"],
"deny": ["Bash(rm:*)"],
}
}
)
)
return project
# ---------------------------
# Unit Tests: normalize_tool_name
# ---------------------------
class TestNormalizeToolName:
def test_lowercase_conversion(self):
# WebSearch is unsupported, so use other tools
assert normalize_tool_name("Read") == "read"
assert normalize_tool_name("GREP") == "grep"
assert normalize_tool_name("WebFetch") == "webfetch"
def test_mcp_pattern_conversion(self):
assert normalize_tool_name("mcp__tools__ls") == "tools_ls"
assert (
normalize_tool_name("mcp__linear-server__list_teams")
== "linear-server_list_teams"
)
assert normalize_tool_name("mcp__pr_comments__get_all") == "pr_comments_get_all"
def test_hyphen_preservation_in_server_name(self):
result = normalize_tool_name("mcp__my-server__my_tool")
assert result == "my-server_my_tool"
def test_unsupported_tools_dropped(self, console):
assert normalize_tool_name("WebSearch", console=console) is None
assert normalize_tool_name("Task", console=console) is None
def test_already_lowercase(self):
assert normalize_tool_name("read") == "read"
assert normalize_tool_name("grep") == "grep"
# ---------------------------
# Unit Tests: tools_list_to_mapping
# ---------------------------
class TestToolsListToMapping:
def test_basic_conversion(self, console):
result = tools_list_to_mapping(["Read", "Grep", "Glob"], console)
assert result == {"*": False, "read": True, "grep": True, "glob": True}
def test_with_mcp_tools(self, console):
result = tools_list_to_mapping(["Read", "mcp__tools__ls"], console)
assert result == {"*": False, "read": True, "tools_ls": True}
def test_unsupported_tools_excluded(self, console):
result = tools_list_to_mapping(["Read", "WebSearch", "Grep"], console)
assert "websearch" not in result
assert result == {"*": False, "read": True, "grep": True}
def test_empty_list(self, console):
result = tools_list_to_mapping([], console)
assert result == {"*": False}
# ---------------------------
# Unit Tests: map_model
# ---------------------------
class TestMapModel:
def test_alias_mapping(self):
assert map_model("sonnet") == "anthropic/claude-sonnet-4-5"
assert map_model("opus") == "anthropic/claude-opus-4-5"
assert map_model("haiku") == "anthropic/claude-haiku-4-5"
def test_versioned_aliases(self):
assert map_model("sonnet-4.5") == "anthropic/claude-sonnet-4-5"
assert map_model("opus-4.5") == "anthropic/claude-opus-4-5"
def test_already_namespaced_passthrough(self):
assert map_model("anthropic/claude-sonnet-4-5") == "anthropic/claude-sonnet-4-5"
assert map_model("openai/gpt-4") == "openai/gpt-4"
def test_case_insensitive(self):
assert map_model("SONNET") == "anthropic/claude-sonnet-4-5"
assert map_model("Opus") == "anthropic/claude-opus-4-5"
def test_none_handling(self):
assert map_model(None) is None
assert map_model("") is None
def test_unknown_passthrough(self):
assert map_model("unknown-model") == "unknown-model"
# ---------------------------
# Unit Tests: ensure_color_hex
# ---------------------------
class TestEnsureColorHex:
def test_known_colors(self, console):
assert ensure_color_hex("blue", console, "test") == "#3B82F6"
assert ensure_color_hex("yellow", console, "test") == "#EAB308"
assert ensure_color_hex("green", console, "test") == "#22C55E"
assert ensure_color_hex("red", console, "test") == "#EF4444"
assert ensure_color_hex("cyan", console, "test") == "#06B6D4"
assert ensure_color_hex("magenta", console, "test") == "#D946EF"
def test_hex_passthrough(self, console):
# Function normalizes to lowercase
assert ensure_color_hex("#FF5733", console, "test") == "#ff5733"
assert ensure_color_hex("FF5733", console, "test") == "#ff5733"
def test_case_insensitive(self, console):
assert ensure_color_hex("BLUE", console, "test") == "#3B82F6"
assert ensure_color_hex("Yellow", console, "test") == "#EAB308"
def test_none_handling(self, console):
assert ensure_color_hex(None, console, "test") is None
assert ensure_color_hex("", console, "test") is None
def test_unknown_color_warning(self, console):
result = ensure_color_hex("purple", console, "test")
assert result == "purple" # Kept as-is with warning
# ---------------------------
# Unit Tests: parse_bash_pattern
# ---------------------------
class TestParseBashPattern:
def test_simple_command(self):
assert parse_bash_pattern("Bash(pwd)") == "pwd"
assert parse_bash_pattern("Bash(git status)") == "git status"
def test_wildcard_pattern(self):
assert parse_bash_pattern("Bash(git log:*)") == "git log *"
assert parse_bash_pattern("Bash(cargo test:*)") == "cargo test *"
def test_pipe_pattern(self):
assert parse_bash_pattern("Bash(env | grep:*)") == "env | grep *"
def test_non_bash_returns_none(self):
assert parse_bash_pattern("WebFetch") is None
assert parse_bash_pattern("Read") is None
assert parse_bash_pattern("mcp__tools__ls") is None
# ---------------------------
# Unit Tests: parse_yaml_frontmatter
# ---------------------------
class TestParseYamlFrontmatter:
def test_with_frontmatter(self):
md = """---
name: test
description: A test
---
Body content here.
"""
fm, body = parse_yaml_frontmatter(md)
assert fm == {"name": "test", "description": "A test"}
assert "Body content here." in body
def test_without_frontmatter(self):
md = "# Just a heading\n\nSome content."
fm, body = parse_yaml_frontmatter(md)
assert fm is None
assert body == md
def test_empty_frontmatter(self):
md = """---
---
Body only.
"""
fm, body = parse_yaml_frontmatter(md)
assert fm == {}
assert "Body only." in body
# ---------------------------
# Unit Tests: extract_title_for_description
# ---------------------------
class TestExtractTitleForDescription:
def test_extracts_h1(self):
md = "# My Command Title\n\nSome content."
assert extract_title_for_description(md, "fallback.md") == "My Command Title"
def test_fallback_to_filename(self):
md = "No heading here, just content."
assert extract_title_for_description(md, "my-command.md") == "My Command"
assert extract_title_for_description(md, "test_command.md") == "Test Command"
def test_first_h1_wins(self):
md = "# First\n\n# Second"
assert extract_title_for_description(md, "fallback.md") == "First"
# ---------------------------
# Unit Tests: transform_agent_markdown
# ---------------------------
class TestTransformAgentMarkdown:
def test_full_transformation(self, console):
md = """---
name: test-agent
description: Test agent description
tools: Read, Grep, mcp__tools__ls
color: yellow
model: sonnet
---
Agent prompt content.
"""
result = transform_agent_markdown(md, "test-agent.md", console)
assert "mode: subagent" in result
assert "description: Test agent description" in result
assert "model: anthropic/claude-sonnet-4-5" in result
assert "color: '#EAB308'" in result
assert "'*': false" in result
assert "read: true" in result
assert "grep: true" in result
assert "tools_ls: true" in result
assert "Agent prompt content." in result
# name should NOT be in output
assert "name: test-agent" not in result
def test_without_optional_fields(self, console):
md = """---
description: Minimal agent
---
Content.
"""
result = transform_agent_markdown(md, "minimal.md", console)
assert "mode: subagent" in result
assert "description: Minimal agent" in result
assert "'*': false" in result
# ---------------------------
# Unit Tests: transform_command_markdown
# ---------------------------
class TestTransformCommandMarkdown:
def test_without_frontmatter(self, console):
md = """# My Command
Do something useful.
"""
result = transform_command_markdown(md, "my-command.md", console)
assert "description: My Command" in result
assert "Do something useful." in result
def test_with_existing_frontmatter(self, console):
md = """---
model: opus
---
# Custom Command
Content here.
"""
result = transform_command_markdown(md, "custom.md", console)
assert "model: anthropic/claude-opus-4-5" in result
assert "description: Custom Command" in result
def test_allowed_tools_warning(self, console, capsys):
"""Test that allowed-tools triggers a warning since OpenCode doesn't support it."""
md = """---
description: Git command
allowed-tools: Bash(git add:*), Bash(git push:*)
---
Push changes.
"""
result = transform_command_markdown(md, "git-push.md", console)
# allowed-tools should be dropped
assert "allowed-tools" not in result
assert "description: Git command" in result
assert "Push changes." in result
# ---------------------------
# Unit Tests: transform_skill_markdown (forward)
# ---------------------------
class TestTransformSkillMarkdown:
def test_basic_transformation(self, console):
"""Test basic skill transformation keeps name and description."""
md = """---
name: git-release
description: Create releases and changelogs
---
## Instructions
Draft release notes from merged PRs.
"""
result = transform_skill_markdown(md, "git-release", console)
assert "name: git-release" in result
assert "description: Create releases and changelogs" in result
assert "Draft release notes" in result
def test_drops_unsupported_fields(self, console, capsys):
"""Test that allowed-tools and model are dropped with warning."""
md = """---
name: safe-reader
description: Read files safely
allowed-tools: Read, Grep, Glob
model: claude-sonnet-4-20250514
---
Read-only access.
"""
result = transform_skill_markdown(md, "safe-reader", console)
# Unsupported fields should be dropped
assert "allowed-tools" not in result
assert "model:" not in result
# Supported fields should remain
assert "name: safe-reader" in result
assert "description: Read files safely" in result
assert "Read-only access." in result
def test_uses_dirname_as_fallback_name(self, console):
"""Test that directory name is used if name field is missing."""
md = """---
description: A skill without name field
---
Instructions here.
"""
result = transform_skill_markdown(md, "my-skill", console)
assert "name: my-skill" in result
assert "description: A skill without name field" in result
class TestDiscoverSkillDirs:
def test_finds_skill_directories(self, tmp_path):
"""Test discovery of Claude Code skill directories."""
skills_dir = tmp_path / ".claude" / "skills"
# Create skill directories
(skills_dir / "git-release").mkdir(parents=True)
(skills_dir / "git-release" / "SKILL.md").write_text(
"---\nname: git-release\n---\n"
)
(skills_dir / "pr-review").mkdir()
(skills_dir / "pr-review" / "SKILL.md").write_text(
"---\nname: pr-review\n---\n"
)
# Create a directory without SKILL.md (should be ignored)
(skills_dir / "incomplete").mkdir()
result = discover_skill_dirs(tmp_path)
assert len(result) == 2
assert result[0].name == "git-release"
assert result[1].name == "pr-review"
def test_returns_empty_when_no_skills_dir(self, tmp_path):
"""Test returns empty list when .claude/skills doesn't exist."""
result = discover_skill_dirs(tmp_path)
assert result == []
# ---------------------------
# Unit Tests: build_permissions_from_settings
# ---------------------------
class TestBuildPermissionsFromSettings:
def test_bash_patterns(self, console):
settings = {
"permissions": {
"allow": ["Bash(git status)", "Bash(git log:*)"],
"deny": ["Bash(rm:*)"],
}
}
perm, tools = build_permissions_from_settings(settings, console)
assert perm["bash"]["git status"] == "allow"
assert perm["bash"]["git log *"] == "allow"
assert perm["bash"]["rm *"] == "deny"
assert perm["bash"]["*"] == "ask"
def test_mcp_tools(self, console):
settings = {
"permissions": {
"allow": ["mcp__tools__ls", "mcp__linear-server__list_teams"],
"deny": [],
}
}
perm, tools = build_permissions_from_settings(settings, console)
assert tools["tools_*"] is True
assert tools["linear-server_*"] is True
def test_regular_tools(self, console):
settings = {"permissions": {"allow": ["WebFetch", "Read"], "deny": []}}
perm, tools = build_permissions_from_settings(settings, console)
assert tools["webfetch"] is True
assert tools["read"] is True
# ---------------------------
# Unit Tests: transform_mcp_servers
# ---------------------------
class TestTransformMcpServers:
def test_stdio_to_local(self, console):
mcp_src = {
"tools": {
"type": "stdio",
"command": "my-tool",
"args": ["mcp"],
"env": {"FOO": "bar"},
}
}
result = transform_mcp_servers(mcp_src, console)
assert result["tools"]["type"] == "local"
assert result["tools"]["command"] == ["my-tool", "mcp"]
assert result["tools"]["environment"] == {"FOO": "bar"}
assert result["tools"]["enabled"] is True
def test_sse_to_remote(self, console):
mcp_src = {"linear": {"type": "sse", "url": "https://mcp.linear.app/sse"}}
result = transform_mcp_servers(mcp_src, console)
assert result["linear"]["type"] == "remote"
assert result["linear"]["url"] == "https://mcp.linear.app/sse"
assert result["linear"]["enabled"] is True
def test_command_array_passthrough(self, console):
mcp_src = {"tool": {"type": "stdio", "command": ["python", "-m", "mcp"]}}
result = transform_mcp_servers(mcp_src, console)
assert result["tool"]["command"] == ["python", "-m", "mcp"]
# ---------------------------
# Integration Tests
# ---------------------------
class TestIntegration:
def test_full_migration_dry_run(self, temp_project, console):
"""Test that dry-run doesn't create files."""
result = run_migration(
root=temp_project,
migrate_agents=True,
migrate_commands=True,
migrate_skills=False,
migrate_permissions=True,
migrate_mcp=False,
include_local_settings=False,
mcp_target="project",
dry_run=True,
conflict="skip",
console=console,
)
assert result == 0
# Dry run should NOT create .opencode directory
# (it only prints what would happen)
def test_agent_migration_creates_files(self, temp_project, console):
"""Test that agent migration creates correct files."""
result = run_migration(
root=temp_project,
migrate_agents=True,
migrate_commands=False,
migrate_skills=False,
migrate_permissions=False,
migrate_mcp=False,
include_local_settings=False,
mcp_target="project",
dry_run=False,
conflict="overwrite",
console=console,
)
assert result == 0
agent_dir = temp_project / ".opencode" / "agent"
assert agent_dir.exists()
agent_file = agent_dir / "test-agent.md"
assert agent_file.exists()
content = agent_file.read_text()
assert "mode: subagent" in content
def test_idempotency(self, temp_project, console):
"""Test that running twice produces same result."""
# First run
run_migration(
root=temp_project,
migrate_agents=True,
migrate_commands=True,
migrate_skills=False,
migrate_permissions=True,
migrate_mcp=False,
include_local_settings=False,
mcp_target="project",
dry_run=False,
conflict="overwrite",
console=console,
)
# Capture state
agent_content = (
temp_project / ".opencode" / "agent" / "test-agent.md"
).read_text()
# Second run
run_migration(
root=temp_project,
migrate_agents=True,
migrate_commands=True,
migrate_skills=False,
migrate_permissions=True,
migrate_mcp=False,
include_local_settings=False,
mcp_target="project",
dry_run=False,
conflict="overwrite",
console=console,
)
# Content should be identical
agent_content_2 = (
temp_project / ".opencode" / "agent" / "test-agent.md"
).read_text()
assert agent_content == agent_content_2
# ---------------------------
# Unit Tests: Reverse Migration
# ---------------------------
class TestReverseMapModel:
def test_full_model_to_alias(self):
assert reverse_map_model("anthropic/claude-sonnet-4-5") == "sonnet"
assert reverse_map_model("anthropic/claude-opus-4-5") == "opus"
assert reverse_map_model("anthropic/claude-haiku-4-5") == "haiku"
def test_already_alias_passthrough(self):
assert reverse_map_model("sonnet") == "sonnet"
assert reverse_map_model("opus") == "opus"
def test_unknown_model_passthrough(self):
assert reverse_map_model("openai/gpt-4") == "openai/gpt-4"
assert reverse_map_model("custom-model") == "custom-model"
def test_none_handling(self):
assert reverse_map_model(None) is None
assert reverse_map_model("") is None
class TestReverseEnsureColor:
def test_hex_to_name(self, console):
assert reverse_ensure_color("#3B82F6", console, "test") == "blue"
assert reverse_ensure_color("#EAB308", console, "test") == "yellow"
assert reverse_ensure_color("#22C55E", console, "test") == "green"
def test_hex_case_insensitive(self, console):
assert reverse_ensure_color("#3b82f6", console, "test") == "blue"
assert reverse_ensure_color("#eab308", console, "test") == "yellow"
def test_already_name_passthrough(self, console):
assert reverse_ensure_color("blue", console, "test") == "blue"
assert reverse_ensure_color("yellow", console, "test") == "yellow"
def test_unknown_color_warning(self, console):
result = reverse_ensure_color("#123456", console, "test")
assert result == "#123456" # Kept as-is
def test_none_handling(self, console):
assert reverse_ensure_color(None, console, "test") is None
assert reverse_ensure_color("", console, "test") is None
class TestDenormalizeToolName:
def test_builtin_tools_to_pascal_case(self):
assert denormalize_tool_name("read") == "Read"
assert denormalize_tool_name("grep") == "Grep"
assert denormalize_tool_name("webfetch") == "WebFetch"
assert denormalize_tool_name("notebookedit") == "NotebookEdit"
def test_mcp_tool_conversion(self):
assert denormalize_tool_name("tools_ls") == "mcp__tools__ls"
assert (
denormalize_tool_name("linear-server_list_teams")
== "mcp__linear-server__list_teams"
)
def test_unknown_tool_capitalize(self):
assert denormalize_tool_name("customtool") == "Customtool"
class TestToolsMappingToList:
def test_basic_conversion(self, console):
mapping = {"*": False, "read": True, "grep": True, "write": False}
result = tools_mapping_to_list(mapping, console)
assert "Grep" in result
assert "Read" in result
assert "Write" not in result
def test_with_mcp_tools(self, console):
mapping = {"*": False, "read": True, "tools_ls": True}
result = tools_mapping_to_list(mapping, console)
assert "Read" in result
assert "mcp__tools__ls" in result
def test_empty_mapping(self, console):
result = tools_mapping_to_list({"*": False}, console)
assert result == []
class TestReverseBuildBashPattern:
def test_simple_command(self):
assert reverse_build_bash_pattern("pwd") == "Bash(pwd)"
assert reverse_build_bash_pattern("git status") == "Bash(git status)"
def test_wildcard_pattern(self):
assert reverse_build_bash_pattern("git log *") == "Bash(git log:*)"
assert reverse_build_bash_pattern("rm *") == "Bash(rm:*)"
class TestReverseTransformAgentMarkdown:
def test_full_transformation(self, console):
md = """---
mode: subagent
description: Test agent description
tools:
'*': false
read: true
grep: true
tools_ls: true
color: '#EAB308'
model: anthropic/claude-sonnet-4-5
---
Agent prompt content.
"""
result = reverse_transform_agent_markdown(md, "test-agent.md", console)
assert "name: test-agent" in result
assert "description: Test agent description" in result
assert "model: sonnet" in result
assert "color: yellow" in result
# Tools should be comma-separated list
assert "tools:" in result
assert "Read" in result
assert "Grep" in result
assert "mcp__tools__ls" in result
assert "Agent prompt content." in result
# mode should NOT be in output (implicit in Claude Code)
assert "mode: subagent" not in result
def test_primary_mode_warning(self, console):
md = """---
mode: primary
description: Primary agent
---
Content.
"""
result = reverse_transform_agent_markdown(md, "primary.md", console)
assert "name: primary" in result
assert "description: Primary agent" in result
class TestReverseTransformCommandMarkdown:
def test_with_model(self, console):
md = """---
description: Test command
model: anthropic/claude-sonnet-4-5
---
Do something useful.
"""
result = reverse_transform_command_markdown(md, "test-command.md", console)
assert "description: Test command" in result
assert "model: sonnet" in result
assert "Do something useful." in result
def test_with_allowed_tools(self, console):
"""Test that allowed-tools field is preserved (supported by both OpenCode and Claude Code)."""
md = """---
description: Git push command
allowed-tools: Bash(git add:*), Bash(git push:*), Bash(git commit:*)
---
Push changes to remote.
"""
result = reverse_transform_command_markdown(md, "push.md", console)
assert "description: Git push command" in result
assert (
"allowed-tools: Bash(git add:*), Bash(git push:*), Bash(git commit:*)"
in result
)
assert "Push changes to remote." in result
def test_without_frontmatter(self, console):
md = """# My Command
Just content here.
"""
result = reverse_transform_command_markdown(md, "my-command.md", console)
assert "Just content here." in result
class TestReverseTransformSkillMarkdown:
def test_skill_transformation(self, console):
md = """---
name: git-release
description: Create releases and changelogs
license: MIT
compatibility: opencode
metadata:
audience: maintainers
---
Skill instructions here.
"""
result = reverse_transform_skill_markdown(md, "git-release", console)
assert "name: git-release" in result
assert "description: Create releases and changelogs" in result
# These should be dropped
assert "license:" not in result
assert "compatibility:" not in result
assert "metadata:" not in result
assert "Skill instructions here." in result
class TestReverseBuildPermissions:
def test_bash_patterns(self, console):
permission = {
"bash": {
"git status": "allow",
"git log *": "allow",
"rm *": "deny",
"*": "ask",
}
}
tools = {}
result = reverse_build_permissions(permission, tools, console)
allow = result["permissions"].get("allow", [])
deny = result["permissions"].get("deny", [])
assert "Bash(git status)" in allow
assert "Bash(git log:*)" in allow
assert "Bash(rm:*)" in deny
def test_tool_permissions(self, console):
permission = {"edit": "allow", "write": "deny"}
tools = {"*": False, "read": True, "grep": True}
result = reverse_build_permissions(permission, tools, console)
allow = result["permissions"].get("allow", [])
deny = result["permissions"].get("deny", [])
assert "Edit" in allow
assert "Write" in deny
assert "Read" in allow
assert "Grep" in allow
def test_mcp_wildcard_tools(self, console):
permission = {}
tools = {"tools_*": True, "linear-server_*": False}
result = reverse_build_permissions(permission, tools, console)
allow = result["permissions"].get("allow", [])
deny = result["permissions"].get("deny", [])
assert "mcp__tools__*" in allow
assert "mcp__linear-server__*" in deny
class TestReverseTransformMcpServers:
def test_local_to_stdio(self, console):
mcp_src = {
"tools": {
"type": "local",
"command": ["my-tool", "mcp"],
"environment": {"FOO": "bar"},
}
}
result = reverse_transform_mcp_servers(mcp_src, console)
assert result["tools"]["type"] == "stdio"
assert result["tools"]["command"] == "my-tool"
assert result["tools"]["args"] == ["mcp"]
assert result["tools"]["env"] == {"FOO": "bar"}
def test_remote_to_sse(self, console):
mcp_src = {"linear": {"type": "remote", "url": "https://mcp.linear.app/sse"}}
result = reverse_transform_mcp_servers(mcp_src, console)
assert result["linear"]["type"] == "sse"
assert result["linear"]["url"] == "https://mcp.linear.app/sse"
def test_string_command_passthrough(self, console):
mcp_src = {"tool": {"type": "local", "command": "my-command"}}
result = reverse_transform_mcp_servers(mcp_src, console)
assert result["tool"]["command"] == "my-command"
assert "args" not in result["tool"]
# ---------------------------
# Reverse Integration Tests
# ---------------------------
@pytest.fixture
def temp_opencode_project(tmp_path: Path) -> Path:
"""Create a temporary project with OpenCode config."""
project = tmp_path / "project"
opencode_dir = project / ".opencode"
(opencode_dir / "agent").mkdir(parents=True)
(opencode_dir / "command").mkdir(parents=True)
(opencode_dir / "skill" / "git-release").mkdir(parents=True)
# Create sample agent
(opencode_dir / "agent" / "test-agent.md").write_text("""---
mode: subagent
description: A test agent
tools:
'*': false
read: true
grep: true
tools_ls: true
color: '#EAB308'
model: anthropic/claude-sonnet-4-5
---
Test agent prompt content.
""")
# Create sample command
(opencode_dir / "command" / "test-command.md").write_text("""---
description: Test command
model: anthropic/claude-sonnet-4-5
---
# Test Command
Do the thing.
""")
# Create sample skill
(opencode_dir / "skill" / "git-release" / "SKILL.md").write_text("""---
name: git-release
description: Create releases and changelogs
license: MIT
---
Skill instructions.
""")
# Create opencode.json
(project / "opencode.json").write_text(
json.dumps(
{
"permission": {
"bash": {"git status": "allow", "*": "ask"},
"edit": "ask",
},
"tools": {"*": False, "read": True, "write": True},
"mcp": {
"tools": {
"type": "local",
"command": ["my-tool", "mcp"],
"environment": {"FOO": "bar"},
"enabled": True,
}
},
}
)
)
return project
class TestReverseIntegration:
def test_full_reverse_migration_dry_run(self, temp_opencode_project, console):
"""Test that dry-run doesn't create files."""
result = run_reverse_migration(
root=temp_opencode_project,
migrate_agents=True,
migrate_commands=True,
migrate_skills=True,
migrate_permissions=True,
migrate_mcp=False,
mcp_target="project",
dry_run=True,
conflict="skip",
console=console,
)
assert result == 0
# Dry run should NOT create .claude directory
def test_agent_reverse_migration_creates_files(
self, temp_opencode_project, console
):
"""Test that agent migration creates correct files."""
result = run_reverse_migration(
root=temp_opencode_project,
migrate_agents=True,
migrate_commands=False,
migrate_skills=False,
migrate_permissions=False,
migrate_mcp=False,
mcp_target="project",
dry_run=False,
conflict="overwrite",
console=console,
)
assert result == 0
agent_dir = temp_opencode_project / ".claude" / "agents"
assert agent_dir.exists()
agent_file = agent_dir / "test-agent.md"
assert agent_file.exists()
content = agent_file.read_text()
assert "name: test-agent" in content
assert "model: sonnet" in content
assert "color: yellow" in content
def test_skill_reverse_migration_creates_files(
self, temp_opencode_project, console
):
"""Test that skill migration creates correct files."""
result = run_reverse_migration(
root=temp_opencode_project,
migrate_agents=False,
migrate_commands=False,
migrate_skills=True,
migrate_permissions=False,
migrate_mcp=False,
mcp_target="project",
dry_run=False,
conflict="overwrite",
console=console,
)
assert result == 0
skill_dir = temp_opencode_project / ".claude" / "skills" / "git-release"
assert skill_dir.exists()
skill_file = skill_dir / "SKILL.md"
assert skill_file.exists()
content = skill_file.read_text()
assert "name: git-release" in content
assert "description: Create releases and changelogs" in content
assert "license:" not in content
def test_reverse_idempotency(self, temp_opencode_project, console):
"""Test that running reverse migration twice produces same result."""
# First run
run_reverse_migration(
root=temp_opencode_project,
migrate_agents=True,
migrate_commands=True,
migrate_skills=True,
migrate_permissions=True,
migrate_mcp=False,
mcp_target="project",
dry_run=False,
conflict="overwrite",
console=console,
)
# Capture state
agent_content = (
temp_opencode_project / ".claude" / "agents" / "test-agent.md"
).read_text()
# Second run
run_reverse_migration(
root=temp_opencode_project,
migrate_agents=True,
migrate_commands=True,
migrate_skills=True,
migrate_permissions=True,
migrate_mcp=False,
mcp_target="project",
dry_run=False,
conflict="overwrite",
console=console,
)
# Content should be identical
agent_content_2 = (
temp_opencode_project / ".claude" / "agents" / "test-agent.md"
).read_text()
assert agent_content == agent_content_2
if __name__ == "__main__":
pytest.main([__file__, "-v"])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment