Skip to content

Commit 103ee08

Browse files
committed
feat(opencode): add native skills mode (--skills) for opencode
opencode's native extension mechanism is skills (.opencode/skills/<name>/SKILL.md with name+description frontmatter), as used by ~/.config/opencode/skills/. The opencode integration previously installed only legacy markdown commands (.opencode/commands/), which modern opencode does not auto-load. Add a --skills option (mirroring Copilot's pattern) that installs Spec Kit commands as native opencode skills under .opencode/skills/speckit-<name>/SKILL.md. Commands remain the default for backward compatibility; the two modes are mutually exclusive. - OpencodeIntegration(MarkdownIntegration) gains options()/setup() branching - _OpencodeSkillsHelper(SkillsIntegration) is an internal, unregistered delegate - effective_invoke_separator/build_command_invocation adapt to skills mode - build_exec_args (opencode run dispatch) preserved unchanged - Tests cover: skill file layout, frontmatter, hyphen separator, init flow Verified: specify init --integration opencode --integration-options=--skills produces 10 .opencode/skills/speckit-*/SKILL.md; default still produces .opencode/commands/; codex unchanged.
1 parent ad601e5 commit 103ee08

2 files changed

Lines changed: 220 additions & 2 deletions

File tree

src/specify_cli/integrations/opencode/__init__.py

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,60 @@
1-
"""opencode integration."""
1+
"""opencode integration.
22
3-
from ..base import MarkdownIntegration
3+
opencode discovers agent extensions from two locations:
4+
5+
- **Skills** (native, recommended): ``.opencode/skills/<name>/SKILL.md`` at the
6+
project level and ``~/.config/opencode/skills/`` globally. Skills carry
7+
``name`` + ``description`` frontmatter and are invoked as ``/speckit-<name>``.
8+
- **Commands** (legacy): ``.opencode/commands/speckit.<name>.md`` slash-command
9+
files, with ``.opencode/command/`` as a deprecated predecessor.
10+
11+
By default this integration installs markdown **commands** (unchanged historic
12+
behaviour). Pass ``--skills`` via ``--integration-options`` to install native
13+
opencode **skills** (``.opencode/skills/speckit-<name>/SKILL.md``) instead —
14+
the layout modern opencode loads automatically. The two modes are mutually
15+
exclusive, mirroring the Copilot ``--skills`` integration.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from typing import Any
21+
22+
from ..base import IntegrationOption, MarkdownIntegration, SkillsIntegration
23+
24+
25+
class _OpencodeSkillsHelper(SkillsIntegration):
26+
"""Internal skills-mode installer for opencode.
27+
28+
Not registered in the integration registry — only used as a delegate by
29+
:class:`OpencodeIntegration` when ``--skills`` is passed. Installs
30+
``speckit-<name>/SKILL.md`` under ``.opencode/skills/``.
31+
"""
32+
33+
key = "opencode"
34+
config = {
35+
"name": "opencode",
36+
"folder": ".opencode/",
37+
"commands_subdir": "skills",
38+
"install_url": "https://opencode.ai",
39+
"requires_cli": True,
40+
}
41+
registrar_config = {
42+
"dir": ".opencode/skills",
43+
"format": "markdown",
44+
"args": "$ARGUMENTS",
45+
"extension": "/SKILL.md",
46+
}
47+
multi_install_safe = True
448

549

650
class OpencodeIntegration(MarkdownIntegration):
51+
"""Integration for opencode.
52+
53+
Default mode installs markdown commands in ``.opencode/commands/``.
54+
With ``--skills`` it installs native opencode skills in
55+
``.opencode/skills/speckit-<name>/SKILL.md``.
56+
"""
57+
758
key = "opencode"
859
config = {
960
"name": "opencode",
@@ -20,6 +71,89 @@ class OpencodeIntegration(MarkdownIntegration):
2071
"extension": ".md",
2172
}
2273

74+
# Mutable flag set by setup() — indicates the active scaffolding mode.
75+
_skills_mode: bool = False
76+
77+
@classmethod
78+
def options(cls) -> list[IntegrationOption]:
79+
return [
80+
IntegrationOption(
81+
"--skills",
82+
is_flag=True,
83+
default=False,
84+
help="Install native opencode skills (.opencode/skills/speckit-<name>/SKILL.md) instead of commands",
85+
),
86+
]
87+
88+
def effective_invoke_separator(
89+
self, parsed_options: dict[str, Any] | None = None
90+
) -> str:
91+
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
92+
if parsed_options and parsed_options.get("skills"):
93+
return "-"
94+
if self._skills_mode:
95+
return "-"
96+
return self.invoke_separator
97+
98+
def build_command_invocation(self, command_name: str, args: str = "") -> str:
99+
"""Skills mode uses ``/speckit-<stem>``; commands mode uses ``/speckit.<stem>``."""
100+
if self._skills_mode:
101+
stem = command_name
102+
if stem.startswith("speckit."):
103+
stem = stem[len("speckit."):]
104+
invocation = "/speckit-" + stem.replace(".", "-")
105+
if args:
106+
invocation = f"{invocation} {args}"
107+
return invocation
108+
return super().build_command_invocation(command_name, args)
109+
110+
def post_process_skill_content(self, content: str) -> str:
111+
"""Delegate to the skills helper for shared hook-guidance injection."""
112+
return _OpencodeSkillsHelper().post_process_skill_content(content)
113+
114+
def setup(
115+
self,
116+
project_root,
117+
manifest,
118+
parsed_options: dict[str, Any] | None = None,
119+
**opts: Any,
120+
):
121+
"""Install commands (default) or native skills (``--skills``)."""
122+
parsed_options = parsed_options or {}
123+
self._skills_mode = bool(parsed_options.get("skills"))
124+
if self._skills_mode:
125+
return self._setup_skills(project_root, manifest, parsed_options, **opts)
126+
return super().setup(project_root, manifest, parsed_options, **opts)
127+
128+
def _setup_skills(
129+
self,
130+
project_root,
131+
manifest,
132+
parsed_options: dict[str, Any] | None = None,
133+
**opts: Any,
134+
):
135+
"""Skills mode: delegate to ``_OpencodeSkillsHelper`` then post-process."""
136+
helper = _OpencodeSkillsHelper()
137+
created = SkillsIntegration.setup(
138+
helper, project_root, manifest, parsed_options, **opts
139+
)
140+
141+
skills_dir = helper.skills_dest(project_root).resolve()
142+
for path in created:
143+
try:
144+
path.resolve().relative_to(skills_dir)
145+
except ValueError:
146+
continue
147+
if path.name != "SKILL.md":
148+
continue
149+
content = path.read_text(encoding="utf-8")
150+
updated = self.post_process_skill_content(content)
151+
if updated != content:
152+
path.write_bytes(updated.encode("utf-8"))
153+
self.record_file_in_manifest(path, project_root, manifest)
154+
155+
return created
156+
23157
def build_exec_args(
24158
self,
25159
prompt: str,

tests/integrations/test_integration_opencode.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,87 @@ def test_setup_writes_to_canonical_dir(self, tmp_path):
197197
assert canonical.is_dir()
198198
assert not legacy.exists()
199199
assert any(canonical.glob("speckit.*.md"))
200+
201+
202+
class TestOpencodeSkillsMode:
203+
"""``--skills`` installs native opencode skills under .opencode/skills/."""
204+
205+
KEY = "opencode"
206+
207+
def test_options_declare_skills_flag(self):
208+
i = get_integration(self.KEY)
209+
skills_opts = [o for o in i.options() if o.name == "--skills"]
210+
assert len(skills_opts) == 1
211+
assert skills_opts[0].is_flag is True
212+
assert skills_opts[0].default is False
213+
214+
def test_setup_skills_creates_skill_files(self, tmp_path):
215+
i = get_integration(self.KEY)
216+
m = IntegrationManifest(self.KEY, tmp_path)
217+
created = i.setup(tmp_path, m, parsed_options={"skills": True})
218+
219+
assert len(created) > 0
220+
skill_files = [f for f in created if "scripts" not in f.parts]
221+
for f in skill_files:
222+
assert f.name == "SKILL.md"
223+
assert f.parent.name.startswith("speckit-")
224+
assert f.parent.parent == (tmp_path / ".opencode" / "skills").resolve()
225+
226+
def test_setup_skills_does_not_create_commands_dir(self, tmp_path):
227+
i = get_integration(self.KEY)
228+
m = IntegrationManifest(self.KEY, tmp_path)
229+
i.setup(tmp_path, m, parsed_options={"skills": True})
230+
231+
assert (tmp_path / ".opencode" / "skills").is_dir()
232+
assert not (tmp_path / ".opencode" / "commands").exists()
233+
234+
def test_skill_frontmatter_has_name_and_description(self, tmp_path):
235+
import yaml
236+
237+
i = get_integration(self.KEY)
238+
m = IntegrationManifest(self.KEY, tmp_path)
239+
i.setup(tmp_path, m, parsed_options={"skills": True})
240+
241+
plan = tmp_path / ".opencode" / "skills" / "speckit-plan" / "SKILL.md"
242+
assert plan.exists()
243+
parts = plan.read_text(encoding="utf-8").split("---", 2)
244+
fm = yaml.safe_load(parts[1])
245+
assert fm["name"] == "speckit-plan"
246+
assert isinstance(fm["description"], str) and fm["description"]
247+
248+
def test_skills_use_hyphen_separator(self, tmp_path):
249+
i = get_integration(self.KEY)
250+
m = IntegrationManifest(self.KEY, tmp_path)
251+
i.setup(tmp_path, m, parsed_options={"skills": True})
252+
specify_skill = (tmp_path / ".opencode" / "skills" / "speckit-specify" / "SKILL.md")
253+
content = specify_skill.read_text(encoding="utf-8")
254+
assert "/speckit." not in content
255+
256+
def test_effective_invoke_separator_skills_mode(self):
257+
i = get_integration(self.KEY)
258+
assert i.effective_invoke_separator({"skills": True}) == "-"
259+
# Reset shared-instance state polluted by earlier skills tests.
260+
i._skills_mode = False
261+
assert i.effective_invoke_separator({"skills": False}) == "."
262+
assert i.effective_invoke_separator(None) == "."
263+
264+
def test_init_with_skills_option_creates_skills(self, tmp_path):
265+
"""`specify init --integration opencode --integration-options=--skills`."""
266+
from typer.testing import CliRunner
267+
from specify_cli import app
268+
269+
target = tmp_path / "oc-skills-proj"
270+
result = CliRunner().invoke(
271+
app,
272+
[
273+
"init", str(target),
274+
"--integration", "opencode",
275+
"--integration-options", "--skills",
276+
"--ignore-agent-tools",
277+
"--script", "sh",
278+
],
279+
catch_exceptions=False,
280+
)
281+
assert result.exit_code == 0, f"init failed: {result.output}"
282+
assert (target / ".opencode" / "skills" / "speckit-specify" / "SKILL.md").exists()
283+
assert not (target / ".opencode" / "commands").exists()

0 commit comments

Comments
 (0)