Skip to content
Open
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
39 changes: 37 additions & 2 deletions src/specify_cli/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,60 @@
"""opencode integration."""

from __future__ import annotations

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:
"""Remove the legacy `.opencode/command` directory.

Called after setup() has already written canonical files to
`.opencode/commands/`. The legacy directory only ever contained
spec-kit-managed files, so it is safe to remove.
Returns the number of entries that were in the legacy directory.
"""
legacy = project_root / ".opencode" / "command"
if not legacy.is_dir():
return 0
count = sum(1 for _ in legacy.iterdir())
shutil.rmtree(legacy)
return count


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 on lines 38 to 40
"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
37 changes: 35 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"
Comment on lines 10 to 15

def test_build_exec_args_uses_run_command_dispatch(self):
Expand Down Expand Up @@ -57,3 +59,34 @@ 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 = tmp_path / ".opencode" / "command"
legacy.mkdir(parents=True)
(legacy / "speckit.specify.md").write_text("old content")

removed = _migrate_legacy_command_dir(tmp_path)

assert removed == 1
assert not legacy.exists()

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

def test_setup_removes_legacy_dir(self, tmp_path):
"""OpencodeIntegration.setup() cleans up legacy .opencode/command/."""
legacy = tmp_path / ".opencode" / "command"
legacy.mkdir(parents=True)
(legacy / "speckit.specify.md").write_text("old content")

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

assert not legacy.exists()
assert (tmp_path / ".opencode" / "commands").is_dir()