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
85 changes: 84 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 @@ -20,6 +26,83 @@ 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: "."

Comment on lines +43 to +51
def build_command_invocation(self, command_name: str, args: str = "") -> str:
if not self._skills_mode:
return super().build_command_invocation(command_name, args)
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
invocation = "/speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation

def dispatch_command(
self,
command_name: str,
args: str = "",
*,
project_root: Path | None = None,
model: str | None = None,
timeout: int = 600,
stream: bool = True,
) -> dict[str, Any]:
if project_root:
skills_dir = project_root / ".opencode" / "skills"
self._skills_mode = skills_dir.is_dir() and any(
d.is_dir() and (d / "SKILL.md").is_file()
for d in skills_dir.glob("speckit-*")
)
return super().dispatch_command(
command_name, args,
project_root=project_root, model=model, timeout=timeout, stream=stream,
)

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:
Comment on lines +92 to +93
Comment on lines +91 to +93
Comment on lines +91 to +93
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 thread
mnriem marked this conversation as resolved.

def build_exec_args(
self,
Expand Down
179 changes: 179 additions & 0 deletions tests/integrations/test_integration_opencode.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Tests for OpencodeIntegration."""

import os

import yaml

import warnings

from specify_cli.agents import CommandRegistrar
Expand Down Expand Up @@ -198,3 +202,178 @@ 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:
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" / "commands"
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" / "commands"
assert command_dir.is_dir()
md_files = list(command_dir.glob("speckit.*.md"))
assert md_files
Comment thread
mnriem marked this conversation as resolved.

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"

import json
init_opts = json.loads((project / ".specify" / "init-options.json").read_text())
assert init_opts.get("ai_skills") is True

commands_dir = project / ".opencode" / "commands"
if commands_dir.exists():
assert not list(commands_dir.glob("*.md"))

def test_build_command_invocation_skills_mode(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.build_command_invocation("speckit.plan", "add OAuth") == "/speckit-plan add OAuth"
assert integration.build_command_invocation("speckit.specify", "") == "/speckit-specify"

def test_build_command_invocation_default_mode(self, tmp_path):
integration = get_integration(self.KEY)
manifest = IntegrationManifest(self.KEY, tmp_path)
integration.setup(tmp_path, manifest, script_type="sh")
assert integration.build_command_invocation("speckit.plan", "add OAuth") == "/speckit.plan add OAuth"

def test_dispatch_command_resets_skills_mode_for_non_skills_project(self, tmp_path):
from unittest import mock

integration = get_integration(self.KEY)
# Manually set _skills_mode to True to simulate a prior dispatch
integration._skills_mode = True

# Create a project_root with no skills layout
project = tmp_path / "regular-project"
project.mkdir()
(project / ".opencode").mkdir()

# Mock subprocess.run to prevent actual CLI invocation
with mock.patch("subprocess.run"):
integration.dispatch_command(
"plan", "test args", project_root=project
)

# Should have reset _skills_mode to False since no skills dir exists
assert integration._skills_mode is False

def test_dispatch_command_detects_skills_project(self, tmp_path):
from unittest import mock

integration = get_integration(self.KEY)
# Start with _skills_mode = False
integration._skills_mode = False

# Create a skills-mode project layout
project = tmp_path / "skills-project"
skills_dir = project / ".opencode" / "skills" / "speckit-plan"
skills_dir.mkdir(parents=True)
(skills_dir / "SKILL.md").write_text("# skill", encoding="utf-8")

# Mock subprocess.run to prevent actual CLI invocation
with mock.patch("subprocess.run"):
integration.dispatch_command(
"plan", "test args", project_root=project
)

# Should have detected and set _skills_mode to True
assert integration._skills_mode is True