Skip to content

Point opencode and pi at a local checkout or a PR - #116

Merged
tony merged 14 commits into
mainfrom
mcp-swap-more-agents
Aug 10, 2026
Merged

Point opencode and pi at a local checkout or a PR#116
tony merged 14 commits into
mainfrom
mcp-swap-more-agents

Conversation

@tony

@tony tony commented Aug 9, 2026

Copy link
Copy Markdown
Member

scripts/mcp_swap.py points installed agent CLIs at a local checkout or a pull request. This extends it from six CLIs to eight and moves per-CLI behavior out of scattered branches onto the registry. Development tooling only — nothing under src/ changes.

Summary

  • Add opencode$XDG_CONFIG_HOME/opencode/opencode.jsonc, resolved the way opencode's own loader resolves it. It is the first config here that is neither plain JSON nor TOML, the first whose server map hangs off something other than mcpServers/mcp_servers, and the first whose entry packs argv into a single command array.
  • Add pi~/.pi/agent/mcp.json. pi ships no MCP client of its own: its README says "No MCP" outright, and the released build contains no MCP code. That file is read by the third-party pi-mcp-adapter extension, so detect reports the missing prerequisite rather than presenting a swap that cannot take effect.
  • Add a JSONC reader and writer that preserves comments. Values come from stdlib json after comments and trailing commas are blanked in place; writes are applied as text splices, so bytes outside a replaced value survive — including a comment written directly above the command it explains. No new dependency: the script's PEP 723 header still lists only tomlkit.
  • Replace the four cli in (...) membership tuples in get_server / set_server / delete_server / _all_server_specs with two declarative CLIInfo fields. Two of those dispatches ended in a bare else that fell through to the TOML key, so a CLI present in CLIS but absent from one tuple reported "no entry" instead of failing; the other two raised AssertionError, which the caller did not catch.
  • Add an opencode panel to the docs install picker. pi gets no panel — it ships no MCP client, so there is nothing for a reader to install into.

Behavior changes for the six existing CLIs

The refactor is not purely internal. Three things change for CLIs this PR does not otherwise touch:

  • A container key holding something other than a table now raises RuntimeError naming the path. Only Claude had that guard before; Codex, Cursor, Gemini, Grok and agy would raise TypeError/AttributeError from setdefault — outside the (RuntimeError, ValueError, OSError) the caller catches — or overwrite the value.
  • Codex and Grok entries are built by a shared _as_toml_table helper rather than inline tomlkit calls. Same output shape.
  • detect's name column is derived from the longest registered name instead of a hardcoded width, so every row of its output shifts by two spaces.

CLIInfo

The three things that actually vary per CLI are now declared rather than branched on:

Field Meaning Values in use
fmt reader/writer selection json, jsonc, toml
container key path to the server map ("mcpServers",), ("mcp_servers",), ("mcp",)
dialect shape of one entry standard, claude, opencode

Both are required, so a CLI cannot be registered without deciding them. The full eight-row path table lives in scripts/README.md; the new rows are opencode$XDG_CONFIG_HOME/opencode/opencode.jsonc (JSONC) and pi~/.pi/agent/mcp.json (JSONC — the suffix says JSON, but the adapter that reads it strips comments and allows trailing commas).

Design decisions

opencode's entry shape has no forgiving path. It takes {"type": "local", "command": [argv...], "environment": {...}}. A scalar command is a decode error that stops opencode starting at all, and an env key is accepted and then dropped in silence. Both spellings are pinned by tests rather than left to a round-trip assertion.

The array is split back apart on read. _spec_from_entry normalizes every dialect down to the portable scalar-command spec, so is_local_uv_directory, local_repo_path and pr_ref stay dialect-agnostic. This is not cosmetic: those helpers drive the "already local — no change" short-circuit, and without the split every invocation rewrites a config that was already correct.

JSONC is spliced, not reserialized. The JSON writer rebuilds the whole document, which for a commented file means deleting every comment in it. No PyPI JSONC parser gives JSONC what tomlkit gives TOML — a format-preserving round trip. The closest candidate, json-five, is unsafe here: given the JSON source {"a": "C:\\x"} it raises, and given {"a": "C:\\u0041"} it returns the 3-character C:A where the value is the 8-character C:\u0041. stdlib reads both correctly. A parser that quietly rewrites an untouched value is the failure this script exists to prevent, so the codec is stdlib plus a string-aware scanner.

