-
Notifications
You must be signed in to change notification settings - Fork 11.9k
feat(docker-agent): add Docker Agent integration #4302
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nervgh
wants to merge
7
commits into
github:main
Choose a base branch
from
nervgh:feat/add-docker-agent-integration
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
c500dd6
feat(docker-agent): add Docker Agent integration
nervgh 0a13ed0
feat(docker-agent): address Copilot's feedback
nervgh 444fd2b
fix(docker-agent): pass prompts after agent configuration
nervgh 3409a06
fix(utils): detect Docker Agent CLI plugin
nervgh d5d93b1
fix(docker-agent): validate plugin availability and agent config
nervgh b27d500
Apply suggestions from code review
nervgh 6c3a889
fix(docker-agent): delimit prompts from CLI flags
nervgh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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): | ||
|
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, | ||
|
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"] | ||
|
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"] | ||
|
nervgh marked this conversation as resolved.
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) | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.