Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/agent_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ body:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.

**Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Docker Agent, Factory Droid, DeepSeek Harness, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Muse Code, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
**Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Docker Agent, Factory Droid, DeepSeek Harness, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Muse Code, Oh My Pi, opencode, OpenHands, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed

- type: input
id: agent-name
Expand Down
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ body:
- Muse Code
- Oh My Pi
- opencode
- OpenHands
- Pi Coding Agent
- Qoder CLI
- Qwen Code
Expand Down
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ body:
- Muse Code
- Oh My Pi
- opencode
- OpenHands
- Pi Coding Agent
- Qoder CLI
- Qwen Code
Expand Down
26 changes: 26 additions & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Muse Code](https://dev.meta.ai/docs/muse-code) | `muse` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `/speckit-<command>` |
| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` |
| [opencode](https://opencode.ai/) | `opencode` | |
| [OpenHands](https://github.com/OpenHands/OpenHands-CLI) | `openhands` | Skills in `.openhands/skills/`; `/speckit-<command>` keyword triggers; headless CLI dispatch. |
| [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 @@ -68,6 +69,30 @@ Use the form exposed by your agent. Copilot's default skills are installed under
`--integration-options="--commands"`. In that layout, Copilot CLI can select an
agent through `/agents` or address it directly in a prompt.

## OpenHands

```bash
specify init my-project --integration openhands --script py
cd my-project
openhands
```

Use `/speckit-specify <feature description>` to activate the generated skill.
Spec Kit uses the supported `.openhands/skills/` directory to keep its installed
files separate from other integrations. OpenHands gives `.agents/skills/`
precedence when a same-named skill exists there; avoid duplicate Spec Kit skills
across these directories. See the [OpenHands skills documentation](https://docs.openhands.dev/overview/skills).
The optional `agent-context` extension maps OpenHands to `AGENTS.md`.

Dispatch runs `openhands --headless -t <prompt>` and adds `--json` for captured
JSONL output. Headless mode automatically approves actions. The CLI has no
`--model` flag: configure the model in OpenHands settings, or export `LLM_MODEL`
and set `SPECKIT_INTEGRATION_OPENHANDS_EXTRA_ARGS=--override-with-envs`.
Passing Spec Kit's model override raises an actionable error.
See the [OpenHands CLI reference](https://docs.openhands.dev/openhands/usage/cli/command-reference).
Run from the project root and ensure the agent workspace has the selected script
runtime (`python3` for `--script py`, Bash for `--script sh`).

## List Available Integrations

```bash
Expand Down Expand Up @@ -316,6 +341,7 @@ The currently declared multi-install safe integrations are:
| `kiro-cli` | `.kiro/prompts` |
| `lingma` | `.lingma/skills` |
| `omp` | `.omp/commands` |
| `openhands` | `.openhands/skills` |
| `pi` | `.pi/prompts` |
| `qodercli` | `.qoder/skills` |
| `qwen` | `.qwen/commands` |
Expand Down
1 change: 1 addition & 0 deletions extensions/agent-context/agent-context-defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"muse": "AGENTS.md",
"omp": "AGENTS.md",
"opencode": "AGENTS.md",
"openhands": "AGENTS.md",
"pi": "AGENTS.md",
"qodercli": "QODER.md",
"qwen": "QWEN.md",
Expand Down
9 changes: 9 additions & 0 deletions integrations/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "alibaba"]
},
"openhands": {
"id": "openhands",
"name": "OpenHands",
"version": "1.0.0",
"description": "OpenHands CLI integration with project-local skills",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"opencode": {
"id": "opencode",
"name": "opencode",
Expand Down
2 changes: 1 addition & 1 deletion src/specify_cli/_invocation_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

# Agents that always render /speckit-<name>, regardless of ai_skills.
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset(
{"devin", "droid", "dsh", "grok", "muse", "qodercli", "trae", "zed"}
{"devin", "droid", "dsh", "grok", "muse", "openhands", "qodercli", "trae", "zed"}
)

# Agents that render /speckit-<name> only when ai_skills is enabled.
Expand Down
1 change: 1 addition & 0 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,7 @@ def init(
or grok_skill_mode
or dsh_skill_mode
or bob_skill_mode
or selected_ai == "openhands"
)

if codex_skill_mode:
Expand Down
2 changes: 2 additions & 0 deletions src/specify_cli/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def _register_builtins() -> None:
from .muse import MuseIntegration
from .omp import OmpIntegration
from .opencode import OpencodeIntegration
from .openhands import OpenhandsIntegration
from .pi import PiIntegration
from .qodercli import QodercliIntegration
from .qwen import QwenIntegration
Expand Down Expand Up @@ -121,6 +122,7 @@ def _register_builtins() -> None:
_register(MuseIntegration())
_register(OmpIntegration())
_register(OpencodeIntegration())
_register(OpenhandsIntegration())
_register(PiIntegration())
_register(QodercliIntegration())
_register(QwenIntegration())
Expand Down
82 changes: 82 additions & 0 deletions src/specify_cli/integrations/openhands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""OpenHands CLI integration using project-local Agent Skills."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any

import yaml

from ..base import IntegrationOption, SkillsIntegration


class OpenhandsIntegration(SkillsIntegration):
"""Install isolated skills and dispatch tasks through OpenHands headless mode."""

key = "openhands"
config = {
"name": "OpenHands",
"folder": ".openhands/",
"commands_subdir": "skills",
"install_url": "https://github.com/OpenHands/OpenHands-CLI",
"requires_cli": True,
}
registrar_config = {
"dir": ".openhands/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True

def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
project_root: Path | None = None,
) -> list[str] | None:
self.validate_runtime_config(integration_args, integration_options)
if model:
raise ValueError(
"OpenHands CLI does not support --model. Configure the model in "
"OpenHands settings, or set LLM_MODEL and "
"SPECKIT_INTEGRATION_OPENHANDS_EXTRA_ARGS=--override-with-envs."
)
args = [self._resolve_executable(), "--headless", "-t", prompt]
self._apply_extra_args_env_var(args)
if output_json:
args.append("--json")
return args

def post_process_skill_content(self, content: str) -> str:
"""Make slash invocations deterministic through OpenHands keyword triggers."""
content = super().post_process_skill_content(content)
if not content.startswith("---\n"):
return content
parts = content.split("---\n", 2)
if len(parts) != 3:
return content
metadata = yaml.safe_load(parts[1])
if not isinstance(metadata, dict) or not metadata.get("name"):
return content
triggers = metadata.setdefault("triggers", [])
trigger = f"/{metadata['name']}"
if trigger not in triggers:
triggers.append(trigger)
return "---\n" + yaml.safe_dump(metadata, sort_keys=False) + "---\n" + parts[2]

@classmethod
def options(cls) -> list[IntegrationOption]:
return super().options() + [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for OpenHands)",
),
]
104 changes: 104 additions & 0 deletions tests/integrations/test_integration_openhands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""OpenHands installation, invocation and headless dispatch contracts."""

import subprocess
from unittest.mock import patch

import pytest
import yaml
from typer.testing import CliRunner

from specify_cli import app
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest

from .test_integration_base_skills import SkillsIntegrationTests


class TestOpenhandsIntegration(SkillsIntegrationTests):
KEY = "openhands"
FOLDER = ".openhands/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".openhands/skills"

def test_multi_install_safe(self):
assert get_integration(self.KEY).multi_install_safe is True

@pytest.mark.parametrize("output_json", [True, False])
def test_headless_dispatch(self, output_json):
integration = get_integration(self.KEY)
prompt = integration.build_command_invocation("speckit.git.commit", "a 'quoted' task")
assert prompt == "/speckit-git-commit a 'quoted' task"
assert integration.build_exec_args(prompt, output_json=output_json) == [
"openhands", "--headless", "-t", prompt,
] + (["--json"] if output_json else [])

def test_executable_and_extra_args(self, monkeypatch):
monkeypatch.setenv("SPECKIT_INTEGRATION_OPENHANDS_EXECUTABLE", "/path with spaces/openhands")
monkeypatch.setenv("SPECKIT_INTEGRATION_OPENHANDS_EXTRA_ARGS", "--override-with-envs")
assert get_integration(self.KEY).build_exec_args("task") == [
"/path with spaces/openhands", "--headless", "-t", "task",
"--override-with-envs", "--json",
]

def test_unsupported_model_has_actionable_error(self):
with pytest.raises(ValueError, match="LLM_MODEL"):
get_integration(self.KEY).build_exec_args("task", model="some-model")

@pytest.mark.parametrize("config", [
{"integration_args": ["--unknown"]},
{"integration_options": {"unknown": True}},
])
def test_rejects_unsupported_runtime_config(self, config):
with pytest.raises(ValueError, match="does not support per-step"):
get_integration(self.KEY).build_exec_args("task", **config)

def test_generated_skills_have_slash_triggers(self, tmp_path):
integration = get_integration(self.KEY)
integration.setup(tmp_path, IntegrationManifest(self.KEY, tmp_path))
for path in (tmp_path / self.REGISTRAR_DIR).glob("*/SKILL.md"):
metadata = yaml.safe_load(path.read_text().split("---", 2)[1])
assert metadata["triggers"] == [f"/{metadata['name']}"]

def test_post_processing_preserves_existing_triggers_and_body(self):
integration = get_integration(self.KEY)
content = "---\nname: speckit-example\ndescription: Example\ntriggers: [existing]\n---\n\nBody\n"
result = integration.post_process_skill_content(content)
metadata = yaml.safe_load(result.split("---", 2)[1])
assert metadata["triggers"] == ["existing", "/speckit-example"]
assert result.endswith("\n\nBody\n")
assert integration.post_process_skill_content(result) == result

@pytest.mark.parametrize("script_type", ["sh", "ps", "py"])
def test_init_and_cli_uninstall(self, tmp_path, monkeypatch, script_type):
project = tmp_path / "project"
result = CliRunner().invoke(app, [
"init", str(project), "--integration", self.KEY,
"--ignore-agent-tools", "--script", script_type, "--non-interactive",
])
assert result.exit_code == 0, result.output
assert "/speckit-specify" in result.output
assert "Start using skills" in result.output
skill = project / self.REGISTRAR_DIR / "speckit-plan/SKILL.md"
assert skill.exists()
assert not (project / "AGENTS.md").exists()
monkeypatch.chdir(project)
result = CliRunner().invoke(app, ["integration", "uninstall", self.KEY])
assert result.exit_code == 0, result.output
assert not skill.exists()

def test_dispatch_passes_workspace_and_returns_jsonl(self, tmp_path):
integration = get_integration(self.KEY)
output = '{"type":"action"}\n{"type":"observation"}\n'
with patch("subprocess.run", return_value=subprocess.CompletedProcess(
args=[], returncode=0, stdout=output, stderr="",
)) as run, patch("shutil.which", return_value=None):
result = integration.dispatch_command(
"speckit.specify", "Build a task tracker",
project_root=tmp_path, stream=False,
)
argv = run.call_args.args[0]
assert argv[:3] == ["openhands", "--headless", "-t"]
assert "/speckit-specify Build a task tracker" in argv[3]
assert argv[-1] == "--json"
assert run.call_args.kwargs["cwd"] == str(tmp_path)
assert result == {"exit_code": 0, "stdout": output, "stderr": ""}
1 change: 1 addition & 0 deletions tests/test_agent_config_consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"muse",
"omp",
"opencode",
"openhands",
"pi",
"qodercli",
"qwen",
Expand Down
Loading