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
3 changes: 2 additions & 1 deletion docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [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` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
| [opencode](https://opencode.ai/) | `opencode` | |
| [opencode](https://opencode.ai/) | `opencode` | Supports `--skills` for agent-skills scaffolding (`speckit-<name>/SKILL.md`) |
| [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) |
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | |
Expand Down Expand Up @@ -131,6 +131,7 @@ Some integrations accept additional options via `--integration-options`:
| ----------- | ------------------- | -------------------------------------------------------------- |
| `generic` | `--commands-dir` | Required. Directory for command files |
| `kimi` | `--migrate-legacy` | Migrate legacy dotted skill directories to hyphenated format |
| `opencode` | `--skills` | Scaffold commands as agent skills (`speckit-<name>/SKILL.md`) instead of `.md` command files |

Example:

Expand Down
60 changes: 36 additions & 24 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,11 @@ def init(
"[dim]Note: --ai-skills is not needed; "
"skills are the default for this integration.[/dim]"
)
elif any(o.name == "--skills" for o in resolved_integration.options()):
console.print(
f"[dim]Note: --ai-skills is deprecated for {resolved_integration.key}; use "
f'[bold]--integration {resolved_integration.key} --integration-options="--skills"[/bold] instead.[/dim]'
)
else:
console.print(
"[dim]Note: --ai-skills has no effect with "
Expand Down Expand Up @@ -819,6 +824,30 @@ def init(

ensure_constitution_from_template(project_path, tracker=tracker)

# Persist the CLI options so later operations (e.g. extension install, preset add)
# can adapt their behaviour without re-scanning the filesystem.
# Must be saved BEFORE extension and preset install so _get_skills_dir() works.
init_opts = {
Comment on lines +827 to +830
"ai": selected_ai,
"integration": resolved_integration.key,
"branch_numbering": branch_numbering or "sequential",
"context_file": resolved_integration.context_file,
"here": here,
"script": selected_script,
"speckit_version": get_speckit_version(),
}
# Ensure ai_skills is set for SkillsIntegration so downstream
# tools (extensions, presets) emit SKILL.md overrides correctly.
# Also set for integrations running in skills mode (e.g. Copilot
# with --skills or Opencode with --skills).
# Use parsed_options as the source of truth for skills mode, not
# _skills_mode (a mutable runtime flag), so the setting persists
# correctly even if the integration is restored without setup().
from .integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist) or integration_parsed_options.get("skills"):
Comment on lines +841 to +847
init_opts["ai_skills"] = True
Comment on lines +839 to +848
save_init_options(project_path, init_opts)

if not no_git:
tracker.start("git")
git_messages = []
Expand Down Expand Up @@ -905,27 +934,6 @@ def init(
# Fix permissions after all installs (scripts + extensions)
ensure_executable_scripts(project_path, tracker=tracker)

# Persist the CLI options so later operations (e.g. preset add)
# can adapt their behaviour without re-scanning the filesystem.
# Must be saved BEFORE preset install so _get_skills_dir() works.
init_opts = {
"ai": selected_ai,
"integration": resolved_integration.key,
"branch_numbering": branch_numbering or "sequential",
"context_file": resolved_integration.context_file,
"here": here,
"script": selected_script,
"speckit_version": get_speckit_version(),
}
# Ensure ai_skills is set for SkillsIntegration so downstream
# tools (extensions, presets) emit SKILL.md overrides correctly.
# Also set for integrations running in skills mode (e.g. Copilot
# with --skills).
from .integrations.base import SkillsIntegration as _SkillsPersist
if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False):
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)

# Install preset if specified
if preset:
try:
Expand Down Expand Up @@ -1044,7 +1052,7 @@ def init(
step_num = 2

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

Expand All @@ -1056,7 +1064,8 @@ def init(
cursor_agent_skill_mode = selected_ai == "cursor-agent" and (ai_skills or _is_skills_integration)
copilot_skill_mode = selected_ai == "copilot" and _is_skills_integration
devin_skill_mode = selected_ai == "devin"
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
opencode_skill_mode = selected_ai == "opencode" and _is_skills_integration
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

if codex_skill_mode and not ai_skills:
# Integration path installed skills; show the helpful notice
Expand All @@ -1071,6 +1080,9 @@ def init(
if devin_skill_mode:
steps_lines.append(f"{step_num}. Start Devin in this project directory; spec-kit skills were installed to [cyan].devin/skills[/cyan]")
step_num += 1
if opencode_skill_mode:
steps_lines.append(f"{step_num}. Start opencode in this project directory; spec-kit skills were installed to [cyan].opencode/skills[/cyan]")
step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"

def _display_cmd(name: str) -> str:
Expand All @@ -1080,7 +1092,7 @@ def _display_cmd(name: str) -> str:
return f"/speckit-{name}"
if kimi_skill_mode:
return f"/skill:speckit-{name}"
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode:
if cursor_agent_skill_mode or copilot_skill_mode or devin_skill_mode or opencode_skill_mode:
return f"/speckit-{name}"
return f"/speckit.{name}"

Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,8 @@ def _register_extension_skills(
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
)
from .integrations.base import IntegrationBase as _IntegrationBase
body = _IntegrationBase.resolve_command_refs(body, "-")
Comment on lines +930 to +931

original_desc = frontmatter.get("description", "")
description = original_desc or f"Extension command: {cmd_name}"
Expand Down
123 changes: 122 additions & 1 deletion src/specify_cli/integrations/opencode/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
"""opencode integration."""

from ..base import MarkdownIntegration
from __future__ import annotations

import subprocess
from pathlib import Path
from typing import Any

from ..base import IntegrationOption, MarkdownIntegration, SkillsIntegration
from ..manifest import IntegrationManifest


class _OpencodeSkillsHelper(SkillsIntegration):
"""Internal delegate used by OpencodeIntegration when --skills is active."""

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",
}
context_file = "AGENTS.md"


class OpencodeIntegration(MarkdownIntegration):
Expand All @@ -20,6 +47,100 @@ 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",
),
Comment on lines +53 to +61
]

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 +64 to +72
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]:
# Derive skills mode from project layout when project_root is provided;
# fall back to _skills_mode only when no root is given. This prevents
# stale _skills_mode=True from a prior setup() affecting subsequent
# dispatches against non-skills projects.
if project_root:
skills_dir = project_root / ".opencode" / "skills"
skills_mode = skills_dir.is_dir() and any(
d.is_dir() and (d / "SKILL.md").is_file()
for d in skills_dir.glob("speckit-*")
)
else:
skills_mode = self._skills_mode

stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
if skills_mode:
invocation = "/speckit-" + stem.replace(".", "-")
else:
invocation = "/speckit." + stem
if args:
invocation = f"{invocation} {args}"

exec_args = self.build_exec_args(invocation, model=model, output_json=not stream)
cwd = str(project_root) if project_root else None

if stream:
try:
result = subprocess.run(exec_args, text=True, cwd=cwd)
except KeyboardInterrupt:
return {"exit_code": 130, "stdout": "", "stderr": "Interrupted by user"}
return {"exit_code": result.returncode, "stdout": "", "stderr": ""}

result = subprocess.run(
exec_args, capture_output=True, text=True, cwd=cwd, timeout=timeout,
)
return {"exit_code": result.returncode, "stdout": result.stdout, "stderr": result.stderr}

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 +140 to +141
Comment on lines +139 to +141
Comment on lines +139 to +141
return _OpencodeSkillsHelper().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
Loading