From 35d149d2184ff47ebd4a1651514bcf3ad9bcd807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E7=94=BB=E5=AE=B6?= Date: Mon, 21 Sep 2026 16:05:05 +0800 Subject: [PATCH] feat(integrations): add OpenHands skills and headless dispatch Assisted-by: Codex (model: GPT-6, autonomous) --- .github/ISSUE_TEMPLATE/agent_request.yml | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 1 + docs/reference/integrations.md | 26 +++++ .../agent-context/agent-context-defaults.json | 1 + integrations/catalog.json | 9 ++ src/specify_cli/_invocation_style.py | 2 +- src/specify_cli/commands/init.py | 1 + src/specify_cli/integrations/__init__.py | 2 + .../integrations/openhands/__init__.py | 82 ++++++++++++++ .../test_integration_openhands.py | 104 ++++++++++++++++++ tests/test_agent_config_consistency.py | 1 + 12 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 src/specify_cli/integrations/openhands/__init__.py create mode 100644 tests/integrations/test_integration_openhands.py diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 05b77a117a..65bd5a0c94 100644 --- a/.github/ISSUE_TEMPLATE/agent_request.yml +++ b/.github/ISSUE_TEMPLATE/agent_request.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a9f1ec2abb..ac2d2b22eb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -93,6 +93,7 @@ body: - Muse Code - Oh My Pi - opencode + - OpenHands - Pi Coding Agent - Qoder CLI - Qwen Code diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 3555be2841..bfa30a0ae1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -87,6 +87,7 @@ body: - Muse Code - Oh My Pi - opencode + - OpenHands - Pi Coding Agent - Qoder CLI - Qwen Code diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 32310cf81f..44ac28022b 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -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-` | | [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-` 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` | | @@ -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 ` 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 ` 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 @@ -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` | diff --git a/extensions/agent-context/agent-context-defaults.json b/extensions/agent-context/agent-context-defaults.json index 8c5f781ab9..d5975463da 100644 --- a/extensions/agent-context/agent-context-defaults.json +++ b/extensions/agent-context/agent-context-defaults.json @@ -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", diff --git a/integrations/catalog.json b/integrations/catalog.json index 55b4ec1fa1..384dc678f8 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -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", diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index bd01a9f7bb..33db3ffc9a 100644 --- a/src/specify_cli/_invocation_style.py +++ b/src/specify_cli/_invocation_style.py @@ -13,7 +13,7 @@ # Agents that always render /speckit-, 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- only when ai_skills is enabled. diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 9cdd86d1d4..00e5e03be9 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -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: diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index ad38366938..8b3a870c0f 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -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 @@ -121,6 +122,7 @@ def _register_builtins() -> None: _register(MuseIntegration()) _register(OmpIntegration()) _register(OpencodeIntegration()) + _register(OpenhandsIntegration()) _register(PiIntegration()) _register(QodercliIntegration()) _register(QwenIntegration()) diff --git a/src/specify_cli/integrations/openhands/__init__.py b/src/specify_cli/integrations/openhands/__init__.py new file mode 100644 index 0000000000..600fd72959 --- /dev/null +++ b/src/specify_cli/integrations/openhands/__init__.py @@ -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)", + ), + ] diff --git a/tests/integrations/test_integration_openhands.py b/tests/integrations/test_integration_openhands.py new file mode 100644 index 0000000000..2404d6f82b --- /dev/null +++ b/tests/integrations/test_integration_openhands.py @@ -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": ""} diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 6ca4de9c6d..f360639583 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -44,6 +44,7 @@ "muse", "omp", "opencode", + "openhands", "pi", "qodercli", "qwen",