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
138 changes: 136 additions & 2 deletions src/specify_cli/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,60 @@
"""opencode integration."""
"""opencode integration.

from ..base import MarkdownIntegration
opencode discovers agent extensions from two locations:

- **Skills** (native, recommended): ``.opencode/skills/<name>/SKILL.md`` at the
project level and ``~/.config/opencode/skills/`` globally. Skills carry
``name`` + ``description`` frontmatter and are invoked as ``/speckit-<name>``.
- **Commands** (legacy): ``.opencode/commands/speckit.<name>.md`` slash-command
files, with ``.opencode/command/`` as a deprecated predecessor.
Comment on lines +5 to +9

By default this integration installs markdown **commands** (unchanged historic
behaviour). Pass ``--skills`` via ``--integration-options`` to install native
opencode **skills** (``.opencode/skills/speckit-<name>/SKILL.md``) instead —
the layout modern opencode loads automatically. The two modes are mutually
exclusive, mirroring the Copilot ``--skills`` integration.
"""

from __future__ import annotations

from typing import Any

from ..base import IntegrationOption, MarkdownIntegration, SkillsIntegration


class _OpencodeSkillsHelper(SkillsIntegration):
"""Internal skills-mode installer for opencode.

Not registered in the integration registry — only used as a delegate by
:class:`OpencodeIntegration` when ``--skills`` is passed. Installs
``speckit-<name>/SKILL.md`` under ``.opencode/skills/``.
"""

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",
}
multi_install_safe = True


class OpencodeIntegration(MarkdownIntegration):
"""Integration for opencode.

Default mode installs markdown commands in ``.opencode/commands/``.
With ``--skills`` it installs native opencode skills in
``.opencode/skills/speckit-<name>/SKILL.md``.
"""

key = "opencode"
config = {
"name": "opencode",
Expand All @@ -20,6 +71,89 @@ class OpencodeIntegration(MarkdownIntegration):
"extension": ".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="Install native opencode skills (.opencode/skills/speckit-<name>/SKILL.md) instead of commands",
),
]

def effective_invoke_separator(
self, parsed_options: dict[str, Any] | None = None
) -> str:
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
if parsed_options and parsed_options.get("skills"):
return "-"
if self._skills_mode:
return "-"
return self.invoke_separator

def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Skills mode uses ``/speckit-<stem>``; commands mode uses ``/speckit.<stem>``."""
if self._skills_mode:
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = "/speckit-" + stem.replace(".", "-")
Comment on lines +100 to +104
if args:
invocation = f"{invocation} {args}"
return invocation
return super().build_command_invocation(command_name, args)

def post_process_skill_content(self, content: str) -> str:
"""Delegate to the skills helper for shared hook-guidance injection."""
return _OpencodeSkillsHelper().post_process_skill_content(content)

def setup(
self,
project_root,
manifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
):
"""Install commands (default) or native skills (``--skills``)."""
parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills"))
if self._skills_mode:
return self._setup_skills(project_root, manifest, parsed_options, **opts)
return super().setup(project_root, manifest, parsed_options, **opts)

def _setup_skills(
self,
project_root,
manifest,
parsed_options: dict[str, Any] | None = None,
**opts: Any,
):
"""Skills mode: delegate to ``_OpencodeSkillsHelper`` then post-process."""
helper = _OpencodeSkillsHelper()
created = SkillsIntegration.setup(
helper, project_root, manifest, parsed_options, **opts
)

skills_dir = helper.skills_dest(project_root).resolve()
for path in created:
try:
path.resolve().relative_to(skills_dir)
except ValueError:
continue
if path.name != "SKILL.md":
continue
content = path.read_text(encoding="utf-8")
updated = self.post_process_skill_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
self.record_file_in_manifest(path, project_root, manifest)

return created

def build_exec_args(
self,
prompt: str,
Expand Down
84 changes: 84 additions & 0 deletions tests/integrations/test_integration_opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,87 @@ 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:
"""``--skills`` installs native opencode skills under .opencode/skills/."""

KEY = "opencode"
Comment on lines +202 to +205

def test_options_declare_skills_flag(self):
i = get_integration(self.KEY)
skills_opts = [o for o in i.options() if o.name == "--skills"]
assert len(skills_opts) == 1
assert skills_opts[0].is_flag is True
assert skills_opts[0].default is False

def test_setup_skills_creates_skill_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m, parsed_options={"skills": True})

assert len(created) > 0
skill_files = [f for f in created if "scripts" not in f.parts]
for f in skill_files:
assert f.name == "SKILL.md"
assert f.parent.name.startswith("speckit-")
assert f.parent.parent == (tmp_path / ".opencode" / "skills").resolve()

def test_setup_skills_does_not_create_commands_dir(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m, parsed_options={"skills": True})

assert (tmp_path / ".opencode" / "skills").is_dir()
assert not (tmp_path / ".opencode" / "commands").exists()

def test_skill_frontmatter_has_name_and_description(self, tmp_path):
import yaml

i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m, parsed_options={"skills": True})

plan = tmp_path / ".opencode" / "skills" / "speckit-plan" / "SKILL.md"
assert plan.exists()
parts = plan.read_text(encoding="utf-8").split("---", 2)
fm = yaml.safe_load(parts[1])
assert fm["name"] == "speckit-plan"
assert isinstance(fm["description"], str) and fm["description"]

def test_skills_use_hyphen_separator(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
i.setup(tmp_path, m, parsed_options={"skills": True})
specify_skill = (tmp_path / ".opencode" / "skills" / "speckit-specify" / "SKILL.md")
content = specify_skill.read_text(encoding="utf-8")
assert "/speckit." not in content

def test_effective_invoke_separator_skills_mode(self):
i = get_integration(self.KEY)
assert i.effective_invoke_separator({"skills": True}) == "-"
# Reset shared-instance state polluted by earlier skills tests.
i._skills_mode = False
assert i.effective_invoke_separator({"skills": False}) == "."
assert i.effective_invoke_separator(None) == "."

def test_init_with_skills_option_creates_skills(self, tmp_path):
"""`specify init --integration opencode --integration-options=--skills`."""
from typer.testing import CliRunner
from specify_cli import app

target = tmp_path / "oc-skills-proj"
result = CliRunner().invoke(
app,
[
"init", str(target),
"--integration", "opencode",
"--integration-options", "--skills",
"--ignore-agent-tools",
"--script", "sh",
],
catch_exceptions=False,
)
assert result.exit_code == 0, f"init failed: {result.output}"
assert (target / ".opencode" / "skills" / "speckit-specify" / "SKILL.md").exists()
assert not (target / ".opencode" / "commands").exists()
Comment on lines +281 to +283