Skip to content

Commit 0d9fd06

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 4f05eff commit 0d9fd06

2 files changed

Lines changed: 263 additions & 1 deletion

File tree

src/specify_cli/integrations/opencode/__init__.py

Lines changed: 84 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,83 @@ class OpencodeIntegration(MarkdownIntegration):
2026
"extension": ".md",
2127
}
2228
context_file = "AGENTS.md"
29+
# Mutable flag set by setup() — indicates the active scaffolding mode.
30+
_skills_mode: bool = False
31+
32+
@classmethod
33+
def options(cls) -> list[IntegrationOption]:
34+
return [
35+
IntegrationOption(
36+
"--skills",
37+
is_flag=True,
38+
default=False,
39+
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .md files",
40+
),
41+
]
42+
43+
def effective_invoke_separator(
44+
self, parsed_options: dict[str, Any] | None = None
45+
) -> str:
46+
if parsed_options and parsed_options.get("skills"):
47+
return "-"
48+
if self._skills_mode:
49+
return "-"
50+
return self.invoke_separator # default: "."
51+
52+
def build_command_invocation(self, command_name: str, args: str = "") -> str:
53+
if not self._skills_mode:
54+
return super().build_command_invocation(command_name, args)
55+
stem = command_name
56+
if stem.startswith("speckit."):
57+
stem = stem[len("speckit."):]
58+
invocation = "/speckit-" + stem.replace(".", "-")
59+
if args:
60+
invocation = f"{invocation} {args}"
61+
return invocation
62+
63+
def dispatch_command(
64+
self,
65+
command_name: str,
66+
args: str = "",
67+
*,
68+
project_root: Path | None = None,
69+
model: str | None = None,
70+
timeout: int = 600,
71+
stream: bool = True,
72+
) -> dict[str, Any]:
73+
if project_root:
74+
skills_dir = project_root / ".opencode" / "skills"
75+
self._skills_mode = skills_dir.is_dir() and any(
76+
d.is_dir() and (d / "SKILL.md").is_file()
77+
for d in skills_dir.glob("speckit-*")
78+
)
79+
return super().dispatch_command(
80+
command_name, args,
81+
project_root=project_root, model=model, timeout=timeout, stream=stream,
82+
)
83+
84+
def setup(
85+
self,
86+
project_root: Path,
87+
manifest: IntegrationManifest,
88+
parsed_options: dict[str, Any] | None = None,
89+
**opts: Any,
90+
) -> list[Path]:
91+
parsed_options = parsed_options or {}
92+
self._skills_mode = bool(parsed_options.get("skills"))
93+
if self._skills_mode:
94+
helper = SkillsIntegration()
95+
helper.key = self.key
96+
helper.config = {**self.config, "commands_subdir": "skills"}
97+
helper.registrar_config = {
98+
"dir": ".opencode/skills",
99+
"format": "markdown",
100+
"args": "$ARGUMENTS",
101+
"extension": "/SKILL.md",
102+
}
103+
helper.context_file = self.context_file
104+
return helper.setup(project_root, manifest, parsed_options, **opts)
105+
return super().setup(project_root, manifest, parsed_options, **opts)
23106

