Cli Enforcer
OpenCode v2 plugin bridging Claude Code as a first-class model provider via the Claude Agent SDK
0
690
近 7 天 507
37.4
生态多维模型
1 天前
2026-10-19
快速安装与配置
opencode.json写入当前项目的 opencode.json,只对这个仓库生效。
opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-cli-enforcer@3.1.0"]
}写入 ~/.config/opencode/opencode.json,对所有项目生效。
~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-cli-enforcer@3.1.0"]
}若你要在本地改造这个插件,先装到项目里再从本地路径引用。
shell
pnpm add -D opencode-cli-enforcerOpenCode 启动时会通过内嵌运行时自动加载 npm 依赖并缓存至本地目录,无需手动在全局环境执行安装。
opencode-cli-enforcer
Claude Code as a first-class OpenCode v2 model provider
Use a Claude subscription (no API key) while OpenCode keeps full ownership of tools, permissions, and session history.
What this plugin is
opencode-cli-enforcer is an OpenCode v2 plugin that registers Claude Code as a first-class model
provider through @anthropic-ai/claude-agent-sdk. It publishes the provider id claude
and a static catalog of Claude Code models, so a user with a Claude subscription can select
claude/* models and generate text without an ANTHROPIC_API_KEY — authentication is
delegated to the Claude Code CLI the SDK drives.
The design principle is a strict separation of duties:
- OpenCode is the sole tool executor. The bridge never runs tools itself. Claude Code only requests tools, and every request is routed back to the OpenCode host, which validates, approves, executes, and returns the result.
- OpenCode keeps its own session history, permissions, and compaction. Claude Code runs as a generation backend behind a parked SDK query; it is never given authority over the workspace.
Everything provider-specific lives behind seven provider-agnostic seams, so the second provider —
the Google Antigravity CLI (agy) — ships as configuration rather than a rewrite. See
Architecture overview and the agy provider note.
Requirements
- Node.js >= 20 (the plugin is ESM-only;
package.jsondeclares"type": "module"). - OpenCode v2 with plugin API
@opencode/plugin2.x. - Claude Code CLI installed and authenticated with a subscription on the same machine. The bridge spawns it through the Agent SDK; it does not ship the CLI.
- Google Antigravity CLI (
agy) — required only for theagyprovider. Install it and authenticate once so it caches its OAuth credentials (~/.gemini/antigravity-cli/antigravity-oauth-token). The bridge drivesagyheadless over its documented stream-json protocol and does not ship the CLI. - A local workspace. Remote / non-local workspaces are out of scope for v1.
Install and registration
OpenCode v2 discovers plugins from the plugins array in opencode.jsonc. Each entry is an object
with a package and an optional options object; the options object is exposed to the plugin as
ctx.options.
Register the published npm package:
{
"plugins": [
{
"package": "opencode-cli-enforcer",
"options": {
"providers": {
"claude": { "enabled": true }
},
"defaultProvider": "claude"
}
}
]
}
Or register a local checkout directly by path (useful for development):
{
"plugins": [
{
"package": "file:/absolute/path/to/opencode-cli-enforcer",
"options": {}
}
]
}
With no options, the plugin runs with safe defaults: the claude provider is enabled, the agy
provider is disabled, and defaultProvider is claude. Restart OpenCode (or reload plugins) after
editing opencode.jsonc. The plugin then registers via
Plugin.define({ id: "cli-providers", setup }) and adds the provider and its model catalog through
ctx.provider.transform, idempotently (a hot reload never duplicates records).
Quick start
- Install and authenticate the Claude Code CLI for your user (subscription login, no API key).
- Add the plugin entry shown above to
opencode.jsonc. - Restart OpenCode.
- Select a model from the
claudeprovider, for exampleclaude/claude-sonnet-5. - Send a prompt. Tool calls made by the model are executed by OpenCode; approvals and permission prompts behave exactly as they do for any other provider.
The catalog is a static projection (the Agent SDK exposes no enumerable model list). It includes the
fable, opus, sonnet, and haiku families; 1M-context variants are requested only for model ids
measured to serve them, and dated snapshot ids are never exposed. See
docs/config.md for the full configuration reference.
To use the Google Antigravity provider instead, install and authenticate the agy CLI, acknowledge
its four constraints, set providers.agy.enabled to true, and optionally set defaultProvider to
"agy". See the agy provider note.
Architecture overview
The bridge is built from seven provider-agnostic Layer-1 seams (src/provider/seams.ts). No
provider-specific type appears in any seam signature; the provider registry
(src/provider/registry.ts) is the only place that binds a provider id to implementations.
| Seam | Responsibility |
|---|---|
CliTransport |
Spawn the backend CLI, build argv, write stdin NDJSON, read stdout lines, capture stderr, map exit codes, and arm a per-turn watchdog. |
EventMapper<Raw, Normalized> |
Translate native events to the normalized TurnResult{status, text, usage, sessionId}. Usage normalization happens here (Claude is per-message; cumulative-per-session counters are converted to turn deltas at this seam). |
SessionStore |
Opaque, provider-neutral conversation handle {provider, conversationRef}. Core never parses a provider's storage format. |
ToolChannel |
Register host tools with the backend (Claude: in-process MCP server). |
PermissionPolicy |
Capability-honest supportsInteractiveApproval flag. |
SteeringPolicy |
Capability-honest supportsMidTurnSteer flag. |
ProviderProbe |
Detect, version, and health-check a provider. |
Parked query + in-process MCP handshake. One Agent SDK query() is spawned per OpenCode
session and kept parked for the life of that session. Its prompt is an ack-carrying async iterable
(src/provider/prompt-stream.ts), so a new turn writes into the live stream instead of respawning
the child. Host tools are served to Claude Code over an in-process MCP server
(src/mcp/server.ts); each tools/call is paired to its result by Claude's own tool_use id
(src/mcp/rendezvous.ts, order-tolerant, two-map), and OpenCode's tool results are delivered back
into the rendezvous on the next step. A mid-turn steer is written to Claude's stdin and acknowledged
before any tool result is delivered, so the steer is observed at the next tool boundary rather
than degrading into a follow-up turn.
Session mirror (REUSE / REBUILD). The OpenCode conversation is mirrored into Claude Code's
~/.claude/projects JSONL so the same session can be reopened in the claude CLI and vice versa.
A per-session state machine (src/session/mirror.ts) chooses one of four transitions each turn —
reuse, clean-start-preserve, clean-start, or rebuild — plus a force-rotate after an abort.
Host history rewrites (compaction, revert) mark the mirror for rebuild; the full-file rewrite
preserves the Claude Code session id, re-materializes @file attachment expansions at stable
ordinals, and is gated by a fail-closed post-write integrity check (src/session/verify.ts): a
mirror the bridge cannot re-read is treated as a failed write, never resumed.
Isolated compaction lane. OpenCode remains the sole compactor. When the host requests
compaction, the bridge answers it with a throwaway one-off summarizer query
(src/session/compaction.ts) that shares nothing with the session's parked query or mirror:
persistSession: false, maxTurns: 1, tools: [], and every filesystem settings/skill/memory
source disabled, with the same scrubbed child environment. If the lane fails, it leaves the host
result unset so OpenCode falls back to its own compaction — a failure never aborts the request.
Error taxonomy. Every surfaced error is classified into exactly one closed category with a
stable, append-only code (src/errors/taxonomy.ts): config, transport, tool, stream,
rate-limit, and session-mirror. Unsendable prompts fail loud before they reach the child
(assertSendablePrompt) rather than silently degrading.
Teardown and leak assertions. Abort signals, backend child exit, and normal turn end all funnel
through a single teardown path (src/errors/teardown.ts): interrupt/close the SDK query, fail the
prompt stream so queued and in-flight acks settle, resolve every parked MCP handler with a text
result (never reject — a rejected handler would strand Claude's tools/call forever), force-rotate
the mirror, and clear per-query state. A fail-loud leak scan then asserts no dangling query, pending
handler, unended stream, or unreleased context remains.
Capability matrix
| Capability | claude (Wave A) |
agy (Wave B, shipped) |
|---|---|---|
| Provider / models registered | Yes — static Claude Code catalog | Yes — static Antigravity catalog |
| Auth | Claude subscription via Claude Code CLI | Antigravity CLI cached OAuth credentials |
| Reasoning / thinking stream | Yes | No — thinking_tokens are counted, but there is no text stream |
| Partial tool input | Yes (streamed) | No — the whole tool call arrives at the step's DONE |
| Streaming granularity | Token-level | Step-level — one ACTIVE/DONE pair per step |
| Tool execution | OpenCode host executes; in-process MCP rendezvous | OpenCode host executes; out-of-process stdio MCP runner + loopback bridge |
Interactive approval (supportsInteractiveApproval) |
Yes | No — declarative settings.json allow-rules / MCP-side enforcement |
Mid-turn steering (supportsMidTurnSteer) |
Yes — acked stdin steer at tool boundaries | No — steering is queued to the next turn boundary |
| Session resume | Yes — REUSE/REBUILD ~/.claude/projects JSONL mirror |
Yes — opaque conversationRef resumed with --conversation; clean-start on history divergence (no rebuild / force-rotate) |
| Usage accounting | Per-message | Cumulative-per-session, normalized to per-turn deltas at the seam |
| Constraint acknowledgment gate | child-env-hygiene, session-mirror-rebuild-cost (acknowledged by default) |
Four constraints; all must be acknowledged before enablement |
Missing capabilities degrade by policy, not by accident: the honest seam flags let the core downgrade an unsupported interaction instead of assuming it silently.
Configuration
Full reference: docs/config.md. It documents every option, its default,
fail-closed validation semantics, the per-provider acknowledgment gate, CLAUDE_CONFIG_DIR
behavior, and child-environment hygiene. The short version:
providers.claude—enabled,modelOverride,debugLogs,toolsAllow,acknowledgedConstraints.providers.agy—enabled,acknowledgedConstraints,dangerouslySkipPermissionsConsent. Enabling it starts the host-tool loopback runner; user-levelagy mcp addregistration stays opt-in.defaultProvider—"claude"(default) or"agy".- Child-env hygiene: the backend child receives
ENABLE_CLAUDEAI_MCP_SERVERS=0,DISABLE_AUTO_COMPACT=1, and everyANTHROPIC_*variable scrubbed.
Operator Actions
These are the actions an operator (not the plugin) is responsible for.
- Install and authenticate the Claude Code CLI. The bridge requires a working, subscription-
authenticated
claudeCLI on the same machine. No API key is stored by the plugin. - Install and authenticate the
agyCLI for theagyprovider. Theagyprovider requires a discoverableagybinary with cached credentials. Runagyonce to complete OAuth login; the bridge only checks that the cached token file exists and never reads it. - Keep
ANTHROPIC_*out of the OpenCode server environment. Never exportANTHROPIC_API_KEY(or anyANTHROPIC_*variable) into the environment that launches OpenCode. An exported key would hijack the child CLI's subscription auth. The bridge scrubsANTHROPIC_*from the child environment as defense in depth, but the operator must not rely on that scrub alone. - Choose the session-mirror location via
CLAUDE_CONFIG_DIR. SetCLAUDE_CONFIG_DIRto move the~/.clauderoot (and therefore the mirroredprojects/JSONL). When unset, the bridge uses~/.claude. The same variable is honored by theclaudeCLI, so both sides see the same files. - Complete the acknowledgment gate before enabling a provider. Enabling a provider whose
constraint register is not fully acknowledged throws a config error and registers nothing. Claude
is acknowledged by default;
agyrequires the explicit acknowledgment gate. - Opt in before mutating agy's user-level MCP config. Registering the host-tool runner with
agy mcp addwrites machine-global config (~/.gemini/config/mcp_config.json); the bridge keeps this off by default and only performs it under explicit operator opt-in. - Reopen mirrored sessions in the
claudeCLI (optional). Because the mirror lands in the standard Claude Code project store, the same conversation can be opened from either side. - Know the integration environment gates (development / CI). The integration suites are opt-in:
CLAUDE_BRIDGE_INTEGRATION=1enables the Wave A live-CLI oracle (the CI integration job is gated on the repository variable of the same name),AGY_BRIDGE_INTEGRATION=1enables the Wave Bagylive-CLI oracle, andAGY_BRIDGE_RECORD=1enables quota-spending fixture recording. Each gate skips cleanly when unset; none is set by the plugin at runtime.
Acknowledgment gate
The configuration carries a per-provider enablement map with an explicit acknowledgment gate. Each provider has a constraint register; enabling it requires acknowledging every constraint id in that register, otherwise setup fails closed with a config error that names the missing acknowledgments.
clauderegister (acknowledged by default):child-env-hygiene,session-mirror-rebuild-cost.agyregister (all four required to enable):tos-third-party-access-ambiguity,fatal-control-frames,no-mid-turn-steer,auto-allowed-workspace-writes.
{
"providers": {
"agy": {
"enabled": true,
"acknowledgedConstraints": [
"tos-third-party-access-ambiguity",
"fatal-control-frames",
"no-mid-turn-steer",
"auto-allowed-workspace-writes"
]
}
}
}
The agy ToS constraint is an explicit operator legal-risk decision. Enabling a provider is an
assumption of risk by the operator running the plugin; the plugin only enforces the gate, it does
not interpret any terms or grant permission. See
docs/agy-tos-acknowledgment.md.
Google Antigravity (agy) — Wave B (shipped)
The agy provider adapts the Google Antigravity CLI behind the same seven seams as claude, so
it is a configuration-level addition rather than a second code path. What began as the planned Wave B
adapter now ships, gated by the Terms-of-Service acknowledgment above. Its constraint register
records why the seams are capability-honest rather than Claude-shaped:
tos-third-party-access-ambiguity— Google Antigravity Additional Terms leave programmatic third-party access ambiguous (Google AI Developer Forum threads185992,186954,185494,186952,187241). Enablingagyis an operator risk decision.fatal-control-frames— in-streamcontrol_request/control_responseframes and slash commands are fatal to the child (exit2); onlyusermessage events may be written to stdin.no-mid-turn-steer—agycannot be steered mid-turn; steering is queued to the next turn boundary.auto-allowed-workspace-writes—agyauto-allows workspace writes by default; enforcement relies on declarative allow-rules and explicit--dangerously-skip-permissionsconsent.
All four must be acknowledged before providers.agy.enabled may be true, exactly as in the
acknowledgment gate above.
Honest capability downgrades
| Capability | agy behavior |
|---|---|
| Reasoning stream | None. thinking_tokens are counted in usage, but there is no thinking text stream. |
| Partial tool input | None. The complete tool call arrives in one tool_info block at a step's DONE. |
| Streaming granularity | Step-level, not token-level. |
| Mid-turn steering | Not supported. Steering is queued and delivered at the next turn boundary. |
| Interactive approval | Not supported. Permissions are declarative. |
| Session mirror | No REUSE/REBUILD JSONL mirror. The conversation is resumed by opaque id; a history divergence clean-starts. |
Host tools through the stdio MCP runner
agy cannot call back into a parked in-process handler, so host tools reach it through a spawned
stdio MCP server. The plugin runs a loopback HTTP bridge bound to 127.0.0.1 with a per-process
bearer token, and the runner (src/agy/runner.ts) is a stdio MCP child that forwards tools/list
and tools/call to that bridge. The runner is what agy spawns, so the tool schemas and the
fail-closed permission checks are exactly the same server the claude path uses — the hop adds no
second tool surface and the bridge is never routable beyond loopback.
Registration, permissions, and resume accounting
- Declarative permissions. With
supportsInteractiveApproval = false,agyenforces asettings.jsonallow list plus MCP-side denial. A tool absent from the allow list is denied at the MCP boundary rather than silently executed. The--dangerously-skip-permissionsbypass is emitted only under explicit operator consent (providers.agy.dangerouslySkipPermissionsConsent). - User-level MCP registration is opt-in. Registering the runner with
agy mcp addmutates machine-global user config (~/.gemini/config/mcp_config.json), so it defaults to a dry-run manifest and only runs when the operator explicitly opts in. - Resume accounting.
agyreports cumulative-per-session usage. The bridge keeps the last cumulative total per conversation out of band and seeds the mapper before a resume, so a resumed turn reports only its own delta and never re-counts the session. See docs/config.md.
Development
npm install
npx tsc --noEmit # typecheck src/
node --import tsx --test tests/**/*.test.ts # run the unit test suite
CI runs the same gates on ubuntu-latest and macos-latest across Node 20, 22, and 24, plus the
commit-message lint, dependency-review, CodeQL, and Scorecard workflows summarized below.
Supply chain and CI/CD
The release path is treated as part of the product's threat model.
SHA-pinned actions. Every uses: across all workflows pins a full 40-character commit SHA with
the upstream tag in a trailing comment, so a moved or deleted tag cannot silently change what runs.
CI declares a read-only top-level grant and escalates per job; in the release pipeline only publish
requests id-token: write and only the GitHub-release job requests contents: write.
Dependabot. .github/dependabot.yml checks the npm and github-actions ecosystems weekly (on
Mondays) under a bounded open-PR limit. Minor and patch bumps are batched into grouped PRs; major npm
bumps are deliberately left ungrouped so breaking changes get individual review. Keeping the Actions
ecosystem fresh is what turns the SHA pins into reviewable PRs instead of silent staleness.
Dependency review. Pull requests run actions/dependency-review-action, which fails on
high-or-worse severity advisories and posts a summary comment only when the review fails.
Commit message lint. CI lints every commit in a push or pull request against Conventional
Commits (commitlint.config.mjs extending @commitlint/config-conventional) — the convention
release-please parses to decide versions. Merge commits are ignored by commitlint's defaults.
CodeQL. .github/workflows/codeql.yml analyzes javascript-typescript with the broader
security-extended query suite on pushes and pull requests to main and on a weekly schedule.
OpenSSF Scorecard. .github/workflows/scorecard.yml runs on branch-protection changes, a weekly
cron, and pushes to main; it publishes the signed results and uploads its SARIF findings to code
scanning, retaining an artifact copy for 14 days.
SBOM and provenance. The release pipeline generates a CycloneDX SBOM from the lockfile with
development dependencies omitted, uploads it as a 90-day artifact, and attaches it to the GitHub
release. The package itself is pushed with npm publish --provenance, producing a signed attestation
that ties the published tarball to the workflow run that built it.
Release automation (release-please). Releases follow the Release-PR pattern: Conventional
Commits land on main, and release-please opens/updates a Release PR that bumps package.json,
package-lock.json, and CHANGELOG.md. Merging that PR creates the v* tag and the GitHub
release with the changelog notes. Tags and releases are never created by hand — not from the
GitHub UI, not from the CLI — the bot owns them so the version, changelog, tag, and release
notes cannot drift apart. The bot runs with a personal access token (RELEASE_PLEASE_TOKEN
secret, fine-grained, Contents + Pull requests read/write) because tags created with the built-in
GITHUB_TOKEN would not trigger the tag-driven pipeline below; a preflight step fails loud when
the secret is missing.
Release approval gate. The tag push starts a three-stage pipeline: verify (the tag must
match the package.json version and be documented in CHANGELOG.md, then typecheck, unit tests,
and the SBOM), publish (gated by the production environment's required reviewers, using OIDC
trusted publishing when vars.NPM_TRUSTED_PUBLISHING == '1' and an npm token otherwise), and the
GitHub-release job, which attaches the SBOM to the bot-created release without touching its notes
(creating the release from the changelog section only as a fallback). Its concurrency group
never cancels in progress, so a publish is not interrupted halfway.
License metadata. The package is MIT: package.json declares "license": "MIT", LICENSE ships
in the npm files allowlist, and the badge above matches both.
License
MIT. See LICENSE.
同类生态推荐
Ocm
@wntic/ocm
Plugin marketplace for opencode: install skills, agents, commands, JS plugins and MCP servers from any git repo — once, globally, kept up to date
Magic Compact
magic-compact
Lossless context compression plugin for OpenCode.
Claude Smart
claude-smart
Self-improving Claude Code, Codex, and OpenCode plugin that turns corrections into Preferences, Project-specific skills, and Shared skills