summaryrefslogtreecommitdiff
path: root/link
diff options
context:
space:
mode:
Diffstat (limited to 'link')
-rw-r--r--link/pi-models.py66
-rw-r--r--link/pi-settings.py181
-rwxr-xr-xlink/pi.sh243
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"))
diff --git a/link/pi.sh b/link/pi.sh
index 22ecd96..0fde248 100755
--- a/link/pi.sh
+++ b/link/pi.sh
@@ -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