Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion src/specify_cli/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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-<name>/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)
Comment on lines +51 to +72

def build_exec_args(
self,
Expand Down
116 changes: 116 additions & 0 deletions tests/integrations/test_integration_opencode.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Comment on lines +67 to +127

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