From b3448031088447d76d80ac6b2bfa08320973a2d9 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Thu, 29 Oct 2026 21:40:43 +0000 Subject: pi: generate mcp.json from ~/.config/pi/mcp.d fragments pi reads user-level MCP servers from one file, and a private layer linked that file to the same copy on every host -- so every host got a server only one machine could run. Build it the way models.json is built, from fragments another layer or one machine drops in mcp.d; this repo ships none, and with none the file is left absent. pi-models.py becomes pi-merge.py, which takes the key to merge and an optional base file, and serves both. --- AGENTS.md | 2 ++ link/pi-merge.py | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ link/pi-models.py | 66 ------------------------------------------ link/pi.sh | 30 ++++++++++++++++++-- 4 files changed, 115 insertions(+), 68 deletions(-) create mode 100644 link/pi-merge.py delete mode 100644 link/pi-models.py diff --git a/AGENTS.md b/AGENTS.md index 2658b34..fd81c8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,7 @@ what it needs through these, each of them optional and absent by default: | `~/.claude/rules/*.md` | extra Claude Code user memory, loaded next to `config/claude/CLAUDE.md` | | `~/.config/pi/settings.d/*.json` | pi settings merged into the generated `~/.pi/agent/settings.json` by `link/pi.sh` | | `~/.config/pi/models.d/*.json` | pi providers merged into the generated `~/.pi/agent/models.json` by `link/pi.sh` | +| `~/.config/pi/mcp.d/*.json` | pi MCP servers, merged into the generated `~/.pi/agent/mcp.json` by `link/pi.sh`; this repo ships none | | `~/.pi/agent/extensions/*`, real files | pi extensions only this machine has -- herdr's pi integration installs `herdr-agent-state.ts` here | pi itself, beside the repo's extensions, which `link/pi.sh` links in one by one | A credential in a pi provider -- `apiKey`, or anything under `headers` -- is a @@ -189,6 +190,7 @@ tracked config says so, and in no repo: | `~/.config/dot/skip` | which `link/` parts this machine does not want, one name per line | `link/lib.sh`, as each part starts | | `~/.config/dot/inbox-key` | the key this machine signs change requests with | `bin/dot-request` | | `~/.config/pi/models.d/*.json` | pi providers only this machine has -- a model served somewhere other than a vendor's own API | `link/pi.sh`, merged over `config/pi/models.json` | +| `~/.config/pi/mcp.d/*.json` | pi MCP servers only this machine has | `link/pi.sh`, merged into `~/.pi/agent/mcp.json` | Not covered today: bat, lsd, and `~/.claude/settings.json` -- which a machine can only leave alone entirely, by naming `claude` in the skip file. diff --git a/link/pi-merge.py b/link/pi-merge.py new file mode 100644 index 0000000..1c2f822 --- /dev/null +++ b/link/pi-merge.py @@ -0,0 +1,85 @@ +"""Generate one of pi's JSON files from the repo and its fragments. + + pi-merge.py + +Run by link/pi.sh, for ~/.pi/agent/models.json (key "providers") and +~/.pi/agent/mcp.json (key "mcpServers"). It lives in a file rather than a +heredoc inside $( ): macOS ships bash 3.2, which does not skip heredoc +content when it scans for the end of a command substitution, so an odd +number of apostrophes in here would stop the whole script parsing -- which +it did, on 2026-09-25. +""" + +import glob, json, os, sys + +merge_key, base_path, frag_dir, target = sys.argv[1:5] + + +def load(path): + with open(path) as f: + return json.load(f) + + +# The repo's file, if there is one, then each fragment's. A fragment may add an +# entry under merge_key or replace one of the same name; any other top-level key +# takes the last file's value. Which entry came from where is reported: a +# fragment quietly shadowing one the repo ships would otherwise be invisible. +# With no repo file every entry comes from a fragment, so only a replacement -- +# one fragment shadowing another's -- is worth a note. +paths = sorted(glob.glob(os.path.join(frag_dir, "*.json"))) +if base_path != "-": + paths.insert(0, base_path) + +# A symlink is the pre-generation state: replace it, even with no fragments, so +# the live file is never a tracked one. +was_link = os.path.islink(target) + +# Nothing to generate from, and nothing there: leave it absent rather than +# create an empty file. +if not paths and not was_link and not os.path.exists(target): + print("unchanged:") + sys.exit(0) + +desired = {} +notes = [] +for path in paths: + try: + layer = load(path) + except (OSError, ValueError) as e: + print("error:cannot read %s: %s" % (path, e)) + sys.exit(0) + for key, value in layer.items(): + if key == merge_key: + entries = desired.setdefault(merge_key, {}) + if path != base_path: + for name in value: + if base_path == "-" and name not in entries: + continue + notes.append("%s %s by %s" % ( + name, + "replaced" if name in entries else "added", + os.path.basename(path), + )) + entries.update(value) + else: + desired[key] = value + +try: + current = None if was_link else load(target) +except (OSError, ValueError): + current = None + +for note in notes: + print("note:" + note) + +if current == desired: + print("unchanged:") +elif os.environ.get("DRY_RUN"): + print("would-write:" + ("replacing the link" if was_link else "merged changes")) +else: + tmp = target + ".dotfiles-tmp" + with open(tmp, "w") as f: + json.dump(desired, f, indent=2) + f.write("\n") + os.replace(tmp, target) + print("wrote:" + ("replaced the link" if was_link else "merged changes")) diff --git a/link/pi-models.py b/link/pi-models.py deleted file mode 100644 index 6188c94..0000000 --- a/link/pi-models.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Generate ~/.pi/agent/models.json from the repo and its fragments. - -Run by link/pi.sh. It lives in a file rather than a heredoc inside $( ): -macOS ships bash 3.2, which does not skip heredoc content when it scans -for the end of a command substitution, so an odd number of apostrophes in -here would stop the whole script parsing -- which it did, on 2026-09-25. -""" - -import glob, json, os, sys - -base_path, frag_dir, target = sys.argv[1:4] - - -def load(path): - with open(path) as f: - return json.load(f) - - -# The repo's providers, then each fragment's. A fragment may add a provider or -# replace one of the same name; any other top-level key takes the last file's -# value. Which provider came from where is reported: a fragment quietly -# shadowing a provider the repo ships would otherwise be invisible. -desired = {} -notes = [] -for path in [base_path] + sorted(glob.glob(os.path.join(frag_dir, "*.json"))): - try: - layer = load(path) - except (OSError, ValueError) as e: - print("error:cannot read %s: %s" % (path, e)) - sys.exit(0) - for key, value in layer.items(): - if key == "providers": - providers = desired.setdefault("providers", {}) - if path != base_path: - for name in value: - notes.append("%s %s by %s" % ( - name, - "replaced" if name in providers else "added", - os.path.basename(path), - )) - providers.update(value) - else: - desired[key] = value - -# A symlink is the pre-generation state: replace it, even with no fragments, so -# the live file is never the tracked one. -was_link = os.path.islink(target) -try: - current = None if was_link else load(target) -except (OSError, ValueError): - current = None - -for note in notes: - print("note:" + note) - -if current == desired: - print("unchanged:") -elif os.environ.get("DRY_RUN"): - print("would-write:" + ("replacing the link" if was_link else "merged changes")) -else: - tmp = target + ".dotfiles-tmp" - with open(tmp, "w") as f: - json.dump(desired, f, indent=2) - f.write("\n") - os.replace(tmp, target) - print("wrote:" + ("replaced the link" if was_link else "merged changes")) diff --git a/link/pi.sh b/link/pi.sh index 5aec48f..727ed2f 100755 --- a/link/pi.sh +++ b/link/pi.sh @@ -1,6 +1,7 @@ #!/bin/bash -# pi's extensions directory, and its settings.json and models.json, generated. +# pi's extensions directory, and its settings.json, models.json and mcp.json, +# generated. # Dry run by default; pass `yes` to apply. set -euo pipefail @@ -98,7 +99,8 @@ esac # never writes it, keeping its runtime state in the sibling models-store.json -- # so what lands is whatever the repo and the fragments say. A hand-edit of # ~/.pi/agent/models.json does not survive a relink; put it in a fragment. -pi_models_out="$(DRY_RUN="$is_dry_run" python3 "$dotfiles_dir/link/pi-models.py" \ +pi_models_out="$(DRY_RUN="$is_dry_run" python3 "$dotfiles_dir/link/pi-merge.py" \ + providers \ "$config_dir/pi/models.json" \ "$xdg_config_dir/pi/models.d" \ "$pi_dir/models.json")" @@ -113,3 +115,27 @@ do error) red "pi models: ${line#*:}" ;; esac done <<< "$pi_models_out" + +# pi's mcp.json, the same way: pi reads user-level MCP servers from this one +# file only, and the servers are another layer's or one machine's business -- +# a browser on one host, a server behind a credential -- so this repo ships +# none. It is built from every ~/.config/pi/mcp.d/*.json alone, and left +# absent where there are none. It used to be a private layer's link to one +# file for every host, which gave each of them a server only one could run. +# `pi mcp add` writes here and does not survive a relink; add a fragment. +pi_mcp_out="$(DRY_RUN="$is_dry_run" python3 "$dotfiles_dir/link/pi-merge.py" \ + mcpServers \ + - \ + "$xdg_config_dir/pi/mcp.d" \ + "$pi_dir/mcp.json")" + +while IFS= read -r line +do + case "${line%%:*}" in + note) yellow "pi mcp: ${line#*:}" ;; + wrote) green "pi mcp: ${line#*:}" ;; + unchanged) green "pi mcp: unchanged" ;; + would-write) yellow "pi mcp will be generated: ${line#*:}" ;; + error) red "pi mcp: ${line#*:}" ;; + esac +done <<< "$pi_mcp_out" -- cgit v1.2.3