#!/bin/bash # 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 # shellcheck source-path=SCRIPTDIR # shellcheck source=lib.sh source "$(dirname "$0")/lib.sh" "$@" # ~/.pi/agent/settings.json is a real file built from config/pi/settings.json # plus every ~/.config/pi/settings.d/*.json that another layer links in. It # cannot be a symlink into a repo: pi has no second global settings file to # layer with, and it writes to this one at runtime (lastChangelogVersion, # /model, `pi install`), which used to dirty the tracked file. # # Merging the sources: list keys (packages, extensions, skills, prompts, # themes) are concatenated in file order, a package deduplicated by its source # (an object entry beats a bare string); any other key takes the last file's # value. Relative paths in a fragment's `extensions`, `skills`, `prompts` and # `themes` resolve against the fragment's real location, so a layer can point # at files in its own checkout. # # Merging with what is already there is three-way, against what this script # generated last time (kept in ~/.local/state/dotfiles/pi-settings.json): a key # -- or a list entry, a package by its source -- that pi or the operator has not # changed since then takes the new generated value, removal included; one they # changed keeps their value. With no record of a last run, what is there is kept # and what is missing is added. So a /model choice or a `pi install` survives # relinks, and a package dropped from a repo really goes. Keys nothing generates # (lastChangelogVersion) are simply kept. # # Written under pi's own settings lock, via a temporary file and a rename; a run # that finds the lock held skips and says so, and the next link catches up. # # Run after other layers' link.sh, so their fragments are in place. pi_dir="$HOME/.pi/agent" if [[ ! -d "$pi_dir" ]] then exit 0 fi # pi's extensions: a real directory, with this repo's tracked extensions linked # in one by one, so an extension only one machine has -- herdr's pi integration # writes herdr-agent-state.ts here -- sits beside them instead of landing in this # checkout. pi loads every *.ts and *.js there and follows links, and it skips a # dangling one silently (resource-loader.js, pi 1.0.0), so the link left behind # by an extension deleted from the repo does no harm. # # Machines used to link the whole directory to the repo. That link goes first: # linking into it would write through to the repo and turn its files into links # to themselves. Tracked files only, so nothing a machine left in the repo's # directory is linked back in. pi_extensions="$pi_dir/extensions" if [[ -L "$pi_extensions" ]] then if [[ -n "${is_dry_run}" ]] then yellow "$pi_extensions is a link to the repo; will become a directory of links to its tracked files" else rm "$pi_extensions" fi fi if [[ ! -L "$pi_extensions" ]] then [[ -n "${is_dry_run}" ]] || mkdir -p "$pi_extensions" while IFS= read -r -d '' f do link "$dotfiles_dir/$f" "$pi_extensions/$(basename "$f")" done < <(git -C "$dotfiles_dir" ls-files -z -- config/pi/extensions) fi pi_status="$(DRY_RUN="$is_dry_run" python3 "$dotfiles_dir/link/pi-settings.py" \ "$config_dir/pi/settings.json" \ "$xdg_config_dir/pi/settings.d" \ "$pi_dir/settings.json" \ "${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles/pi-settings.json" \ "$config_dir")" case "${pi_status%%:*}" in wrote) green "pi settings: ${pi_status#*:}" ;; unchanged) green "pi settings: unchanged" ;; would-write) yellow "pi settings will be generated: ${pi_status#*:}" ;; locked) yellow "pi settings: pi holds its settings lock; left as is, the next link catches up" ;; error) red "pi settings: ${pi_status#*:}" ;; esac # pi's models.json, generated too. It used to be a plain symlink to the tracked # file, which left a machine whose models come from somewhere other than a # vendor's own API with nowhere to say so: pi reads providers from exactly one # path (join(getAgentDir(), "models.json")), and a `providers` block in a # settings fragment is inert, since the settings schema has no such key. # # So the same shape as the settings above: the repo's file, then every # ~/.config/pi/models.d/*.json another layer or this machine puts there. Unlike # the settings this is a plain generated file with no three-way merge -- pi # 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-merge.py" \ providers \ "$config_dir/pi/models.json" \ "$xdg_config_dir/pi/models.d" \ "$pi_dir/models.json")" while IFS= read -r line do case "${line%%:*}" in note) yellow "pi models: ${line#*:}" ;; wrote) green "pi models: ${line#*:}" ;; unchanged) green "pi models: unchanged" ;; would-write) yellow "pi models will be generated: ${line#*:}" ;; 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"