diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f97dbde09f..253fac86cb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -557,6 +557,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 " @@ -818,6 +823,27 @@ 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). + 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) + if not no_git: tracker.start("git") git_messages = [] @@ -904,27 +930,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: @@ -1043,7 +1048,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) @@ -1055,7 +1060,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 @@ -1070,6 +1076,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: @@ -1079,7 +1088,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/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 4fa9c724ac..7cb4a57048 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,98 @@ 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]: + # Detect skills mode from project layout when not already set via setup(). + # Kept local so the singleton's _skills_mode is never mutated here. + skills_mode = self._skills_mode + if not skills_mode and project_root: + skills_dir = project_root / ".opencode" / "skills" + if skills_dir.is_dir(): + skills_mode = any( + d.is_dir() and (d / "SKILL.md").is_file() + for d in skills_dir.glob("speckit-*") + ) + + 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..10687b8ef5 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,180 @@ 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