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/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 68c6fb5151..6faecc03f5 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,85 @@ 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 +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 +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": "./.specify/docker-compose.yml", + "container_name": "opencode-dev", + "http_url": "http://localhost:9000", + "container_workspace": "/workspace" + } +} +``` + +**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/pyproject.toml b/pyproject.toml index 5351c6744d..f4c98311bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ packages = ["src/specify_cli"] "extensions/catalog.json" = "specify_cli/core_pack/extensions/catalog.json" # 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 = [ @@ -93,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 715623edba..e454955940 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: @@ -1126,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 @@ -2726,6 +2740,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,6 +5610,154 @@ 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( + 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, ensure_opencode_docker_assets + + _project_root = project_root or _require_specify_project() + + ensure_opencode_docker_assets(_project_root) + + # 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, 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: + 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( + 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 = project_root or _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, 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(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( + 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 = project_root or _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, project_root=_project_root + ) + + 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/__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/