Skip to content

Commit ac6514e

Browse files
committed
feat(opencode): add --skills support to opencode integration
Adds opt-in `--skills` support to OpencodeIntegration, producing `speckit-<name>/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"`
1 parent abb5fe7 commit ac6514e

2 files changed

Lines changed: 105 additions & 1 deletion

File tree

src/specify_cli/integrations/opencode/__init__.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
"""opencode integration."""
22

3-
from ..base import MarkdownIntegration
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
from ..base import IntegrationOption, MarkdownIntegration, SkillsIntegration
9+
from ..manifest import IntegrationManifest
410

511

612
class OpencodeIntegration(MarkdownIntegration):
@@ -20,6 +26,38 @@ class OpencodeIntegration(MarkdownIntegration):
2026
}
2127
context_file = "AGENTS.md"
2228

29+
@classmethod
30+
def options(cls) -> list[IntegrationOption]:
31+
return [
32+
IntegrationOption(
33+
"--skills",
34+
is_flag=True,
35+
default=False,
36+
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .md files",
37+
),
38+
]
39+
40+
def setup(
41+
self,
42+
project_root: Path,
43+
manifest: IntegrationManifest,
44+
parsed_options: dict[str, Any] | None = None,
45+
**opts: Any,
46+
) -> list[Path]:
47+
if (parsed_options or {}).get("skills"):
48+
helper = SkillsIntegration()
49+
helper.key = self.key
50+
helper.config = {**self.config, "commands_subdir": "skills"}
51+
helper.registrar_config = {
52+
"dir": ".opencode/skills",
53+
"format": "markdown",
54+
"args": "$ARGUMENTS",
55+
"extension": "/SKILL.md",
56+
}
57+
helper.context_file = self.context_file
58+
return helper.setup(project_root, manifest, parsed_options, **opts)
59+
return super().setup(project_root, manifest, parsed_options, **opts)
60+
2361
def build_exec_args(
2462
self,
2563
prompt: str,

tests/integrations/test_integration_opencode.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Tests for OpencodeIntegration."""
22

3+
import yaml
4+
35
from specify_cli.integrations import get_integration
6+
from specify_cli.integrations.manifest import IntegrationManifest
47

58
from .test_integration_base_markdown import MarkdownIntegrationTests
69

@@ -57,3 +60,66 @@ def test_build_exec_args_keeps_plain_prompt_dispatch(self):
5760
args = integration.build_exec_args("explain this repository", output_json=False)
5861

5962
assert args == ["opencode", "run", "explain this repository"]
63+
64+
65+
class TestOpencodeSkillsMode:
66+
KEY = "opencode"
67+
68+
def test_skills_option_declared(self):
69+
integration = get_integration(self.KEY)
70+
opts = integration.options()
71+
names = [o.name for o in opts]
72+
assert "--skills" in names
73+
skills_opt = next(o for o in opts if o.name == "--skills")
74+
assert skills_opt.is_flag is True
75+
assert skills_opt.default is False
76+
77+
def test_skills_mode_creates_skill_md_files(self, tmp_path):
78+
integration = get_integration(self.KEY)
79+
manifest = IntegrationManifest(self.KEY, tmp_path)
80+
created = integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh")
81+
82+
skill_files = [p for p in created if p.name == "SKILL.md"]
83+
assert skill_files
84+
85+
skills_dir = tmp_path / ".opencode" / "skills"
86+
assert skills_dir.is_dir()
87+
88+
specify_skill = skills_dir / "speckit-specify" / "SKILL.md"
89+
assert specify_skill.exists()
90+
91+
def test_skills_mode_does_not_create_md_command_files(self, tmp_path):
92+
integration = get_integration(self.KEY)
93+
manifest = IntegrationManifest(self.KEY, tmp_path)
94+
integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh")
95+
96+
command_dir = tmp_path / ".opencode" / "command"
97+
md_files = list(command_dir.glob("*.md")) if command_dir.exists() else []
98+
assert md_files == []
99+
100+
def test_skills_mode_frontmatter(self, tmp_path):
101+
integration = get_integration(self.KEY)
102+
manifest = IntegrationManifest(self.KEY, tmp_path)
103+
integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh")
104+
105+
skill_path = tmp_path / ".opencode" / "skills" / "speckit-plan" / "SKILL.md"
106+
assert skill_path.exists()
107+
108+
content = skill_path.read_text(encoding="utf-8")
109+
parts = content.split("---", 2)
110+
parsed = yaml.safe_load(parts[1])
111+
112+
assert parsed["name"] == "speckit-plan"
113+
assert "description" in parsed
114+
assert "compatibility" in parsed
115+
assert parsed["metadata"]["author"] == "github-spec-kit"
116+
117+
def test_default_mode_unchanged(self, tmp_path):
118+
integration = get_integration(self.KEY)
119+
manifest = IntegrationManifest(self.KEY, tmp_path)
120+
integration.setup(tmp_path, manifest, script_type="sh")
121+
122+
command_dir = tmp_path / ".opencode" / "command"
123+
assert command_dir.is_dir()
124+
md_files = list(command_dir.glob("*.md"))
125+
assert md_files

0 commit comments

Comments
 (0)