diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 4fa9c724ac..8ad6bd147b 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): @@ -20,6 +26,83 @@ 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 build_command_invocation(self, command_name: str, args: str = "") -> str: + if not self._skills_mode: + return super().build_command_invocation(command_name, args) + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + invocation = "/speckit-" + stem.replace(".", "-") + if args: + invocation = f"{invocation} {args}" + return invocation + + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + if project_root: + skills_dir = project_root / ".opencode" / "skills" + self._skills_mode = skills_dir.is_dir() and any( + d.is_dir() and (d / "SKILL.md").is_file() + for d in skills_dir.glob("speckit-*") + ) + return super().dispatch_command( + command_name, args, + project_root=project_root, model=model, timeout=timeout, stream=stream, + ) + + 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 ba2d15711f..1018e9a3c4 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -1,5 +1,9 @@ """Tests for OpencodeIntegration.""" +import os + +import yaml + import warnings from specify_cli.agents import CommandRegistrar @@ -198,3 +202,178 @@ def test_setup_writes_to_canonical_dir(self, tmp_path): assert canonical.is_dir() assert not legacy.exists() assert any(canonical.glob("speckit.*.md")) + + +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" / "commands" + 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" / "commands" + assert command_dir.is_dir() + md_files = list(command_dir.glob("speckit.*.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" + + import json + init_opts = json.loads((project / ".specify" / "init-options.json").read_text()) + assert init_opts.get("ai_skills") is True + + commands_dir = project / ".opencode" / "commands" + if commands_dir.exists(): + assert not list(commands_dir.glob("*.md")) + + def test_build_command_invocation_skills_mode(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.build_command_invocation("speckit.plan", "add OAuth") == "/speckit-plan add OAuth" + assert integration.build_command_invocation("speckit.specify", "") == "/speckit-specify" + + def test_build_command_invocation_default_mode(self, tmp_path): + integration = get_integration(self.KEY) + manifest = IntegrationManifest(self.KEY, tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + assert integration.build_command_invocation("speckit.plan", "add OAuth") == "/speckit.plan add OAuth" + + def test_dispatch_command_resets_skills_mode_for_non_skills_project(self, tmp_path): + from unittest import mock + + integration = get_integration(self.KEY) + # Manually set _skills_mode to True to simulate a prior dispatch + integration._skills_mode = True + + # Create a project_root with no skills layout + project = tmp_path / "regular-project" + project.mkdir() + (project / ".opencode").mkdir() + + # Mock subprocess.run to prevent actual CLI invocation + with mock.patch("subprocess.run"): + integration.dispatch_command( + "plan", "test args", project_root=project + ) + + # Should have reset _skills_mode to False since no skills dir exists + assert integration._skills_mode is False + + def test_dispatch_command_detects_skills_project(self, tmp_path): + from unittest import mock + + integration = get_integration(self.KEY) + # Start with _skills_mode = False + integration._skills_mode = False + + # Create a skills-mode project layout + project = tmp_path / "skills-project" + skills_dir = project / ".opencode" / "skills" / "speckit-plan" + skills_dir.mkdir(parents=True) + (skills_dir / "SKILL.md").write_text("# skill", encoding="utf-8") + + # Mock subprocess.run to prevent actual CLI invocation + with mock.patch("subprocess.run"): + integration.dispatch_command( + "plan", "test args", project_root=project + ) + + # Should have detected and set _skills_mode to True + assert integration._skills_mode is True