diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 17db2bd11b..f4b0910985 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -1,6 +1,12 @@ """opencode integration.""" -from ..base import MarkdownIntegration +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, MarkdownIntegration, SkillsIntegration +from ..manifest import IntegrationManifest class OpencodeIntegration(MarkdownIntegration): @@ -19,6 +25,51 @@ class OpencodeIntegration(MarkdownIntegration): "extension": ".md", } context_file = "AGENTS.md" + # Mutable flag set by setup() — indicates the active scaffolding mode. + _skills_mode: bool = False + + @classmethod + def options(cls) -> list[IntegrationOption]: + return [ + IntegrationOption( + "--skills", + is_flag=True, + default=False, + help="Scaffold commands as agent skills (speckit-/SKILL.md) instead of .md files", + ), + ] + + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + if parsed_options and parsed_options.get("skills"): + return "-" + if self._skills_mode: + return "-" + return self.invoke_separator # default: "." + + def setup( + self, + project_root: Path, + manifest: IntegrationManifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + parsed_options = parsed_options or {} + self._skills_mode = bool(parsed_options.get("skills")) + if self._skills_mode: + helper = SkillsIntegration() + helper.key = self.key + helper.config = {**self.config, "commands_subdir": "skills"} + helper.registrar_config = { + "dir": ".opencode/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + helper.context_file = self.context_file + return helper.setup(project_root, manifest, parsed_options, **opts) + return super().setup(project_root, manifest, parsed_options, **opts) def build_exec_args( self, diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index 427fd15167..8d47c098b5 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -1,6 +1,11 @@ """Tests for OpencodeIntegration.""" +import os + +import yaml + from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_markdown import MarkdownIntegrationTests @@ -57,3 +62,114 @@ 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 TestOpencodeSkillsMode: + KEY = "opencode" + + def test_skills_option_declared(self): + integration = get_integration(self.KEY) + opts = integration.options() + names = [o.name for o in opts] + assert "--skills" in names + skills_opt = next(o for o in opts if o.name == "--skills") + assert skills_opt.is_flag is True + assert skills_opt.default is False + + def test_skills_mode_creates_skill_md_files(self, tmp_path): + integration = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + created = integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh") + + skill_files = [p for p in created if p.name == "SKILL.md"] + assert skill_files + + skills_dir = tmp_path / ".opencode" / "skills" + assert skills_dir.is_dir() + + specify_skill = skills_dir / "speckit-specify" / "SKILL.md" + assert specify_skill.exists() + + def test_skills_mode_does_not_create_md_command_files(self, tmp_path): + integration = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh") + + command_dir = tmp_path / ".opencode" / "command" + md_files = list(command_dir.glob("*.md")) if command_dir.exists() else [] + assert md_files == [] + + def test_skills_mode_frontmatter(self, tmp_path): + integration = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh") + + skill_path = tmp_path / ".opencode" / "skills" / "speckit-plan" / "SKILL.md" + assert skill_path.exists() + + content = skill_path.read_text(encoding="utf-8") + parts = content.split("---", 2) + parsed = yaml.safe_load(parts[1]) + + assert parsed["name"] == "speckit-plan" + assert "description" in parsed + assert "compatibility" in parsed + assert parsed["metadata"]["author"] == "github-spec-kit" + + def test_default_mode_unchanged(self, tmp_path): + integration = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + + command_dir = tmp_path / ".opencode" / "command" + assert command_dir.is_dir() + md_files = list(command_dir.glob("*.md")) + assert md_files + + def test_effective_invoke_separator_skills_mode(self): + integration = get_integration(self.KEY) + assert integration.effective_invoke_separator({"skills": True}) == "-" + + def test_effective_invoke_separator_default_mode(self): + integration = get_integration(self.KEY) + assert integration.effective_invoke_separator({}) == "." + + def test_skills_mode_flag_set_on_instance(self, tmp_path): + integration = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh") + assert integration._skills_mode is True + + def test_skills_mode_resets_on_default_setup(self, tmp_path): + integration = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh") + assert integration._skills_mode is True + + manifest2 = IntegrationManifest(self.KEY, tmp_path) + integration.setup(tmp_path, manifest2, script_type="sh") + assert integration._skills_mode is False + + def test_init_cli_with_skills_option(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "opencode-skills" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "opencode", + "--integration-options", "--skills", + "--script", "sh", "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + skills_dir = project / ".opencode" / "skills" + assert skills_dir.is_dir(), "Skills directory was not created" + plan_skill = skills_dir / "speckit-plan" / "SKILL.md" + assert plan_skill.exists(), "speckit-plan/SKILL.md not found" + assert not list((project / ".opencode" / "command").glob("*.md")) if (project / ".opencode" / "command").exists() else True