Two carve-outs, both on a config with nothing to preserve: seeding an entry into a config that parsed empty also writes "$schema" (opencode injects that line itself on first load, so writing it avoids an immediate second edit), and a file with no content at all is generated rather than spliced.

opencode is user-scope only in the install picker. opencode mcp add resolves its target with resolveConfigPath(Global.Path.config, true) on the non-interactive path, so it writes the global config whichever scope was chosen. A Project panel would name a file the command it prints never touches.

pi is registered with its limitation stated, not hidden. The alternative was to leave pi out, since a config no agent reads is a swap that reports success and does nothing. Registering the path keeps the swap landing where the adapter looks, and the detect note keeps status honest about what an agent will actually run. If a pi-family agent that speaks MCP natively is ever needed, that is omp, not pi.

Test plan

  • uv run ruff format ., uv run ruff check ., uv run mypy src tests, uv run pytest --reruns 0, just build-docs
  • tests/test_mcp_swap.py — both new CLIs through set/get/delete; the opencode dialect in both directions including a PR spec; JSONC byte-fidelity across line and block comments, a trailing comma, an absent final newline, non-ASCII, // inside a URL, /* inside a string, a Windows path and a literal \u escape; comment survival including one inside the replaced entry; symlinked config; $schema seeding; the detect adapter note with and without the adapter present
  • A guard test asserts the fake_home fixture covers every registered CLI — it replaces CLIS wholesale, so a CLI missing from it fails unrelated doctor tests with KeyError
  • Manual: every registered CLI swapped and reverted byte-identically in one run against a sandboxed HOME and XDG_CONFIG_HOME, with doctor and status reading both new shapes back
  • Manual: --pr, --dry-run, empty config, absent config, and replacing an entry that already carries an environment table
  • Negative control: disabling the JSONC writer so jsonc falls through to the JSON one turns the comment-fidelity and byte-fidelity tests red

Sandboxing this script needs XDG_CONFIG_HOME set alongside HOME — opencode's path comes from the former, so a HOME-only sandbox reads and writes the real config. --dry-run prints the resolved path in its diff header.

references/cli-matrix.md gains a row per CLI: opencode's cells read "not yet verified" and pi's read n/a, since there is no MCP client to drive. That file's value is that every cell was confirmed against a running agent, and opencode has not been driven through the harness yet.

tony added 7 commits August 9, 2026 15:02
why: Three things vary per CLI -- the file format, the key path to the
server map, and the shape of one entry -- but only the format was
recorded on CLIInfo. The other two were spelled as `cli in (...)`
membership tuples repeated across get_server, set_server,
delete_server and _all_server_specs. Two of those four dispatches end
in a bare `else` that falls through to the TOML `mcp_servers` key, so a
CLI registered in CLIS but forgotten in one tuple reports "no entry"
instead of failing; the other two raise AssertionError, which the
caller's (RuntimeError, ValueError, OSError) handler does not catch.

what:
- Add `container` (key path to the server map) and `dialect` (entry
  shape) to CLIInfo, both required so a new CLI cannot be added
  without deciding them
- Replace the four membership dispatches with one `_server_map()`
  accessor that walks the key path and creates intermediates on demand
- Extend the non-mapping guard Claude already had to every CLI:
  a container key holding something other than a table now raises
  RuntimeError naming the path, rather than a TypeError out of
  setdefault
- Rename `to_json_dict(include_stdio_type=)` to `to_entry_dict(dialect)`
  and move the TOML table build behind `_as_toml_table()`, so the two
  writers no longer duplicate the entry shape

No behavior change for the six registered CLIs; the existing 123
mcp_swap tests pass unmodified apart from the fixture gaining the two
new required fields.
why: A config format the script cannot round-trip is one it must not
write. tomlkit gives TOML a format-preserving round trip; JSON goes
through stdlib json.dumps, which reserializes the whole document. For a
JSONC file that is doubly wrong -- json.loads rejects `//` outright, and
anything that did parse would come back stripped of every comment.

