diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml index 785f9193e3..377cc92f1f 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, Docker Agent, 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 - type: input id: agent-name diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 03fa6c124f..f5b701a77f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -73,6 +73,7 @@ body: - Command Code - Cursor - Devin for Terminal + - Docker Agent - Factory Droid - Firebender - Forge diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4613c8ebae..e4abc5cb49 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -67,6 +67,7 @@ body: - Command Code - Cursor - Devin for Terminal + - Docker Agent - Factory Droid - Firebender - Forge diff --git a/AGENTS.md b/AGENTS.md index b6975339c3..0a6fd11b6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,8 @@ src/specify_cli/integrations/ │ └── __init__.py ├── copilot/ # Example: IntegrationBase subclass (custom setup) │ └── __init__.py +├── docker_agent/ # Example: Docker Agent SkillsIntegration subclass +│ └── __init__.py └── ... # One subpackage per supported agent ``` diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 57b079bcd1..3132211d44 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -17,6 +17,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify | [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` | | | [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-` | +| [Docker Agent](https://docs.docker.com/ai/docker-agent/) | `docker-agent` | Skills-based integration; installs skills into `.agents/skills/` (the same directory used by Codex and Zed). In the selected agent YAML, enable local skills with `skills: true` and provide filesystem read access. Detects either the standalone `docker-agent` binary or the Docker CLI plugin (`docker agent`). Configure workflow dispatch with `SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS=./agent.yaml`; the Spec Kit prompt is appended after these arguments. Not multi-install safe by default because the skills directory is shared. | | [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 | | [Forge](https://forgecode.dev/) | `forge` | | diff --git a/integrations/catalog.json b/integrations/catalog.json index f3f7a7fe7f..a3f4e7c05f 100644 --- a/integrations/catalog.json +++ b/integrations/catalog.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "updated_at": "2026-07-27T00:00:00Z", + "updated_at": "2026-08-26T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json", "integrations": { "alquimia": { @@ -102,6 +102,15 @@ "repository": "https://github.com/github/spec-kit", "tags": ["cli", "skills"] }, + "docker-agent": { + "id": "docker-agent", + "name": "Docker Agent", + "version": "1.0.0", + "description": "Docker Agent skills-based integration", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "tags": ["cli", "skills", "docker"] + }, "qwen": { "id": "qwen", "name": "Qwen Code", diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py index 0562ea0142..300ca4ff58 100644 --- a/src/specify_cli/_utils.py +++ b/src/specify_cli/_utils.py @@ -16,6 +16,45 @@ CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude" CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude" +DOCKER_AGENT_CHECK_TIMEOUT = 5 + + +def docker_agent_command(executable: str | None = None) -> list[str] | None: + """Return a runnable Docker Agent command, or ``None`` if unavailable. + + Docker Agent is distributed either as the standalone ``docker-agent`` + executable or as the ``docker agent`` Docker CLI plugin. The plugin form + is verified with a bounded, read-only version probe so a plain Docker CLI + is not mistaken for an installed Docker Agent. + """ + resolved_from_path = executable is None + if executable is None: + if shutil.which("docker-agent"): + return ["docker-agent", "run"] + executable = shutil.which("docker") + if executable is None: + return None + + executable_name = Path(executable).name.lower() + if executable_name in {"docker", "docker.exe"}: + command = [executable, "agent", "version"] + run_command = [executable, "agent", "run"] + else: + # An explicit non-Docker executable is an operator override. Preserve + # the existing override contract without probing a custom binary. + return [executable, "run"] + try: + result = subprocess.run( + command, + capture_output=True, + check=False, + timeout=DOCKER_AGENT_CHECK_TIMEOUT, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + return ["docker", "agent", "run"] if resolved_from_path else run_command def relative_extension_path_violation(value: Any) -> str | None: @@ -137,6 +176,8 @@ def check_tool(tool: str, tracker=None) -> bool: found = shutil.which("kiro-cli") is not None or shutil.which("kiro") is not None elif tool == "rovodev": found = shutil.which("acli") is not None + elif tool == "docker-agent": + found = docker_agent_command() is not None else: found = shutil.which(tool) is not None diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 75c2f9d0de..041c27fec9 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -60,6 +60,7 @@ def _register_builtins() -> None: from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration from .devin import DevinIntegration + from .docker_agent import DockerAgentIntegration from .droid import DroidIntegration from .firebender import FirebenderIntegration from .forge import ForgeIntegration @@ -100,6 +101,7 @@ def _register_builtins() -> None: _register(CopilotIntegration()) _register(CursorAgentIntegration()) _register(DevinIntegration()) + _register(DockerAgentIntegration()) _register(DroidIntegration()) _register(FirebenderIntegration()) _register(ForgeIntegration()) diff --git a/src/specify_cli/integrations/docker_agent/__init__.py b/src/specify_cli/integrations/docker_agent/__init__.py new file mode 100644 index 0000000000..d962f1f994 --- /dev/null +++ b/src/specify_cli/integrations/docker_agent/__init__.py @@ -0,0 +1,126 @@ +"""Docker Agent integration — skills-based Docker CLI agent. + +Docker Agent discovers project skills from ``.agents/skills`` when the selected +agent configuration enables local skills and filesystem reads. Runtime +configuration is owned by Docker Agent and is not managed by Spec Kit. +""" + +from __future__ import annotations + +import os +import shlex + +from specify_cli._utils import docker_agent_command + +from ..base import IntegrationOption, SkillsIntegration + + +class DockerAgentIntegration(SkillsIntegration): + """Integration for Docker Agent.""" + + key = "docker-agent" + config = { + "name": "Docker Agent", + "folder": ".agents/", + "commands_subdir": "skills", + "install_url": "https://docs.docker.com/ai/docker-agent/getting-started/installation/", + # Docker Agent is exposed as either `docker-agent` or `docker agent`. + "requires_cli": True, + } + registrar_config = { + "dir": ".agents/skills", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": "/SKILL.md", + } + # Docker Agent shares the ``.agents/skills`` layout with Codex and Zed. + # Keep co-installation opt-in until shared manifest ownership is supported. + multi_install_safe = False + + # Docker Agent hooks are configured in the selected agent YAML under + # ``agents..hooks``. Spec Kit does not edit that user-owned file, so + # hooks are intentionally not exposed through the integration event system. + + def _agent_command(self) -> list[str]: + """Return the available Docker Agent command form.""" + + # The shared executable override supports both a standalone + # ``docker-agent`` binary and the Docker CLI plugin form. + executable = self._resolve_executable() + command = docker_agent_command( + None if executable == self.key else executable + ) + if command is None: + # Preserve the normal executable-shaped argv for dispatch callers; + # preflight and the subprocess runner report the unavailable CLI. + return [executable, "run"] + return command + + + @classmethod + def options(cls) -> list[IntegrationOption]: + opts = super().options() + opts.append( + IntegrationOption( + "--skills", + is_flag=True, + default=True, + help="Install as agent skills (default for Docker Agent)", + ) + ) + return opts + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build a headless Docker Agent invocation with an agent config.""" + extra_env_name = "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS" + extra_args = os.environ.get(extra_env_name, "").strip() + if not extra_args: + raise ValueError( + "Docker Agent requires an agent configuration reference. " + f"Set {extra_env_name}, for example: " + f"{extra_env_name}=./agent.yaml" + ) + # Validate only the argument shape here: require a first positional + # agent reference and reject malformed quoting or a leading option. + # The reference may be a local file or a registry reference, so its + # existence and validity are intentionally left to Docker Agent. + try: + first_arg = shlex.split(extra_args)[0] + except (IndexError, ValueError) as exc: + raise ValueError( + f"{extra_env_name} must start with an agent configuration reference, " + "for example ./agent.yaml" + ) from exc + if first_arg.startswith("-"): + raise ValueError( + f"{extra_env_name} must start with an agent configuration reference, " + "for example ./agent.yaml" + ) + + args = [*self._agent_command(), "--exec"] + + # Extra args carry the required agent source (for example + # ``./agent.yaml``) and any Docker Agent CLI flags. The shared helper + # also preserves shell-style quoting when splitting multiple args. + self._apply_extra_args_env_var(args) + + if output_json: + args.append("--json") + if model: + args.extend(["--model", model]) + + # Stop Cobra flag parsing before the user prompt so values such as + # ``--help`` or ``--json`` are passed as messages, not CLI options. + # For example, the complete argv is + # ``docker-agent run --exec ./agent.yaml --agent root -- --help``; + # everything before ``--`` is parsed by Docker Agent, while ``--help`` + # is passed to the configured agent as the user message. + args.extend(["--", prompt]) + + return args diff --git a/tests/integrations/test_integration_docker_agent.py b/tests/integrations/test_integration_docker_agent.py new file mode 100644 index 0000000000..d962ba978f --- /dev/null +++ b/tests/integrations/test_integration_docker_agent.py @@ -0,0 +1,133 @@ +"""Tests for the Docker Agent integration.""" + +import pytest + +from specify_cli.integrations.docker_agent import DockerAgentIntegration + +from .test_integration_base_skills import SkillsIntegrationTests + + +class TestDockerAgentIntegration(SkillsIntegrationTests): + KEY = "docker-agent" + FOLDER = ".agents/" + COMMANDS_SUBDIR = "skills" + REGISTRAR_DIR = ".agents/skills" + + def test_multi_install_is_opt_in(self): + assert DockerAgentIntegration().multi_install_safe is False + + +def test_extra_args_are_applied_to_build_exec_args(monkeypatch): + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", + "./agent.yaml --agent root --model openai/gpt-5", + ) + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker" if name == "docker" else None, + ) + monkeypatch.setattr("subprocess.run", lambda *args, **kwargs: type("Result", (), {"returncode": 0})()) + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == [ + "docker", + "agent", + "run", + "--exec", + "./agent.yaml", + "--agent", + "root", + "--model", + "openai/gpt-5", + "--", + "prompt", + ] + + +def test_prompt_is_passed_after_agent_config(monkeypatch): + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml" + ) + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker" if name == "docker" else None, + ) + monkeypatch.setattr("subprocess.run", lambda *args, **kwargs: type("Result", (), {"returncode": 0})()) + + args = DockerAgentIntegration().build_exec_args( + "/speckit-specify prompt", output_json=False + ) + + assert args == [ + "docker", + "agent", + "run", + "--exec", + "./agent.yaml", + "--", + "/speckit-specify prompt", + ] + + +def test_prompt_starting_with_flag_is_delimited(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setattr("shutil.which", lambda name: None) + + args = DockerAgentIntegration().build_exec_args("--help", output_json=False) + + assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "--help"] + + +def test_requires_agent_config(monkeypatch): + monkeypatch.delenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", raising=False) + with pytest.raises(ValueError, match="requires an agent configuration reference"): + DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + +def test_uses_standalone_executable(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setattr( + "shutil.which", + lambda name: "/usr/bin/docker-agent" if name == "docker-agent" else None, + ) + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"] + + +def test_standalone_executable_has_priority(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/docker-agent") + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == ["docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"] + + +def test_executable_override(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", "/opt/docker-agent" + ) + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == ["/opt/docker-agent", "run", "--exec", "./agent.yaml", "--", "prompt"] + + +def test_docker_executable_override_uses_agent_subcommand(monkeypatch): + monkeypatch.setenv("SPECKIT_INTEGRATION_DOCKER_AGENT_EXTRA_ARGS", "./agent.yaml") + monkeypatch.setenv( + "SPECKIT_INTEGRATION_DOCKER_AGENT_EXECUTABLE", "/opt/docker" + ) + + monkeypatch.setattr( + "subprocess.run", + lambda *args, **kwargs: type("Result", (), {"returncode": 0})(), + ) + + args = DockerAgentIntegration().build_exec_args("prompt", output_json=False) + + assert args == ["/opt/docker", "agent", "run", "--exec", "./agent.yaml", "--", "prompt"] diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 0cebe7bc33..abb5b76574 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -24,6 +24,7 @@ "command-code", "cursor-agent", "devin", + "docker-agent", "droid", "firebender", "forge", diff --git a/tests/test_check_tool.py b/tests/test_check_tool.py index 9520046168..ef8a9da2fb 100644 --- a/tests/test_check_tool.py +++ b/tests/test_check_tool.py @@ -120,6 +120,42 @@ def fake_which(name): with patch("shutil.which", side_effect=fake_which): assert check_tool("rovodev") is True + def test_docker_agent_plugin_fallback(self): + """docker-agent should detect a working Docker CLI plugin form.""" + + def fake_which(name): + return "/usr/bin/docker" if name == "docker" else None + + with ( + patch("shutil.which", side_effect=fake_which), + patch("subprocess.run") as run, + ): + run.return_value.returncode = 0 + assert check_tool("docker-agent") is True + run.assert_called_once_with( + ["/usr/bin/docker", "agent", "version"], + capture_output=True, + check=False, + timeout=5, + ) + + def test_docker_agent_missing(self): + """docker-agent should be missing when neither form is installed.""" + with patch("shutil.which", return_value=None): + assert check_tool("docker-agent") is False + + def test_docker_agent_plugin_missing(self): + """Plain Docker CLI should not count as Docker Agent.""" + def fake_which(name): + return "/usr/bin/docker" if name == "docker" else None + + with ( + patch("shutil.which", side_effect=fake_which), + patch("subprocess.run") as run, + ): + run.return_value.returncode = 1 + assert check_tool("docker-agent") is False + class TestCheckTip: """`specify check` should point users to the existing version check."""