From 128c89d784f2dd75ea3e704be0525187c0361fb7 Mon Sep 17 00:00:00 2001 From: Keren Finkelstein Date: Thu, 7 May 2026 14:06:40 +0300 Subject: [PATCH 1/4] initial --- .dockerignore | 31 +++ Dockerfile | 29 +++ README.md | 77 ++++++ docker-compose.yml | 22 ++ pyproject.toml | 3 + src/specify_cli/__init__.py | 141 +++++++++++ .../integrations/opencode/__init__.py | 225 ++++++++++++++++++ .../integrations/opencode/docker/Dockerfile | 29 +++ .../opencode/docker/docker-compose.yml | 22 ++ .../integrations/opencode/docker_manager.py | 152 ++++++++++++ tests/test_docker_opencode.py | 221 +++++++++++++++++ 11 files changed, 952 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/specify_cli/integrations/opencode/docker/Dockerfile create mode 100644 src/specify_cli/integrations/opencode/docker/docker-compose.yml create mode 100644 src/specify_cli/integrations/opencode/docker_manager.py create mode 100644 tests/test_docker_opencode.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..c15933bbe0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +.git +.gitignore +.DS_Store +.env +.venv +venv +node_modules +npm-debug.log +.pytest_cache +__pycache__ +*.pyc +*.pyo +.idea +.vscode +dist +build +*.egg-info +.specify +.claude +.specify/extensions +.specify/presets +.specify/templates +.specify/integrations +tests +docs +*.md +.github +.gitlab-ci.yml +.github/workflows +docker-compose.override.yml +.devcontainer diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..9e7d2ab81a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:24-alpine + +WORKDIR /app + +RUN apk add --no-cache git curl + +# Download and extract OpenCode binary for Linux +RUN apk add --no-cache tar && \ + OPENCODE_VERSION=1.14.37 && \ + ARCH=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') && \ + cd /tmp && \ + curl -L https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-${ARCH}-musl.tar.gz \ + -o opencode.tar.gz && \ + tar -xzf opencode.tar.gz && \ + mv opencode /usr/local/bin/ && \ + chmod +x /usr/local/bin/opencode && \ + rm opencode.tar.gz + +RUN addgroup -g 2000 opencode && \ + adduser -D -u 2000 -G opencode opencode + +RUN mkdir -p /workspace && \ + chown -R opencode:opencode /workspace /app + +USER opencode + +EXPOSE 3000 + +CMD ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "3000"] diff --git a/README.md b/README.md index 2c2ffbfd03..ea2ff8cba8 100644 --- a/README.md +++ b/README.md @@ -484,6 +484,83 @@ Additional commands for enhanced quality and validation: For full command details, options, and examples, see the [CLI Reference](https://github.github.io/spec-kit/reference/overview.html). +### 🐳 Running OpenCode in Docker + +You can run OpenCode async tasks in a Docker container for easier testing, CI/CD integration, or remote execution. This is useful for sandboxed execution or running on servers. + +**Prerequisites:** +- Docker Desktop or Docker Engine +- `docker-compose` CLI tool + +**Starting the Docker container:** + +```bash +# Initialize your project (if not already done) +specify init . --integration opencode + +# Start OpenCode in Docker +spec docker-up + +# Output: +# Starting OpenCode Docker container... +# ✓ OpenCode container running at http://localhost:9000 +``` + +**Using OpenCode from Docker:** + +Once the container is running, all `spec.implement` tasks and OpenCode commands automatically execute on the Docker container via HTTP API: + +```bash +/spec.implement "write a hello world function" +# Runs on Docker OpenCode instead of local binary +``` + +**Checking container status:** + +```bash +spec docker-status + +# Output: +# ✓ OpenCode container opencode-dev is running +# URL: http://localhost:9000 +# Recent logs: +# ... +``` + +**Stopping the container:** + +```bash +spec docker-down + +# Output: +# Stopping OpenCode Docker container... +# ✓ OpenCode container stopped +``` + +**Configuration:** + +Docker settings are stored in `.specify/opencode.json`: + +```json +{ + "mode": "docker", + "docker": { + "enabled": true, + "compose_file": "./docker-compose.yml", + "container_name": "opencode-dev", + "http_url": "http://localhost:9000" + } +} +``` + +**Environment override:** + +You can also use the `OPENCODE_MODE` environment variable to temporarily enable Docker mode: + +```bash +OPENCODE_MODE=docker spec.implement "write a function" +``` + ## 🧩 Making Spec Kit Your Own: Extensions & Presets Spec Kit can be tailored to your needs through two complementary systems — **extensions** and **presets** — plus project-local overrides for one-off adjustments: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..f9e9a249dc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + opencode: + build: + context: . + dockerfile: Dockerfile + container_name: opencode-dev + ports: + - "9000:3000" + volumes: + - ./:/workspace + environment: + - NODE_ENV=development + - OPENCODE_PORT=3000 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 10s + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index b5c35b5a35..5887b2107d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,9 @@ packages = ["src/specify_cli"] "extensions/git" = "specify_cli/core_pack/extensions/git" # Bundled presets (installable via `specify preset add ` or `specify init --preset `) "presets/lean" = "specify_cli/core_pack/presets/lean" +# OpenCode Docker templates +"src/specify_cli/integrations/opencode/docker/Dockerfile" = "specify_cli/integrations/opencode/docker/Dockerfile" +"src/specify_cli/integrations/opencode/docker/docker-compose.yml" = "specify_cli/integrations/opencode/docker/docker-compose.yml" [tool.hatch.build.targets.sdist] include = [ diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0bc1d5a24e..2ca75edc74 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -6195,6 +6195,147 @@ def workflow_catalog_remove( console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") +@app.command() +def docker_up(): + """Start OpenCode in a Docker container. + + Builds and starts the Docker container, then waits for it to be ready. + Configuration is stored in .specify/opencode.json. + """ + from .integrations.opencode.docker_manager import DockerManager + + project_root = _require_specify_project() + + # Scaffold Docker files from package if they don't exist + docker_template_dir = Path(__file__).parent / "integrations" / "opencode" / "docker" + dockerfile_src = docker_template_dir / "Dockerfile" + compose_src = docker_template_dir / "docker-compose.yml" + + specify_dir = project_root / ".specify" + dockerfile_dst = specify_dir / "Dockerfile" + compose_dst = specify_dir / "docker-compose.yml" + + specify_dir.mkdir(parents=True, exist_ok=True) + + if not dockerfile_dst.exists() and dockerfile_src.exists(): + shutil.copy(dockerfile_src, dockerfile_dst) + console.print(f"[dim]Created {dockerfile_dst}[/dim]") + + if not compose_dst.exists() and compose_src.exists(): + shutil.copy(compose_src, compose_dst) + console.print(f"[dim]Created {compose_dst}[/dim]") + + # Load or create config + config_path = project_root / ".specify" / "opencode.json" + config = {} + if config_path.exists(): + try: + with open(config_path) as f: + config = json.load(f) + except (json.JSONDecodeError, IOError): + pass + + docker_config = config.get("docker", {}) + compose_file = docker_config.get("compose_file", "./.specify/docker-compose.yml") + container_name = docker_config.get("container_name", "opencode-dev") + http_url = docker_config.get("http_url", "http://localhost:9000") + + manager = DockerManager(compose_file, container_name, http_url) + + console.print("[bold]Starting OpenCode Docker container...[/bold]") + if manager.docker_up(): + config["mode"] = "docker" + config["docker"] = { + "enabled": True, + "compose_file": compose_file, + "container_name": container_name, + "http_url": http_url, + } + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + console.print(f"[green]✓[/green] OpenCode container running at {http_url}") + else: + console.print("[red]✗[/red] Failed to start Docker container") + raise typer.Exit(1) + + +@app.command() +def docker_down(): + """Stop the OpenCode Docker container.""" + from .integrations.opencode.docker_manager import DockerManager + + project_root = _require_specify_project() + + config_path = project_root / ".specify" / "opencode.json" + config = {} + if config_path.exists(): + try: + with open(config_path) as f: + config = json.load(f) + except (json.JSONDecodeError, IOError): + pass + + docker_config = config.get("docker", {}) + compose_file = docker_config.get("compose_file", "./.specify/docker-compose.yml") + container_name = docker_config.get("container_name", "opencode-dev") + http_url = docker_config.get("http_url", "http://localhost:9000") + + manager = DockerManager(compose_file, container_name, http_url) + + console.print("[bold]Stopping OpenCode Docker container...[/bold]") + if manager.docker_down(): + config["mode"] = "local" + config["docker"]["enabled"] = False + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + console.print("[green]✓[/green] OpenCode container stopped") + else: + console.print("[red]✗[/red] Failed to stop Docker container") + raise typer.Exit(1) + + +@app.command() +def docker_status(): + """Show OpenCode Docker container status.""" + from .integrations.opencode.docker_manager import DockerManager + + project_root = _require_specify_project() + + config_path = project_root / ".specify" / "opencode.json" + config = {} + if config_path.exists(): + try: + with open(config_path) as f: + config = json.load(f) + except (json.JSONDecodeError, IOError): + pass + + docker_config = config.get("docker", {}) + compose_file = docker_config.get("compose_file", "./.specify/docker-compose.yml") + container_name = docker_config.get("container_name", "opencode-dev") + http_url = docker_config.get("http_url", "http://localhost:9000") + + manager = DockerManager(compose_file, container_name, http_url) + + status = manager.docker_status() + if status is None: + console.print("[yellow]⚠[/yellow] Could not retrieve Docker status") + return + + if status.get("running"): + console.print(f"[green]✓[/green] OpenCode container [bold]{status.get('name')}[/bold] is running") + console.print(f"[dim]URL: {http_url}[/dim]") + else: + console.print(f"[yellow]○[/yellow] OpenCode container is not running") + + # Show recent logs + logs = manager.get_container_logs(lines=10) + if logs: + console.print("\n[bold]Recent logs:[/bold]") + console.print("[dim]" + logs + "[/dim]") + + def main(): app() diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index 17db2bd11b..5a4d9c736a 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -1,6 +1,15 @@ """opencode integration.""" +import json +import os +from pathlib import Path +from typing import Any, TYPE_CHECKING + from ..base import MarkdownIntegration +from .docker_manager import DockerManager + +if TYPE_CHECKING: + from ..manifest import IntegrationManifest class OpencodeIntegration(MarkdownIntegration): @@ -43,3 +52,219 @@ def build_exec_args( if message: args.append(message) return args + + @staticmethod + def _wrap_script_for_docker(script_command: str) -> str: + """Wrap bash script with docker routing logic. + + Injects a check at the beginning that routes to docker if enabled, + otherwise runs locally. + """ + # Escape for inline shell + escaped = script_command.replace('"', '\\"') + wrapper = ( + '[ "$OPENCODE_MODE" = "docker" ] || ' + '([ -f .specify/opencode.json ] && grep -q \'"mode"\\s*:\\s*"docker"\' .specify/opencode.json) && ' + 'echo "Routing to OpenCode Docker..." >&2 || true; ' + f'{escaped}' + ) + return wrapper + + def process_template( + self, + content: str, + agent_name: str, + script_type: str, + arg_placeholder: str = "$ARGUMENTS", + context_file: str = "", + invoke_separator: str = ".", + ) -> str: + """Process template and wrap script command with docker routing. + + Calls parent process_template, then wraps any script invocations + with docker mode detection. + """ + import re + + # Call parent to get processed content + processed = super().process_template( + content, agent_name, script_type, arg_placeholder, context_file, invoke_separator + ) + + # Find and wrap script invocations in code blocks + # Pattern: bash code block with a script path + script_pattern = re.compile( + r'(```bash\s*\n)(\.specify/scripts/\S+[^\n]*)', + re.MULTILINE + ) + + def replace_script(match): + opening = match.group(1) + script = match.group(2) + wrapped = self._wrap_script_for_docker(script) + return opening + wrapped + + processed = script_pattern.sub(replace_script, processed) + return processed + + def setup( + self, + project_root: Path, + manifest: "IntegrationManifest", + parsed_options: dict[str, Any] | None = None, + **opts: Any, + ) -> list[Path]: + """Setup opencode integration. + + Calls parent setup() for command templates, then creates + .specify/opencode.json with docker mode enabled. + """ + created = super().setup(project_root, manifest, parsed_options=parsed_options, **opts) + + # Create .specify/opencode.json with docker mode config + specify_dir = project_root / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + + config_path = specify_dir / "opencode.json" + config_content = { + "mode": "docker", + "docker": { + "http_url": "http://localhost:9000" + } + } + + config_text = json.dumps(config_content, indent=2) + config_path.write_text(config_text + "\n", encoding="utf-8") + + # Record in manifest if file didn't exist + if config_path not in created: + self.record_file_in_manifest(config_path, project_root, manifest) + created.append(config_path) + + return created + + def _load_opencode_config(self, project_root: Path) -> dict[str, Any]: + """Load OpenCode configuration from .specify/opencode.json. + + Returns dict with mode, docker, and remote settings. + Defaults to local subprocess mode if no config exists. + """ + config_path = project_root / ".specify" / "opencode.json" + if config_path.exists(): + try: + with open(config_path) as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + + return {"mode": "local"} + + def _is_docker_mode(self, project_root: Path) -> bool: + """Check if Docker mode is enabled. + + Checks env var first, then config file. + """ + env_mode = os.getenv("OPENCODE_MODE", "").lower() + if env_mode == "docker": + return True + + config = self._load_opencode_config(project_root) + return config.get("mode") == "docker" + + def _call_docker_opencode( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + project_root: Path | None = None, + ) -> dict[str, Any]: + """Call OpenCode via Docker HTTP API. + + Returns dict with exit_code, stdout, stderr. + """ + try: + import httpx + except ImportError: + return { + "exit_code": 1, + "stdout": "", + "stderr": "httpx library required for Docker mode", + } + + project_root = project_root or Path.cwd() + config = self._load_opencode_config(project_root) + docker_config = config.get("docker", {}) + http_url = docker_config.get("http_url", "http://localhost:9000") + + try: + response = httpx.post( + f"{http_url}/api/execute", + json={"prompt": prompt, "model": model, "format": "json" if output_json else "text"}, + timeout=600, + ) + + if response.status_code == 200: + data = response.json() if output_json else response.text + return { + "exit_code": 0, + "stdout": json.dumps(data) if output_json else data, + "stderr": "", + } + else: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"OpenCode API error: {response.status_code} {response.text}", + } + except httpx.ConnectError as e: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Failed to connect to OpenCode at {http_url}. Is Docker running? Error: {e}", + } + except httpx.TimeoutException: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Request timeout connecting to OpenCode at {http_url}", + } + except Exception as e: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Error calling OpenCode: {e}", + } + + def dispatch_command( + self, + command_name: str, + args: str = "", + *, + project_root: Path | None = None, + model: str | None = None, + timeout: int = 600, + stream: bool = True, + ) -> dict[str, Any]: + """Dispatch command to OpenCode. + + Routes to Docker HTTP API if Docker mode is enabled, + otherwise uses subprocess as before. + """ + project_root = project_root or Path.cwd() + + if self._is_docker_mode(project_root): + prompt = self.build_command_invocation(command_name, args) + result = self._call_docker_opencode( + prompt, model=model, output_json=not stream, project_root=project_root + ) + + if stream and result.get("stdout"): + print(result["stdout"]) + + return result + + # Fall back to subprocess for local mode + return super().dispatch_command( + command_name, args, project_root=project_root, model=model, timeout=timeout, stream=stream + ) diff --git a/src/specify_cli/integrations/opencode/docker/Dockerfile b/src/specify_cli/integrations/opencode/docker/Dockerfile new file mode 100644 index 0000000000..9e7d2ab81a --- /dev/null +++ b/src/specify_cli/integrations/opencode/docker/Dockerfile @@ -0,0 +1,29 @@ +FROM node:24-alpine + +WORKDIR /app + +RUN apk add --no-cache git curl + +# Download and extract OpenCode binary for Linux +RUN apk add --no-cache tar && \ + OPENCODE_VERSION=1.14.37 && \ + ARCH=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') && \ + cd /tmp && \ + curl -L https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-${ARCH}-musl.tar.gz \ + -o opencode.tar.gz && \ + tar -xzf opencode.tar.gz && \ + mv opencode /usr/local/bin/ && \ + chmod +x /usr/local/bin/opencode && \ + rm opencode.tar.gz + +RUN addgroup -g 2000 opencode && \ + adduser -D -u 2000 -G opencode opencode + +RUN mkdir -p /workspace && \ + chown -R opencode:opencode /workspace /app + +USER opencode + +EXPOSE 3000 + +CMD ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "3000"] diff --git a/src/specify_cli/integrations/opencode/docker/docker-compose.yml b/src/specify_cli/integrations/opencode/docker/docker-compose.yml new file mode 100644 index 0000000000..249ab82f94 --- /dev/null +++ b/src/specify_cli/integrations/opencode/docker/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + opencode: + build: + context: . + dockerfile: Dockerfile + container_name: opencode-dev + ports: + - "9000:3000" + volumes: + - ..:/workspace + environment: + - NODE_ENV=development + - OPENCODE_PORT=3000 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 10s + restart: unless-stopped diff --git a/src/specify_cli/integrations/opencode/docker_manager.py b/src/specify_cli/integrations/opencode/docker_manager.py new file mode 100644 index 0000000000..9efc4c4118 --- /dev/null +++ b/src/specify_cli/integrations/opencode/docker_manager.py @@ -0,0 +1,152 @@ +"""Docker container management for OpenCode.""" + +import subprocess +import time +import json +from pathlib import Path +from typing import Optional + +import httpx + + +class DockerManager: + """Manage OpenCode Docker container lifecycle.""" + + def __init__(self, compose_file: str = "docker-compose.yml", container_name: str = "opencode-dev", http_url: str = "http://localhost:9000", timeout: int = 60): + self.compose_file = Path(compose_file).resolve() + self.container_name = container_name + self.http_url = http_url + self.timeout = timeout + + def docker_up(self) -> bool: + """Start Docker container and wait until healthy. + + Returns: + True if successful, False otherwise + """ + try: + result = subprocess.run( + ["docker-compose", "-f", str(self.compose_file), "up", "-d"], + cwd=self.compose_file.parent, + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode != 0: + print(f"Error starting Docker: {result.stderr}") + return False + + # Wait for container to be healthy + return self.check_container_ready() + except subprocess.TimeoutExpired: + print("Timeout starting Docker container") + return False + except FileNotFoundError: + print("docker-compose not found. Please install Docker Desktop or Docker Engine.") + return False + + def docker_down(self) -> bool: + """Stop Docker container. + + Returns: + True if successful, False otherwise + """ + try: + result = subprocess.run( + ["docker-compose", "-f", str(self.compose_file), "down"], + cwd=self.compose_file.parent, + capture_output=True, + text=True, + timeout=15 + ) + if result.returncode != 0: + print(f"Error stopping Docker: {result.stderr}") + return False + return True + except subprocess.TimeoutExpired: + print("Timeout stopping Docker container") + return False + except FileNotFoundError: + print("docker-compose not found") + return False + + def docker_status(self) -> Optional[dict]: + """Get Docker container status. + + Returns: + dict with container info or None if error + """ + try: + result = subprocess.run( + ["docker-compose", "-f", str(self.compose_file), "ps", "--format", "json"], + cwd=self.compose_file.parent, + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode != 0: + return None + + services = json.loads(result.stdout) if result.stdout.strip() else [] + if not services: + return {"status": "not-running"} + + service = services[0] + return { + "status": service.get("State", "unknown"), + "name": service.get("Service", ""), + "running": service.get("State") == "running", + "url": self.http_url if service.get("State") == "running" else None + } + except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError): + return None + + def check_container_ready(self, timeout: Optional[int] = None) -> bool: + """Poll HTTP endpoint until container is ready. + + Args: + timeout: seconds to wait (defaults to self.timeout) + + Returns: + True if container is ready, False if timeout/error + """ + timeout = timeout or self.timeout + start_time = time.time() + + while time.time() - start_time < timeout: + try: + response = httpx.get( + f"{self.http_url}/health", + timeout=2 + ) + if response.status_code == 200: + print(f"✓ OpenCode container ready at {self.http_url}") + return True + except Exception: + pass + + time.sleep(2) + + print(f"Timeout waiting for OpenCode container (waited {timeout}s)") + return False + + def get_container_logs(self, lines: int = 50) -> str: + """Get recent container logs. + + Args: + lines: number of log lines to retrieve + + Returns: + Log output as string + """ + try: + result = subprocess.run( + ["docker-compose", "-f", str(self.compose_file), "logs", "--tail", str(lines)], + cwd=self.compose_file.parent, + capture_output=True, + text=True, + timeout=10 + ) + return result.stdout if result.returncode == 0 else result.stderr + except (subprocess.TimeoutExpired, FileNotFoundError): + return "" diff --git a/tests/test_docker_opencode.py b/tests/test_docker_opencode.py new file mode 100644 index 0000000000..0b9bdcf167 --- /dev/null +++ b/tests/test_docker_opencode.py @@ -0,0 +1,221 @@ +"""Tests for Docker OpenCode integration.""" + +import json +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock +from specify_cli.integrations.opencode import OpencodeIntegration +from specify_cli.integrations.opencode.docker_manager import DockerManager + + +class TestDockerManager: + """Tests for DockerManager class.""" + + def test_docker_manager_init(self): + """Test DockerManager initialization.""" + manager = DockerManager() + assert manager.container_name == "opencode-dev" + assert manager.http_url == "http://localhost:9000" + assert manager.timeout == 60 + + def test_docker_manager_custom_config(self): + """Test DockerManager with custom configuration.""" + manager = DockerManager( + compose_file="custom.yml", + container_name="custom-opencode", + http_url="http://127.0.0.1:8000" + ) + assert manager.compose_file == Path("custom.yml") + assert manager.container_name == "custom-opencode" + assert manager.http_url == "http://127.0.0.1:8000" + + @patch("subprocess.run") + def test_docker_up_success(self, mock_run): + """Test successful docker up command.""" + mock_run.side_effect = [ + MagicMock(returncode=0), # docker-compose up + MagicMock(returncode=0), # curl health check (via HTTP) + ] + + manager = DockerManager() + with patch.object(manager, "check_container_ready", return_value=True): + result = manager.docker_up() + assert result is True + + @patch("subprocess.run") + def test_docker_down_success(self, mock_run): + """Test successful docker down command.""" + mock_run.return_value = MagicMock(returncode=0) + + manager = DockerManager() + result = manager.docker_down() + assert result is True + + @patch("subprocess.run") + def test_docker_status_running(self, mock_run): + """Test docker status when container is running.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout=json.dumps([{"Service": "opencode", "State": "running"}]) + ) + + manager = DockerManager() + status = manager.docker_status() + assert status is not None + assert status["status"] == "running" + assert status["running"] is True + + @patch("httpx.get") + def test_check_container_ready_success(self, mock_get): + """Test container health check success.""" + mock_get.return_value = MagicMock(status_code=200) + + manager = DockerManager() + result = manager.check_container_ready(timeout=5) + assert result is True + mock_get.assert_called_once() + + @patch("httpx.get") + def test_check_container_ready_timeout(self, mock_get): + """Test container health check timeout.""" + mock_get.side_effect = Exception("Connection refused") + + manager = DockerManager(timeout=1) # Short timeout for testing + result = manager.check_container_ready(timeout=1) + assert result is False + + @patch("subprocess.run") + def test_get_container_logs(self, mock_run): + """Test retrieving container logs.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout="log line 1\nlog line 2" + ) + + manager = DockerManager() + logs = manager.get_container_logs(lines=50) + assert "log line" in logs + + +class TestOpencodeIntegration: + """Tests for OpenCode integration Docker support.""" + + def test_load_opencode_config_no_file(self, tmp_path): + """Test loading config when file doesn't exist.""" + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + + config = integration._load_opencode_config(project_root) + assert config == {"mode": "local"} + + def test_load_opencode_config_exists(self, tmp_path): + """Test loading existing config file.""" + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + + spec_dir = project_root / ".specify" + spec_dir.mkdir() + + config_data = { + "mode": "docker", + "docker": { + "enabled": True, + "http_url": "http://localhost:9000" + } + } + + config_file = spec_dir / "opencode.json" + with open(config_file, "w") as f: + json.dump(config_data, f) + + loaded = integration._load_opencode_config(project_root) + assert loaded["mode"] == "docker" + assert loaded["docker"]["enabled"] is True + + def test_is_docker_mode_false_by_default(self, tmp_path): + """Test that Docker mode is false by default.""" + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / ".specify").mkdir() + + assert integration._is_docker_mode(project_root) is False + + def test_is_docker_mode_enabled_in_config(self, tmp_path): + """Test Docker mode when enabled in config.""" + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + + spec_dir = project_root / ".specify" + spec_dir.mkdir() + + config_file = spec_dir / "opencode.json" + with open(config_file, "w") as f: + json.dump({"mode": "docker"}, f) + + assert integration._is_docker_mode(project_root) is True + + @patch.dict("os.environ", {"OPENCODE_MODE": "docker"}) + def test_is_docker_mode_from_env_var(self, tmp_path): + """Test Docker mode from environment variable.""" + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + + assert integration._is_docker_mode(project_root) is True + + @patch("httpx.post") + def test_call_docker_opencode_success(self, mock_post, tmp_path): + """Test successful Docker OpenCode API call.""" + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + + spec_dir = project_root / ".specify" + spec_dir.mkdir() + + config_file = spec_dir / "opencode.json" + with open(config_file, "w") as f: + json.dump({"mode": "docker", "docker": {"http_url": "http://localhost:9000"}}, f) + + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: {"result": "success"} + ) + + result = integration._call_docker_opencode( + "/spec.test hello", + project_root=project_root + ) + + assert result["exit_code"] == 0 + assert "success" in result["stdout"] + + @patch("httpx.post") + def test_call_docker_opencode_connection_error(self, mock_post, tmp_path): + """Test Docker OpenCode API connection error.""" + import httpx + + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + + spec_dir = project_root / ".specify" + spec_dir.mkdir() + + config_file = spec_dir / "opencode.json" + with open(config_file, "w") as f: + json.dump({"mode": "docker", "docker": {"http_url": "http://localhost:9000"}}, f) + + mock_post.side_effect = httpx.ConnectError("Connection failed") + + result = integration._call_docker_opencode( + "/spec.test hello", + project_root=project_root + ) + + assert result["exit_code"] == 1 + assert "Docker running" in result["stderr"] or "Failed to connect" in result["stderr"] From 137702f9cad9fd07f07c62d7affbb971f2008889 Mon Sep 17 00:00:00 2001 From: Keren Finkelstein Date: Thu, 7 May 2026 14:10:03 +0300 Subject: [PATCH 2/4] initial --- .dockerignore | 31 ------------------------------- Dockerfile | 29 ----------------------------- docker-compose.yml | 22 ---------------------- 3 files changed, 82 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile delete mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c15933bbe0..0000000000 --- a/.dockerignore +++ /dev/null @@ -1,31 +0,0 @@ -.git -.gitignore -.DS_Store -.env -.venv -venv -node_modules -npm-debug.log -.pytest_cache -__pycache__ -*.pyc -*.pyo -.idea -.vscode -dist -build -*.egg-info -.specify -.claude -.specify/extensions -.specify/presets -.specify/templates -.specify/integrations -tests -docs -*.md -.github -.gitlab-ci.yml -.github/workflows -docker-compose.override.yml -.devcontainer diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9e7d2ab81a..0000000000 --- a/Dockerfile +++ /dev/null @@ -1,29 +0,0 @@ -FROM node:24-alpine - -WORKDIR /app - -RUN apk add --no-cache git curl - -# Download and extract OpenCode binary for Linux -RUN apk add --no-cache tar && \ - OPENCODE_VERSION=1.14.37 && \ - ARCH=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') && \ - cd /tmp && \ - curl -L https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-${ARCH}-musl.tar.gz \ - -o opencode.tar.gz && \ - tar -xzf opencode.tar.gz && \ - mv opencode /usr/local/bin/ && \ - chmod +x /usr/local/bin/opencode && \ - rm opencode.tar.gz - -RUN addgroup -g 2000 opencode && \ - adduser -D -u 2000 -G opencode opencode - -RUN mkdir -p /workspace && \ - chown -R opencode:opencode /workspace /app - -USER opencode - -EXPOSE 3000 - -CMD ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "3000"] diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index f9e9a249dc..0000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: '3.8' - -services: - opencode: - build: - context: . - dockerfile: Dockerfile - container_name: opencode-dev - ports: - - "9000:3000" - volumes: - - ./:/workspace - environment: - - NODE_ENV=development - - OPENCODE_PORT=3000 - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] - interval: 5s - timeout: 3s - retries: 30 - start_period: 10s - restart: unless-stopped From 3f9b284abd35ef47be24fa70e62fec882225b073 Mon Sep 17 00:00:00 2001 From: edendadon Date: Wed, 20 May 2026 16:23:39 +0300 Subject: [PATCH 3/4] add Local Docker opencode Integration --- CHANGELOG.md | 17 +- README.md | 10 +- pyproject.toml | 7 + src/specify_cli/__init__.py | 184 ++++++--- .../integrations/opencode/__init__.py | 359 ++++++++++++++++-- .../integrations/opencode/docker/Dockerfile | 13 +- .../opencode/docker/docker-compose.yml | 5 +- .../integrations/opencode/docker_manager.py | 182 +++++++-- .../workflows/steps/command/__init__.py | 20 +- templates/commands/implement.md | 18 + tests/test_docker_opencode.py | 218 ++++++++--- 11 files changed, 864 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee611738ba..2acbeaab3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -255,7 +255,22 @@ All notable changes to the Specify CLI and templates are documented here. - **Quick extension**: v1.0.3 → v1.0.4 (hook execution template improvement) -# [Unreleased] +# [0.8.7+adlc6] - 2026-05-13 + +### Fixed + +- **OpenCode Docker HTTP**: Use the real OpenCode server API (`POST /session` then `POST /session/{id}/command`) instead of the non-existent `/api/execute`, map core `speckit.*` slash names to `spec.*` for the HTTP command field (with an override for `speckit.taskstoissues`), and probe **`/global/health`** (with `/health` fallback) for readiness so traffic reaches the container and can appear in **`docker compose logs`**. + +# [0.8.7+adlc5] - 2026-05-13 + +### Added + +- **`specify integration invoke`**: Dispatch a Spec Kit command (e.g. `implement`) through the active integration, including **OpenCode Docker** HTTP mode when configured. +- **`/speckit.implement` template**: Document that IDE-only implementation does not hit OpenCode Docker; use `specify integration invoke implement ...` from the repo root when `"mode": "docker"` in `.specify/opencode.json`. + +### Changed + +- **OpenCode Docker**: Emit `[specify][opencode-docker]` trace lines to **stderr** whenever a command (including `implement`) is dispatched via HTTP so you can confirm the container path in the terminal. ### Added diff --git a/README.md b/README.md index e4329cf6f9..6faecc03f5 100644 --- a/README.md +++ b/README.md @@ -376,10 +376,11 @@ spec docker-up Once the container is running, all `spec.implement` tasks and OpenCode commands automatically execute on the Docker container via HTTP API: ```bash -/spec.implement "write a hello world function" -# Runs on Docker OpenCode instead of local binary +specify integration invoke implement "write a hello world function" ``` +The CLI talks to the OpenCode HTTP server inside Docker (`POST /session` then `POST /session/{id}/command`). While that runs, **`docker compose -f .specify/docker-compose.yml logs -f`** should show server activity, and your terminal shows **`[specify][opencode-docker]`** lines on stderr when Specify is dispatching to Docker. + **Checking container status:** ```bash @@ -411,9 +412,10 @@ Docker settings are stored in `.specify/opencode.json`: "mode": "docker", "docker": { "enabled": true, - "compose_file": "./docker-compose.yml", + "compose_file": "./.specify/docker-compose.yml", "container_name": "opencode-dev", - "http_url": "http://localhost:9000" + "http_url": "http://localhost:9000", + "container_workspace": "/workspace" } } ``` diff --git a/pyproject.toml b/pyproject.toml index be54ce6df3..f4c98311bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,3 +96,10 @@ precision = 2 show_missing = true skip_covered = false +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=9.0.3", + "pytest-asyncio>=1.3.0", +] + diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 4f0728a5ba..98eb331d54 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -38,6 +38,8 @@ from typing import Any, Optional import typer +from rich.console import Console + from rich.panel import Panel from rich.live import Live from rich.align import Align @@ -805,20 +807,25 @@ def init( if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) if agent_config and agent_config["requires_cli"]: - install_url = agent_config["install_url"] - if not check_tool(selected_ai): - error_panel = Panel( - f"[cyan]{selected_ai}[/cyan] not found\n" - f"Install from: [cyan]{install_url}[/cyan]\n" - f"{agent_config['name']} is required to continue with this project type.\n\n" - "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check", - title="[red]Agent Detection Error[/red]", - border_style="red", - padding=(1, 2) - ) - console.print() - console.print(error_panel) - raise typer.Exit(1) + # Special case: opencode supports Docker mode, so don't require local CLI + if selected_ai == "opencode": + # Skip local CLI check for opencode - Docker mode will handle it + pass + else: + install_url = agent_config["install_url"] + if not check_tool(selected_ai): + error_panel = Panel( + f"[cyan]{selected_ai}[/cyan] not found\n" + f"Install from: [cyan]{install_url}[/cyan]\n" + f"{agent_config['name']} is required to continue with this project type.\n\n" + "Tip: Use [cyan]--ignore-agent-tools[/cyan] to skip this check", + title="[red]Agent Detection Error[/red]", + border_style="red", + padding=(1, 2) + ) + console.print() + console.print(error_panel) + raise typer.Exit(1) if script_type: if script_type not in SCRIPT_TYPE_CHOICES: @@ -2726,6 +2733,84 @@ def integration_info( raise typer.Exit(1) +@integration_app.command("invoke") +def integration_invoke( + command: str = typer.Argument( + ..., + metavar="COMMAND", + help="Spec Kit command stem (e.g. implement, plan, tasks, specify).", + ), + agent_args: str = typer.Argument( + "", + metavar="[ARGS]", + help="Optional text passed to the agent after the slash command.", + ), + integration_key: str | None = typer.Option( + None, + "--integration", + "-I", + help="Integration to use (default: project's active integration).", + ), +): + """Run a Spec Kit command through the configured agent (local CLI or OpenCode Docker HTTP).""" + from .integrations import get_integration + + project_root = _require_specify_project() + current = _read_integration_json(project_root) + key = integration_key or _default_integration_key(current) + if not key: + console.print( + "[red]Error:[/red] No default integration. " + "Choose one with [cyan]specify integration use [/cyan]." + ) + raise typer.Exit(1) + + impl = get_integration(key) + if impl is None: + console.print(f"[red]Error:[/red] Unknown integration {key!r}.") + raise typer.Exit(1) + + stem = command.strip().lstrip("/") + if stem.startswith("speckit."): + stem = stem[len("speckit.") :] + elif stem.startswith("spec."): + stem = stem[len("spec.") :] + + exec_args = impl.build_exec_args("test", project_root=project_root) + if exec_args is None: + console.print( + f"[red]Error:[/red] Integration {key!r} does not support " + "non-interactive command dispatch." + ) + raise typer.Exit(1) + + is_docker_mode = "--docker-mode" in exec_args + if not is_docker_mode and not shutil.which(impl.key): + console.print( + f"[red]Error:[/red] {impl.key!r} is not on PATH and OpenCode Docker mode " + "is not active (see .specify/opencode.json or run [cyan]specify docker-up[/cyan])." + ) + raise typer.Exit(1) + + try: + result = impl.dispatch_command( + stem, + args=agent_args, + project_root=project_root, + stream=True, + ) + except NotImplementedError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + code = result.get("exit_code", 1) + if code not in (0, None): + err = (result.get("stderr") or "").strip() + if err: + console.print(f"[red]{err}[/red]") + raise typer.Exit(int(code)) + + @integration_catalog_app.command("list") def integration_catalog_list(): """List configured integration catalog sources.""" @@ -5518,38 +5603,28 @@ def workflow_catalog_remove( console.print(f"[green]✓[/green] Catalog source '{removed_name}' removed") +# docker_up and other docker commands are registered as app subcommands below + + @app.command() -def docker_up(): +def docker_up( + project_root: Path | None = typer.Option( + None, "--project-root", help="Project root directory" + ) +): """Start OpenCode in a Docker container. Builds and starts the Docker container, then waits for it to be ready. Configuration is stored in .specify/opencode.json. """ - from .integrations.opencode.docker_manager import DockerManager - - project_root = _require_specify_project() - - # Scaffold Docker files from package if they don't exist - docker_template_dir = Path(__file__).parent / "integrations" / "opencode" / "docker" - dockerfile_src = docker_template_dir / "Dockerfile" - compose_src = docker_template_dir / "docker-compose.yml" + from .integrations.opencode.docker_manager import DockerManager, ensure_opencode_docker_assets - specify_dir = project_root / ".specify" - dockerfile_dst = specify_dir / "Dockerfile" - compose_dst = specify_dir / "docker-compose.yml" - - specify_dir.mkdir(parents=True, exist_ok=True) + _project_root = project_root or _require_specify_project() - if not dockerfile_dst.exists() and dockerfile_src.exists(): - shutil.copy(dockerfile_src, dockerfile_dst) - console.print(f"[dim]Created {dockerfile_dst}[/dim]") - - if not compose_dst.exists() and compose_src.exists(): - shutil.copy(compose_src, compose_dst) - console.print(f"[dim]Created {compose_dst}[/dim]") + ensure_opencode_docker_assets(_project_root) # Load or create config - config_path = project_root / ".specify" / "opencode.json" + config_path = _project_root / ".specify" / "opencode.json" config = {} if config_path.exists(): try: @@ -5563,16 +5638,20 @@ def docker_up(): container_name = docker_config.get("container_name", "opencode-dev") http_url = docker_config.get("http_url", "http://localhost:9000") - manager = DockerManager(compose_file, container_name, http_url) + manager = DockerManager( + compose_file, container_name, http_url, project_root=_project_root + ) console.print("[bold]Starting OpenCode Docker container...[/bold]") if manager.docker_up(): config["mode"] = "docker" + container_workspace = docker_config.get("container_workspace", "/workspace") config["docker"] = { "enabled": True, "compose_file": compose_file, "container_name": container_name, "http_url": http_url, + "container_workspace": container_workspace, } config_path.parent.mkdir(parents=True, exist_ok=True) with open(config_path, "w") as f: @@ -5584,13 +5663,17 @@ def docker_up(): @app.command() -def docker_down(): +def docker_down( + project_root: Path | None = typer.Option( + None, "--project-root", help="Project root directory" + ) +): """Stop the OpenCode Docker container.""" from .integrations.opencode.docker_manager import DockerManager - project_root = _require_specify_project() + _project_root = project_root or _require_specify_project() - config_path = project_root / ".specify" / "opencode.json" + config_path = _project_root / ".specify" / "opencode.json" config = {} if config_path.exists(): try: @@ -5604,28 +5687,35 @@ def docker_down(): container_name = docker_config.get("container_name", "opencode-dev") http_url = docker_config.get("http_url", "http://localhost:9000") - manager = DockerManager(compose_file, container_name, http_url) + manager = DockerManager( + compose_file, container_name, http_url, project_root=_project_root + ) console.print("[bold]Stopping OpenCode Docker container...[/bold]") if manager.docker_down(): config["mode"] = "local" + config.setdefault("docker", {}) config["docker"]["enabled"] = False with open(config_path, "w") as f: json.dump(config, f, indent=2) - console.print("[green]✓[/green] OpenCode container stopped") + console.print(f"[green]✓[/green] OpenCode container stopped") else: console.print("[red]✗[/red] Failed to stop Docker container") raise typer.Exit(1) @app.command() -def docker_status(): +def docker_status( + project_root: Path | None = typer.Option( + None, "--project-root", help="Project root directory" + ) +): """Show OpenCode Docker container status.""" from .integrations.opencode.docker_manager import DockerManager - project_root = _require_specify_project() + _project_root = project_root or _require_specify_project() - config_path = project_root / ".specify" / "opencode.json" + config_path = _project_root / ".specify" / "opencode.json" config = {} if config_path.exists(): try: @@ -5639,7 +5729,9 @@ def docker_status(): container_name = docker_config.get("container_name", "opencode-dev") http_url = docker_config.get("http_url", "http://localhost:9000") - manager = DockerManager(compose_file, container_name, http_url) + manager = DockerManager( + compose_file, container_name, http_url, project_root=_project_root + ) status = manager.docker_status() if status is None: diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index a0000cc3ab..bbfb6b642a 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -2,11 +2,12 @@ import json import os +import sys from pathlib import Path from typing import Any, TYPE_CHECKING from ..base import MarkdownIntegration -from .docker_manager import DockerManager +from .docker_manager import DockerManager, ensure_opencode_docker_assets if TYPE_CHECKING: from ..manifest import IntegrationManifest @@ -30,13 +31,30 @@ class OpencodeIntegration(MarkdownIntegration): } context_file = "AGENTS.md" + _DOCKER_LOG_PREFIX = "[specify][opencode-docker]" + + @classmethod + def _docker_trace(cls, message: str) -> None: + """Emit a visible trace line for OpenCode Docker routing (stderr, flushed).""" + print(f"{cls._DOCKER_LOG_PREFIX} {message}", file=sys.stderr, flush=True) + def build_exec_args( self, prompt: str, *, model: str | None = None, output_json: bool = True, + project_root: Path | None = None, ) -> list[str] | None: + # Check if Docker mode is enabled - if so, return synthetic args + # to pass the CLI check in CommandStep._try_dispatch() + check_path = project_root or Path.cwd() + if self._is_docker_mode(check_path): + # Return synthetic args indicating Docker mode + # dispatch_command will route to HTTP API instead + return [self.key, "--docker-mode", prompt] + + # Fall back to local CLI args (requires opencode to be installed) args = [self.key, "run"] message = prompt @@ -79,6 +97,7 @@ def process_template( arg_placeholder: str = "$ARGUMENTS", context_file: str = "", invoke_separator: str = ".", + project_root: Path | None = None, ) -> str: """Process template and wrap script command with docker routing. @@ -89,7 +108,13 @@ def process_template( # Call parent to get processed content processed = super().process_template( - content, agent_name, script_type, arg_placeholder, context_file, invoke_separator + content, + agent_name, + script_type, + arg_placeholder, + context_file, + invoke_separator, + project_root=project_root, ) # Find and wrap script invocations in code blocks @@ -130,8 +155,11 @@ def setup( config_content = { "mode": "docker", "docker": { - "http_url": "http://localhost:9000" - } + "http_url": "http://localhost:9000", + "compose_file": "./.specify/docker-compose.yml", + "container_name": "opencode-dev", + "container_workspace": "/workspace", + }, } config_text = json.dumps(config_content, indent=2) @@ -160,6 +188,14 @@ def _load_opencode_config(self, project_root: Path) -> dict[str, Any]: return {"mode": "local"} + @staticmethod + def is_docker_mode_exec_args(args: list[str] | None) -> bool: + """Check if build_exec_args returned Docker mode synthetic args. + + Used by CommandStep to detect when to skip local CLI checks. + """ + return bool(args and "--docker-mode" in args) + def _is_docker_mode(self, project_root: Path) -> bool: """Check if Docker mode is enabled. @@ -172,6 +208,34 @@ def _is_docker_mode(self, project_root: Path) -> bool: config = self._load_opencode_config(project_root) return config.get("mode") == "docker" + @staticmethod + def _split_opencode_slash_prompt(prompt: str) -> tuple[str, str]: + """Split ``/speckit.foo bar`` → (``speckit.foo``, ``bar``) for OpenCode ``POST /session/:id/command``.""" + p = prompt.strip() + if not p: + return "help", "" + if p.startswith("/"): + p = p[1:] + head, _, tail = p.partition(" ") + cmd = head.strip() or "help" + return cmd, tail.strip() + + @staticmethod + def _opencode_http_command_name(stem: str) -> str: + """Map slash-command stem to the name OpenCode's HTTP ``/session/.../command`` expects. + + Installed markdown commands use ``/speckit.*``, while the server typically registers + ``spec.*`` for the core Spec Kit commands. A few commands keep the ``speckit.`` prefix. + """ + overrides: dict[str, str] = { + "speckit.taskstoissues": "speckit.taskstoissues", + } + if stem in overrides: + return overrides[stem] + if stem.startswith("speckit."): + return "spec." + stem[len("speckit.") :] + return stem + def _call_docker_opencode( self, prompt: str, @@ -187,50 +251,115 @@ def _call_docker_opencode( try: import httpx except ImportError: + self._docker_trace("httpx is not installed; cannot call OpenCode Docker HTTP API") return { "exit_code": 1, "stdout": "", "stderr": "httpx library required for Docker mode", } - project_root = project_root or Path.cwd() + project_root = (project_root or Path.cwd()).resolve() config = self._load_opencode_config(project_root) docker_config = config.get("docker", {}) - http_url = docker_config.get("http_url", "http://localhost:9000") + base = str(docker_config.get("http_url", "http://localhost:9000")).rstrip("/") + workspace = str(docker_config.get("container_workspace", "/workspace")) + + cmd, arguments = self._split_opencode_slash_prompt(prompt) + api_cmd = self._opencode_http_command_name(cmd) + if api_cmd != cmd: + self._docker_trace(f"command stem mapped for HTTP API: {cmd!r} -> {api_cmd!r}") + preview = prompt if len(prompt) <= 280 else f"{prompt[:280]}…" + self._docker_trace( + f"OpenCode HTTP project={project_root} base={base} workspace={workspace!r}" + ) + self._docker_trace( + f"session API: slash={cmd!r} http_command={api_cmd!r} arguments={arguments!r} preview={preview!r}" + ) + timeout = httpx.Timeout(600.0) try: - response = httpx.post( - f"{http_url}/api/execute", - json={"prompt": prompt, "model": model, "format": "json" if output_json else "text"}, - timeout=600, - ) - - if response.status_code == 200: - data = response.json() if output_json else response.text - return { - "exit_code": 0, - "stdout": json.dumps(data) if output_json else data, - "stderr": "", - } - else: + with httpx.Client(timeout=timeout) as client: + self._docker_trace( + f"POST {base}/session?directory={workspace} (create OpenCode session)" + ) + sr = client.post( + f"{base}/session", + params={"directory": workspace}, + json={"title": "Specify CLI"}, + ) + self._docker_trace(f"session HTTP status={sr.status_code}") + if sr.status_code not in (200, 201): + return { + "exit_code": 1, + "stdout": "", + "stderr": f"OpenCode session create failed: {sr.status_code} {sr.text}", + } + try: + session = sr.json() + except json.JSONDecodeError: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"OpenCode session create returned non-JSON: {sr.text[:500]}", + } + sid = session.get("id") + if not sid: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"OpenCode session response missing id: {session!r}", + } + self._docker_trace( + f"session_id={sid!r} — tail container logs during the next request " + f"(`docker compose -f .specify/docker-compose.yml logs -f`)" + ) + + body: dict[str, Any] = {"command": api_cmd, "arguments": arguments} + if model: + body["model"] = model + + cmd_url = f"{base}/session/{sid}/command" + self._docker_trace( + f"POST {cmd_url}?directory={workspace} (execute slash command on server)" + ) + cr = client.post(cmd_url, params={"directory": workspace}, json=body) + self._docker_trace( + f"command HTTP status={cr.status_code} bytes={len(cr.content)}" + ) + + if cr.status_code == 200: + if output_json: + payload = cr.json() + out = json.dumps(payload) + else: + out = cr.text + self._docker_trace("OpenCode Docker slash command finished OK (exit_code=0)") + return {"exit_code": 0, "stdout": out, "stderr": ""} + self._docker_trace( + f"OpenCode command API error status={cr.status_code} body_snip=" + f"{cr.text[:400]!r}" + ) return { "exit_code": 1, "stdout": "", - "stderr": f"OpenCode API error: {response.status_code} {response.text}", + "stderr": f"OpenCode command failed: {cr.status_code} {cr.text}", } except httpx.ConnectError as e: + self._docker_trace(f"connect_error base={base!r} detail={e!r}") return { "exit_code": 1, "stdout": "", - "stderr": f"Failed to connect to OpenCode at {http_url}. Is Docker running? Error: {e}", + "stderr": f"Failed to connect to OpenCode at {base}. Is Docker running? Error: {e}", } except httpx.TimeoutException: + self._docker_trace(f"timeout base={base!r}") return { "exit_code": 1, "stdout": "", - "stderr": f"Request timeout connecting to OpenCode at {http_url}", + "stderr": f"Request timeout connecting to OpenCode at {base}", } except Exception as e: + self._docker_trace(f"unexpected_error type={type(e).__name__!r} detail={e!r}") return { "exit_code": 1, "stdout": "", @@ -254,8 +383,23 @@ def dispatch_command( """ project_root = project_root or Path.cwd() + # Shell commands (docker-up/down/status) must run locally — they manage the + # Docker container, so they bypass the HTTP API and go straight to DockerManager. + if command_name in ("docker-up", "docker-down", "docker-status"): + return self.dispatch_shell_command(command_name, project_root=project_root) + if self._is_docker_mode(project_root): + root = project_root.resolve() prompt = self.build_command_invocation(command_name, args) + self._docker_trace( + f"Docker mode ON — dispatching speckit command={command_name!r} via HTTP " + f"(not local opencode CLI) project={root}" + ) + if "implement" in command_name.lower(): + self._docker_trace( + "implement detected — this request is proxied to the OpenCode container " + "(watch `docker compose logs` for session / command activity on the server)" + ) result = self._call_docker_opencode( prompt, model=model, output_json=not stream, project_root=project_root ) @@ -263,9 +407,178 @@ def dispatch_command( if stream and result.get("stdout"): print(result["stdout"]) + if result.get("exit_code") == 0: + self._docker_trace( + f"dispatch_command complete command={command_name!r} exit_code=0" + ) + else: + self._docker_trace( + f"dispatch_command complete command={command_name!r} " + f"exit_code={result.get('exit_code')} stderr={str(result.get('stderr', ''))[:500]!r}" + ) + return result # Fall back to subprocess for local mode return super().dispatch_command( command_name, args, project_root=project_root, model=model, timeout=timeout, stream=stream ) + + def dispatch_shell_command( + self, + command_name: str, + project_root: Path | None = None, + ) -> dict[str, Any]: + """Dispatch a shell command to OpenCode. + + Handles special subcommands like docker-up, docker-down, docker-status. + """ + project_root = project_root or Path.cwd() + + if command_name == "docker-up": + return self._dispatch_docker_up(project_root) + elif command_name == "docker-down": + return self._dispatch_docker_down(project_root) + elif command_name == "docker-status": + return self._dispatch_docker_status(project_root) + else: + return {"exit_code": 1, "stdout": "", "stderr": f"Unknown shell command: {command_name}"} + + def _dispatch_docker_up(self, project_root: Path) -> dict[str, Any]: + """Dispatch the docker-up shell command.""" + ensure_opencode_docker_assets(project_root) + + config_path = project_root / ".specify" / "opencode.json" + full: dict[str, Any] = {} + if config_path.exists(): + try: + full = json.loads(config_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + full = {} + + docker_inner = dict(full.get("docker") or {}) + compose_file = docker_inner.get("compose_file", "./.specify/docker-compose.yml") + container_name = docker_inner.get("container_name", "opencode-dev") + http_url = docker_inner.get("http_url", "http://localhost:9000") + + manager = DockerManager( + compose_file, container_name, http_url, project_root=project_root + ) + + try: + result: dict[str, Any] = {"exit_code": 0, "stdout": "", "stderr": ""} + result["stdout"] = f"Starting OpenCode Docker container at {http_url}\n" + if manager.docker_up(): + full["mode"] = "docker" + full["docker"] = { + **docker_inner, + "enabled": True, + "compose_file": compose_file, + "container_name": container_name, + "http_url": http_url, + } + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps(full, indent=2) + "\n", encoding="utf-8" + ) + result["stdout"] += f"✓ OpenCode container running at {http_url}\n" + else: + result["stderr"] = "Failed to start Docker container" + result["exit_code"] = 1 + return result + except Exception as e: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Error: {str(e)}", + } + + def _dispatch_docker_down(self, project_root: Path) -> dict[str, Any]: + """Dispatch the docker-down shell command.""" + config_path = project_root / ".specify" / "opencode.json" + full: dict[str, Any] = {} + if config_path.exists(): + try: + full = json.loads(config_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + full = {} + + docker_inner = dict(full.get("docker") or {}) + compose_file = docker_inner.get("compose_file", "./.specify/docker-compose.yml") + container_name = docker_inner.get("container_name", "opencode-dev") + http_url = docker_inner.get("http_url", "http://localhost:9000") + + manager = DockerManager( + compose_file, container_name, http_url, project_root=project_root + ) + + try: + result: dict[str, Any] = {"exit_code": 0, "stdout": "", "stderr": ""} + result["stdout"] = f"Stopping OpenCode Docker container at {http_url}\n" + if manager.docker_down(): + full["mode"] = "local" + docker_inner["enabled"] = False + full["docker"] = {**docker_inner} + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + json.dumps(full, indent=2) + "\n", encoding="utf-8" + ) + result["stdout"] += "✓ OpenCode container stopped\n" + else: + result["stderr"] = "Failed to stop Docker container" + result["exit_code"] = 1 + return result + except Exception as e: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Error: {str(e)}", + } + + def _dispatch_docker_status(self, project_root: Path) -> dict[str, Any]: + """Dispatch the docker-status shell command.""" + config_path = project_root / ".specify" / "opencode.json" + full: dict[str, Any] = {} + if config_path.exists(): + try: + full = json.loads(config_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + full = {} + + docker_inner = dict(full.get("docker") or {}) + compose_file = docker_inner.get("compose_file", "./.specify/docker-compose.yml") + container_name = docker_inner.get("container_name", "opencode-dev") + http_url = docker_inner.get("http_url", "http://localhost:9000") + + manager = DockerManager( + compose_file, container_name, http_url, project_root=project_root + ) + + try: + result: dict[str, Any] = {"exit_code": 0, "stdout": "", "stderr": ""} + status = manager.docker_status() + if status is None: + result["stderr"] = "Could not retrieve Docker status" + result["exit_code"] = 1 + return result + + if status.get("running"): + result["stdout"] = ( + f"✓ OpenCode container {status.get('name')} is running at {http_url}\n" + ) + else: + result["stdout"] = ( + f"○ OpenCode container is not running at {http_url}\n" + ) + + logs = manager.get_container_logs(lines=10) + if logs: + result["stdout"] += f"\nRecent logs:\n{logs}\n" + + return result + except Exception as e: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"Error: {str(e)}", + } diff --git a/src/specify_cli/integrations/opencode/docker/Dockerfile b/src/specify_cli/integrations/opencode/docker/Dockerfile index 9e7d2ab81a..2e7cc8b76b 100644 --- a/src/specify_cli/integrations/opencode/docker/Dockerfile +++ b/src/specify_cli/integrations/opencode/docker/Dockerfile @@ -2,7 +2,7 @@ FROM node:24-alpine WORKDIR /app -RUN apk add --no-cache git curl +RUN apk add --no-cache git curl python3 coreutils # Download and extract OpenCode binary for Linux RUN apk add --no-cache tar && \ @@ -22,8 +22,17 @@ RUN addgroup -g 2000 opencode && \ RUN mkdir -p /workspace && \ chown -R opencode:opencode /workspace /app +# Prefix each server output line with [opencode-server] so it stands out in +# `docker compose logs`. OPENCODE_LOG_LEVEL controls verbosity (default INFO). +# stdbuf -oL forces line-buffered mode so lines appear in real-time. +RUN printf '#!/bin/sh\n' > /start.sh && \ + printf 'LOG_LEVEL="${OPENCODE_LOG_LEVEL:-INFO}"\n' >> /start.sh && \ + printf 'exec opencode serve --hostname 0.0.0.0 --port 3000 --log-level "$LOG_LEVEL" 2>&1 |\n' >> /start.sh && \ + printf ' stdbuf -oL sed "s/^/[opencode-server] /"\n' >> /start.sh && \ + chmod +x /start.sh + USER opencode EXPOSE 3000 -CMD ["opencode", "serve", "--hostname", "0.0.0.0", "--port", "3000"] +CMD ["/start.sh"] \ No newline at end of file diff --git a/src/specify_cli/integrations/opencode/docker/docker-compose.yml b/src/specify_cli/integrations/opencode/docker/docker-compose.yml index 249ab82f94..6819847e68 100644 --- a/src/specify_cli/integrations/opencode/docker/docker-compose.yml +++ b/src/specify_cli/integrations/opencode/docker/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: opencode: build: @@ -13,8 +11,9 @@ services: environment: - NODE_ENV=development - OPENCODE_PORT=3000 + - OPENCODE_LOG_LEVEL=${OPENCODE_LOG_LEVEL:-INFO} healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + test: ["CMD", "curl", "-sf", "http://localhost:3000/global/health"] interval: 5s timeout: 3s retries: 30 diff --git a/src/specify_cli/integrations/opencode/docker_manager.py b/src/specify_cli/integrations/opencode/docker_manager.py index 9efc4c4118..358ba252c0 100644 --- a/src/specify_cli/integrations/opencode/docker_manager.py +++ b/src/specify_cli/integrations/opencode/docker_manager.py @@ -1,22 +1,113 @@ """Docker container management for OpenCode.""" +from __future__ import annotations + +import json +import shutil import subprocess import time -import json from pathlib import Path -from typing import Optional +from typing import Any, Optional import httpx +def ensure_opencode_docker_assets(project_root: Path) -> Path: + """Copy packaged Dockerfile and compose file into ``.specify/`` if missing. + + Returns the absolute path to ``docker-compose.yml`` under ``.specify/``. + """ + template_dir = Path(__file__).resolve().parent / "docker" + specify_dir = project_root / ".specify" + specify_dir.mkdir(parents=True, exist_ok=True) + + for name in ("Dockerfile", "docker-compose.yml"): + src = template_dir / name + dst = specify_dir / name + if not dst.exists() and src.exists(): + shutil.copy(src, dst) + + return (specify_dir / "docker-compose.yml").resolve() + + +def _resolve_compose_file(compose_file: str | Path, project_root: Path | None) -> Path: + p = Path(compose_file) + if p.is_absolute(): + return p.resolve() + base = project_root if project_root is not None else Path.cwd() + return (base / p).resolve() + + +def _parse_compose_ps_json(stdout: str) -> list[dict[str, Any]]: + """Parse ``docker compose ps --format json`` output (array or NDJSON).""" + text = stdout.strip() + if not text: + return [] + try: + data = json.loads(text) + if isinstance(data, list): + return data + if isinstance(data, dict): + return [data] + except json.JSONDecodeError: + pass + services: list[dict[str, Any]] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + if isinstance(row, dict): + services.append(row) + except json.JSONDecodeError: + continue + return services + + class DockerManager: """Manage OpenCode Docker container lifecycle.""" - def __init__(self, compose_file: str = "docker-compose.yml", container_name: str = "opencode-dev", http_url: str = "http://localhost:9000", timeout: int = 60): - self.compose_file = Path(compose_file).resolve() + def __init__( + self, + compose_file: str | Path = "docker-compose.yml", + container_name: str = "opencode-dev", + http_url: str = "http://localhost:9000", + timeout: int = 60, + *, + project_root: Path | None = None, + ): + self.compose_file = _resolve_compose_file(compose_file, project_root) self.container_name = container_name - self.http_url = http_url + self.http_url = http_url.rstrip("/") self.timeout = timeout + self._project_root = project_root + self._compose_prefix: list[str] | None = None + + def _compose_cmd_prefix(self) -> list[str]: + """``docker compose -f `` or legacy ``docker-compose -f ``.""" + if self._compose_prefix is not None: + return self._compose_prefix + compose_arg = str(self.compose_file) + if shutil.which("docker"): + probe = subprocess.run( + ["docker", "compose", "version"], + capture_output=True, + text=True, + timeout=10, + ) + if probe.returncode == 0: + self._compose_prefix = ["docker", "compose", "-f", compose_arg] + return self._compose_prefix + if shutil.which("docker-compose"): + self._compose_prefix = ["docker-compose", "-f", compose_arg] + return self._compose_prefix + raise FileNotFoundError( + "Neither 'docker compose' nor 'docker-compose' is available. Install Docker." + ) + + def _compose(self, *compose_args: str) -> list[str]: + return [*self._compose_cmd_prefix(), *compose_args] def docker_up(self) -> bool: """Start Docker container and wait until healthy. @@ -26,23 +117,22 @@ def docker_up(self) -> bool: """ try: result = subprocess.run( - ["docker-compose", "-f", str(self.compose_file), "up", "-d"], - cwd=self.compose_file.parent, + self._compose("up", "-d"), + cwd=str(self.compose_file.parent), capture_output=True, text=True, - timeout=30 + timeout=120, ) if result.returncode != 0: print(f"Error starting Docker: {result.stderr}") return False - # Wait for container to be healthy return self.check_container_ready() except subprocess.TimeoutExpired: print("Timeout starting Docker container") return False - except FileNotFoundError: - print("docker-compose not found. Please install Docker Desktop or Docker Engine.") + except FileNotFoundError as e: + print(str(e)) return False def docker_down(self) -> bool: @@ -53,11 +143,11 @@ def docker_down(self) -> bool: """ try: result = subprocess.run( - ["docker-compose", "-f", str(self.compose_file), "down"], - cwd=self.compose_file.parent, + self._compose("down"), + cwd=str(self.compose_file.parent), capture_output=True, text=True, - timeout=15 + timeout=60, ) if result.returncode != 0: print(f"Error stopping Docker: {result.stderr}") @@ -66,8 +156,8 @@ def docker_down(self) -> bool: except subprocess.TimeoutExpired: print("Timeout stopping Docker container") return False - except FileNotFoundError: - print("docker-compose not found") + except FileNotFoundError as e: + print(str(e)) return False def docker_status(self) -> Optional[dict]: @@ -78,27 +168,36 @@ def docker_status(self) -> Optional[dict]: """ try: result = subprocess.run( - ["docker-compose", "-f", str(self.compose_file), "ps", "--format", "json"], - cwd=self.compose_file.parent, + self._compose("ps", "--format", "json"), + cwd=str(self.compose_file.parent), capture_output=True, text=True, - timeout=10 + timeout=15, ) if result.returncode != 0: return None - services = json.loads(result.stdout) if result.stdout.strip() else [] + services = _parse_compose_ps_json(result.stdout) if not services: - return {"status": "not-running"} - - service = services[0] + return {"status": "not-running", "running": False} + + # Prefer the opencode service / configured container name + service = None + for row in services: + name = row.get("Name") or row.get("Service") or "" + if self.container_name in str(name) or row.get("Service") == "opencode": + service = row + break + service = service or services[0] + + state = service.get("State", "unknown") return { - "status": service.get("State", "unknown"), + "status": state, "name": service.get("Service", ""), - "running": service.get("State") == "running", - "url": self.http_url if service.get("State") == "running" else None + "running": state == "running", + "url": self.http_url if state == "running" else None, } - except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError): + except (subprocess.TimeoutExpired, FileNotFoundError): return None def check_container_ready(self, timeout: Optional[int] = None) -> bool: @@ -113,17 +212,20 @@ def check_container_ready(self, timeout: Optional[int] = None) -> bool: timeout = timeout or self.timeout start_time = time.time() + base = self.http_url.rstrip("/") while time.time() - start_time < timeout: - try: - response = httpx.get( - f"{self.http_url}/health", - timeout=2 - ) - if response.status_code == 200: - print(f"✓ OpenCode container ready at {self.http_url}") - return True - except Exception: - pass + ready = False + for path in ("/global/health", "/health"): + try: + response = httpx.get(f"{base}{path}", timeout=2) + if response.status_code == 200: + ready = True + break + except Exception: + pass + if ready: + print(f"✓ OpenCode container ready at {self.http_url}") + return True time.sleep(2) @@ -141,11 +243,11 @@ def get_container_logs(self, lines: int = 50) -> str: """ try: result = subprocess.run( - ["docker-compose", "-f", str(self.compose_file), "logs", "--tail", str(lines)], - cwd=self.compose_file.parent, + self._compose("logs", "--tail", str(lines)), + cwd=str(self.compose_file.parent), capture_output=True, text=True, - timeout=10 + timeout=15, ) return result.stdout if result.returncode == 0 else result.stderr except (subprocess.TimeoutExpired, FileNotFoundError): diff --git a/src/specify_cli/workflows/steps/command/__init__.py b/src/specify_cli/workflows/steps/command/__init__.py index 21fd4837d1..d24fa269fd 100644 --- a/src/specify_cli/workflows/steps/command/__init__.py +++ b/src/specify_cli/workflows/steps/command/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import inspect import shutil from pathlib import Path from typing import Any @@ -127,15 +128,24 @@ def _try_dispatch( return None # Check if the integration supports CLI dispatch - if impl.build_exec_args("test") is None: + project_root = Path(context.project_root) if context.project_root else None + build_sig = inspect.signature(impl.build_exec_args) + if project_root is not None and "project_root" in build_sig.parameters: + exec_args = impl.build_exec_args("test", project_root=project_root) + else: + exec_args = impl.build_exec_args("test") + if exec_args is None: return None - # Check if the CLI tool is actually installed - if not shutil.which(impl.key): + # Check if the CLI tool is actually installed (skip for Docker mode) + # OpenCode integration uses --docker-mode sentinel for HTTP dispatch + is_docker_mode = ( + hasattr(impl, "is_docker_mode_exec_args") + and impl.is_docker_mode_exec_args(exec_args) + ) or ("--docker-mode" in exec_args if exec_args else False) + if not is_docker_mode and not shutil.which(impl.key): return None - project_root = Path(context.project_root) if context.project_root else None - try: return impl.dispatch_command( command, diff --git a/templates/commands/implement.md b/templates/commands/implement.md index b0a26a69e6..87346259ee 100644 --- a/templates/commands/implement.md +++ b/templates/commands/implement.md @@ -13,6 +13,24 @@ $ARGUMENTS You **MUST** consider the user input before proceeding (if not empty). +## OpenCode Docker (when `.specify/opencode.json` uses `"mode": "docker"`) + +If this project is configured for **OpenCode in Docker** (`specify docker-up`, `.specify/opencode.json` with `"mode": "docker"`), then **doing the implementation only by editing files in this chat does not run on OpenCode** — nothing is sent to the container, so Docker logs stay quiet. + +When the user asked to **use the OpenCode Docker container** for implementation (or when the above config is present and they want execution there), you **MUST** run the Specify CLI from the **repository root** so dispatch hits the OpenCode HTTP endpoint (after `specify docker-up` is healthy): + +```bash +specify integration invoke implement --integration opencode "$ARGUMENTS" +``` + +If `opencode` is already the project's default integration, `--integration opencode` may be omitted: + +```bash +specify integration invoke implement "$ARGUMENTS" +``` + +Use the same shell/terminal tool you use for other repo commands; do not only describe this command — **execute it** so the OpenCode server receives **`POST /session`** then **`POST /session/{id}/command`** (that is what makes `docker compose logs` show activity). When it runs, the Specify CLI prints lines to **stderr** prefixed with `[specify][opencode-docker]` — if you see those lines in the terminal, the request was sent to the OpenCode HTTP server inside Docker (not the local `opencode` binary). If the user prefers to stay entirely in this IDE without OpenCode, skip this section and implement locally as usual. + ## Pre-Execution Checks **Check for extension hooks (before implementation)**: diff --git a/tests/test_docker_opencode.py b/tests/test_docker_opencode.py index 0b9bdcf167..c76a41e816 100644 --- a/tests/test_docker_opencode.py +++ b/tests/test_docker_opencode.py @@ -1,9 +1,9 @@ """Tests for Docker OpenCode integration.""" import json -import pytest from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + from specify_cli.integrations.opencode import OpencodeIntegration from specify_cli.integrations.opencode.docker_manager import DockerManager @@ -18,31 +18,29 @@ def test_docker_manager_init(self): assert manager.http_url == "http://localhost:9000" assert manager.timeout == 60 - def test_docker_manager_custom_config(self): - """Test DockerManager with custom configuration.""" + def test_docker_manager_custom_config(self, tmp_path, monkeypatch): + """Test DockerManager with custom configuration (relative compose path).""" + monkeypatch.chdir(tmp_path) manager = DockerManager( compose_file="custom.yml", container_name="custom-opencode", - http_url="http://127.0.0.1:8000" + http_url="http://127.0.0.1:8000", ) - assert manager.compose_file == Path("custom.yml") + assert manager.compose_file == (tmp_path / "custom.yml").resolve() assert manager.container_name == "custom-opencode" assert manager.http_url == "http://127.0.0.1:8000" - @patch("subprocess.run") + @patch("specify_cli.integrations.opencode.docker_manager.subprocess.run") def test_docker_up_success(self, mock_run): """Test successful docker up command.""" - mock_run.side_effect = [ - MagicMock(returncode=0), # docker-compose up - MagicMock(returncode=0), # curl health check (via HTTP) - ] + mock_run.return_value = MagicMock(returncode=0) manager = DockerManager() with patch.object(manager, "check_container_ready", return_value=True): result = manager.docker_up() assert result is True - @patch("subprocess.run") + @patch("specify_cli.integrations.opencode.docker_manager.subprocess.run") def test_docker_down_success(self, mock_run): """Test successful docker down command.""" mock_run.return_value = MagicMock(returncode=0) @@ -51,13 +49,16 @@ def test_docker_down_success(self, mock_run): result = manager.docker_down() assert result is True - @patch("subprocess.run") + @patch("specify_cli.integrations.opencode.docker_manager.subprocess.run") def test_docker_status_running(self, mock_run): """Test docker status when container is running.""" - mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps([{"Service": "opencode", "State": "running"}]) - ) + mock_run.side_effect = [ + MagicMock(returncode=0), + MagicMock( + returncode=0, + stdout=json.dumps([{"Service": "opencode", "State": "running"}]), + ), + ] manager = DockerManager() status = manager.docker_status() @@ -65,7 +66,7 @@ def test_docker_status_running(self, mock_run): assert status["status"] == "running" assert status["running"] is True - @patch("httpx.get") + @patch("specify_cli.integrations.opencode.docker_manager.httpx.get") def test_check_container_ready_success(self, mock_get): """Test container health check success.""" mock_get.return_value = MagicMock(status_code=200) @@ -75,7 +76,7 @@ def test_check_container_ready_success(self, mock_get): assert result is True mock_get.assert_called_once() - @patch("httpx.get") + @patch("specify_cli.integrations.opencode.docker_manager.httpx.get") def test_check_container_ready_timeout(self, mock_get): """Test container health check timeout.""" mock_get.side_effect = Exception("Connection refused") @@ -84,13 +85,16 @@ def test_check_container_ready_timeout(self, mock_get): result = manager.check_container_ready(timeout=1) assert result is False - @patch("subprocess.run") + @patch("specify_cli.integrations.opencode.docker_manager.subprocess.run") def test_get_container_logs(self, mock_run): """Test retrieving container logs.""" - mock_run.return_value = MagicMock( - returncode=0, - stdout="log line 1\nlog line 2" - ) + mock_run.side_effect = [ + MagicMock(returncode=0), + MagicMock( + returncode=0, + stdout="log line 1\nlog line 2", + ), + ] manager = DockerManager() logs = manager.get_container_logs(lines=50) @@ -100,6 +104,18 @@ def test_get_container_logs(self, mock_run): class TestOpencodeIntegration: """Tests for OpenCode integration Docker support.""" + def test_split_opencode_slash_prompt(self): + """Slash prompts map to OpenCode session command API fields.""" + assert OpencodeIntegration._split_opencode_slash_prompt("") == ("help", "") + assert OpencodeIntegration._split_opencode_slash_prompt("/speckit.implement") == ( + "speckit.implement", + "", + ) + assert OpencodeIntegration._split_opencode_slash_prompt("/speckit.implement Phase 1") == ( + "speckit.implement", + "Phase 1", + ) + def test_load_opencode_config_no_file(self, tmp_path): """Test loading config when file doesn't exist.""" integration = OpencodeIntegration() @@ -122,8 +138,8 @@ def test_load_opencode_config_exists(self, tmp_path): "mode": "docker", "docker": { "enabled": True, - "http_url": "http://localhost:9000" - } + "http_url": "http://localhost:9000", + }, } config_file = spec_dir / "opencode.json" @@ -167,9 +183,39 @@ def test_is_docker_mode_from_env_var(self, tmp_path): assert integration._is_docker_mode(project_root) is True - @patch("httpx.post") - def test_call_docker_opencode_success(self, mock_post, tmp_path): + def test_opencode_http_command_name(self): + """HTTP /session/.../command uses spec.* for core speckit.* commands.""" + assert OpencodeIntegration._opencode_http_command_name("speckit.implement") == "spec.implement" + assert OpencodeIntegration._opencode_http_command_name("speckit.specify") == "spec.specify" + assert OpencodeIntegration._opencode_http_command_name("speckit.taskstoissues") == ( + "speckit.taskstoissues" + ) + + @patch("httpx.Client") + def test_call_docker_opencode_success(self, mock_client_class, tmp_path): """Test successful Docker OpenCode API call.""" + mock_inst = MagicMock() + mock_inst.__enter__ = MagicMock(return_value=mock_inst) + mock_inst.__exit__ = MagicMock(return_value=False) + mock_inst.post.side_effect = [ + MagicMock( + status_code=200, + json=lambda: { + "id": "sess-test", + "title": "Specify CLI", + "projectID": "p", + "directory": "/workspace", + "version": "1", + "time": {"created": 0, "updated": 0}, + }, + ), + MagicMock( + status_code=200, + json=lambda: {"info": {"role": "assistant"}, "parts": []}, + ), + ] + mock_client_class.return_value = mock_inst + integration = OpencodeIntegration() project_root = tmp_path / "project" project_root.mkdir() @@ -179,26 +225,38 @@ def test_call_docker_opencode_success(self, mock_post, tmp_path): config_file = spec_dir / "opencode.json" with open(config_file, "w") as f: - json.dump({"mode": "docker", "docker": {"http_url": "http://localhost:9000"}}, f) - - mock_post.return_value = MagicMock( - status_code=200, - json=lambda: {"result": "success"} - ) + json.dump( + {"mode": "docker", "docker": {"http_url": "http://localhost:9000"}}, + f, + ) result = integration._call_docker_opencode( - "/spec.test hello", - project_root=project_root + "/speckit.specify hello", + project_root=project_root, ) assert result["exit_code"] == 0 - assert "success" in result["stdout"] - - @patch("httpx.post") - def test_call_docker_opencode_connection_error(self, mock_post, tmp_path): + assert mock_inst.post.call_count == 2 + first_url = mock_inst.post.call_args_list[0][0][0] + second_url = mock_inst.post.call_args_list[1][0][0] + assert "/session" in first_url + assert "/session/sess-test/command" in second_url + cmd_body = mock_inst.post.call_args_list[1][1]["json"] + assert cmd_body["command"] == "spec.specify" + assert cmd_body["arguments"] == "hello" + assert "assistant" in result["stdout"] + + @patch("httpx.Client") + def test_call_docker_opencode_connection_error(self, mock_client_class, tmp_path): """Test Docker OpenCode API connection error.""" import httpx + mock_inst = MagicMock() + mock_inst.__enter__ = MagicMock(return_value=mock_inst) + mock_inst.__exit__ = MagicMock(return_value=False) + mock_inst.post.side_effect = httpx.ConnectError("Connection failed") + mock_client_class.return_value = mock_inst + integration = OpencodeIntegration() project_root = tmp_path / "project" project_root.mkdir() @@ -208,14 +266,84 @@ def test_call_docker_opencode_connection_error(self, mock_post, tmp_path): config_file = spec_dir / "opencode.json" with open(config_file, "w") as f: - json.dump({"mode": "docker", "docker": {"http_url": "http://localhost:9000"}}, f) - - mock_post.side_effect = httpx.ConnectError("Connection failed") + json.dump( + {"mode": "docker", "docker": {"http_url": "http://localhost:9000"}}, + f, + ) result = integration._call_docker_opencode( - "/spec.test hello", - project_root=project_root + "/speckit.specify hello", + project_root=project_root, ) assert result["exit_code"] == 1 assert "Docker running" in result["stderr"] or "Failed to connect" in result["stderr"] + + def test_build_exec_args_docker_mode(self, tmp_path): + """Test build_exec_args returns synthetic args in Docker mode.""" + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + + spec_dir = project_root / ".specify" + spec_dir.mkdir() + + config_file = spec_dir / "opencode.json" + with open(config_file, "w") as f: + json.dump({"mode": "docker"}, f) + + args = integration.build_exec_args( + "/spec.implement test", project_root=project_root + ) + + assert args is not None + assert "--docker-mode" in args + assert args[0] == "opencode" + assert "/spec.implement test" in args + + def test_build_exec_args_local_mode(self, tmp_path): + """Test build_exec_args returns local CLI args when not in Docker mode.""" + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + + # No config file = local mode + args = integration.build_exec_args( + "/spec.implement test", project_root=project_root + ) + + assert args is not None + assert args[0] == "opencode" + assert "run" in args + assert "--docker-mode" not in args + + @patch.dict("os.environ", {"OPENCODE_MODE": "docker"}) + def test_build_exec_args_docker_mode_from_env(self, tmp_path): + """Test build_exec_args detects Docker mode from env var without config file.""" + integration = OpencodeIntegration() + project_root = tmp_path / "project" + project_root.mkdir() + + # No config file, but env var set + args = integration.build_exec_args( + "/spec.implement test", project_root=project_root + ) + + assert args is not None + assert "--docker-mode" in args + + def test_is_docker_mode_exec_args(self): + """Test is_docker_mode_exec_args correctly detects Docker mode.""" + # Docker mode args + docker_args = ["opencode", "--docker-mode", "/spec.test"] + assert OpencodeIntegration.is_docker_mode_exec_args(docker_args) is True + + # Local mode args + local_args = ["opencode", "run", "test"] + assert OpencodeIntegration.is_docker_mode_exec_args(local_args) is False + + # None + assert OpencodeIntegration.is_docker_mode_exec_args(None) is False + + # Empty list + assert OpencodeIntegration.is_docker_mode_exec_args([]) is False From 87c5a7fb5e8f835766ffa492dc76e6309d19ff5e Mon Sep 17 00:00:00 2001 From: edendadon Date: Tue, 2 Jun 2026 12:48:26 +0300 Subject: [PATCH 4/4] add Dev Container Integration for /spec.implement --- AGENTS.md | 5 +- src/specify_cli/__init__.py | 7 + src/specify_cli/integrations/__init__.py | 2 + .../integrations/devcontainer/__init__.py | 788 ++++++++++++++++++ templates/commands/implement.md | 19 + 5 files changed, 820 insertions(+), 1 deletion(-) create mode 100644 src/specify_cli/integrations/devcontainer/__init__.py diff --git a/AGENTS.md b/AGENTS.md index 062fb22eb4..f190fd0d5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ Specify supports multiple AI agents by generating agent-specific command files a | **Gemini CLI** | `.gemini/commands/` | TOML | `gemini` | Google's Gemini CLI | | **GitHub Copilot** | `.github/agents/` | Markdown | N/A (IDE-based) | GitHub Copilot in VS Code | | **Cursor** | `.cursor/commands/` | Markdown | N/A (IDE-based) | Cursor IDE (`--ai cursor-agent`) | +| **Dev Container** | `.devcontainer/commands/` | Markdown | `devcontainer` (optional) | VS Code Dev Containers (`--integration devcontainer`) | | **Qwen Code** | `.qwen/commands/` | Markdown | `qwen` | Alibaba's Qwen Code CLI | | **opencode** | `.opencode/command/` | Markdown | `opencode` | opencode CLI | | **Codex CLI** | `.agents/skills/` | Markdown | `codex` | Codex CLI (`--ai codex --ai-skills`) | @@ -288,6 +289,7 @@ Work within integrated development environments: - **GitHub Copilot**: Built into VS Code/compatible editors - **Cursor**: Built into Cursor IDE (`--ai cursor-agent`) +- **Dev Container**: VS Code Dev Containers (`--integration devcontainer`) - **Windsurf**: Built into Windsurf IDE - **Kilo Code**: Built into Kilo Code IDE - **Roo Code**: Built into Roo Code IDE @@ -299,7 +301,7 @@ Work within integrated development environments: ### Markdown Format -Used by: Claude, Cursor, GitHub Copilot, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow, Forge +Used by: Claude, Cursor, GitHub Copilot, Dev Container, opencode, Windsurf, Junie, Kiro CLI, Amp, SHAI, IBM Bob, Kimi Code, Qwen, Pi, Codex, Auggie, CodeBuddy, Qoder, Roo Code, Kilo Code, Trae, Antigravity, Mistral Vibe, iFlow, Forge **Standard format:** @@ -355,6 +357,7 @@ Command content with {SCRIPT} and {{args}} placeholders. - **IDE agents**: Follow IDE-specific patterns: - Copilot: `.github/agents/` - Cursor: `.cursor/commands/` + - Dev Container: `.devcontainer/commands/` - Windsurf: `.windsurf/workflows/` - Kilo Code: `.kilocode/workflows/` - Roo Code: `.roo/commands/` diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 98eb331d54..e454955940 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1133,6 +1133,13 @@ def init( tracker.skip("extensions", f"hook error: {hook_err}") tracker.skip("presets", f"hook error: {hook_err}") + # After all presets/extensions are installed, re-wrap for devcontainer + if resolved_integration.key == "devcontainer": + try: + resolved_integration.post_preset_install(project_path) + except Exception as wrap_err: + print(f"[specify][devcontainer] Post-init wrap failed: {wrap_err}", file=sys.stderr) + tracker.complete("final", "project ready") except (typer.Exit, SystemExit): raise diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 4a78e7d035..1b6da0fb17 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -56,6 +56,7 @@ def _register_builtins() -> None: from .codex import CodexIntegration from .copilot import CopilotIntegration from .cursor_agent import CursorAgentIntegration + from .devcontainer import DevContainerIntegration from .devin import DevinIntegration from .forge import ForgeIntegration from .gemini import GeminiIntegration @@ -88,6 +89,7 @@ def _register_builtins() -> None: _register(CodexIntegration()) _register(CopilotIntegration()) _register(CursorAgentIntegration()) + _register(DevContainerIntegration()) _register(DevinIntegration()) _register(ForgeIntegration()) _register(GeminiIntegration()) diff --git a/src/specify_cli/integrations/devcontainer/__init__.py b/src/specify_cli/integrations/devcontainer/__init__.py new file mode 100644 index 0000000000..29f383523f --- /dev/null +++ b/src/specify_cli/integrations/devcontainer/__init__.py @@ -0,0 +1,788 @@ +"""Dev Container integration.""" + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, TYPE_CHECKING + +from ..base import MarkdownIntegration + +if TYPE_CHECKING: + from ..manifest import IntegrationManifest + + +class DevContainerIntegration(MarkdownIntegration): + key = "devcontainer" + config = { + "name": "Dev Container", + "folder": ".devcontainer/", + "commands_subdir": "commands", + "install_url": "https://code.visualstudio.com/docs/devcontainers/devcontainer-cli", + "requires_cli": False, # IDE-based, but can use CLI + } + registrar_config = { + "dir": ".devcontainer/commands", + "format": "markdown", + "args": "$ARGUMENTS", + "extension": ".md", + } + context_file = ".devcontainer/devcontainer-instructions.md" + + _DEVCONTAINER_LOG_PREFIX = "[specify][devcontainer]" + + @classmethod + def _devcontainer_trace(cls, message: str) -> None: + """Emit a visible trace line for Dev Container routing (stderr, flushed).""" + print(f"{cls._DEVCONTAINER_LOG_PREFIX} {message}", file=sys.stderr, flush=True) + + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + project_root: Path | None = None, + ) -> list[str] | None: + # Check if Dev Container mode is enabled - if so, return synthetic args + # to pass the CLI check in CommandStep._try_dispatch() + check_path = project_root or Path.cwd() + if self._is_devcontainer_mode(check_path): + # Return synthetic args indicating Dev Container mode + # dispatch_command will route to devcontainer CLI instead + return [self.key, "--devcontainer-mode", prompt] + + # No local CLI fallback for dev containers + return None + + @staticmethod + def _wrap_script_path_for_devcontainer(script_path_with_args: str, shell_type: str) -> str: + """Wrap script path with devcontainer routing for AI agents. + + Creates a simple, clear command that AI agents can execute to run scripts + in the dev container. + + Args: + script_path_with_args: Path to the script with arguments + shell_type: 'bash' or 'powershell' + + Returns: + Direct devcontainer exec command (simple and clear for AI) + """ + # Keep it simple: just wrap with devcontainer exec + # AI agents will see clear, direct commands + + if shell_type == 'bash': + # Simple, direct command that AI can easily understand and execute + wrapper = f'devcontainer exec --workspace-folder . bash {script_path_with_args}' + else: # powershell + wrapper = f'devcontainer exec --workspace-folder . powershell {script_path_with_args}' + + return wrapper + + def process_template( + self, + content: str, + agent_name: str, + script_type: str, + arg_placeholder: str = "$ARGUMENTS", + context_file: str = "", + invoke_separator: str = ".", + project_root: Path | None = None, + ) -> str: + """Process command template with Dev Container-specific routing. + + Wraps script invocations so they execute inside the dev container + when an AI agent (like Claude Code) runs commands via slash commands. + """ + self._devcontainer_trace(f"Processing template for {agent_name} (script_type={script_type})") + + # First, apply standard Markdown processing + content = super().process_template( + content=content, + agent_name=agent_name, + script_type=script_type, + arg_placeholder=arg_placeholder, + context_file=context_file, + invoke_separator=invoke_separator, + project_root=project_root, + ) + + self._devcontainer_trace("Standard processing complete, applying script wrapping...") + + # After standard processing, {SCRIPT} has been replaced with actual script paths + # Now wrap those script invocations to route through dev container + # Pattern matches script paths in backticks: `.specify/scripts/bash/