From 1fecdea25f2c8137cad7017918d8f64db2ba8eb9 Mon Sep 17 00:00:00 2001 From: Tine Kondo Date: Fri, 8 May 2026 07:05:12 +0200 Subject: [PATCH] feat(opencode): add `--skills` support to `opencode` integration Adds opt-in `--skills` support to OpencodeIntegration, producing `speckit-/SKILL.md` files under `.opencode/skills/` instead of flat `.md` files. Opencode natively supports this format (https://opencode.ai/docs/skills/). Activate via: `specify init --integration opencode --integration-options="--skills"` --- docs/reference/integrations.md | 3 +- src/specify_cli/__init__.py | 60 +++-- src/specify_cli/extensions.py | 2 + .../integrations/opencode/__init__.py | 123 ++++++++- .../integrations/test_integration_opencode.py | 238 ++++++++++++++++++ 5 files changed, 400 insertions(+), 26 deletions(-) diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index ec6c894652..e9681d888b 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -26,7 +26,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` | | [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically | | [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | | -| [opencode](https://opencode.ai/) | `opencode` | | +| [opencode](https://opencode.ai/) | `opencode` | Supports `--skills` for agent-skills scaffolding (`speckit-/SKILL.md`) | | [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) | | [Qoder CLI](https://qoder.com/cli) | `qodercli` | | | [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | | @@ -131,6 +131,7 @@ Some integrations accept additional options via `--integration-options`: | ----------- | ------------------- | -------------------------------------------------------------- | | `generic` | `--commands-dir` | Required. Directory for command files | | `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format | +| `opencode` | `--skills` | Scaffold commands as agent skills (`speckit-/SKILL.md`) instead of `.md` command files | Example: diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 41fb994726..eafc640369 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -558,6 +558,11 @@ def init( "[dim]Note: --ai-skills is not needed; " "skills are the default for this integration.[/dim]" ) + elif any(o.name == "--skills" for o in resolved_integration.options()): + console.print( + f"[dim]Note: --ai-skills is deprecated for {resolved_integration.key}; use " + f'[bold]--integration {resolved_integration.key} --integration-options="--skills"[/bold] instead.[/dim]' + ) else: console.print( "[dim]Note: --ai-skills has no effect with " @@ -819,6 +824,30 @@ def init( ensure_constitution_from_template(project_path, tracker=tracker) + # Persist the CLI options so later operations (e.g. extension install, preset add) + # can adapt their behaviour without re-scanning the filesystem. + # Must be saved BEFORE extension and preset install so _get_skills_dir() works. + init_opts = { + "ai": selected_ai, + "integration": resolved_integration.key, + "branch_numbering": branch_numbering or "sequential", + "context_file": resolved_integration.context_file, + "here": here, + "script": selected_script, + "speckit_version": get_speckit_version(), + } + # Ensure ai_skills is set for SkillsIntegration so downstream + # tools (extensions, presets) emit SKILL.md overrides correctly. + # Also set for integrations running in skills mode (e.g. Copilot + # with --skills or Opencode with --skills). + # Use parsed_options as the source of truth for skills mode, not + # _skills_mode (a mutable runtime flag), so the setting persists + # correctly even if the integration is restored without setup(). + from .integrations.base import SkillsIntegration as _SkillsPersist + if isinstance(resolved_integration, _SkillsPersist) or integration_parsed_options.get("skills"): + init_opts["ai_skills"] = True + save_init_options(project_path, init_opts) + if not no_git: tracker.start("git") git_messages = [] @@ -905,27 +934,6 @@ def init( # Fix permissions after all installs (scripts + extensions) ensure_executable_scripts(project_path, tracker=tracker) - # Persist the CLI options so later operations (e.g. preset add) - # can adapt their behaviour without re-scanning the filesystem. - # Must be saved BEFORE preset install so _get_skills_dir() works. - init_opts = { - "ai": selected_ai, - "integration": resolved_integration.key, - "branch_numbering": branch_numbering or "sequential", - "context_file": resolved_integration.context_file, - "here": here, - "script": selected_script, - "speckit_version": get_speckit_version(), - } - # Ensure ai_skills is set for SkillsIntegration so downstream - # tools (extensions, presets) emit SKILL.md overrides correctly. - # Also set for integrations running in skills mode (e.g. Copilot - # with --skills). - from .integrations.base import SkillsIntegration as _SkillsPersist - if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): - init_opts["ai_skills"] = True - save_init_options(project_path, init_opts) - # Install preset if specified if preset: try: @@ -1044,7 +1052,7 @@ def init( step_num = 2 # Determine skill display mode for the next-steps panel. - # Skills integrations (codex, claude, kimi, agy, trae, cursor-agent, copilot, devin) should show skill invocation syntax. + # Skills integrations (codex, claude, kimi, agy, trae, cursor-agent, copilot, devin, opencode --skills) should show skill invocation syntax. from .integrations.base import SkillsIntegration as _SkillsInt _is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False) @@ -1056,7 +1064,8 @@ def init( cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration) copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration devin_skill_mode = selected_ai == "devin" - native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode + opencode_skill_mode = selected_ai == "opencode" and _is_skills_integration + native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or opencode_skill_mode if codex_skill_mode and not ai_skills: # Integration path installed skills; show the helpful notice @@ -1071,6 +1080,9 @@ def init( if devin_skill_mode: steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]") step_num += 1 + if opencode_skill_mode: + steps_lines.append(f"{step_num}. Start opencode in this project directory; spec-kit skills were installed to [cyan].opencode/skills[/cyan]") + step_num += 1 usage_label = "skills" if native_skill_mode else "slash commands" def _display_cmd(name: str) -> str: @@ -1080,7 +1092,7 @@ def _display_cmd(name: str) -> str: return f"/speckit-{name}" if kimi_skill_mode: return f"/skill:speckit-{name}" - if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode: + if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or opencode_skill_mode: return f"/speckit-{name}" return f"/speckit.{name}" diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index f657de06ce..47c429f954 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -927,6 +927,8 @@ def _register_extension_skills( body = registrar.resolve_skill_placeholders( selected_ai, frontmatter, body, self.project_root ) + from .integrations.base import IntegrationBase as _IntegrationBase + body = _IntegrationBase.resolve_command_refs(body, "-") original_desc = frontmatter.get("description", "") description = original_desc or f"Extension command: {cmd_name}" diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 4fa9c724ac..caaa401e7d 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -1,6 +1,33 @@ """opencode integration.""" -from ..base import MarkdownIntegration +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any + +from ..base import IntegrationOption, MarkdownIntegration, SkillsIntegration +from ..manifest import IntegrationManifest + + +class _OpencodeSkillsHelper(SkillsIntegration): + """Internal delegate used by OpencodeIntegration when --skills is active.""" + + 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", + } + context_file = "AGENTS.md" class OpencodeIntegration(MarkdownIntegration): @@ -20,6 +47,100 @@ 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]: + # Derive skills mode from project layout when project_root is provided; + # fall back to _skills_mode only when no root is given. This prevents + # stale _skills_mode=True from a prior setup() affecting subsequent + # dispatches against non-skills projects. + if project_root: + skills_dir = project_root / ".opencode" / "skills" + skills_mode = skills_dir.is_dir() and any( + d.is_dir() and (d / "SKILL.md").is_file() + for d in skills_dir.glob("speckit-*") + ) + else: + skills_mode = self._skills_mode + + stem = command_name + if stem.startswith("speckit."): + stem = stem[len("speckit."):] + if skills_mode: + invocation = "/speckit-" + stem.replace(".", "-") + else: + invocation = "/speckit." + stem + if args: + invocation = f"{invocation} {args}" + + exec_args = self.build_exec_args(invocation, model=model, output_json=not stream) + cwd = str(project_root) if project_root else None + + if stream: + try: + result = subprocess.run(exec_args, text=True, cwd=cwd) + except KeyboardInterrupt: + return {"exit_code": 130, "stdout": "", "stderr": "Interrupted by user"} + return {"exit_code": result.returncode, "stdout": "", "stderr": ""} + + result = subprocess.run( + exec_args, capture_output=True, text=True, cwd=cwd, timeout=timeout, + ) + return {"exit_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr} + + 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: + return _OpencodeSkillsHelper().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..c50446b0c4 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,237 @@ 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_uses_dotted_invocation_for_non_skills_project(self, tmp_path): + from unittest import mock + + integration = get_integration(self.KEY) + integration._skills_mode = False # no prior skills setup + + project = tmp_path / "regular-project" + project.mkdir() + (project / ".opencode").mkdir() + + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock(returncode=0) + integration.dispatch_command( + "speckit.plan", "test args", project_root=project, stream=False, + ) + + called_args = mock_run.call_args[0][0] + assert "--command" in called_args + assert called_args[called_args.index("--command") + 1] == "speckit.plan" + # singleton _skills_mode is not mutated by dispatch + assert integration._skills_mode is False + + def test_dispatch_command_uses_hyphenated_invocation_for_skills_project(self, tmp_path): + from unittest import mock + + integration = get_integration(self.KEY) + integration._skills_mode = False # start without skills + + 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") + + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock(returncode=0) + integration.dispatch_command( + "speckit.plan", "test args", project_root=project, stream=False, + ) + + called_args = mock_run.call_args[0][0] + assert "--command" in called_args + assert called_args[called_args.index("--command") + 1] == "speckit-plan" + # singleton _skills_mode is not mutated by dispatch + assert integration._skills_mode is False + + def test_init_with_git_extension_skills_mode(self, tmp_path): + """Test that git extension installs as skills when --skills is used.""" + from unittest import mock + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "opencode-git-skills" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + with mock.patch("specify_cli.is_git_repo", return_value=True): + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "opencode", + "--integration-options", "--skills", + "--script", "sh", "--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" + + git_skills = [d for d in skills_dir.iterdir() if d.name.startswith("speckit-git-")] + assert git_skills, "Git extension skills not created under .opencode/skills/" + + for skill_dir in git_skills: + content = (skill_dir / "SKILL.md").read_text(encoding="utf-8") + assert "__SPECKIT_COMMAND_" not in content, \ + f"Unresolved command token in {skill_dir / 'SKILL.md'}" + + def test_dispatch_stale_skills_mode_overridden_by_project_layout(self, tmp_path): + """Stale _skills_mode=True should not affect non-skills projects.""" + from unittest import mock + + integration = get_integration(self.KEY) + integration._skills_mode = True # stale flag from prior skills setup + + project = tmp_path / "regular-project" + project.mkdir() + (project / ".opencode").mkdir() + # No .opencode/skills/ — this is a non-skills project + + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock(returncode=0) + integration.dispatch_command( + "speckit.plan", "test args", project_root=project, stream=False, + ) + + called_args = mock_run.call_args[0][0] + assert "--command" in called_args + # Should use dotted invocation despite stale _skills_mode=True + assert called_args[called_args.index("--command") + 1] == "speckit.plan" + # singleton _skills_mode remains unchanged + assert integration._skills_mode is True