Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions src/specify_cli/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,104 @@
"""opencode integration."""

from __future__ import annotations

import filecmp
import shutil
from pathlib import Path
from typing import Any

from ..base import MarkdownIntegration
from ..manifest import IntegrationManifest


def _migrate_legacy_command_dir(project_root: Path) -> int:
"""Migrate the legacy `.opencode/command` directory to `.opencode/commands`.

Called after setup() has written canonical files to `.opencode/commands/`.
For each legacy file:
- If a same-named file exists in the new dir and is byte-identical, delete legacy.
- If a same-named file exists but content differs (user-customized), preserve legacy.
- If no counterpart exists, move legacy to the new dir.
Symlinks are unlinked rather than traversed, and filesystem errors are silenced.

Returns the number of entries removed or moved from the legacy directory.
"""
legacy = project_root / ".opencode" / "command"

if legacy.is_symlink():
try:
legacy.unlink()
return 1
except OSError:
return 0

if not legacy.is_dir():
return 0

new_dir = project_root / ".opencode" / "commands"
count = 0

for item in legacy.iterdir():
counterpart = new_dir / item.name
try:
if counterpart.exists():
# Only delete when byte-identical; preserve user customizations.
if item.is_file() and counterpart.is_file() and filecmp.cmp(item, counterpart, shallow=False):
_remove_item(item)
count += 1
# else: user-customized or non-file; leave in place
else:
new_dir.mkdir(parents=True, exist_ok=True)
item.rename(counterpart)
count += 1
except OSError:
pass

try:
legacy.rmdir()
except OSError:
pass

return count


def _remove_item(item: Path) -> None:
"""Remove a file or directory, handling symlinks specially."""
if item.is_symlink() or item.is_file():
item.unlink()
else:
shutil.rmtree(item)


class OpencodeIntegration(MarkdownIntegration):
key = "opencode"
config = {
"name": "opencode",
"folder": ".opencode/",
"commands_subdir": "command",
"commands_subdir": "commands",
"install_url": "https://opencode.ai",
"requires_cli": True,
}
registrar_config = {
"dir": ".opencode/command",
"dir": ".opencode/commands",
"format": "markdown",
Comment thread
mnriem marked this conversation as resolved.
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"

def setup(
self,
project_root: Path,
manifest: IntegrationManifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
) -> list[Path]:
"""Install commands and remove any legacy `.opencode/command` directory."""
created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts)
_migrate_legacy_command_dir(project_root)
return created

def build_exec_args(
self,
prompt: str,
Expand Down
90 changes: 88 additions & 2 deletions tests/integrations/test_integration_opencode.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
"""Tests for OpencodeIntegration."""

from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from specify_cli.integrations.opencode import _migrate_legacy_command_dir

from .test_integration_base_markdown import MarkdownIntegrationTests


class TestOpencodeIntegration(MarkdownIntegrationTests):
KEY = "opencode"
FOLDER = ".opencode/"
COMMANDS_SUBDIR = "command"
REGISTRAR_DIR = ".opencode/command"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".opencode/commands"
CONTEXT_FILE = "AGENTS.md"

def test_build_exec_args_uses_run_command_dispatch(self):
Expand Down Expand Up @@ -57,3 +59,87 @@ def test_build_exec_args_keeps_plain_prompt_dispatch(self):
args = integration.build_exec_args("explain this repository", output_json=False)

assert args == ["opencode", "run", "explain this repository"]


class TestOpencodeCommandMigration:
"""Test legacy .opencode/command → .opencode/commands migration."""

def test_removes_legacy_command_dir(self, tmp_path):
"""Legacy file is removed when byte-identical to counterpart."""
legacy = tmp_path / ".opencode" / "command"
legacy.mkdir(parents=True)
(legacy / "speckit.specify.md").write_text("identical content")
new_dir = tmp_path / ".opencode" / "commands"
new_dir.mkdir(parents=True)
(new_dir / "speckit.specify.md").write_text("identical content")

removed = _migrate_legacy_command_dir(tmp_path)

assert removed == 1
assert not legacy.exists()
Comment on lines +67 to +79

def test_preserves_user_customized_legacy_command(self, tmp_path):
"""Legacy file with different content (user-customized) is preserved."""
legacy = tmp_path / ".opencode" / "command"
legacy.mkdir(parents=True)
(legacy / "speckit.specify.md").write_text("my customization")
new_dir = tmp_path / ".opencode" / "commands"
new_dir.mkdir(parents=True)
(new_dir / "speckit.specify.md").write_text("canonical content")

removed = _migrate_legacy_command_dir(tmp_path)

assert removed == 0 # nothing was removed or moved
assert (legacy / "speckit.specify.md").exists() # user file preserved
assert legacy.exists() # dir not removed (still has content)

def test_no_op_when_no_legacy_dir(self, tmp_path):
removed = _migrate_legacy_command_dir(tmp_path)
assert removed == 0

def test_moves_user_owned_files_to_new_dir(self, tmp_path):
"""Files without a counterpart in the new dir are moved, not deleted."""
legacy = tmp_path / ".opencode" / "command"
legacy.mkdir(parents=True)
(legacy / "my-custom-command.md").write_text("user content")
new_dir = tmp_path / ".opencode" / "commands"
new_dir.mkdir(parents=True)

removed = _migrate_legacy_command_dir(tmp_path)

assert removed == 1
assert not (legacy / "my-custom-command.md").exists()
assert (new_dir / "my-custom-command.md").read_text() == "user content"

def test_handles_symlink_without_following(self, tmp_path):
"""A symlink at the legacy path is unlinked, not traversed via rmtree."""
target = tmp_path / "real_dir"
target.mkdir()
(legacy_parent := tmp_path / ".opencode").mkdir(parents=True)
legacy = legacy_parent / "command"
legacy.symlink_to(target)

removed = _migrate_legacy_command_dir(tmp_path)

assert removed == 1
assert not legacy.exists()
assert target.is_dir() # target is untouched

def test_setup_removes_legacy_dir(self, tmp_path):
"""setup() preserves user-customized files and moves files without counterparts."""
legacy = tmp_path / ".opencode" / "command"
legacy.mkdir(parents=True)
(legacy / "speckit.specify.md").write_text("old content")
(legacy / "my-custom.md").write_text("user content")

i = get_integration("opencode")
m = IntegrationManifest("opencode", tmp_path)
i.setup(tmp_path, m)

assert (tmp_path / ".opencode" / "commands").is_dir()
# User-customized speckit.specify.md is preserved in legacy dir
assert (legacy / "speckit.specify.md").exists()
assert legacy.exists() # dir still exists because it has the customized file
# my-custom.md has no counterpart, so it was moved to new dir
assert not (legacy / "my-custom.md").exists()
assert (tmp_path / ".opencode" / "commands" / "my-custom.md").read_text() == "user content"