#!/bin/bash # pi's settings.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_status="$(DRY_RUN="$is_dry_run" python3 - \ "$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" <<'PY' import glob, json, os, sys base_path, frag_dir, target, state_path, config_dir = sys.argv[1:6] LIST_KEYS = ("packages", "extensions", "skills", "prompts", "themes") PATH_KEYS = ("extensions", "skills", "prompts", "themes") def load(path): with open(path) as f: return json.load(f) def source(entry): return entry if isinstance(entry, str) else entry.get("source") def add_packages(into, entries): for entry in entries: key = source(entry) existing = next((i for i, e in enumerate(into) if source(e) == key), None) if existing is None: into.append(entry) elif not isinstance(entry, str): into[existing] = entry # What the repos want now. desired = {} 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) here = os.path.dirname(os.path.realpath(path)) for key, value in layer.items(): if key in PATH_KEYS and path != base_path: value = [v if os.path.isabs(os.path.expanduser(v)) else os.path.normpath(os.path.join(here, v)) for v in value] if key == "packages": add_packages(desired.setdefault("packages", []), value) elif key in LIST_KEYS: merged = desired.setdefault(key, []) merged.extend(v for v in value if v not in merged) else: desired[key] = value # What is there. A symlink is the pre-generation state: its target is a tracked # file this repo now owns, so treat it as already matching. def snapshot(): """What is on disk now, to notice a write that lands before the lock.""" try: return (os.path.islink(target), open(target).read()) except OSError: return (os.path.islink(target), None) on_disk = snapshot() first_run = False if os.path.islink(target): first_run = True try: local = load(target) except (OSError, ValueError): local = {} local = {k: v for k, v in local.items() if k not in desired and k not in LIST_KEYS and k != "packages"} local.update(json.loads(json.dumps(desired))) last = desired else: try: local = load(target) except FileNotFoundError: local = {} except ValueError as e: print("error:%s is not valid JSON, leaving it: %s" % (target, e)) sys.exit(0) # No record of a previous run -- a new machine where pi made its own file # first, or lost state: nothing is known to have come from the repos, so # keys already there are kept and keys missing arrive. try: last = load(state_path) except (OSError, ValueError): last = {} MISSING = object() def three_way(ours, before, theirs): """ours: on disk; before: generated last time; theirs: generated now.""" if ours == before: return theirs return ours def merge_list(key, identity): """Per entry, three-way, in generated order then local-only extras.""" ours = {identity(e): e for e in local.get(key, [])} before = {identity(e): e for e in last.get(key, [])} theirs = {identity(e): e for e in desired.get(key, [])} merged = [] for ident in [identity(e) for e in desired.get(key, [])] + \ [identity(e) for e in local.get(key, []) if identity(e) not in theirs]: value = three_way(ours.get(ident, MISSING), before.get(ident, MISSING), theirs.get(ident, MISSING)) if value is not MISSING: merged.append(value) return merged result = {} for key in list(local) + [k for k in desired if k not in local]: if key in LIST_KEYS: merged = merge_list(key, source if key == "packages" else (lambda e: e)) if merged or key in local: result[key] = merged continue value = three_way(local.get(key, MISSING), last.get(key, MISSING), desired.get(key, MISSING)) if value is not MISSING: result[key] = value changed = first_run or result != local lock = target + ".lock" def take_lock(): """pi's own lock (proper-lockfile: a directory, stale after 10s), so neither side can read, then write over the other's change.""" import time for _ in range(2): try: os.mkdir(lock) return True except FileExistsError: try: if time.time() - os.stat(lock).st_mtime > 10: os.rmdir(lock) continue except FileNotFoundError: continue return False return False def save_state(): os.makedirs(os.path.dirname(state_path), exist_ok=True) with open(state_path + ".tmp", "w") as f: json.dump(desired, f, indent=2) os.replace(state_path + ".tmp", state_path) if not changed: print("unchanged:") if not os.environ.get("DRY_RUN"): save_state() elif os.environ.get("DRY_RUN"): print("would-write:" + ("replacing the link" if first_run else "merged changes")) elif not take_lock(): print("locked:") elif snapshot() != on_disk: # pi wrote between the read above and taking the lock: merge again next time # rather than write over it. os.rmdir(lock) print("locked:") else: try: tmp = target + ".dotfiles-tmp" with open(tmp, "w") as f: json.dump(result, f, indent=2) f.write("\n") os.replace(tmp, target) save_state() finally: os.rmdir(lock) print("wrote:" + ("replaced the link" if first_run else "merged changes")) PY )" 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