feat: bring claude, pi and prime-agent to the opencode standard (+ claude-vllm) #101

Closed
michal wants to merge 0 commits from feat/agent-parity into main
Owner

Brings Claude Code, pi and prime-agent up to the standard the opencode addon
set, and adds claude-vllm. Four commits, each independently reviewable.

1. claude-vllm — Claude Code on the homelab LLM

llm.ad.itaz.eu is LiteLLM in front of vLLM, and LiteLLM already serves the
Anthropic Messages API
on /v1/messages — verified with a real completion. So
there is no bridge to build; pointing ANTHROPIC_BASE_URL at it is the whole
integration.

claude-vllm reuses whatever another agent is already configured with
(~/.pi/agent~/.prime/agent → opencode's config, first hit wins), taking
the base URL and credential from the same source so one gateway's URL is never
paired with another's key.

Two settings that are easy to miss:

  • ANTHROPIC_SMALL_FAST_MODEL / ANTHROPIC_DEFAULT_HAIKU_MODEL — otherwise the
    background and summarisation calls ask for a real Haiku the gateway does not
    serve, and every one 404s.
  • CLAUDE_CODE_MAX_CONTEXT_TOKENS from the provider's declared contextWindow
    Claude Code assumes 200k for models it has no table for, and deepseek-v4-*
    is 393k, so it would auto-compact at half capacity.

On claude-mcpctl: mcpctl's LLM layer is a client, not a server. mcpd
serves /api/v1/llms (management) and its adapters call out to providers for
gating, prompt selection and agent chat; nothing serves /v1/messages. Routing
Claude through mcpctl would mean adding an Anthropic passthrough that re-wraps
LiteLLM — worth doing only if mcpctl in the LLM path buys something of its own
(per-project gating of model calls, prompt audit, budgets), which is an mcpd
endpoint rather than a wrapper script.

2. One constant mcpctl MCP entry for Claude Code

config claude named the .mcp.json entry after the project, and the file is
merged — so configuring a second project left the first mounted alongside it.
Every project you had ever configured stayed connected, with duplicate tool
names and nothing saying which was active.

The entry is now always mcpctl; switching rewrites what sits behind it. Claude
Code can reconnect an existing MCP server from /mcp, so a switch lands without
restarting, and the tool prefix stays stable. Legacy entries are retired on the
next run, recognised by the pairing that makes it safe: our command, named
after the very project it bridges to. A hand-configured server is never touched.

Shaping lives in config/claude-mcp.ts as pure functions, so the merge,
migration and active-project detection are unit-tested rather than inferred from
a command's side effects. Also adds --dry-run and --skip-marker.

3. Claude Code: status line + /mcpctl switcher

The two things every other host had and Claude Code did not.

  • mcpctl statusline resolves the project from .mcp.json, falling back to a
    .mcpctl-project marker. It reads the directory from the JSON Claude Code
    pipes in, so it follows /cwd. Prints nothing when no project is active.
  • /mcpctl [project] switches and reminds you to reconnect from /mcp.
    allowed-tools is scoped to four exact mcpctl invocations.

Three things found by running it, not by reasoning:

  • Claude Code rewrites settings.json and strips unknown keys from
    statusLine
    — the _mcpctl_managed marker came back gone. Ownership is now
    decided by the command string. (Hooks keep their marker; statusLine does not.)
    A composed line like my-prompt && mcpctl statusline is deliberately not
    claimed.
  • Every !`…` block in a slash command is permission-checked against
    allowed-tools.
    Omitting statusline failed the whole command before the
    model saw anything. A test now asserts every pre-executed command is covered.
  • Setting ANTHROPIC_AUTH_TOKEN and ANTHROPIC_API_KEY makes Claude Code
    warn
    that auth may not work. claude-vllm now sets only the former.

Plus a pre-existing test-isolation bug this work would have made worse:
config claude wrote into the developer's real ~/.claude when the suite ran —
which is how an untagged duplicate of the skills-sync SessionStart hook got
there. Everything now honours CLAUDE_CONFIG_DIR (Claude Code's own override:
correct behaviour first, isolation second), config claude gains
--claude-dir, and the suite is verified to leave ~/.claude byte-identical.

4. prime-agent switcher extracted; pi --dry-run; docs

The prime-agent switcher existed only as a 275-line string literal, so
nothing typechecked or linted it — the exact gap that let a wrong
ctx.ui.select() shape ship in the pi extension. It now lives at
src/prime-agent-ext/mcpctl-switch.ts with a generator, a tsconfig checking it
against the real @earendil-works/pi-coding-agent types, eslint coverage and an
embed-freshness test.

The extraction was verified byte-identical before any edit. Linting then
found six problems in code nothing had ever checked — object-truthiness null
guards, a nullable-string conditional, a missing return type. All
behaviour-preserving, but exactly the class of thing that ships silently.

Also: config pi --dry-run (the last agent without it); the SessionStart hook
installer drops untagged duplicates of its own exact command; and
docs/claude-integration.md + docs/prime-agent-extension.md, the two
integrations with no page.

Deliberately not done

prime-agent keeps its per-project MCP entry name. Its switcher already
unmounts the previous project, so it never accumulates entries the way config claude did — the bug the constant name fixes does not exist there. The
remaining gap is tool-prefix stability. Changing it would also re-key
auth.json from mcp:<project> to mcp:mcpctl, giving up per-project token
caching and needing a migration. Reasoning is written up in
docs/prime-agent-extension.md rather than quietly skipped.

Verification

Live, in a real Claude Code session: status line renders mcpctl:homeautomation;
/mcpctl docmost switches and the line updates to mcpctl:docmost in the same
session; claude-vllm --model deepseek-v4-fast completes against the homelab.

683 tests pass (57 files). tsc --build plus all three extension typechecks
(pi-ext, opencode-ext, prime-agent-ext) and the completions check are
clean. ~/.claude verified byte-identical after a full suite run.

Parity, after

claude prime-agent pi opencode
Extension source typechecked n/a
Generator + embed-freshness test n/a
--dry-run
Config-dir override
Docs page
Switcher + active-project indicator
Brings Claude Code, pi and prime-agent up to the standard the opencode addon set, and adds `claude-vllm`. Four commits, each independently reviewable. ## 1. `claude-vllm` — Claude Code on the homelab LLM `llm.ad.itaz.eu` is LiteLLM in front of vLLM, and **LiteLLM already serves the Anthropic Messages API** on `/v1/messages` — verified with a real completion. So there is no bridge to build; pointing `ANTHROPIC_BASE_URL` at it is the whole integration. `claude-vllm` reuses whatever another agent is already configured with (`~/.pi/agent` → `~/.prime/agent` → opencode's config, first hit wins), taking the base URL and credential from the same source so one gateway's URL is never paired with another's key. Two settings that are easy to miss: - `ANTHROPIC_SMALL_FAST_MODEL` / `ANTHROPIC_DEFAULT_HAIKU_MODEL` — otherwise the background and summarisation calls ask for a real Haiku the gateway does not serve, and every one 404s. - `CLAUDE_CODE_MAX_CONTEXT_TOKENS` from the provider's declared `contextWindow` — Claude Code assumes 200k for models it has no table for, and `deepseek-v4-*` is 393k, so it would auto-compact at half capacity. **On `claude-mcpctl`:** mcpctl's LLM layer is a *client*, not a server. mcpd serves `/api/v1/llms` (management) and its adapters call out to providers for gating, prompt selection and agent chat; nothing serves `/v1/messages`. Routing Claude through mcpctl would mean adding an Anthropic passthrough that re-wraps LiteLLM — worth doing only if mcpctl in the LLM path buys something of its own (per-project gating of model calls, prompt audit, budgets), which is an mcpd endpoint rather than a wrapper script. ## 2. One constant `mcpctl` MCP entry for Claude Code `config claude` named the `.mcp.json` entry after the project, and the file is *merged* — so configuring a second project left the first mounted alongside it. Every project you had ever configured stayed connected, with duplicate tool names and nothing saying which was active. The entry is now always `mcpctl`; switching rewrites what sits behind it. Claude Code can reconnect an existing MCP server from `/mcp`, so a switch lands without restarting, and the tool prefix stays stable. Legacy entries are retired on the next run, recognised by the pairing that makes it safe: *our* command, named after the very project it bridges to. A hand-configured server is never touched. Shaping lives in `config/claude-mcp.ts` as pure functions, so the merge, migration and active-project detection are unit-tested rather than inferred from a command's side effects. Also adds `--dry-run` and `--skip-marker`. ## 3. Claude Code: status line + `/mcpctl` switcher The two things every other host had and Claude Code did not. - `mcpctl statusline` resolves the project from `.mcp.json`, falling back to a `.mcpctl-project` marker. It reads the directory from the JSON Claude Code pipes in, so it follows `/cwd`. Prints nothing when no project is active. - `/mcpctl [project]` switches and reminds you to reconnect from `/mcp`. `allowed-tools` is scoped to four exact `mcpctl` invocations. Three things found by running it, not by reasoning: - **Claude Code rewrites `settings.json` and strips unknown keys from `statusLine`** — the `_mcpctl_managed` marker came back gone. Ownership is now decided by the command string. (Hooks keep their marker; statusLine does not.) A composed line like `my-prompt && mcpctl statusline` is deliberately not claimed. - **Every `` !`…` `` block in a slash command is permission-checked against `allowed-tools`.** Omitting `statusline` failed the whole command before the model saw anything. A test now asserts every pre-executed command is covered. - **Setting `ANTHROPIC_AUTH_TOKEN` *and* `ANTHROPIC_API_KEY` makes Claude Code warn** that auth may not work. `claude-vllm` now sets only the former. **Plus a pre-existing test-isolation bug** this work would have made worse: `config claude` wrote into the developer's real `~/.claude` when the suite ran — which is how an untagged duplicate of the skills-sync `SessionStart` hook got there. Everything now honours `CLAUDE_CONFIG_DIR` (Claude Code's own override: correct behaviour first, isolation second), `config claude` gains `--claude-dir`, and the suite is verified to leave `~/.claude` byte-identical. ## 4. prime-agent switcher extracted; pi `--dry-run`; docs The prime-agent switcher existed **only** as a 275-line string literal, so nothing typechecked or linted it — the exact gap that let a wrong `ctx.ui.select()` shape ship in the pi extension. It now lives at `src/prime-agent-ext/mcpctl-switch.ts` with a generator, a tsconfig checking it against the real `@earendil-works/pi-coding-agent` types, eslint coverage and an embed-freshness test. The extraction was verified **byte-identical** before any edit. Linting then found six problems in code nothing had ever checked — object-truthiness null guards, a nullable-string conditional, a missing return type. All behaviour-preserving, but exactly the class of thing that ships silently. Also: `config pi --dry-run` (the last agent without it); the SessionStart hook installer drops untagged duplicates of its own exact command; and `docs/claude-integration.md` + `docs/prime-agent-extension.md`, the two integrations with no page. ## Deliberately not done **prime-agent keeps its per-project MCP entry name.** Its switcher already unmounts the previous project, so it never accumulates entries the way `config claude` did — the bug the constant name fixes does not exist there. The remaining gap is tool-prefix stability. Changing it would also re-key `auth.json` from `mcp:<project>` to `mcp:mcpctl`, giving up per-project token caching and needing a migration. Reasoning is written up in `docs/prime-agent-extension.md` rather than quietly skipped. ## Verification Live, in a real Claude Code session: status line renders `mcpctl:homeautomation`; `/mcpctl docmost` switches and the line updates to `mcpctl:docmost` in the same session; `claude-vllm --model deepseek-v4-fast` completes against the homelab. 683 tests pass (57 files). `tsc --build` plus all three extension typechecks (`pi-ext`, `opencode-ext`, `prime-agent-ext`) and the completions check are clean. `~/.claude` verified byte-identical after a full suite run. ### Parity, after | | claude | prime-agent | pi | opencode | |---|---|---|---|---| | Extension source typechecked | n/a | ✅ | ✅ | ✅ | | Generator + embed-freshness test | n/a | ✅ | ✅ | ✅ | | `--dry-run` | ✅ | ✅ | ✅ | ✅ | | Config-dir override | ✅ | ✅ | ✅ | ✅ | | Docs page | ✅ | ✅ | ✅ | ✅ | | Switcher + active-project indicator | ✅ | ✅ | ✅ | ✅ |
michal added 4 commits 2026-08-09 18:45:28 +00:00
The gateway at llm.ad.itaz.eu is LiteLLM in front of vLLM, and LiteLLM already
serves the Anthropic Messages API on /v1/messages — verified with a real
completion. So Claude Code needs no bridge: pointing ANTHROPIC_BASE_URL at it is
the whole integration.

`claude-vllm` exists only to stop you pasting four exports each time. It reuses
what another agent is already configured with — ~/.pi/agent, ~/.prime/agent, then
opencode's config, first hit wins — taking the base URL and the credential from
the same source so one gateway's URL is never paired with another's key.

Beyond the obvious ANTHROPIC_* vars it sets two that are easy to miss:

  - ANTHROPIC_SMALL_FAST_MODEL / ANTHROPIC_DEFAULT_HAIKU_MODEL, or the
    background and summarisation calls ask the gateway for a real Haiku it does
    not serve and every one 404s;
  - CLAUDE_CODE_MAX_CONTEXT_TOKENS from the provider's declared contextWindow,
    because Claude Code assumes 200k for models it has no table for — and
    deepseek-v4-* is 393k, so it would auto-compact at half capacity.

On `claude-mcpctl`: mcpctl's LLM layer is a client, not a server. mcpd serves
/api/v1/llms (management) and its adapters call out to providers for gating,
prompt selection and agent chat; nothing serves /v1/messages. Routing Claude
through mcpctl would mean adding an Anthropic-shaped passthrough that re-wraps
LiteLLM — worth doing only if mcpctl in the LLM path buys something of its own
(per-project gating of model calls, prompt audit, budgets), which is a mcpd
endpoint rather than a wrapper script.

Verified: `claude-vllm --model deepseek-v4-fast -- -p "..."` completes against
deepseek on the homelab, with the unknown-model context warning gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
`config claude` named the `.mcp.json` entry after the project, and the file is
merged rather than rewritten — so configuring a second project left the first
one mounted alongside it. Every project you had ever configured stayed
connected, with duplicate tool names and nothing saying which was active.

The entry is now always `mcpctl`, and switching rewrites what sits behind that
name. Claude Code can reconnect an existing MCP server from inside a session, so
a switch lands without restarting the app, and the tool prefix stays stable
across switches. Entries an older CLI wrote are retired on the next run —
recognised by the pairing that makes retiring them safe: our command, named
after the very project it bridges to. A hand-configured server is never touched.

The shaping lives in config/claude-mcp.ts as pure functions so the merge,
migration and active-project detection are unit-tested rather than inferred from
a command's side effects.

Also brings two parity gaps in line with `config opencode` / `config prime-agent`:
--dry-run, and --skip-marker for when the caller must not re-scope the directory
it runs in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
Claude Code had neither of the things opencode, pi and prime-agent all have: a
visible active project, and a way to change it from inside a session. It has no
plugin API that can draw a widget or open a picker, but it does run a command
for its status line and it does load slash commands — which is enough for both.

  - `mcpctl statusline` prints the active project (from .mcp.json, falling back
    to a .mcpctl-project marker) and is wired into settings.json. It reads the
    directory out of the JSON Claude Code pipes in, so it follows /cwd rather
    than reporting wherever the binary was launched. Prints nothing when no
    project is active: an empty line beats "none" on every unrelated repo.
  - `/mcpctl [project]` switches and reminds you to reconnect from /mcp.
    allowed-tools is scoped to the four exact mcpctl invocations it needs.

Three things found by running it rather than reasoning about it:

  - Claude Code REWRITES settings.json against its own schema and strips
    unknown keys from `statusLine` — our `_mcpctl_managed` marker came back
    gone, so ownership is now determined by the command string. (Hooks keep
    their marker; statusLine does not.) A composed line like
    `my-prompt && mcpctl statusline` is deliberately not claimed.
  - Every `!`-prefixed block in a slash command is permission-checked against
    allowed-tools. Omitting `statusline` failed the whole command before the
    model saw anything. A test now asserts every pre-executed command is
    covered.
  - Setting ANTHROPIC_AUTH_TOKEN *and* ANTHROPIC_API_KEY makes Claude Code warn
    that auth may not work; claude-vllm now sets only the former and clears an
    inherited API key.

Also fixes a pre-existing test-isolation bug this work would have made worse:
`config claude` wrote into the developer's real ~/.claude when the suite ran,
which is how an untagged duplicate of the skills-sync SessionStart hook got
there. Both the hook installer and the new UI installers now honour
CLAUDE_CONFIG_DIR (Claude Code's own override — correct behaviour first,
isolation second), `config claude` gains --claude-dir for parity with --pi-dir
and --opencode-dir, and the suite is verified to leave ~/.claude byte-identical.

Verified live: status line renders `mcpctl:homeautomation`, `/mcpctl docmost`
switches and the line updates to `mcpctl:docmost` in the same session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
refactor(prime-agent): extract the /mcpctl switcher to typechecked source; pi --dry-run; docs
Some checks failed
CI/CD / lint (pull_request) Successful in 1m11s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / typecheck (pull_request) Successful in 3m20s
CI/CD / smoke (pull_request) Failing after 2m1s
CI/CD / build (pull_request) Successful in 2m37s
CI/CD / publish (pull_request) Has been skipped
a9fcd83ed8
The prime-agent switcher existed only as a 275-line string literal inside
prime-agent-extension.ts, so nothing typechecked or linted it — the exact gap
that let a wrong ctx.ui.select() option shape ship in the pi extension. It now
lives at src/prime-agent-ext/mcpctl-switch.ts with a generator, a tsconfig
checking it against the real @earendil-works/pi-coding-agent types, eslint
coverage and an embed-freshness test, matching pi and opencode.

The extraction was verified byte-identical before any edit, so the behaviour
shipped today is exactly what was captured. Linting it then found six problems
in code nothing had ever checked: object-truthiness null guards, a nullable
string conditional and a missing return type. All behaviour-preserving to fix,
but exactly the class of thing that ships silently when nothing is looking.

Also:

  - `config pi` gains --dry-run, the last agent without it.
  - The SessionStart hook installer now drops untagged duplicates of its own
    exact command — rows left behind before the marker existed, or by a suite
    that used to write into a real ~/.claude. Invisible in the UI; they just run
    the sync twice per session. A hook the user wrote is never touched, even one
    calling `mcpctl skills sync` with different flags.
  - docs/claude-integration.md and docs/prime-agent-extension.md, the two
    integrations that had no page.

prime-agent deliberately keeps its per-project MCP entry name rather than the
constant `mcpctl` claude and opencode now use: its switcher already unmounts the
previous project, so it never accumulates entries, and re-keying auth.json from
mcp:<project> to mcp:mcpctl would give up per-project token caching and needs a
migration. Documented as its own change rather than folded in here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
michal closed this pull request 2026-08-09 18:53:54 +00:00
Some checks failed
CI/CD / lint (pull_request) Successful in 1m11s
CI/CD / test (pull_request) Successful in 1m24s
CI/CD / typecheck (pull_request) Successful in 3m20s
CI/CD / smoke (pull_request) Failing after 2m1s
CI/CD / build (pull_request) Successful in 2m37s
CI/CD / publish (pull_request) Has been skipped

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: michal/mcpctl#101