Skip to content

Commit 4ff4b3c

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 3b024f9 commit 4ff4b3c

5 files changed

Lines changed: 400 additions & 26 deletions

File tree

docs/reference/integrations.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
2626
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
2727
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
2828
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
29-
| [opencode](https://opencode.ai/) | `opencode` | |
29+
| [opencode](https://opencode.ai/) | `opencode` | Supports `--skills` for agent-skills scaffolding (`speckit-<name>/SKILL.md`) |
3030
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
3131
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
3232
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
@@ -131,6 +131,7 @@ Some integrations accept additional options via `--integration-options`:
131131
| ----------- | ------------------- | -------------------------------------------------------------- |
132132
| `generic` | `--commands-dir` | Required. Directory for command files |
133133
| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format |
134+
| `opencode` | `--skills` | Scaffold commands as agent skills (`speckit-<name>/SKILL.md`) instead of `.md` command files |
134135

135136
Example:
136137

src/specify_cli/__init__.py

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,11 @@ def init(
587587
"[dim]Note: --ai-skills is not needed; "
588588
"skills are the default for this integration.[/dim]"
589589
)
590+
elif any(o.name == "--skills" for o in resolved_integration.options()):
591+
console.print(
592+
f"[dim]Note: --ai-skills is deprecated for {resolved_integration.key}; use "
593+
f'[bold]--integration {resolved_integration.key} --integration-options="--skills"[/bold] instead.[/dim]'
594+
)
590595
else:
591596
console.print(
592597
"[dim]Note: --ai-skills has no effect with "
@@ -848,6 +853,30 @@ def init(
848853

849854
ensure_constitution_from_template(project_path, tracker=tracker)
850855

856+
# Persist the CLI options so later operations (e.g. extension install, preset add)
857+
# can adapt their behaviour without re-scanning the filesystem.
858+
# Must be saved BEFORE extension and preset install so _get_skills_dir() works.
859+
init_opts = {
860+
"ai": selected_ai,
861+
"integration": resolved_integration.key,
862+
"branch_numbering": branch_numbering or "sequential",
863+
"context_file": resolved_integration.context_file,
864+
"here": here,
865+
"script": selected_script,
866+
"speckit_version": get_speckit_version(),
867+
}
868+
# Ensure ai_skills is set for SkillsIntegration so downstream
869+
# tools (extensions, presets) emit SKILL.md overrides correctly.
870+
# Also set for integrations running in skills mode (e.g. Copilot
871+
# with --skills or Opencode with --skills).
872+
# Use parsed_options as the source of truth for skills mode, not
873+
# _skills_mode (a mutable runtime flag), so the setting persists
874+
# correctly even if the integration is restored without setup().
875+
from .integrations.base import SkillsIntegration as _SkillsPersist
876+
if isinstance(resolved_integration, _SkillsPersist) or integration_parsed_options.get("skills"):
877+
init_opts["ai_skills"] = True
878+
save_init_options(project_path, init_opts)
879+
851880
if not no_git:
852881
tracker.start("git")
853882
git_messages = []
@@ -935,27 +964,6 @@ def init(
935964
# Fix permissions after all installs (scripts + extensions)
936965
ensure_executable_scripts(project_path, tracker=tracker)
937966

938-
# Persist the CLI options so later operations (e.g. preset add)
939-
# can adapt their behaviour without re-scanning the filesystem.
940-
# Must be saved BEFORE preset install so _get_skills_dir() works.
941-
init_opts = {
942-
"ai": selected_ai,
943-
"integration": resolved_integration.key,
944-
"branch_numbering": branch_numbering or "sequential",
945-
"context_file": resolved_integration.context_file,
946-
"here": here,
947-
"script": selected_script,
948-
"speckit_version": get_speckit_version(),
949-
}
950-
# Ensure ai_skills is set for SkillsIntegration so downstream
951-
# tools (extensions, presets) emit SKILL.md overrides correctly.
952-
# Also set for integrations running in skills mode (e.g. Copilot
953-
# with --skills).
954-
from .integrations.base import SkillsIntegration as _SkillsPersist
955-
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
956-
init_opts["ai_skills"] = True
957-
save_init_options(project_path, init_opts)
958-
959967
# Install preset if specified
960968
if preset:
961969
try:
@@ -1087,7 +1095,7 @@ def init(
10871095
step_num = 2
10881096

10891097
# Determine skill display mode for the next-steps panel.
1090-
# Skills integrations (codex, claude, kimi, agy, trae, cursor-agent, copilot, devin) should show skill invocation syntax.
1098+
# Skills integrations (codex, claude, kimi, agy, trae, cursor-agent, copilot, devin, opencode --skills) should show skill invocation syntax.
10911099
from .integrations.base import SkillsIntegration as _SkillsInt
10921100
_is_skills_integration = isinstance(resolved_integration, _SkillsInt) or getattr(resolved_integration, "_skills_mode", False)
10931101

@@ -1099,7 +1107,8 @@ def init(
10991107
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
11001108
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
11011109
devin_skill_mode = selected_ai == "devin"
1102-
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode
1110+
opencode_skill_mode = selected_ai == "opencode" and _is_skills_integration
1111+
native_skill_mode = codex_skill_mode or claude_skill_mode or kimi_skill_mode or agy_skill_mode or trae_skill_mode or cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or opencode_skill_mode
11031112

11041113
if codex_skill_mode and not ai_skills:
11051114
# Integration path installed skills; show the helpful notice
@@ -1114,6 +1123,9 @@ def init(
11141123
if devin_skill_mode:
11151124
steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]")
11161125
step_num += 1
1126+
if opencode_skill_mode:
1127+
steps_lines.append(f"{step_num}. Start opencode in this project directory; spec-kit skills were installed to [cyan].opencode/skills[/cyan]")
1128+
step_num += 1
11171129
usage_label = "skills" if native_skill_mode else "slash commands"
11181130

11191131
def _display_cmd(name: str) -> str:
@@ -1123,7 +1135,7 @@ def _display_cmd(name: str) -> str:
11231135
return f"/speckit-{name}"
11241136
if kimi_skill_mode:
11251137
return f"/skill:speckit-{name}"
1126-
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
1138+
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or opencode_skill_mode:
11271139
return f"/speckit-{name}"
11281140
return f"/speckit.{name}"
11291141

src/specify_cli/extensions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,8 @@ def _register_extension_skills(
924924
body = registrar.resolve_skill_placeholders(
925925
selected_ai, frontmatter, body, self.project_root
926926
)
927+
from .integrations.base import IntegrationBase as _IntegrationBase
928+
body = _IntegrationBase.resolve_command_refs(body, "-")
927929

928930
original_desc = frontmatter.get("description", "")
929931
description = original_desc or f"Extension command: {cmd_name}"

src/specify_cli/integrations/opencode/__init__.py

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

3-
from ..base import MarkdownIntegration
3+
from __future__ import annotations
4+
5+
import subprocess
6+
from pathlib import Path
7+
from typing import Any
8+
9+
from ..base import IntegrationOption, MarkdownIntegration, SkillsIntegration
10+
from ..manifest import IntegrationManifest
11+
12+
13+
class _OpencodeSkillsHelper(SkillsIntegration):
14+
"""Internal delegate used by OpencodeIntegration when --skills is active."""
15+
16+
key = "opencode"
17+
config = {
18+
"name": "opencode",
19+
"folder": ".opencode/",
20+
"commands_subdir": "skills",
21+
"install_url": "https://opencode.ai",
22+
"requires_cli": True,
23+
}
24+
registrar_config = {
25+
"dir": ".opencode/skills",
26+
"format": "markdown",
27+
"args": "$ARGUMENTS",
28+
"extension": "/SKILL.md",
29+
}
30+
context_file = "AGENTS.md"
431

532

633
class OpencodeIntegration(MarkdownIntegration):
@@ -20,6 +47,100 @@ class OpencodeIntegration(MarkdownIntegration):
2047
"extension": ".md",
2148
}
2249
context_file = "AGENTS.md"
50+
# Mutable flag set by setup() — indicates the active scaffolding mode.
51+
_skills_mode: bool = False
52+
53+
@classmethod
54+
def options(cls) -> list[IntegrationOption]:
55+
return [
56+
IntegrationOption(
57+
"--skills",
58+
is_flag=True,
59+
default=False,
60+
help="Scaffold commands as agent skills (speckit-<name>/SKILL.md) instead of .md files",
61+
),
62+
]
63+
64+
def effective_invoke_separator(
65+
self, parsed_options: dict[str, Any] | None = None
66+
) -> str:
67+
if parsed_options and parsed_options.get("skills"):
68+
return "-"
69+
if self._skills_mode:
70+
return "-"
71+
return self.invoke_separator # default: "."
72+
73+
def build_command_invocation(self, command_name: str, args: str = "") -> str:
74+
if not self._skills_mode:
75+
return super().build_command_invocation(command_name, args)
76+
stem = command_name
77+
if stem.startswith("speckit."):
78+
stem = stem[len("speckit."):]
79+
invocation = "/speckit-" + stem.replace(".", "-")
80+
if args:
81+
invocation = f"{invocation} {args}"
82+
return invocation
83+
84+
def dispatch_command(
85+
self,
86+
command_name: str,
87+
args: str = "",
88+
*,
89+
project_root: Path | None = None,
90+
model: str | None = None,
91+
timeout: int = 600,
92+
stream: bool = True,
93+
) -> dict[str, Any]:
94+
# Derive skills mode from project layout when project_root is provided;
95+
# fall back to _skills_mode only when no root is given. This prevents
96+
# stale _skills_mode=True from a prior setup() affecting subsequent
97+
# dispatches against non-skills projects.
98+
if project_root:
99+
skills_dir = project_root / ".opencode" / "skills"
100+
skills_mode = skills_dir.is_dir() and any(
101+
d.is_dir() and (d / "SKILL.md").is_file()
102+
for d in skills_dir.glob("speckit-*")
103+
)
104+
else:
105+
skills_mode = self._skills_mode
106+
107+
stem = command_name
108+
if stem.startswith("speckit."):
109+
stem = stem[len("speckit."):]
110+
if skills_mode:
111+
invocation = "/speckit-" + stem.replace(".", "-")
112+
else:
113+
invocation = "/speckit." + stem
114+
if args:
115+
invocation = f"{invocation} {args}"
116+
117+
exec_args = self.build_exec_args(invocation, model=model, output_json=not stream)
118+
cwd = str(project_root) if project_root else None
119+
120+
if stream:
121+
try:
122+
result = subprocess.run(exec_args, text=True, cwd=cwd)
123+
except KeyboardInterrupt:
124+
return {"exit_code": 130, "stdout": "", "stderr": "Interrupted by user"}
125+
return {"exit_code": result.returncode, "stdout": "", "stderr": ""}
126+
127+
result = subprocess.run(
128+
exec_args, capture_output=True, text=True, cwd=cwd, timeout=timeout,
129+
)
130+
return {"exit_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}
131+
132+
def setup(
133+
self,
134+
project_root: Path,
135+
manifest: IntegrationManifest,
136+
parsed_options: dict[str, Any] | None = None,
137+
**opts: Any,
138+
) -> list[Path]:
139+
parsed_options = parsed_options or {}
140+
self._skills_mode = bool(parsed_options.get("skills"))
141+
if self._skills_mode:
142+
return _OpencodeSkillsHelper().setup(project_root, manifest, parsed_options, **opts)
143+
return super().setup(project_root, manifest, parsed_options, **opts)
23144

24145
def build_exec_args(
25146
self,

0 commit comments

Comments
 (0)