diff --git a/CLAUDE.md b/CLAUDE.md index 0e26199..fb923e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,9 +122,9 @@ ANTHROPIC_API_KEY = "sk-ant-..." ## Runners -The factory supports multiple CLI backends via the runner abstraction (`factory/runners/`). By default, it uses Claude Code (`claude` CLI). Bob Shell (`bob` CLI) and OpenAI Codex (`codex` CLI) are also supported as switchable alternatives. +The factory supports multiple CLI backends via the runner abstraction (`factory/runners/`). By default, it uses Claude Code (`claude` CLI). Bob Shell (`bob` CLI), OpenAI Codex (`codex` CLI), and OpenCode (`opencode` CLI) are also supported as switchable alternatives. -**Runner selection:** Set `FACTORY_RUNNER=codex` (or `bob`) to switch backends, or pass `--runner codex` to individual commands. Default is `claude`. +**Runner selection:** Set `FACTORY_RUNNER=codex` (or `bob`, `opencode`) to switch backends, or pass `--runner codex` to individual commands. Default is `claude`. **Bob Shell specifics:** - Requires `BOBSHELL_API_KEY` environment variable to be set @@ -157,6 +157,22 @@ CODEX_API_KEY = "..." ``` Then run: `factory ceo /path/to/project --profile codex` +**OpenCode specifics:** +- Auth handled by OpenCode's own provider system (`opencode auth login` or provider-specific env vars) +- Uses `opencode run ` subcommand pattern; system prompt is prepended to the task message (no `--append-system-prompt`) +- Headless mode uses `--dangerously-skip-permissions` (gated on parameter) and `--format default` +- Model selection via `--model` flag (e.g., `anthropic/claude-sonnet-4-20250514`) +- Install: `curl -fsSL https://opencode.ai/install | bash` + +**OpenCode dry-run mode:** Set `FACTORY_OPENCODE_DRY_RUN=1` to test OpenCode integration without spending tokens. + +**OpenCode config profile example** (`~/.factory/config.toml`): +```toml +[credentials.opencode] +FACTORY_RUNNER = "opencode" +``` +Then run: `factory ceo /path/to/project --profile opencode` + **Important:** Target projects should add `.factory/` to their `.gitignore`. The factory writes experiment data, usage logs, and potentially sensitive auth files (`.factory/.bob_auth`) to this directory. These are project-local artifacts that should not be committed to version control. ## Running the factory diff --git a/factory/runners/__init__.py b/factory/runners/__init__.py index 5be2d1c..fa769f4 100644 --- a/factory/runners/__init__.py +++ b/factory/runners/__init__.py @@ -10,26 +10,30 @@ from factory.runners.claude import ClaudeRunner from factory.runners.codex import CodexRunner, is_codex_dry_run from factory.runners.protocol import Runner +from factory.runners.opencode import OpenCodeRunner, is_opencode_dry_run __all__ = [ "Runner", "ClaudeRunner", "BobRunner", "CodexRunner", + "OpenCodeRunner", "get_runner", "RunnerName", "is_dry_run", "is_codex_dry_run", + "is_opencode_dry_run", "should_stream", "stream_subprocess", ] -RunnerName = Literal["claude", "bob", "codex"] +RunnerName = Literal["claude", "bob", "codex", "opencode"] _RUNNERS: dict[str, type[Runner]] = { "claude": ClaudeRunner, # type: ignore[dict-item] "bob": BobRunner, # type: ignore[dict-item] "codex": CodexRunner, # type: ignore[dict-item] + "opencode": OpenCodeRunner, # type: ignore[dict-item] } @@ -42,7 +46,7 @@ def get_runner(name: str | None = None, project_path: Path | None = None) -> Run 3. Default to "claude" Args: - name: Runner name ("claude", "bob", or "codex"). + name: Runner name ("claude", "bob", "codex", or "opencode"). project_path: Path to the project. Passed to BobRunner for cycle state lookup. Raises: diff --git a/factory/runners/opencode.py b/factory/runners/opencode.py new file mode 100644 index 0000000..12b3e9f --- /dev/null +++ b/factory/runners/opencode.py @@ -0,0 +1,156 @@ +"""OpenCodeRunner — OpenCode CLI backend implementation.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import subprocess +from pathlib import Path + +from factory.runners._stream import should_stream, stream_subprocess + +logger = logging.getLogger(__name__) + + +def _make_opencode_env() -> dict[str, str]: + """Build subprocess env: strip VIRTUAL_ENV.""" + return {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"} + + +def is_opencode_dry_run() -> bool: + """Return True if OpenCode dry-run mode is enabled.""" + from factory.user_config import resolve + + val = resolve("opencode_dry_run", env_var="FACTORY_OPENCODE_DRY_RUN") or "" + return val.lower() in ("1", "true", "yes") + + +def _combine_prompt_and_task(prompt: str, task: str) -> str: + """Prepend system prompt to task since OpenCode has no --append-system-prompt flag.""" + return f"{prompt}\n\n---\n\n{task}" + + +class OpenCodeRunner: + """Runner implementation for OpenCode CLI.""" + + name: str = "opencode" + + async def headless( + self, + prompt: str, + task: str, + cwd: Path, + *, + timeout: float = 600.0, + model: str | None = None, + dangerously_skip_permissions: bool = True, + role: str = "unknown", + session_name: str | None = None, + ) -> tuple[str, int]: + """Run a headless OpenCode invocation. + + Returns (stdout, return_code). + """ + _ = session_name + if is_opencode_dry_run(): + return self._dry_run_response(role, cwd, task) + + combined = _combine_prompt_and_task(prompt, task) + + cmd = [ + "opencode", + "run", + combined, + "--dir", str(cwd), + "--format", "default", + ] + if dangerously_skip_permissions: + cmd.append("--dangerously-skip-permissions") + if model: + cmd.extend(["--model", model]) + + logger.info("OpenCodeRunner headless: cwd=%s, model=%s, role=%s", cwd, model, role) + + env = _make_opencode_env() + + stream = should_stream() + prefix = f"[opencode:{role}]" if stream else None + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + env=env, + ) + stdout_bytes, stderr_bytes = await asyncio.wait_for( + stream_subprocess(proc, stream=stream, prefix=prefix), + timeout=timeout, + ) + except asyncio.TimeoutError: + proc.kill() # type: ignore[union-attr] + await proc.wait() # type: ignore[union-attr] + logger.error("OpenCodeRunner timed out after %ss", timeout) + return f"Agent timed out after {timeout}s", 1 + except FileNotFoundError: + logger.error("'opencode' CLI not found on PATH") + return "Error: 'opencode' CLI not found on PATH", 1 + + stdout = stdout_bytes.decode() + stderr = stderr_bytes.decode() + + if proc.returncode != 0: + logger.warning("OpenCodeRunner exited with code %d: %s", proc.returncode, stderr[:200]) + + return stdout, proc.returncode or 0 + + def interactive_run( + self, + prompt: str, + task: str, + cwd: Path, + *, + model: str | None = None, + role: str = "ceo", + dangerously_skip_permissions: bool = False, + session_name: str | None = None, + ) -> int: + """Run an interactive OpenCode session as a subprocess. + + Returns the exit code so the caller can clean up in a finally block. + """ + _ = role, session_name + + if is_opencode_dry_run(): + print("[DRY-RUN] Would exec: opencode run --interactive") + print(f"[DRY-RUN] Task: {task[:200]}...") + return 0 + + combined = _combine_prompt_and_task(prompt, task) + + cmd = ["opencode", "run", "--interactive", combined, "--dir", str(cwd)] + if dangerously_skip_permissions: + cmd.append("--dangerously-skip-permissions") + if model: + cmd.extend(["--model", model]) + + logger.info("OpenCodeRunner interactive_run: cwd=%s", cwd) + + env = _make_opencode_env() + result = subprocess.run(cmd, cwd=cwd, env=env) + return result.returncode + + def _dry_run_response(self, role: str, cwd: Path, task: str) -> tuple[str, int]: + """Return a stub response for dry-run mode.""" + response = ( + f"[DRY-RUN] OpenCodeRunner would have executed:\n" + f" role: {role}\n" + f" cwd: {cwd}\n" + f" task: {task[:100]}...\n" + f"\n" + f"Dry-run stub response: Task acknowledged." + ) + logger.info("OpenCodeRunner dry-run: role=%s, cwd=%s", role, cwd) + return response, 0 diff --git a/tests/test_opencode_runner.py b/tests/test_opencode_runner.py new file mode 100644 index 0000000..2797554 --- /dev/null +++ b/tests/test_opencode_runner.py @@ -0,0 +1,357 @@ +"""Tests for factory/runners/opencode.py — OpenCodeRunner implementation.""" + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from factory.runners import get_runner +from factory.runners.opencode import OpenCodeRunner, is_opencode_dry_run + + +class TestGetRunnerOpenCode: + def test_explicit_opencode(self) -> None: + runner = get_runner("opencode") + assert runner.name == "opencode" + + def test_from_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FACTORY_RUNNER", "opencode") + runner = get_runner() + assert runner.name == "opencode" + + def test_explicit_overrides_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FACTORY_RUNNER", "opencode") + runner = get_runner("claude") + assert runner.name == "claude" + + +class TestOpenCodeDryRun: + def test_dry_run_true(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FACTORY_OPENCODE_DRY_RUN", "1") + assert is_opencode_dry_run() is True + + def test_dry_run_false(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + assert is_opencode_dry_run() is False + + def test_dry_run_true_word(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FACTORY_OPENCODE_DRY_RUN", "true") + assert is_opencode_dry_run() is True + + async def test_headless_dry_run_returns_stub( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("FACTORY_OPENCODE_DRY_RUN", "1") + + runner = OpenCodeRunner() + stdout, code = await runner.headless( + prompt="You are a test agent.", + task="Say hello", + cwd=tmp_path, + role="researcher", + ) + + assert code == 0 + assert "[DRY-RUN]" in stdout + assert "researcher" in stdout + + def test_interactive_run_dry_run( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + monkeypatch.setenv("FACTORY_OPENCODE_DRY_RUN", "1") + + runner = OpenCodeRunner() + code = runner.interactive_run( + prompt="Test prompt", + task="Test task", + cwd=tmp_path, + role="ceo", + ) + + assert code == 0 + captured = capsys.readouterr() + assert "[DRY-RUN]" in captured.out + + +class TestOpenCodeEnv: + def test_virtual_env_stripped(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VIRTUAL_ENV", "/some/venv") + + from factory.runners.opencode import _make_opencode_env + + env = _make_opencode_env() + assert "VIRTUAL_ENV" not in env + + def test_preserves_other_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SOME_VAR", "test-value") + + from factory.runners.opencode import _make_opencode_env + + env = _make_opencode_env() + assert env["SOME_VAR"] == "test-value" + + +class TestOpenCodeHeadless: + async def test_builds_correct_command( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch( + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"output", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + stdout, code = await runner.headless( + prompt="You are a test agent.", + task="Say hello", + cwd=tmp_path, + timeout=60.0, + model="anthropic/claude-sonnet-4-20250514", + ) + + assert code == 0 + assert stdout == "output" + + call_args = mock_exec.call_args[0] + assert call_args[0] == "opencode" + assert call_args[1] == "run" + assert "--format" in call_args + assert "default" in call_args + assert "--dangerously-skip-permissions" in call_args + assert "--dir" in call_args + assert "--model" in call_args + assert "anthropic/claude-sonnet-4-20250514" in call_args + + async def test_no_model_flag_when_none( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch( + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"ok", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + await runner.headless( + prompt="Test", + task="Test", + cwd=tmp_path, + model=None, + ) + + call_args = mock_exec.call_args[0] + assert "--model" not in call_args + + async def test_prompt_prepended_to_task( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch( + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"ok", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + await runner.headless( + prompt="You are the CEO.", + task="Run the experiment", + cwd=tmp_path, + ) + + call_args = mock_exec.call_args[0] + combined = call_args[2] + assert "You are the CEO." in combined + assert "Run the experiment" in combined + assert "---" in combined + + async def test_no_skip_permissions_when_false( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch( + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"ok", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + await runner.headless( + prompt="Test", + task="Test", + cwd=tmp_path, + dangerously_skip_permissions=False, + ) + + call_args = mock_exec.call_args[0] + assert "--dangerously-skip-permissions" not in call_args + + async def test_handles_missing_binary( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + with patch( + "asyncio.create_subprocess_exec", + new_callable=AsyncMock, + side_effect=FileNotFoundError, + ): + runner = OpenCodeRunner() + stdout, code = await runner.headless( + prompt="Test", + task="Test", + cwd=tmp_path, + ) + + assert code == 1 + assert "not found" in stdout.lower() + + async def test_passes_env_without_virtual_env( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIRTUAL_ENV", "/some/venv") + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch( + "factory.runners.opencode.stream_subprocess", new_callable=AsyncMock + ) as mock_stream: + mock_stream.return_value = (b"ok", b"") + + with patch( + "asyncio.create_subprocess_exec", new_callable=AsyncMock + ) as mock_exec: + mock_proc = AsyncMock() + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + await runner.headless( + prompt="Test", + task="Test", + cwd=tmp_path, + ) + + call_kwargs = mock_exec.call_args.kwargs + assert "VIRTUAL_ENV" not in call_kwargs["env"] + + +class TestOpenCodeInteractive: + def test_interactive_run_builds_correct_command( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = type("Result", (), {"returncode": 0})() + code = runner.interactive_run( + prompt="You are the CEO.", + task="Start session", + cwd=tmp_path, + model="anthropic/claude-sonnet-4-20250514", + dangerously_skip_permissions=True, + ) + + assert code == 0 + cmd = mock_run.call_args[0][0] + assert cmd[0] == "opencode" + assert cmd[1] == "run" + assert "--interactive" in cmd + assert "--dangerously-skip-permissions" in cmd + assert "--model" in cmd + assert "anthropic/claude-sonnet-4-20250514" in cmd + + def test_interactive_run_no_skip_permissions_without_flag( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = type("Result", (), {"returncode": 0})() + runner.interactive_run( + prompt="Test", + task="Test", + cwd=tmp_path, + dangerously_skip_permissions=False, + ) + + cmd = mock_run.call_args[0][0] + assert "--dangerously-skip-permissions" not in cmd + + def test_interactive_run_passes_env( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("VIRTUAL_ENV", "/some/venv") + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = type("Result", (), {"returncode": 0})() + runner.interactive_run( + prompt="Test", + task="Test", + cwd=tmp_path, + ) + + call_kwargs = mock_run.call_args.kwargs + assert "VIRTUAL_ENV" not in call_kwargs["env"] + + def test_interactive_prompt_prepended_to_task( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("FACTORY_OPENCODE_DRY_RUN", raising=False) + + runner = OpenCodeRunner() + + with patch("subprocess.run") as mock_run: + mock_run.return_value = type("Result", (), {"returncode": 0})() + runner.interactive_run( + prompt="You are the CEO.", + task="Start session", + cwd=tmp_path, + ) + + cmd = mock_run.call_args[0][0] + combined_args = [arg for arg in cmd if "You are the CEO." in arg] + assert len(combined_args) == 1 + assert "Start session" in combined_args[0]