diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 785f9193e3..8ee5019afa 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, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, 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, 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, Oh My Pi, opencode, 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 03fa6c124f..2ba56d91eb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -74,6 +74,7 @@ body: - Cursor - Devin for Terminal - Factory Droid + - DeepSeek Harness - Firebender - Forge - Gemini CLI diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4613c8ebae..5404548bd2 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -68,6 +68,7 @@ body: - Cursor - Devin for Terminal - Factory Droid + - DeepSeek Harness - Firebender - Forge - Gemini CLI diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 57b079bcd1..c97988c6c9 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -16,6 +16,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-` | | [Command Code](https://commandcode.ai/docs) | `command-code` | Skills-based integration; installs skills into `.commandcode/skills/` and invokes them as `$speckit-` | | [Cursor](https://cursor.sh/) | `cursor-agent` | | +| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `dsh` | Skills-based integration; installs skills into `.dsh/skills` and invokes them as `/speckit-` | | [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | | [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-` | | [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ | diff --git a/integrations/catalog.json b/integrations/catalog.json index f3f7a7fe7f..dc9a47c3ce 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -66,6 +66,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli", "skills", "factory"] }, + "dsh": { + "id": "dsh", + "name": "DeepSeek Harness", + "version": "1.0.0", + "description": "DeepSeek Harness (DSH) CLI skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills"] + }, "amp": { "id": "amp", "name": "Amp", diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py index ec6ac0f323..3233a6bab4 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", "grok", "qodercli", "trae", "zed"} + {"devin", "droid", "dsh", "grok", "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 4af9427bfa..2f686e2fa9 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -1013,6 +1013,7 @@ def init( devin_skill_mode = selected_ai == "devin" zed_skill_mode = selected_ai == "zed" and _is_skills_integration grok_skill_mode = selected_ai == "grok" and _is_skills_integration + dsh_skill_mode = selected_ai == "dsh" and _is_skills_integration cline_skill_mode = selected_ai == "cline" forge_skill_mode = selected_ai == "forge" bob_skill_mode = selected_ai == "bob" and _is_skills_integration @@ -1028,6 +1029,7 @@ def init( or devin_skill_mode or zed_skill_mode or grok_skill_mode + or dsh_skill_mode or bob_skill_mode ) @@ -1066,6 +1068,11 @@ def init( f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]" ) step_num += 1 + if dsh_skill_mode: + steps_lines.append( + f"{step_num}. Start DSH ([cyan]dsh web[/cyan]) in this project directory; spec-kit skills were installed to [cyan].dsh/skills[/cyan]" + ) + step_num += 1 if bob_skill_mode: steps_lines.append( f"{step_num}. Start Bob in this project directory; spec-kit skills were installed to [cyan].bob/skills[/cyan]" diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 75c2f9d0de..8008809dcd 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -61,6 +61,7 @@ def _register_builtins() -> None: from .cursor_agent import CursorAgentIntegration from .devin import DevinIntegration from .droid import DroidIntegration + from .dsh import DshIntegration from .firebender import FirebenderIntegration from .forge import ForgeIntegration from .gemini import GeminiIntegration @@ -101,6 +102,7 @@ def _register_builtins() -> None: _register(CursorAgentIntegration()) _register(DevinIntegration()) _register(DroidIntegration()) + _register(DshIntegration()) _register(FirebenderIntegration()) _register(ForgeIntegration()) _register(GeminiIntegration()) diff --git a/src/specify_cli/integrations/dsh/__init__.py b/src/specify_cli/integrations/dsh/__init__.py new file mode 100644 index 0000000000..ca74a218e6 --- /dev/null +++ b/src/specify_cli/integrations/dsh/__init__.py @@ -0,0 +1,63 @@ +"""DeepSeek Harness (DSH) integration — skills-based agent. + +DSH discovers project skills from ``.dsh/skills`` (its native root, highest +provider rank) and from the shared ``.agents/skills`` root, one level deep, +using ``/SKILL.md`` directory bundles with ``name``/``description`` +frontmatter — the same agentskills.io layout Spec Kit scaffolds for other +skills-based agents. Skills are user-invocable through the ``/``-trigger +input in the DSH Web GUI (and any TUI/ACP front end): typing +``/speckit-specify `` ships the literal token plus the +user text, and the harness injects the skill's ```` into the +turn. Project guidance in ``AGENTS.md`` at the repo root is loaded +automatically by DSH, so no context-file handling is needed here. + +See: https://github.com/deepseek-ai/deepseek-harness +""" + +from __future__ import annotations + +from ..base import SkillsIntegration + + +class DshIntegration(SkillsIntegration): + """Integration for the DeepSeek Harness (DSH) agent.""" + + key = "dsh" + config = { + "name": "DeepSeek Harness", + "folder": ".dsh/", + "commands_subdir": "skills", + "install_url": "https://github.com/deepseek-ai/deepseek-harness", + "requires_cli": True, + } + registrar_config = { + "dir": ".dsh/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + # ``.dsh/`` is a static, unique agent root that no other integration + # writes into, so co-installing DSH alongside other agents is safe. + multi_install_safe = True + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build non-interactive CLI args for DSH. + + DSH's one-shot mode is ``dsh --profile headless ""``: the + runner submits the task as an ordinary user message, waits for + quiescence, and prints the last assistant message to stdout. The + headless profile recognizes whitespace-bounded ``/name`` tokens + naming user-invocable skills, so a slash-command prompt such as + ``/speckit-specify build photo albums`` loads the skill exactly as + an interactive session would. The CLI has no structured-JSON output + flag, so ``output_json`` and ``model`` are ignored. + """ + args = [self._resolve_executable(), "--profile", "headless", prompt] + self._apply_extra_args_env_var(args) + return args diff --git a/tests/integrations/test_integration_dsh.py b/tests/integrations/test_integration_dsh.py new file mode 100644 index 0000000000..0c95e8a210 --- /dev/null +++ b/tests/integrations/test_integration_dsh.py @@ -0,0 +1,263 @@ +"""Tests for DshIntegration (DeepSeek Harness).""" + +import json + +import pytest +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 TestDshIntegration(SkillsIntegrationTests): + KEY = "dsh" + FOLDER = ".dsh/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".dsh/skills" + + def test_options_include_skills_flag(self): + """Not applicable to DSH — DSH is always skills-based with no --skills flag.""" + pytest.skip("DSH is always skills-based and does not expose a --skills option") + + def test_options_do_not_include_skills_flag(self): + """DSH is always skills-based; no --skills option is exposed.""" + i = get_integration(self.KEY) + assert i is not None + opts = i.options() + skills_opts = [o for o in opts if o.name == "--skills"] + assert len(skills_opts) == 0, ( + "DSH is always skills-based and should not expose a --skills option" + ) + + +class TestDshBuildExecArgs: + """Regression tests for DshIntegration.build_exec_args. + + DSH's one-shot mode is ``dsh --profile headless ""``. The CLI has + no structured-output or model flag, so ``output_json``/``model`` must + not add anything, and the integration must stay CLI-dispatchable + (``None`` is the IDE-only sentinel checked by CommandStep). + """ + + def test_returns_args_not_none_for_dispatch(self): + """DSH is CLI-dispatchable; build_exec_args must not return None.""" + from specify_cli.integrations.dsh import DshIntegration + + impl = DshIntegration() + args = impl.build_exec_args("/speckit-specify build photo albums") + assert args is not None, ( + "DshIntegration.build_exec_args must not return None. " + "None is the codebase sentinel for IDE-only integrations; " + "DSH is dispatchable via 'dsh --profile headless'." + ) + assert args == [ + "dsh", + "--profile", + "headless", + "/speckit-specify build photo albums", + ] + + def test_output_json_and_model_do_not_change_command_line(self): + """DSH has no --output-format/--model flags for the headless profile.""" + from specify_cli.integrations.dsh import DshIntegration + + impl = DshIntegration() + base = impl.build_exec_args("hello") + assert impl.build_exec_args("hello", output_json=True) == base + assert impl.build_exec_args("hello", output_json=False) == base + assert impl.build_exec_args("hello", model="deepseek-chat") == base + + +class TestDshInitFlow: + """--integration dsh creates expected files.""" + + def test_integration_dsh_creates_skills(self, tmp_path): + """--integration dsh should create skills in .dsh/skills.""" + runner = CliRunner() + target = tmp_path / "test-proj" + result = runner.invoke( + app, + ["init", str(target), "--integration", "dsh", "--ignore-agent-tools", "--script", "sh"], + ) + + assert result.exit_code == 0, f"init --integration dsh failed: {result.output}" + assert (target / ".dsh" / "skills" / "speckit-plan" / "SKILL.md").exists() + + +class TestDshSkillCompatibility: + """DSH-specific invariants the generated skills must satisfy. + + The DSH filesystem skill provider discovers one-level-deep + ``/SKILL.md`` bundles and parses the frontmatter as an open YAML + object, requiring a kebab-case ``name`` and a ``description``; extra + keys (``compatibility``, ``metadata``) are tolerated. These tests pin + the properties DSH relies on so a template change cannot silently + break discovery. + """ + + def _setup_skills(self, tmp_path): + integration = get_integration("dsh") + manifest = IntegrationManifest("dsh", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + return tmp_path / ".dsh" / "skills" + + def test_skill_names_are_kebab_case(self, tmp_path): + import re + + skills_dir = self._setup_skills(tmp_path) + skill_dirs = [d for d in skills_dir.iterdir() if d.is_dir()] + assert skill_dirs, "no skill directories were created" + for skill_dir in skill_dirs: + assert re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", skill_dir.name), ( + f"skill directory {skill_dir.name!r} is not kebab-case; " + "DSH rejects non-kebab-case skill names" + ) + + def test_skill_frontmatter_has_name_and_description(self, tmp_path): + import yaml + + skills_dir = self._setup_skills(tmp_path) + for skill_dir in sorted(skills_dir.iterdir()): + skill_file = skill_dir / "SKILL.md" + assert skill_file.exists(), f"missing SKILL.md in {skill_dir}" + content = skill_file.read_text(encoding="utf-8") + assert content.startswith("---\n"), f"{skill_file} missing frontmatter" + lines = content.splitlines(keepends=True) + close = next( + i for i in range(1, len(lines)) if lines[i].rstrip() == "---" + ) + frontmatter = yaml.safe_load("".join(lines[1:close])) + assert isinstance(frontmatter, dict) + # DSH requires a non-empty name matching the bundle directory and + # a non-empty description for its model-facing skill catalog. + assert frontmatter.get("name") == skill_dir.name + assert isinstance(frontmatter.get("description"), str) + assert frontmatter["description"].strip() + + def test_skill_directory_is_one_level_deep(self, tmp_path): + """DSH discovery only recognizes //SKILL.md — the + SKILL.md file must sit directly inside a single skill directory, + not in nested subdirectories.""" + skills_dir = self._setup_skills(tmp_path) + for skill_dir in sorted(skills_dir.iterdir()): + if not skill_dir.is_dir(): + continue + assert (skill_dir / "SKILL.md").exists() + children = [p for p in skill_dir.iterdir() if p.is_dir()] + assert not children, ( + f"nested directories under {skill_dir} are invisible to DSH " + "skill discovery" + ) + + +class TestDshMultiInstallSafe: + """DSH confines itself to an isolated ``.dsh/`` root that no other + integration touches, so it must be declared multi-install safe.""" + + def test_multi_install_safe_is_true(self): + integration = get_integration("dsh") + assert integration.multi_install_safe is True + + def test_dsh_root_does_not_overlap_other_safe_integrations(self): + from pathlib import PurePosixPath + + from specify_cli.integrations import INTEGRATION_REGISTRY + + dsh_root = PurePosixPath(".dsh") + for key, integration in INTEGRATION_REGISTRY.items(): + if key == "dsh" or not integration.multi_install_safe: + continue + folder = (integration.config or {}).get("folder") + if not folder: + continue + other = PurePosixPath(str(folder).rstrip("/")) + for left, right in ((dsh_root, other), (other, dsh_root)): + try: + left.relative_to(right) + except ValueError: + continue + raise AssertionError( + f"dsh agent root .dsh overlaps multi-install-safe " + f"integration {key!r} root {other}" + ) + + +class TestDshHookInvocations: + """DSH is in ALWAYS_SLASH_AGENTS: hook messages and init output must + reference slash-invokable skills regardless of the persisted ai_skills + flag, because the DSH Web GUI invokes skills as ``/speckit-``.""" + + def test_hooks_render_skill_invocation(self, tmp_path): + from specify_cli.extensions import HookExecutor + + project = tmp_path / "dsh-hooks" + project.mkdir() + init_options = project / ".specify" / "init-options.json" + init_options.parent.mkdir(parents=True, exist_ok=True) + init_options.write_text(json.dumps({"ai": "dsh", "ai_skills": False})) + + hook_executor = HookExecutor(project) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + }, + ], + ) + + assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message + + def test_init_persists_ai_skills_for_dsh(self, tmp_path, monkeypatch): + """specify init --integration dsh must persist ai_skills: true, + so HookExecutor renders slash-skill invocations.""" + from specify_cli.extensions import HookExecutor + + project = tmp_path / "dsh-init-test" + project.mkdir() + monkeypatch.chdir(project) + runner = CliRunner() + result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "dsh", + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, f"init failed: {result.output}" + + opts_path = project / ".specify" / "init-options.json" + assert opts_path.exists() + opts = json.loads(opts_path.read_text(encoding="utf-8")) + assert opts.get("ai") == "dsh" + assert opts.get("ai_skills") is True, ( + f"init must persist ai_skills=true for DSH, got: {opts.get('ai_skills')}" + ) + + hook_executor = HookExecutor(project) + message = hook_executor.format_hook_message( + "before_plan", + [ + { + "extension": "test-ext", + "command": "speckit.plan", + "optional": False, + }, + ], + ) + assert "Executing: `/speckit-plan`" in message, ( + "Hook rendering must produce /speckit-plan for DSH" + ) + assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 0d0a724bd8..87b30a48d3 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -28,7 +28,7 @@ "gemini", "tabnine", # Stage 5 — skills, generic & option-driven integrations "codex", "kimi", "agy", "zed", "generic", - "droid", "command-code", + "droid", "command-code", "dsh", ] diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 0cebe7bc33..4addd4b12c 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -25,6 +25,7 @@ "cursor-agent", "devin", "droid", + "dsh", "firebender", "forge", "gemini",