diff options
| author | Igor Soarez <igor@soarez.org> | 2026-09-25 12:20:13 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-09-25 12:20:13 +0300 |
| commit | ed21d1270007a8f9cbaa2ca7da0fee1eefeb405f (patch) | |
| tree | 9f71c392b5c087b55304d1bb7123d0e436c0f343 | |
| parent | 220553f1f016707a883632edb38b5e767728df97 (diff) | |
link/pi.sh: the generators move into files, and parse under bash 3.2 again
57e49dd made link/pi.sh unparseable on macOS. bash 3.2 does not skip heredoc
content when it scans for the end of a command substitution, so the three
apostrophes in a comment inside the new heredoc read as an unterminated quote
and the script stopped parsing at end of file. ./link.sh on a Mac aborted
partway: settings still generated, models.json stayed the symlink the commit
meant to replace, and everything after that point never ran. Linux was fine,
and so was every check I ran -- `bash` on a Mac with Homebrew is 5.x, which
parses it happily. Reported from the machine it broke.
Rewording the comment would fix it and leave the trap armed for the next edit,
so the two python programs move into link/pi-settings.py and link/pi-models.py
instead, called by path. The text is unchanged; there is no longer a heredoc
inside $( ) to trip over, and both files now compile-check on their own.
Verified under /bin/bash 3.2: first apply replaces both symlinks, a package
removed by hand still survives a relink, fragments merge for settings and for
models, and repeat runs report unchanged.
| -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 |
