diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 17db2bd11b..c06e12125c 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -1,6 +1,73 @@ """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): @@ -8,18 +75,30 @@ class OpencodeIntegration(MarkdownIntegration): 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", "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, diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index 427fd15167..78a02fd984 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -1,6 +1,8 @@ """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 @@ -8,8 +10,8 @@ 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): @@ -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() + + 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"