The obvious dependency was measured and rejected. json-five round-trips
comments via its model API, but it raises on the valid JSON string
"C:\\x" and silently decodes the six literal characters \u0041 to "A".
stdlib json reads both correctly. A parser that quietly rewrites a value
nobody touched is the exact failure this script is built to prevent, so
it is not worth a PEP 723 line.

what:
- Parse JSONC by blanking comments and trailing commas in place --
  offsets preserved -- then handing the result to stdlib json, so escape
  semantics are the standard library's rather than a reimplementation's
- Apply writes as text splices located by a string-aware scanner, one
  splice at a time with a rescan between, so every byte outside a
  replaced value survives untouched. Same technique opencode's own
  writer uses through jsonc-parser's modify()
- Render short scalar arrays inline so a swapped `command` stays on one
  line instead of exploding a dotfiles-tracked config into a large diff
- Dispatch dump_config_bytes on the exact format instead of
  `!= "json"`, which would have sent a third format to the TOML writer
  and put TOML bytes in a JSON file

Verified byte-identical round trips for line and block comments,
trailing commas, absent final newline, non-ASCII, `//` inside a URL,
`/*` inside a string, Windows paths and a literal \u escape. No CLI uses
fmt="jsonc" yet; the codec lands ahead of its first consumer.
why: opencode is the seventh agent CLI on this machine and the first
whose config differs from the others in all three axes at once: the file
is JSONC, the server map hangs off `mcp` rather than `mcpServers`, and
one entry packs argv into a single `command` array with its environment
table spelled `environment`. Getting any of that wrong is not a soft
failure -- a scalar `command` is a decode error that stops opencode from
starting at all, and an `env` key is dropped without a word.

