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
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, 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
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 @@ -73,6 +73,7 @@ body:
- Command Code
- Cursor
- Devin for Terminal
- Docker Agent
- Factory Droid
- Firebender
- Forge
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 @@ -67,6 +67,7 @@ body:
- Command Code
- Cursor
- Devin for Terminal
- Docker Agent
- Factory Droid
- Firebender
- Forge
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<command>` |
| [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-<command>` |
| [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-<command>` |
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
Expand Down
11 changes: 10 additions & 1 deletion integrations/catalog.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -102,6 +102,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"docker-agent": {
Comment thread
nervgh marked this conversation as resolved.
"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",
Expand Down
41 changes: 41 additions & 0 deletions src/specify_cli/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

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 @@ -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
Expand Down Expand Up @@ -100,6 +101,7 @@ def _register_builtins() -> None:
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
_register(DockerAgentIntegration())
Comment thread
nervgh marked this conversation as resolved.
_register(DroidIntegration())
_register(FirebenderIntegration())
_register(ForgeIntegration())
Expand Down
126 changes: 126 additions & 0 deletions src/specify_cli/integrations/docker_agent/__init__.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
nervgh marked this conversation as resolved.
"""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,
Comment thread
nervgh marked this conversation as resolved.
}
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.<name>.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"]
Comment thread
nervgh marked this conversation as resolved.
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"]
Comment thread
nervgh marked this conversation as resolved.
Comment thread
nervgh marked this conversation as resolved.

# 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)
Comment thread
nervgh marked this conversation as resolved.

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
133 changes: 133 additions & 0 deletions tests/integrations/test_integration_docker_agent.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading