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
4.9 KiB
mcpctl × Claude Code
What mcpctl config claude --project X wires up
| Piece | Where | Purpose |
|---|---|---|
| MCP entry | ./.mcp.json |
one server, always named mcpctl, running the stdio bridge mcpctl mcp -p X |
| Project marker | ./.mcpctl-project |
what skills sync reads to know the scope (--skip-marker opts out) |
| Skills | ~/.claude/skills/ |
the project's SKILL.md bundles |
| SessionStart hook | ~/.claude/settings.json |
mcpctl skills sync --quiet on every session |
| Status line | ~/.claude/settings.json |
the active project, bottom of the screen |
/mcpctl |
~/.claude/commands/mcpctl.md |
switch projects from inside a session |
--claude-dir (or Claude Code's own CLAUDE_CONFIG_DIR) redirects everything
under ~/.claude.
One server named mcpctl, not one per project
The MCP entry used to be named after the project. Because .mcp.json is
merged, configuring a second project left the first mounted too — every
project you had ever configured stayed connected, with duplicate tool names and
nothing marking which was active.
There is now exactly one entry, mcpctl, and switching rewrites what sits
behind it:
{ "mcpServers": { "mcpctl": { "command": "mcpctl", "args": ["mcp", "-p", "docmost"] } } }
Two things follow. The tool prefix is stable across switches, so the model never
sees a tool namespace disappear mid-conversation. And because Claude Code can
reconnect an existing MCP server from /mcp, a switch lands without restarting
the app.
Entries an older CLI wrote are retired on the next run. They are 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, even one
called docmost, unless it also runs mcpctl mcp -p docmost, at which point it
is the same entry anyway.
Switching: /mcpctl
❯ /mcpctl docmost
● Bash(mcpctl config claude --project docmost --skip-marker)
⎿ Wrote .mcp.json (1 server(s))
● Now on docmost. Reconnect the mcpctl server from /mcp for its tools to load.
With no argument it lists the projects and asks.
Claude Code slash commands are prompt files, not code, so unlike opencode's
picker this drives the model through CLI calls — there is no keyboard picker to
be had. allowed-tools is scoped to the four exact mcpctl invocations it
needs, so accepting it does not hand the session a general shell.
Every
!`…`block in a slash command is permission-checked againstallowed-toolsbefore the model runs. Omitting one fails the whole command with a permission error and no explanation. A test asserts every pre-executed command in ours is covered.
--skip-marker is deliberate: the session's directory is whatever you happened
to open, and re-scoping it would silently change which skills sync into it.
The status line
mcpctl:docmost
⏵⏵ bypass permissions on · ← for agents
mcpctl statusline resolves the project from .mcp.json, falling back to a
.mcpctl-project marker up the tree so a checkout that is scoped but not yet
wired still reports. It reads the directory from the JSON Claude Code pipes in,
so it follows /cwd rather than reporting wherever the binary was launched, and
prints nothing when no project is active — an empty status line beats one
saying "none" on every unrelated repo.
It is never installed over yours
A status line is a single slot, so overwriting a custom one silently deletes
work. When config claude finds a foreign one it leaves it and prints the
snippet to add instead:
Left your existing status line alone (my-fancy-prompt).
To show the project too, append: $(mcpctl statusline)
Ownership is decided by the command string, not a marker. Claude Code rewrites
settings.jsonagainst its own schema and strips unknown keys fromstatusLine— a tagged entry comes back as a bare{type, command}. (Hooks keep their marker;statusLinedoes not.) Matching on the command is what stops us reporting our own line as foreign forever. A line that merely composes ours —my-prompt && mcpctl statusline— is yours, and is left alone.
Skills
mcpctl skills sync installs into ~/.claude/skills/. Claude Code is the only
target that also gets hooks, postInstall and mcpServers auto-attach; pi,
prime-agent and opencode share the simpler flat-tree semantics.
The SessionStart hook keeps them current. It carries a _mcpctl_managed marker,
and an install now also drops untagged duplicates of that exact command —
rows left behind before the marker existed (or by a test suite that used to
write into a real ~/.claude). They are invisible in the UI and just run the
sync twice per session. A hook you wrote is never touched, even one that also
calls mcpctl skills sync with different flags.
Running Claude Code on the homelab LLM
See claude-vllm.md.