what:
- Register opencode: binary `opencode`, `$XDG_CONFIG_HOME/opencode/
  opencode.jsonc` (honouring XDG the way opencode's own loader does),
  fmt jsonc, container ("mcp",), dialect opencode
- Add the opencode dialect to both directions: written as
  {"type": "local", "command": [argv...]} with "environment", and read
  back by splitting the array into the portable command/args pair
- Seed "$schema" when creating an entry in a config that was empty;
  opencode writes that line itself on first load, so writing it here
  avoids a second edit landing right after the swap
- Derive the detect column width from the longest registered name
  instead of a hardcoded 7, which "opencode" overflows

Splitting the array on read is what makes `is_local_uv_directory`,
`local_repo_path` and `pr_ref` keep working, and those are what the
"already local -- no change" check depends on. Without it every run
would rewrite a config that was already correct.

Verified end to end against a sandboxed HOME/XDG_CONFIG_HOME: add,
replace, revert byte-identical, second-run idempotence, a comment living
inside the replaced entry, an existing `environment` table, an empty
file, a symlinked config, --pr, and status reading each shape back.
why: pi is the eighth agent CLI here, and the only one that ships no MCP
client. Its README says "No MCP" outright, the released 0.84.1 build
contains no MCP code, and its Settings interface has no key that could
hold a server. MCP reaches pi only through the third-party
`pi-mcp-adapter` extension, which reads ~/.pi/agent/mcp.json in the
Claude-Desktop `mcpServers` schema.

That leaves one honest way to support pi. This script's value rests on
`status` telling the truth about what an agent will actually run, so
writing a file pi ignores and reporting success would cost more than not
supporting pi at all. Registering the path and naming the missing
prerequisite keeps both: the swap lands where the adapter looks, and
`detect` says why it will not take effect yet.

what:
- Register pi: binary `pi`, ~/.pi/agent/mcp.json, fmt json,
  container ("mcpServers",), standard dialect -- no new dialect needed,
  the adapter speaks the same shape cursor and gemini do
- `detect` appends "needs the pi-mcp-adapter package; pi has no built-in
  MCP client" whenever that package is absent from
  ~/.pi/agent/npm/node_modules

Verified end to end against a sandboxed HOME: detect's caveat, add,
status, and revert byte-identical, with an unrelated server left alone.
why: The two new CLIs introduce axes nothing in the suite exercised: a
JSONC config, a container key that is neither mcpServers nor
mcp_servers, an entry that packs argv into one array, and a config read
by an extension rather than by the agent. The JSONC writer also makes a
stronger promise than the JSON one -- it splices text, so it owes byte
fidelity rather than only value fidelity, and that has to be asserted on
bytes.

what:
- test_fake_home_covers_every_registered_cli: the fixture replaces CLIS
  wholesale, so a CLI missing from it raises KeyError out of half a dozen
  unrelated doctor tests. Names the invariant once
- Registration and set/get/delete round-trips for both CLIs, which is
  what proves each name reached all four container branches
- opencode dialect both directions: argv packed into one array, env
  written as "environment", and the array split back into command+args
  so is_local_uv_directory, local_repo_path and pr_ref keep working
- Comment fidelity: line, block and trailing comments, a comment living
  inside the entry being replaced, sibling servers, symlinked config,
  $schema seeding, and a second swap reporting no change
- PRESERVED_JSONC byte-identical round-trips, including `//` inside a
  URL, `/*` inside a string, a Windows path and a literal \u escape --
  the cases that make a naive comment-stripper corrupt a value
- A parity test asserting JSONC values match stdlib json wherever stdlib
  can parse the body at all

Verified these fail for the right reason: disabling the JSONC writer so
jsonc falls through to the plain JSON one turns 8 of them red, the
comment and byte-fidelity ones included.
why: Eight places enumerate the agent CLIs, and they had already drifted
apart before this branch -- scripts/README.md claimed four CLIs when six
were supported, and its extension guide named three per-CLI branch sites
when there were four. Adding two more CLIs without reconciling them
leaves the docs describing a script that no longer exists.

what:
- Module docstring: line 6 is the argparse description, so it no longer
  tries to list every CLI by name. The Scope section gains the two new
  config paths, opencode's three-sibling-global-files caveat, and pi's
  missing MCP client
- scripts/README.md: the CLI table now lists all eight with their
  formats, and the extension guide describes CLIInfo's fmt/container/
  dialect fields instead of branch sites that no longer exist. Adds the
  ALL_CLIS warning -- a CLI missing from it has its state dropped on
  load, so revert forgets the swap
- docs install widget: an opencode panel. `opencode mcp add tmux --
  <cmd>` is non-interactive given a name and a `--` command, so it is a
  CLI panel; that also avoids its array-command shape, which the shared
  JSON body cannot express. _cli_body falls through to codex by default,
  so the branch is explicit
- Skill and cli-matrix: opencode added to the skill's CLI list and both
  new CLIs described from source. Their matrix row reads "not yet
  verified" rather than guessing -- that file's value is that every cell
  was empirically confirmed, and neither has been driven through the
  harness
- justfile: the mcp-detect comment listed four CLIs; it now names none
- CHANGES: entries under Development for the swap-script work, and under
  Documentation for the install-widget panel

pi is deliberately absent from the install widget and has no matrix row:
it cannot consume MCP, so there is nothing for a user to install into.
why: CI runs `uv run mypy .`, which covers scripts/; the chain in
AGENTS.md is `uv run mypy src tests`, which does not. The opencode work
was typed against the narrower invocation and broke the build.

what:
- Annotate the opencode entry dict, which lost its `dict[str, t.Any]`
  when the dialect branch was added and was then inferred narrowly
  enough that assigning `environment` failed
- Overload `_server_map` on `create`, matching `_claude_project_node`
  and `_claude_user_servers`, so a create=True call is not Optional at
  the call site
- Annotate its cursor so the walk returns a mapping rather than Any

`just mypy` type-checks every .py file and would have caught this;
`uv run mypy src tests` is the invocation that does not.
@codecov-commenter

codecov-commenter commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.93960% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.24%. Comparing base (34a5d9e) to head (e0433d0).

Files with missing lines Patch % Lines
scripts/mcp_swap.py 90.84% 17 Missing and 10 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #116      +/-   ##
==========================================
+ Coverage   85.39%   86.24%   +0.84%     
==========================================
  Files          46       46              
  Lines        3801     4042     +241     
  Branches      538      599      +61     
==========================================
+ Hits         3246     3486     +240     
+ Misses        408      404       -4     
- Partials      147      152       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 7 commits August 9, 2026 18:42
why: The insertion branch asks whether an object already has content by
looking at the comment-blanked text, where a comment is indistinguishable
from whitespace. An object holding only a comment therefore looked empty,
and the insert spliced over the whole interior and took the comment with
it -- silently, in a file the user wrote by hand.

what: Measure the interior in the original text and anchor the splice
after what it actually holds. A genuinely empty interior rstrips to
nothing and the anchor collapses to the old splice point, so every
previously working insert is byte-identical.

Covers the same splice at the document root, where there is no enclosing
member, and adds the comment-only object to the byte-fidelity cases.
why: Removing a member spliced from the end of the previous member to
past the following comma, so a member between two others took the comma
on both sides and left its neighbours undelimited. The next merge pass
then raised JSONDecodeError, which the caller catches as a bad config,
so the swap reported opencode unreadable and skipped it. Reachable
without doing anything unusual: an entry carrying `enabled` or `timeout`
-- both valid opencode fields the swap does not write -- hits it.

what: Take exactly one delimiter with the member. Every member but the
first takes the comma before it; the first takes the comma after. Read
that comma out of the blanked text, so a comma inside a comment is not
mistaken for the separator and a real one behind a comment is still
found.

Chosen over two larger alternatives after both were built and measured:
across 5,508 generated documents this and a helper-based rewrite emitted
identical bytes, and a third approach that also preserved the deleted
member's comment corrupted files -- it stripped the newline terminating
a `//` comment, pulling the closing brace inside it.

A comment sitting above a removed member is still removed with it. That
is unchanged, and settling it means first deciding whether such a comment
documents the member or the object; re-parenting it onto the next member
would leave a false statement in the user's file.
why: pi's MCP file is read by pi-mcp-adapter, which parses it through
strip-json-comments with trailing commas allowed. Registering it as
fmt="json" sent it to strict json.loads, so a config the adapter reads
without complaint came back as a JSONDecodeError and status and
use-local reported pi unreadable and skipped it. The .json suffix is
misleading; the format the reader accepts is JSONC.

what: fmt="jsonc". The container key and entry dialect are unchanged --
the adapter speaks the same Claude-Desktop mcpServers shape cursor and
gemini do. Comments and a trailing comma now survive a swap as well.
why: The panel offered Project alongside User and named
`./opencode.json` as its destination, but emitted the same command for
both. `opencode mcp add` resolves its target with
resolveConfigPath(Global.Path.config, true) on the non-interactive path,
so it writes the global file whichever scope was picked. A reader
following the Project panel would register the server for every project
while believing it was scoped to one repo.

what: opencode offers User only. The prose that pointed at
`opencode mcp add` for workspace precedence is corrected in the same
pass -- that command cannot reach a project file; editing
`$PWD/opencode.json` by hand can.
The CLI table and the scope note still called it JSON, which is what the
suffix says and not what the adapter reading it accepts.
why: The insertion path built the member with an f-string, so the key went
in raw while every value went through json.dumps. `--server` takes an
arbitrary string: give it one holding a backslash, a quote, or a newline
and the emitted text does not parse back. The member is then never found
on the next pass, so the merge re-inserts it until the pass ceiling --
burning CPU for over an hour while holding the exclusive swap lock, then
failing with "JSONC merge did not converge".

what: Render the key with json.dumps, honouring the same ensure_ascii the
values use.

Found by exercising the flag surface rather than the config surface; the
config-shape matrix passes either way because a derived server name never
contains one of these characters.
why: opencode's config path was taken from $XDG_CONFIG_HOME verbatim. A
relative value resolves against the working directory, so the swap read
one file when run from one directory and another from elsewhere, and the
backup path recorded in the state file was relative too. Revert from any
other directory then reported the backup missing, and because a missing
backup leaves its state entry in place, that CLI stayed wedged.

what: Fall back to ~/.config unless the variable is absolute, which is
what the XDG spec requires -- absolute or ignored.
@tony
tony merged commit f328210 into main Aug 10, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants