Skip to content

Commit 7ee5fd4

Browse files
authored
feat(providers): add opencode CLI provider (#436)
* feat(providers): add opencode CLI provider * test(providers): add unit tests for opencode CLI provider * fix(providers): address code review for opencode provider - Replace --dangerously-skip-permissions with read-only permission profile via OPENCODE_CONFIG_CONTENT env var (deny: edit, bash, external_directory, doom_loop) - Add model discovery via 'opencode models' to show available models in UI - Fix model name format to use provider/model (e.g., deepseek/deepseek-v4-pro) - Fix install command to use curl instead of npm - Remove dead rate-limiter entry (opencode uses semaphore serialization) - Accumulate tokens across multi-step runs instead of overwriting - Fix redundant except clause - Update tests to verify security config and model discovery * fix(providers): tighten read-only permission profile for opencode Add webfetch, websearch, and task to deny set to prevent: - task subagent bypassing top-level edit/bash denies - network egress for data exfiltration from private repos
1 parent 9c6b6be commit 7ee5fd4

21 files changed

Lines changed: 1241 additions & 11 deletions

File tree

.github/CONTRIBUTING.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,27 @@ repowise/
9090
- Keep functions small and focused
9191
- Write docstrings for public APIs
9292

93+
### Adding a new LLM provider
94+
95+
1. **Create `packages/core/src/repowise/core/providers/llm/<name>.py`**
96+
- Subclass `BaseProvider` and implement `generate()`, `provider_name`, `model_name`
97+
- For local CLI providers, use `asyncio.create_subprocess_exec` (never `shell=True`), validate user-supplied model names against a safe character set, and resolve paths with `Path.resolve()`
98+
- See `opencode.py` for a clean reference implementation
99+
100+
2. **Register** in `registry.py` — add to `_BUILTIN_PROVIDERS` and the `_missing` package map
101+
102+
3. **Wire up configuration** in these files:
103+
- `rate_limiter.py` — add `RateLimitConfig` to `PROVIDER_DEFAULTS`
104+
- `provider_config.py` — add entry to `PROVIDER_CATALOG`
105+
- `provider_selection.py` — add to `_PROVIDER_DEFAULTS`, `_PROVIDER_ENV`, `_PROVIDER_SIGNUP`, and detection
106+
- `helpers.py` — add validation in `validate_provider_config()`
107+
108+
4. **Update the web UI** — add to `PROVIDERS`, `MODEL_PLACEHOLDERS`, and `PROVIDER_ENV_VARS` in `provider-section.tsx` and `run-config-form.tsx`
109+
110+
5. **Add tests** in `tests/unit/test_providers/` — mock the subprocess, test success/error/timeout paths (see `test_codex_cli_provider.py` for the pattern)
111+
112+
6. **Write docs**`docs/<NAME>.md` and `website/<name>.md`, following `docs/CODEX.md` and `docs/OPENCODE.md`.
113+
93114
Adding a new language or LLM provider has a dedicated recipe — see
94115
[docs/LANGUAGE_SUPPORT.md](../docs/LANGUAGE_SUPPORT.md).
95116

docs/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
---
1111

12+
## [Unreleased]
13+
14+
### Added
15+
- **OpenCode CLI provider.** A new local OpenCode LLM provider runs documentation generation through the local OpenCode CLI via `opencode run --format json`. Uses `asyncio.create_subprocess_exec` (no shell), parses JSONL output, validates model names against a safe character set, and treats `opencode/*` cost as `$0.00`. No API keys are stored — OpenCode manages its own auth and model selection through its provider system. Interactive selection detects the OpenCode CLI on `PATH` and shows helpful install/setup instructions when it's missing.
16+
17+
---
18+
1219
## [0.18.0] — 2026-06-08
1320

1421
### Added

docs/CLI_REFERENCE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ In workspace mode, adds: repo scanning, per-repo indexing, cross-repo analysis (
5050

5151
| Flag | Description |
5252
|------|-------------|
53-
| `--provider` | LLM provider: `anthropic`, `openai`, `openrouter`, `gemini`, `deepseek`, `ollama`, `litellm`, `codex_cli`, `mock` |
53+
| `--provider` | LLM provider: `anthropic`, `openai`, `openrouter`, `gemini`, `deepseek`, `ollama`, `litellm`, `codex_cli`, `opencode`, `mock` |
5454
| `--model` | Model name override (e.g., `claude-sonnet-4-6`) |
5555
| `--embedder` | Embedder for semantic search: `gemini`, `openai`, `mock` |
5656
| `--index-only` | Skip LLM generation. Only parse, build graph, and index git. Free. |
@@ -79,6 +79,7 @@ In workspace mode, adds: repo scanning, per-repo indexing, cross-repo analysis (
7979
repowise init # interactive
8080
repowise init --provider anthropic --yes # automated
8181
repowise init --provider codex_cli --codex --yes # use authenticated Codex CLI
82+
repowise init --provider opencode --yes # use local OpenCode CLI
8283
repowise init --index-only # free, no LLM
8384
repowise init --dry-run # preview cost
8485
repowise init --test-run # quick test (10 files)

docs/OPENCODE.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# OpenCode Integration
2+
3+
Repowise supports OpenCode via the `opencode` LLM provider, which runs
4+
documentation generation through your local OpenCode CLI installation.
5+
No API keys are managed by repowise — OpenCode handles all authentication
6+
and model selection through its own provider system.
7+
8+
## Prerequisites
9+
10+
Install OpenCode:
11+
12+
```bash
13+
curl -fsSL https://opencode.ai/install | bash
14+
```
15+
16+
Then run `opencode` once to set up your model provider and authentication:
17+
18+
```bash
19+
opencode
20+
```
21+
22+
Verify the CLI is available:
23+
24+
```bash
25+
opencode --version
26+
```
27+
28+
Repowise detects OpenCode automatically: when `opencode` is in `PATH`,
29+
the interactive provider selection shows it as "available" and it can
30+
be used immediately.
31+
32+
## `opencode` Provider
33+
34+
Use `opencode` when you want Repowise page generation to run through
35+
your local OpenCode CLI instead of an API key:
36+
37+
```bash
38+
repowise init --provider opencode --yes
39+
```
40+
41+
You can also persist it:
42+
43+
```bash
44+
REPOWISE_PROVIDER=opencode repowise update
45+
```
46+
47+
The provider runs:
48+
49+
```bash
50+
opencode run --format json --dangerously-skip-permissions --dir /absolute/path/to/repo
51+
```
52+
53+
Repowise sends the combined system + user prompt on **stdin**, parses
54+
OpenCode's **JSONL** output (extracting text from `text` events and
55+
token usage from `step_finish` events), and treats `opencode/*` cost
56+
as `$0.00` because billing is handled by OpenCode's own subscription/auth.
57+
58+
### Default model
59+
60+
`opencode/default` uses OpenCode's configured default model — no
61+
`--model` flag is passed. To use a specific model:
62+
63+
```bash
64+
repowise init --provider opencode --model opencode/deepseek-v4-pro
65+
```
66+
67+
Or use a bare model slug (the `opencode/` prefix is optional):
68+
69+
```bash
70+
repowise init --provider opencode --model deepseek-v4-pro
71+
```
72+
73+
### Listing available models
74+
75+
```bash
76+
opencode models # all available models
77+
opencode models opencode # models from the opencode provider
78+
```
79+
80+
### Reasoning
81+
82+
The opencode provider does not pass reasoning effort flags. OpenCode
83+
handles reasoning internally through its own model/agent configuration.
84+
85+
## Security
86+
87+
The provider enforces several safety measures:
88+
89+
- Uses `asyncio.create_subprocess_exec` (no shell), so every argument
90+
is passed as a distinct list element — shell injection is impossible.
91+
- Model names are validated against a safe character set
92+
(`[a-zA-Z0-9][a-zA-Z0-9._/\-]*`), rejecting shell metacharacters
93+
before anything reaches the subprocess.
94+
- All paths are resolved with `Path.resolve()` before being passed to
95+
`--dir`.
96+
- Subprocess execution is serialized via `asyncio.Semaphore(1)`.
97+
- A 600-second hard timeout with process kill prevents runaway calls.
98+
99+
## Comparison with Codex CLI
100+
101+
| Aspect | `opencode` | `codex_cli` |
102+
|--------|-----------|-------------|
103+
| CLI command | `opencode run` | `codex exec` |
104+
| Auth | OpenCode providers | `codex login` |
105+
| Output format | JSONL via `--format json` | JSONL via `--json` |
106+
| Reasoning modes | Not passed (OpenCode manages it) | `model_reasoning_effort` mapping |
107+
| Sandbox | OpenCode manages its own | `--sandbox read-only` |
108+
| Model discovery | `opencode models` | `codex debug models --bundled` |
109+
| Editor integration | None | `.codex/config.toml`, hooks, plugin |
110+
| API keys stored | No | No |
111+
112+
## Official OpenCode Docs
113+
114+
- [OpenCode](https://opencode.ai)
115+
- [OpenCode GitHub](https://github.com/anomalyco/opencode)
116+
- [OpenCode Docs](https://opencode.ai/docs)
117+
- [OpenCode Download](https://opencode.ai/download)

docs/architecture/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1618,7 +1618,7 @@ Key files:
16181618
Full configuration with defaults (`.repowise/config.yaml`):
16191619

16201620
```yaml
1621-
provider: anthropic # anthropic | openai | openrouter | gemini | deepseek | ollama | litellm | mock
1621+
provider: anthropic # anthropic | openai | openrouter | gemini | deepseek | ollama | litellm | codex_cli | opencode | mock
16221622
model: claude-sonnet-4-5 # passed through to the provider
16231623
embedding_provider: anthropic
16241624
embedding_model: voyage-3

packages/cli/src/repowise/cli/commands/init_cmd/command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ def _run_generation_phase(
207207
default=None,
208208
help=(
209209
"LLM provider name (anthropic, openai, openrouter, gemini, "
210-
"deepseek, ollama, litellm, codex_cli, mock)."
210+
"deepseek, ollama, litellm, codex_cli, opencode, mock)."
211211
),
212212
)
213213
@click.option("--model", default=None, help="Model identifier override.")

packages/cli/src/repowise/cli/cost_estimator/pricing.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"llama": (0.0, 0.0),
3737
"mock": (0.0, 0.0),
3838
"codex_cli/": (0.0, 0.0),
39+
"opencode/": (0.0, 0.0),
3940
}
4041

4142

packages/cli/src/repowise/cli/helpers.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,8 @@ def _resolve_base_url(name: str) -> str | None:
706706
kwargs["base_url"] = base_url
707707
if provider_name == "codex_cli" and repo_path is not None:
708708
kwargs["repo_path"] = repo_path
709+
if provider_name == "opencode" and repo_path is not None:
710+
kwargs["repo_path"] = repo_path
709711

710712
# Pass API key from environment if available
711713
if provider_name == "anthropic" and os.environ.get("ANTHROPIC_API_KEY"):
@@ -787,7 +789,7 @@ def _resolve_base_url(name: str) -> str | None:
787789
"or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / "
788790
"OLLAMA_BASE_URL / GEMINI_API_KEY / GOOGLE_API_KEY / DEEPSEEK_API_KEY / "
789791
"LITELLM_API_KEY. Use REPOWISE_PROVIDER=codex_cli to use an authenticated "
790-
"Codex CLI subscription."
792+
"Codex CLI subscription, or REPOWISE_PROVIDER=opencode to use opencode."
791793
)
792794

793795

@@ -837,6 +839,19 @@ def _is_env_var_exists(var_name: str) -> bool:
837839
)
838840
return warnings
839841

842+
if provider_name == "opencode":
843+
import shutil
844+
if not shutil.which("opencode"):
845+
warnings.append(
846+
"Provider 'opencode' requires the opencode CLI.\n"
847+
" Install: curl -fsSL https://opencode.ai/install | bash\n"
848+
" Setup: run 'opencode' once to configure your provider\n"
849+
" Models: opencode models (list available models)\n"
850+
" More: https://opencode.ai\n"
851+
" Usage: repowise init --provider opencode --model opencode/openai/gpt-5"
852+
)
853+
return warnings
854+
840855
# Validate specific provider
841856
if provider_name not in provider_env_vars:
842857
warnings.append(f"Unknown provider '{provider_name}' - cannot validate configuration")

packages/cli/src/repowise/cli/ui/provider_selection.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"anthropic": "claude-sonnet-4-6",
2727
"deepseek": "deepseek-v4-flash",
2828
"codex_cli": "codex_cli/default",
29+
"opencode": "opencode/default",
2930
"ollama": "llama3.2",
3031
"openrouter": "anthropic/claude-sonnet-4.6",
3132
"litellm": "groq/llama-3.1-70b-versatile",
@@ -37,6 +38,7 @@
3738
"anthropic": "ANTHROPIC_API_KEY",
3839
"deepseek": "DEEPSEEK_API_KEY",
3940
"codex_cli": "__CODEX_CLI__",
41+
"opencode": "__OPENCODE_CLI__",
4042
"ollama": "OLLAMA_BASE_URL",
4143
"openrouter": "OPENROUTER_API_KEY",
4244
}
@@ -47,6 +49,7 @@
4749
"anthropic": "https://console.anthropic.com/settings/keys",
4850
"deepseek": "https://platform.deepseek.com/api_keys",
4951
"codex_cli": "https://developers.openai.com/codex/cli",
52+
"opencode": "https://opencode.ai",
5053
"ollama": "https://ollama.com/download",
5154
"openrouter": "https://openrouter.ai/keys",
5255
}
@@ -69,6 +72,13 @@ def _detect_codex_cli_status() -> tuple[bool, bool]:
6972
return installed, is_codex_logged_in() if installed else False
7073

7174

75+
def _detect_opencode_status() -> bool:
76+
"""Return ``True`` if the opencode CLI is installed on PATH."""
77+
import shutil
78+
79+
return shutil.which("opencode") is not None
80+
81+
7282
def _detect_provider_status() -> dict[str, str]:
7383
"""Return {provider: env_var_name} for providers whose key is set."""
7484
status: dict[str, str] = {}
@@ -80,6 +90,9 @@ def _detect_provider_status() -> dict[str, str]:
8090
installed, logged_in = _detect_codex_cli_status()
8191
if installed and logged_in:
8292
status[prov] = "codex CLI"
93+
elif prov == "opencode":
94+
if _detect_opencode_status():
95+
status[prov] = "opencode CLI"
8396
elif os.environ.get(env_var):
8497
status[prov] = env_var
8598
return status
@@ -120,6 +133,11 @@ def _interactive_provider_name(
120133
status_text = "[yellow]✗ codex login required[/yellow]"
121134
else:
122135
status_text = "[dim]✗ codex CLI not found[/dim]"
136+
elif prov == "opencode":
137+
if _detect_opencode_status():
138+
status_text = f"[{OK}]✓ opencode CLI available[/]"
139+
else:
140+
status_text = "[dim]✗ opencode CLI not found[/dim]"
123141
else:
124142
status_text = f"[{OK}]✓ API key set[/]" if prov in detected else "[dim]✗ no key[/dim]"
125143
default_model = _PROVIDER_DEFAULTS.get(prov, "")
@@ -129,6 +147,8 @@ def _interactive_provider_name(
129147
label = f"{prov} [dim](recommended)[/dim]"
130148
elif prov == "codex_cli":
131149
label = f"{prov} [dim](uses Codex CLI auth)[/dim]"
150+
elif prov == "opencode":
151+
label = f"{prov} [dim](uses opencode CLI auth)[/dim]"
132152
table.add_row(f"[{idx}]", label, status_text, default_model)
133153

134154
console.print()
@@ -175,6 +195,28 @@ def _interactive_provider_name(
175195
"or select another provider.[/]"
176196
)
177197
return _interactive_provider_name(console, model_flag, repo_path=repo_path)
198+
if chosen == "opencode":
199+
console.print()
200+
console.print(
201+
" [bold]opencode[/bold] is a local AI coding CLI that manages its own "
202+
"models and authentication."
203+
)
204+
console.print()
205+
console.print(f" Install: [{BRAND}]curl -fsSL https://opencode.ai/install | bash[/]")
206+
console.print(f" Setup: [{BRAND}]opencode[/] (first run sets up your provider)")
207+
console.print(f" Models: [{BRAND}]opencode models[/] (list available models)")
208+
console.print(f" More info: [{BRAND}]https://opencode.ai[/]")
209+
console.print()
210+
console.print(
211+
f" To use a specific model: [{BRAND}]repowise init --provider opencode "
212+
"--model opencode/deepseek/deepseek-v4-pro[/]"
213+
)
214+
console.print()
215+
console.print(
216+
f" [{WARN}]opencode CLI not detected. Install it and retry, "
217+
"or select another provider.[/]"
218+
)
219+
return _interactive_provider_name(console, model_flag, repo_path=repo_path)
178220
env_var = _PROVIDER_ENV[chosen]
179221
signup_url = _PROVIDER_SIGNUP.get(chosen, "")
180222
console.print()

packages/core/src/repowise/core/generation/cost_tracker.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ def _get_pricing(model: str) -> dict[str, float]:
6565
"""Return pricing for *model*, falling back and warning if unknown."""
6666
if model.startswith("codex_cli/"):
6767
return {"input": 0.0, "output": 0.0}
68+
if model.startswith("opencode/"):
69+
return {"input": 0.0, "output": 0.0}
6870
if model in _PRICING:
6971
return _PRICING[model]
7072
if model not in _warned_models:

0 commit comments

Comments
 (0)