24107
def build_exec_args(
25108
self,

tests/integrations/test_integration_opencode.py

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

3+
import os
4+
5+
import yaml
6+
37
import warnings
48

59
from specify_cli.agents import CommandRegistrar
@@ -198,3 +202,178 @@ def test_setup_writes_to_canonical_dir(self, tmp_path):
198202
assert canonical.is_dir()
199203
assert not legacy.exists()
200204
assert any(canonical.glob("speckit.*.md"))
205+
206+
207+
class TestOpencodeSkillsMode:
208+
KEY = "opencode"
209+
210+
def test_skills_option_declared(self):
211+
integration = get_integration(self.KEY)
212+
opts = integration.options()
213+
names = [o.name for o in opts]
214+
assert "--skills" in names
215+
skills_opt = next(o for o in opts if o.name == "--skills")
216+
assert skills_opt.is_flag is True
217+
assert skills_opt.default is False
218+
219+
def test_skills_mode_creates_skill_md_files(self, tmp_path):
220+
integration = get_integration(self.KEY)
221+
manifest = IntegrationManifest(self.KEY, tmp_path)
222+
created = integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh")
223+
224+
skill_files = [p for p in created if p.name == "SKILL.md"]
225+
assert skill_files
226+
227+
skills_dir = tmp_path / ".opencode" / "skills"
228+
assert skills_dir.is_dir()
229+
230+
specify_skill = skills_dir / "speckit-specify" / "SKILL.md"
231+
assert specify_skill.exists()
232+
233+
def test_skills_mode_does_not_create_md_command_files(self, tmp_path):
234+
integration = get_integration(self.KEY)
235+
manifest = IntegrationManifest(self.KEY, tmp_path)
236+
integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh")
237+
238+
command_dir = tmp_path / ".opencode" / "commands"
239+
md_files = list(command_dir.glob("*.md")) if command_dir.exists() else []
240+
assert md_files == []
241+
242+
def test_skills_mode_frontmatter(self, tmp_path):
243+
integration = get_integration(self.KEY)
244+
manifest = IntegrationManifest(self.KEY, tmp_path)
245+
integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh")
246+
247+
skill_path = tmp_path / ".opencode" / "skills" / "speckit-plan" / "SKILL.md"
248+
assert skill_path.exists()
249+
250+
content = skill_path.read_text(encoding="utf-8")
251+
parts = content.split("---", 2)
252+
parsed = yaml.safe_load(parts[1])
253+
254+
assert parsed["name"] == "speckit-plan"
255+
assert "description" in parsed
256+
assert "compatibility" in parsed
257+
assert parsed["metadata"]["author"] == "github-spec-kit"
258+
259+
def test_default_mode_unchanged(self, tmp_path):
260+
integration = get_integration(self.KEY)
261+
manifest = IntegrationManifest(self.KEY, tmp_path)
262+
integration.setup(tmp_path, manifest, script_type="sh")
263+
264+
command_dir = tmp_path / ".opencode" / "commands"
265+
assert command_dir.is_dir()
266+
md_files = list(command_dir.glob("speckit.*.md"))
267+
assert md_files
268+
269+
def test_effective_invoke_separator_skills_mode(self):
270+
integration = get_integration(self.KEY)
271+
assert integration.effective_invoke_separator({"skills": True}) == "-"
272+
273+
def test_effective_invoke_separator_default_mode(self):
274+
integration = get_integration(self.KEY)
275+
assert integration.effective_invoke_separator({}) == "."
276+
277+
def test_skills_mode_flag_set_on_instance(self, tmp_path):
278+
integration = get_integration(self.KEY)
279+
manifest = IntegrationManifest(self.KEY, tmp_path)
280+
integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh")
281+
assert integration._skills_mode is True
282+
283+
def test_skills_mode_resets_on_default_setup(self, tmp_path):
284+
integration = get_integration(self.KEY)
285+
manifest = IntegrationManifest(self.KEY, tmp_path)
286+
integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh")
287+
assert integration._skills_mode is True
288+
289+
manifest2 = IntegrationManifest(self.KEY, tmp_path)
290+
integration.setup(tmp_path, manifest2, script_type="sh")
291+
assert integration._skills_mode is False
292+
293+
def test_init_cli_with_skills_option(self, tmp_path):
294+
from typer.testing import CliRunner
295+
from specify_cli import app
296+
297+
project = tmp_path / "opencode-skills"
298+
project.mkdir()
299+
old_cwd = os.getcwd()
300+
try:
301+
os.chdir(project)
302+
result = CliRunner().invoke(app, [
303+
"init", "--here", "--integration", "opencode",
304+
"--integration-options", "--skills",
305+
"--script", "sh", "--no-git", "--ignore-agent-tools",
306+
], catch_exceptions=False)
307+
finally:
308+
os.chdir(old_cwd)
309+
310+
assert result.exit_code == 0, f"init failed: {result.output}"
311+
skills_dir = project / ".opencode" / "skills"
312+
assert skills_dir.is_dir(), "Skills directory was not created"
313+
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
314+
assert plan_skill.exists(), "speckit-plan/SKILL.md not found"
315+
316+
import json
317+
init_opts = json.loads((project / ".specify" / "init-options.json").read_text())
318+
assert init_opts.get("ai_skills") is True
319+
320+
commands_dir = project / ".opencode" / "commands"
321+
if commands_dir.exists():
322+
assert not list(commands_dir.glob("*.md"))
323+
324+
def test_build_command_invocation_skills_mode(self, tmp_path):
325+
integration = get_integration(self.KEY)
326+
manifest = IntegrationManifest(self.KEY, tmp_path)
327+
integration.setup(tmp_path, manifest, parsed_options={"skills": True}, script_type="sh")
328+
329+
assert integration.build_command_invocation("speckit.plan", "add OAuth") == "/speckit-plan add OAuth"
330+
assert integration.build_command_invocation("speckit.specify", "") == "/speckit-specify"
331+
332+
def test_build_command_invocation_default_mode(self, tmp_path):
333+
integration = get_integration(self.KEY)
334+
manifest = IntegrationManifest(self.KEY, tmp_path)
335+
integration.setup(tmp_path, manifest, script_type="sh")
336+
assert integration.build_command_invocation("speckit.plan", "add OAuth") == "/speckit.plan add OAuth"
337+
338+
def test_dispatch_command_resets_skills_mode_for_non_skills_project(self, tmp_path):
339+
from unittest import mock
340+
341+
integration = get_integration(self.KEY)
342+
# Manually set _skills_mode to True to simulate a prior dispatch
343+
integration._skills_mode = True
344+
345+
# Create a project_root with no skills layout
346+
project = tmp_path / "regular-project"
347+
project.mkdir()
348+
(project / ".opencode").mkdir()
349+
350+
# Mock subprocess.run to prevent actual CLI invocation
351+
with mock.patch("subprocess.run"):
352+
integration.dispatch_command(
353+
"plan", "test args", project_root=project
354+
)
355+
356+
# Should have reset _skills_mode to False since no skills dir exists
357+
assert integration._skills_mode is False
358+
359+
def test_dispatch_command_detects_skills_project(self, tmp_path):
360+
from unittest import mock
361+
362+
integration = get_integration(self.KEY)
363+
# Start with _skills_mode = False
364+
integration._skills_mode = False
365+
366+
# Create a skills-mode project layout
367+
project = tmp_path / "skills-project"
368+
skills_dir = project / ".opencode" / "skills" / "speckit-plan"
369+
skills_dir.mkdir(parents=True)
370+
(skills_dir / "SKILL.md").write_text("# skill", encoding="utf-8")
371+
372+
# Mock subprocess.run to prevent actual CLI invocation
373+
with mock.patch("subprocess.run"):
374+
integration.dispatch_command(
375+
"plan", "test args", project_root=project
376+
)
377+
378+
# Should have detected and set _skills_mode to True
379+
assert integration._skills_mode is True

0 commit comments

Comments
 (0)