summaryrefslogtreecommitdiff
path: root/link
diff options
context:
space:
mode:
Diffstat (limited to 'link')
-rw-r--r--link/pi-merge.py85
-rw-r--r--link/pi-models.py66
-rwxr-xr-xlink/pi.sh30
3 files changed, 113 insertions, 68 deletions
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 <key> <base|-> <fragment dir> <target>
+
+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"