Skip to content

Commit 4da99dc

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 7106858 commit 4da99dc

6 files changed

Lines changed: 420 additions & 19 deletions

File tree

docs/reference/integrations.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
2828
| [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` |
2929
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
3030
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
31-
| [opencode](https://opencode.ai/) | `opencode` | |
31+
| [opencode](https://opencode.ai/) | `opencode` | Supports `--skills` for agent-skills scaffolding (`speckit-<name>/SKILL.md`) |
3232
| [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) |
3333
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
3434
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
@@ -134,6 +134,7 @@ Some integrations accept additional options via `--integration-options`:
134134
| ----------- | ------------------- | -------------------------------------------------------------- |
135135
| `generic` | `--commands-dir` | Required. Directory for command files |
136136
| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format |
137+
| `opencode` | `--skills` | Scaffold commands as agent skills (`speckit-<name>/SKILL.md`) instead of `.md` command files |
137138

138139
Example:
139140

src/specify_cli/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,6 @@ def _print_cli_warning(
444444
from .commands import init as _init_cmd # noqa: E402
445445
_init_cmd.register(app)
446446

447-
448447
@app.command()
449448
def check():
450449
"""Check that all required tools are installed."""

src/specify_cli/commands/init.py

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,26 @@ def init(
369369

370370
ensure_constitution_from_template(project_path, tracker=tracker)
371371

372+
# Persist the CLI options so later operations (e.g. extension install, preset add)
373+
# can adapt their behaviour without re-scanning the filesystem.
374+
# Must be saved BEFORE extension and preset install so _get_skills_dir() works.
375+
init_opts = {
376+
"ai": selected_ai,
377+
"integration": resolved_integration.key,
378+
"branch_numbering": branch_numbering or "sequential",
379+
"context_file": resolved_integration.context_file,
380+
"here": here,
381+
"script": selected_script,
382+
"speckit_version": get_speckit_version(),
383+
}
384+
# Use parsed_options as the source of truth for skills mode, not
385+
# _skills_mode (a mutable runtime flag), so the setting persists
386+
# correctly even if the integration is restored without setup().
387+
from ..integrations.base import SkillsIntegration as _SkillsPersist
388+
if isinstance(resolved_integration, _SkillsPersist) or integration_parsed_options.get("skills"):
389+
init_opts["ai_skills"] = True
390+
save_init_options(project_path, init_opts)
391+
372392
if not no_git:
373393
tracker.start("git")
374394
git_messages = []
@@ -448,19 +468,6 @@ def init(
448468
sanitized_wf = str(wf_err).replace('\n', ' ').strip()
449469
tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
450470

451-
init_opts = {
452-
"ai": selected_ai,
453-
"integration": resolved_integration.key,
454-
"branch_numbering": branch_numbering or "sequential",
455-
"here": here,
456-
"script": selected_script,
457-
"speckit_version": get_speckit_version(),
458-
}
459-
from ..integrations.base import SkillsIntegration as _SkillsPersist
460-
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
461-
init_opts["ai_skills"] = True
462-
save_init_options(project_path, init_opts)
463-
464471
# --- agent-context extension (bundled, auto-installed) ---
465472
# Installed after init-options.json is written so that skill
466473
# registration can read ai_skills + integration key.
@@ -627,8 +634,8 @@ def init(
627634
cursor_agent_skill_mode = selected_ai == "cursor-agent" and _is_skills_integration
628635
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
629636
devin_skill_mode = selected_ai == "devin"
630-
cline_skill_mode = selected_ai == "cline"
631-
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
637+
opencode_skill_mode = selected_ai == "opencode" and _is_skills_integration
638+
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
632639

633640
if codex_skill_mode:
634641
steps_lines.append(f"{step_num}. Start Codex in this project directory; spec-kit skills were installed to [cyan].agents/skills[/cyan]")
@@ -642,6 +649,9 @@ def init(
642649
if devin_skill_mode:
643650
steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]")
644651
step_num += 1
652+
if opencode_skill_mode:
653+
steps_lines.append(f"{step_num}. Start opencode in this project directory; spec-kit skills were installed to [cyan].opencode/skills[/cyan]")
654+
step_num += 1
645655
usage_label = "skills" if native_skill_mode else "slash commands"
646656

647657
def _display_cmd(name: str) -> str:
@@ -651,7 +661,7 @@ def _display_cmd(name: str) -> str:
651661
return f"/speckit-{name}"
652662
if kimi_skill_mode:
653663
return f"/skill:speckit-{name}"
654-
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or cline_skill_mode:
664+
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or opencode_skill_mode:
655665
return f"/speckit-{name}"
656666
return f"/speckit.{name}"
657667

src/specify_cli/extensions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,8 @@ def _register_extension_skills(
982982
body = registrar.resolve_skill_placeholders(
983983
selected_ai, frontmatter, body, self.project_root
984984
)
985+
from .integrations.base import IntegrationBase as _IntegrationBase
986+
body = _IntegrationBase.resolve_command_refs(body, "-")
985987

986988
original_desc = frontmatter.get("description", "")
987989
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)