diff options
Diffstat (limited to 'link')
| -rw-r--r-- | link/pi-models.py | 66 | ||||
| -rw-r--r-- | link/pi-settings.py | 181 | ||||
| -rwxr-xr-x | link/pi.sh | 243 |
3 files changed, 251 insertions, 239 deletions
diff --git a/link/pi-models.py b/link/pi-models.py new file mode 100644 index 0000000..6188c94 --- /dev/null +++ b/link/pi-models.py @@ -0,0 +1,66 @@ +"""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-settings.py b/link/pi-settings.py new file mode 100644 index 0000000..0da5b99 --- /dev/null +++ b/link/pi-settings.py @@ -0,0 +1,181 @@ +"""Generate ~/.pi/agent/settings.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, 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")) @@ -40,187 +40,12 @@ then exit 0 fi -pi_status="$(DRY_RUN="$is_dry_run" python3 - \ +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" <<'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 -)" + "$config_dir")" case "${pi_status%%:*}" in wrote) green "pi settings: ${pi_status#*:}" ;; @@ -242,70 +67,10 @@ 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 - \ +pi_models_out="$(DRY_RUN="$is_dry_run" python3 "$dotfiles_dir/link/pi-models.py" \ "$config_dir/pi/models.json" \ "$xdg_config_dir/pi/models.d" \ - "$pi_dir/models.json" <<'PY' -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")) -PY -)" + "$pi_dir/models.json")" while IFS= read -r line do |
