diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 0f734b7f41..e0b90d3110 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -1,9 +1,60 @@ -"""opencode integration.""" +"""opencode integration. -from ..base import MarkdownIntegration +opencode discovers agent extensions from two locations: + +- **Skills** (native, recommended): ``.opencode/skills//SKILL.md`` at the + project level and ``~/.config/opencode/skills/`` globally. Skills carry + ``name`` + ``description`` frontmatter and are invoked as ``/speckit-``. +- **Commands** (legacy): ``.opencode/commands/speckit..md`` slash-command + files, with ``.opencode/command/`` as a deprecated predecessor. + +By default this integration installs markdown **commands** (unchanged historic +behaviour). Pass ``--skills`` via ``--integration-options`` to install native +opencode **skills** (``.opencode/skills/speckit-/SKILL.md``) instead — +the layout modern opencode loads automatically. The two modes are mutually +exclusive, mirroring the Copilot ``--skills`` integration. +""" + +from __future__ import annotations + +from typing import Any + +from ..base import IntegrationOption, MarkdownIntegration, SkillsIntegration + + +class _OpencodeSkillsHelper(SkillsIntegration): + """Internal skills-mode installer for opencode. + + Not registered in the integration registry — only used as a delegate by + :class:`OpencodeIntegration` when ``--skills`` is passed. Installs + ``speckit-/SKILL.md`` under ``.opencode/skills/``. + """ + + key = "opencode" + config = { + "name": "opencode", + "folder": ".opencode/", + "commands_subdir": "skills", + "install_url": "https://opencode.ai", + "requires_cli": True, + } + registrar_config = { + "dir": ".opencode/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + multi_install_safe = True class OpencodeIntegration(MarkdownIntegration): + """Integration for opencode. + + Default mode installs markdown commands in ``.opencode/commands/``. + With ``--skills`` it installs native opencode skills in + ``.opencode/skills/speckit-/SKILL.md``. + """ + key = "opencode" config = { "name": "opencode", @@ -20,6 +71,89 @@ class OpencodeIntegration(MarkdownIntegration): "extension": ".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="Install native opencode skills (.opencode/skills/speckit-/SKILL.md) instead of commands", + ), + ] + + def effective_invoke_separator( + self, parsed_options: dict[str, Any] | None = None + ) -> str: + """Return ``"-"`` when skills mode is requested, ``"."`` otherwise.""" + if parsed_options and parsed_options.get("skills"): + return "-" + if self._skills_mode: + return "-" + return self.invoke_separator + + def build_command_invocation(self, command_name: str, args: str = "") -> str: + """Skills mode uses ``/speckit-``; commands mode uses ``/speckit.``.""" + if self._skills_mode: + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + invocation = "/speckit-" + stem.replace(".", "-") + if args: + invocation = f"{invocation} {args}" + return invocation + return super().build_command_invocation(command_name, args) + + def post_process_skill_content(self, content: str) -> str: + """Delegate to the skills helper for shared hook-guidance injection.""" + return _OpencodeSkillsHelper().post_process_skill_content(content) + + def setup( + self, + project_root, + manifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ): + """Install commands (default) or native skills (``--skills``).""" + parsed_options = parsed_options or {} + self._skills_mode = bool(parsed_options.get("skills")) + if self._skills_mode: + return self._setup_skills(project_root, manifest, parsed_options, **opts) + return super().setup(project_root, manifest, parsed_options, **opts) + + def _setup_skills( + self, + project_root, + manifest, + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ): + """Skills mode: delegate to ``_OpencodeSkillsHelper`` then post-process.""" + helper = _OpencodeSkillsHelper() + created = SkillsIntegration.setup( + helper, project_root, manifest, parsed_options, **opts + ) + + skills_dir = helper.skills_dest(project_root).resolve() + for path in created: + try: + path.resolve().relative_to(skills_dir) + except ValueError: + continue + if path.name != "SKILL.md": + continue + content = path.read_text(encoding="utf-8") + updated = self.post_process_skill_content(content) + if updated != content: + path.write_bytes(updated.encode("utf-8")) + self.record_file_in_manifest(path, project_root, manifest) + + 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 b9464fdea3..1bafd96c48 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -197,3 +197,87 @@ 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: + """``--skills`` installs native opencode skills under .opencode/skills/.""" + + KEY = "opencode" + + def test_options_declare_skills_flag(self): + i = get_integration(self.KEY) + skills_opts = [o for o in i.options() if o.name == "--skills"] + assert len(skills_opts) == 1 + assert skills_opts[0].is_flag is True + assert skills_opts[0].default is False + + def test_setup_skills_creates_skill_files(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + created = i.setup(tmp_path, m, parsed_options={"skills": True}) + + assert len(created) > 0 + skill_files = [f for f in created if "scripts" not in f.parts] + for f in skill_files: + assert f.name == "SKILL.md" + assert f.parent.name.startswith("speckit-") + assert f.parent.parent == (tmp_path / ".opencode" / "skills").resolve() + + def test_setup_skills_does_not_create_commands_dir(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m, parsed_options={"skills": True}) + + assert (tmp_path / ".opencode" / "skills").is_dir() + assert not (tmp_path / ".opencode" / "commands").exists() + + def test_skill_frontmatter_has_name_and_description(self, tmp_path): + import yaml + + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m, parsed_options={"skills": True}) + + plan = tmp_path / ".opencode" / "skills" / "speckit-plan" / "SKILL.md" + assert plan.exists() + parts = plan.read_text(encoding="utf-8").split("---", 2) + fm = yaml.safe_load(parts[1]) + assert fm["name"] == "speckit-plan" + assert isinstance(fm["description"], str) and fm["description"] + + def test_skills_use_hyphen_separator(self, tmp_path): + i = get_integration(self.KEY) + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m, parsed_options={"skills": True}) + specify_skill = (tmp_path / ".opencode" / "skills" / "speckit-specify" / "SKILL.md") + content = specify_skill.read_text(encoding="utf-8") + assert "/speckit." not in content + + def test_effective_invoke_separator_skills_mode(self): + i = get_integration(self.KEY) + assert i.effective_invoke_separator({"skills": True}) == "-" + # Reset shared-instance state polluted by earlier skills tests. + i._skills_mode = False + assert i.effective_invoke_separator({"skills": False}) == "." + assert i.effective_invoke_separator(None) == "." + + def test_init_with_skills_option_creates_skills(self, tmp_path): + """`specify init --integration opencode --integration-options=--skills`.""" + from typer.testing import CliRunner + from specify_cli import app + + target = tmp_path / "oc-skills-proj" + result = CliRunner().invoke( + app, + [ + "init", str(target), + "--integration", "opencode", + "--integration-options", "--skills", + "--ignore-agent-tools", + "--script", "sh", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0, f"init failed: {result.output}" + assert (target / ".opencode" / "skills" / "speckit-specify" / "SKILL.md").exists() + assert not (target / ".opencode" / "commands").exists()