summaryrefslogtreecommitdiff
path: root/link
diff options
context:
space:
mode:
Diffstat (limited to 'link')
-rwxr-xr-xlink/claude.sh220
-rwxr-xr-xlink/hammerspoon.sh22
-rwxr-xr-xlink/icloud.sh23
-rwxr-xr-xlink/kwin.sh30
-rw-r--r--link/lib.sh122
-rwxr-xr-xlink/pi.sh231
-rwxr-xr-xlink/skills.sh129
-rwxr-xr-xlink/ssh.sh70
8 files changed, 847 insertions, 0 deletions
diff --git a/link/claude.sh b/link/claude.sh
new file mode 100755
index 0000000..b7b3f9e
--- /dev/null
+++ b/link/claude.sh
@@ -0,0 +1,220 @@
+#!/bin/bash
+
+# Claude Code and Claude Desktop: settings, user memory, MCP servers. Dry run by
+# default; pass `yes` to apply.
+
+set -euo pipefail
+
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=lib.sh
+source "$(dirname "$0")/lib.sh" "$@"
+
+link "$config_dir/claude/settings.json" "$HOME/.claude/settings.json"
+
+# User-level memory, loaded into every Claude Code session on this machine. What
+# only makes sense on some machines goes in ~/.claude/rules/, which Claude Code
+# loads beside it, linked there by whichever layer owns it.
+link "$config_dir/claude/CLAUDE.md" "$HOME/.claude/CLAUDE.md"
+
+# User-level MCP servers. This repo defines none: every *.json in
+# ~/.config/claude/mcp-servers.d/ maps server names to specs, and other layers
+# link their files in. This part is the only thing that registers them, so it
+# can also unregister: the names it registered last time are kept in a
+# manifest, and any no longer defined in the directory are removed -- from
+# Claude Code and Claude Desktop alike. Servers registered by any other means
+# are left alone.
+#
+# They cannot be linked: Claude Code only reads them from ~/.claude.json, which
+# it also rewrites constantly with session state, so they are pushed in with
+# `claude mcp add-json`. Re-adding on every run is deliberate -- it is how an
+# edited definition takes effect.
+#
+# Run by link.sh after other layers' link.sh, so their files are in place on a
+# first link.
+mcp_dir="$xdg_config_dir/claude/mcp-servers.d"
+mcp_manifest="${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles/claude-mcp-servers"
+claude_desktop_dir="$HOME/Library/Application Support/Claude"
+
+have_claude=""
+command -v claude >/dev/null && have_claude=y
+have_desktop=""
+[[ -d "$claude_desktop_dir" ]] && have_desktop=y
+
+# Nothing to register with: done. Checked before anything else runs, python3
+# included -- on a Mac without the command line tools /usr/bin/python3 is a stub
+# that fails.
+if [[ -z "$have_claude" && -z "$have_desktop" ]]
+then
+ exit 0
+fi
+
+# name<TAB>spec, one per server, from every file in the directory. A name
+# defined twice, or a file that is not a JSON object of servers, skips MCP
+# registration for this run -- loudly, and without touching the manifest -- but
+# does not stop link.sh from linking everything else.
+if ! mcp_wanted="$(python3 -c 'import glob, json, os, sys
+servers = {}
+for path in sorted(glob.glob(os.path.join(sys.argv[1], "*.json"))):
+ try:
+ defined = json.load(open(path))
+ except (OSError, ValueError) as e:
+ sys.exit("cannot read %s: %s" % (path, e))
+ if not isinstance(defined, dict):
+ sys.exit("%s is not an object of MCP servers" % path)
+ for name, spec in defined.items():
+ if name in servers:
+ sys.exit("MCP server %s is defined twice (again in %s)" % (name, path))
+ servers[name] = spec
+for name, spec in servers.items():
+ print(name, json.dumps(spec), sep="\t")' "$mcp_dir" 2>&1)"
+then
+ red "MCP servers not registered: $mcp_wanted"
+ exit 0
+fi
+
+mcp_wanted_names="$(printf '%s\n' "$mcp_wanted" | cut -f1 | sed '/^$/d' | sort)"
+mcp_previous_names=""
+if [[ -r "$mcp_manifest" ]]
+then
+ mcp_previous_names="$(sed '/^$/d' "$mcp_manifest" | sort)"
+fi
+# Registered last time, not wanted now.
+mcp_stale_names="$(comm -23 <(printf '%s\n' "$mcp_previous_names" | sed '/^$/d') \
+ <(printf '%s\n' "$mcp_wanted_names" | sed '/^$/d'))"
+
+if [[ -n "$have_claude" ]]
+then
+ while IFS= read -r name
+ do
+ [[ -n "$name" ]] || continue
+ if [[ -n "${is_dry_run}" ]]
+ then
+ echo "Will unregister MCP server (user scope): $name"
+ elif claude mcp remove --scope user "$name" >/dev/null 2>&1
+ then
+ yellow "MCP server (user scope) removed: $name"
+ else
+ yellow "MCP server (user scope) already gone: $name"
+ fi
+ done <<< "$mcp_stale_names"
+
+ while IFS=$'\t' read -r name spec
+ do
+ [[ -n "$name" ]] || continue
+ if [[ -n "${is_dry_run}" ]]
+ then
+ echo "Will register MCP server (user scope): $name"
+ else
+ green "MCP server (user scope): $name"
+ claude mcp remove --scope user "$name" >/dev/null 2>&1 || true
+ claude mcp add-json --scope user "$name" "$spec" >/dev/null
+ fi
+ done <<< "$mcp_wanted"
+fi
+
+# The same servers for Claude Desktop, which is a separate app reading a
+# separate file - registering with Claude Code above does nothing for it. Not a
+# symlink for the same reason as ~/.claude.json: the app rewrites this file with
+# its own UI preferences, so only the servers this part manages are touched --
+# the wanted ones set, the stale ones removed -- and the rest of the file,
+# including servers added some other way, is left as it is. Only where Desktop
+# is installed, so other machines stay clean.
+#
+# Two fixups on the way in, both because Desktop is a GUI app and so inherits
+# launchd's PATH (/usr/bin:/bin:/usr/sbin:/sbin) rather than a login shell's.
+# Claude Code needs neither, being started from a shell.
+#
+# command resolved to an absolute path, since a bare "npx" lives in
+# /opt/homebrew/bin and is not on that PATH.
+#
+# env.PATH set to include the command's own directory. Resolving the command
+# is not sufficient on its own: /opt/homebrew/bin/npx is a
+# "#!/usr/bin/env node" script, so spawning it with a stripped PATH
+# gets "env: node: No such file or directory" - the interpreter is
+# looked up on PATH even when the script itself was not.
+#
+# Desktop only reads the file at launch, so an edit needs a restart to take.
+if [[ -n "$have_desktop" ]]
+then
+ desktop_status="$(DRY_RUN="$is_dry_run" MCP_WANTED="$mcp_wanted" MCP_STALE="$mcp_stale_names" python3 -c 'import json, os, shutil, sys
+
+dst = sys.argv[1]
+
+GUI_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"
+
+wanted = {}
+missing = []
+for line in os.environ["MCP_WANTED"].splitlines():
+ if not line:
+ continue
+ name, spec = line.split("\t", 1)
+ spec = json.loads(spec)
+ command = spec.get("command")
+ if command:
+ if not os.path.isabs(command):
+ found = shutil.which(command)
+ if not found:
+ missing.append(name + " (" + command + ")")
+ continue
+ command = found
+ spec["command"] = command
+ bindir = os.path.dirname(command)
+ env = dict(spec.get("env") or {})
+ parts = [p for p in env.get("PATH", GUI_PATH).split(":") if p and p != bindir]
+ env["PATH"] = ":".join([bindir] + parts)
+ spec["env"] = env
+ wanted[name] = spec
+stale = [n for n in os.environ["MCP_STALE"].splitlines() if n]
+
+try:
+ with open(dst) as f:
+ config = json.load(f)
+except (FileNotFoundError, ValueError):
+ config = {}
+
+servers = dict(config.get("mcpServers") or {})
+before = dict(servers)
+for name in stale:
+ servers.pop(name, None)
+servers.update(wanted)
+
+summary = ",".join(sorted(wanted)) or "none"
+if stale:
+ summary += " (removing " + ",".join(stale) + ")"
+if missing:
+ summary += "; skipped, command not on PATH: " + ", ".join(missing)
+
+# Unchanged also covers a Mac with Desktop and no servers to manage: the file is
+# not created just to hold an empty mcpServers.
+if servers == before:
+ print("unchanged:" + summary)
+ sys.exit(0)
+
+if os.environ.get("DRY_RUN"):
+ print("would-write:" + summary)
+ sys.exit(0)
+
+config["mcpServers"] = servers
+tmp = dst + ".tmp"
+with open(tmp, "w") as f:
+ json.dump(config, f, indent=2)
+ f.write("\n")
+os.replace(tmp, dst)
+print("wrote:" + summary)
+' "$claude_desktop_dir/claude_desktop_config.json")"
+
+ case "${desktop_status%%:*}" in
+ wrote) green "MCP server (Claude Desktop): ${desktop_status#*:} - restart Claude Desktop to load" ;;
+ unchanged) green "MCP server (Claude Desktop): ${desktop_status#*:}" ;;
+ would-write) echo "Will register MCP server (Claude Desktop): ${desktop_status#*:}" ;;
+ esac
+fi
+
+# Last, so a run that fails part-way still knows next time what it has left to
+# remove. Removal is idempotent on both sides, so listing a server that one of
+# them never got is harmless.
+if [[ -z "${is_dry_run}" ]]
+then
+ mkdir -p "$(dirname "$mcp_manifest")"
+ printf '%s\n' "$mcp_wanted_names" | sed '/^$/d' > "$mcp_manifest"
+fi
diff --git a/link/hammerspoon.sh b/link/hammerspoon.sh
new file mode 100755
index 0000000..71c9c91
--- /dev/null
+++ b/link/hammerspoon.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+
+# Hammerspoon config. Dry run by default; pass `yes` to apply.
+
+set -euo pipefail
+
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=lib.sh
+source "$(dirname "$0")/lib.sh" "$@"
+
+link "$config_dir/hammerspoon/init.lua" "$xdg_config_dir/hammerspoon/init.lua"
+
+if [[ "$(uname)" == "Darwin" ]]
+then
+ if [[ -n "${is_dry_run}" ]]
+ then
+ echo "Will set: "
+ echo "defaults write org.hammerspoon.Hammerspoon MJConfigFile \"~/.config/hammerspoon/init.lua\""
+ else
+ defaults write org.hammerspoon.Hammerspoon MJConfigFile "$HOME/.config/hammerspoon/init.lua"
+ fi
+fi
diff --git a/link/icloud.sh b/link/icloud.sh
new file mode 100755
index 0000000..6daa287
--- /dev/null
+++ b/link/icloud.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+
+# Short names for iCloud Drive, on a Mac signed into it. Dry run by default;
+# pass `yes` to apply.
+
+set -euo pipefail
+
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=lib.sh
+source "$(dirname "$0")/lib.sh" "$@"
+
+# ~/.icloud is iCloud Drive itself and ~/.cloud its dotfiles folder, which holds
+# what cannot go in a repo. ~/.cloud is load-bearing: hammerspoon reads its text
+# snippets from ~/.cloud/shorts.
+#
+# These used to be made by ~/.cloud/link.sh, along with ~/.keys and ~/.ssh as
+# links into iCloud. Those two are gone for good: ~/.ssh is a real local
+# directory, and the one key still kept in iCloud is linked by a private layer.
+if [[ -d "$icloud_dir" ]]
+then
+ link "$icloud_dir" "$HOME/.icloud"
+ link "$icloud_dir/dotfiles" "$HOME/.cloud"
+fi
diff --git a/link/kwin.sh b/link/kwin.sh
new file mode 100755
index 0000000..409bd57
--- /dev/null
+++ b/link/kwin.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+
+# KWin window-layout script, on Plasma hosts only. Dry run by default; pass
+# `yes` to apply.
+
+set -euo pipefail
+
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=lib.sh
+source "$(dirname "$0")/lib.sh" "$@"
+
+# KWin counterpart of the hammerspoon window shortcuts, on Plasma hosts only.
+# Linking installs the script but KWin runs only those enabled in kwinrc; the
+# reconfigure makes a running session pick it up without a re-login, and is
+# allowed to fail when no KWin is running. --print-reply is what detects that:
+# without it dbus-send exits 0 as soon as the call is sent, even to a session
+# bus with no KWin on it.
+kwin_script="sz-window-layout"
+if command -v kwin_wayland >/dev/null && command -v kwriteconfig6 >/dev/null
+then
+ link "$config_dir/kwin/$kwin_script" "$HOME/.local/share/kwin/scripts/$kwin_script"
+ if [[ -n "${is_dry_run}" ]]
+ then
+ echo "Will set: kwinrc [Plugins] ${kwin_script}Enabled=true"
+ else
+ kwriteconfig6 --file kwinrc --group Plugins --key "${kwin_script}Enabled" true
+ dbus-send --session --print-reply --dest=org.kde.KWin /KWin org.kde.KWin.reconfigure >/dev/null 2>&1 \
+ || yellow "KWin not reachable; $kwin_script loads at next login"
+ fi
+fi
diff --git a/link/lib.sh b/link/lib.sh
new file mode 100644
index 0000000..1899f00
--- /dev/null
+++ b/link/lib.sh
@@ -0,0 +1,122 @@
+# shellcheck shell=bash
+# shellcheck disable=SC2034 # the paths below are for whichever script sources this
+#
+# Shared by link.sh and the link/*.sh parts: the dry-run flag, colours, link and
+# link_all, and the paths they all use. Source it with the script's own
+# arguments:
+#
+# source "$(dirname "$0")/lib.sh" "$@"
+#
+# Dry run unless the first argument is `yes`.
+#
+# Other repos' link.sh source this file instead of keeping copies, finding it
+# through $DOT_LINK_LIB, which link.sh exports for them. Keep these names and
+# the `yes` convention stable, or change them there as well.
+
+is_dry_run="y"
+if [[ "${1:-}" == "yes" ]]
+then
+ is_dry_run=""
+fi
+
+color() {
+ color_code="$1"
+ shift
+ # Black 0;30 Dark Gray 1;30
+ # Red 0;31 Light Red 1;31
+ # Green 0;32 Light Green 1;32
+ # Brown/Orange 0;33 Yellow 1;33
+ # Blue 0;34 Light Blue 1;34
+ # Purple 0;35 Light Purple 1;35
+ # Cyan 0;36 Light Cyan 1;36
+ # Light Gray 0;37 White 1;37
+ yes_color="\033[${color_code}m"
+ no_color='\033[0m'
+ echo -e "${yes_color}$*${no_color}"
+}
+
+green() {
+ color '0;32' "$@"
+}
+
+yellow() {
+ color '1;33' "$@"
+}
+
+red() {
+ color '0;31' "$@"
+}
+
+# Link up dotfiles
+link() {
+ local target="$1"
+ local link="$2"
+ if [[ -z "$1" ]]
+ then
+ >&2 echo "Error: need valid target"
+ return 1
+ fi
+ if [[ -z "$2" ]]
+ then
+ >&2 echo "Error: need valid link"
+ return 1
+ fi
+ target="$(realpath "${target}")"
+
+ if [[ -n "${is_dry_run}" ]]
+ then
+ local actual
+ actual="$(readlink "$link" || true)"
+ if [[ -n "${actual}" ]] ; then
+ actual="$(realpath "$actual" || true)"
+ fi
+
+ if [[ -L "$link" && "$target" == "$actual" ]]
+ then
+ green "$link already exists and is correct"
+ return
+ elif [[ -e "$link" ]]
+ then
+ if diff "$link" "$target" > /dev/null
+ then
+ yellow "$link already exists and will be deleted first but contents are the same. $target != $actual"
+ else
+ red "$link WILL CHANGE"
+ fi
+ else
+ yellow "$link will be created"
+ fi
+ else
+ mkdir -p "$(dirname "$2")"
+ rm -rf "$2"
+ ln -sf "$1" "$2"
+ fi
+}
+
+link_all() {
+ from="$1"
+ to="$2"
+ # Process substitution rather than a pipe, so `link` runs in this shell and
+ # not a subshell; NUL-delimited so names with spaces survive (SC2044/SC2086).
+ while IFS= read -r -d '' f
+ do
+ link "$f" "$to/$(basename "$f")"
+ done < <(find "$from" -mindepth 1 -maxdepth 1 -print0)
+}
+
+# Run another script with this interface, dry or not as this one is.
+run_link() {
+ if [[ -n "${is_dry_run}" ]]
+ then
+ "$1"
+ else
+ "$1" yes
+ fi
+}
+
+dotfiles_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+config_dir="$dotfiles_dir/config"
+xdg_config_dir="${XDG_CONFIG_HOME:-$HOME/.config}"
+
+# iCloud Drive, on a Mac signed into it; absent everywhere else.
+icloud_dir="$HOME/Library/Mobile Documents/com~apple~CloudDocs"
diff --git a/link/pi.sh b/link/pi.sh
new file mode 100755
index 0000000..2f0a05c
--- /dev/null
+++ b/link/pi.sh
@@ -0,0 +1,231 @@
+#!/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
diff --git a/link/skills.sh b/link/skills.sh
new file mode 100755
index 0000000..298883f
--- /dev/null
+++ b/link/skills.sh
@@ -0,0 +1,129 @@
+#!/bin/bash
+
+# Agent skills, into every installed agent. Dry run by default; pass `yes` to
+# apply.
+
+set -euo pipefail
+
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=lib.sh
+source "$(dirname "$0")/lib.sh" "$@"
+
+# Agent skills (portable SKILL.md format), linked per skill into every installed
+# agent's skills dir, so skills from plugins or other tooling are left alone.
+# Two sources: this repo's skills/, and ~/.config/agent-skills.d/, where another
+# layer links skills of its own.
+# Agents that copy skills rather than follow links are not covered.
+#
+# Each entry is "<agent dir>:<skills dir>"; only where the agent dir already
+# exists, so machines without a given agent stay clean.
+#
+# Guards, because link() starts with `rm -rf`:
+# - a real file or directory in an agent's skills dir (Claude Code keeps its
+# own `synced/` there) is never replaced; the skill is skipped, loudly.
+# - a skill name defined in both sources is linked from neither, and said so
+# once, rather than one silently winning.
+# Pruned: dangling links in the hook dir, and dangling links in an agent's
+# skills dir that point into either source -- a skill removed from a layer goes.
+# Nothing else is touched.
+hook_dir="$xdg_config_dir/agent-skills.d"
+own_dir="$dotfiles_dir/skills"
+
+# Remove $1 if it is a symlink whose target is gone and whose recorded target
+# starts with one of the remaining arguments.
+prune_dangling() {
+ local entry="$1" prefix target
+ shift
+ [[ -L "$entry" && ! -e "$entry" ]] || return 0
+ target="$(readlink "$entry")"
+ for prefix in "$@"
+ do
+ if [[ "$target" == "$prefix"/* ]]
+ then
+ if [[ -n "${is_dry_run}" ]]
+ then
+ yellow "$entry dangles and will be removed"
+ else
+ rm "$entry"
+ fi
+ return 0
+ fi
+ done
+}
+
+if [[ -d "$hook_dir" ]]
+then
+ for entry in "$hook_dir"/*
+ do
+ [[ -L "$entry" && ! -e "$entry" ]] || continue
+ if [[ -n "${is_dry_run}" ]]
+ then
+ yellow "$entry dangles and will be removed"
+ else
+ rm "$entry"
+ fi
+ done
+fi
+
+# Names defined in both sources, reported once and skipped for every agent.
+duplicates=" "
+if [[ -d "$hook_dir" && -d "$own_dir" ]]
+then
+ for skill in "$hook_dir"/*
+ do
+ [[ -e "$skill" ]] || continue
+ name="$(basename "$skill")"
+ if [[ -e "$own_dir/$name" ]]
+ then
+ red "skill $name is defined in both $own_dir and $hook_dir; linking neither"
+ duplicates="$duplicates$name "
+ fi
+ done
+fi
+
+kimi_home="${KIMI_CODE_HOME:-$HOME/.local/share/kimi}"
+[[ -d "$kimi_home" ]] || kimi_home="$HOME/.kimi-code"
+for entry in \
+ "$HOME/.claude:$HOME/.claude/skills" \
+ "$HOME/.pi/agent:$HOME/.pi/agent/skills" \
+ "$HOME/.codex:$HOME/.codex/skills" \
+ "$HOME/.config/opencode:$HOME/.config/opencode/skills" \
+ "$HOME/.cursor:$HOME/.cursor/skills" \
+ "$HOME/.gemini:$HOME/.gemini/skills" \
+ "$HOME/.copilot:$HOME/.copilot/skills" \
+ "$HOME/.grok:$HOME/.grok/skills" \
+ "$kimi_home:$kimi_home/skills"
+do
+ agent_dir="${entry%%:*}"
+ skills_dir="${entry#*:}"
+ [[ -d "$agent_dir" ]] || continue
+
+ if [[ -d "$skills_dir" ]]
+ then
+ for existing in "$skills_dir"/*
+ do
+ prune_dangling "$existing" "$own_dir" "$hook_dir"
+ done
+ fi
+
+ for source_dir in "$own_dir" "$hook_dir"
+ do
+ [[ -d "$source_dir" ]] || continue
+ for skill in "$source_dir"/*
+ do
+ [[ -e "$skill" ]] || continue
+ name="$(basename "$skill")"
+ if [[ "$duplicates" == *" $name "* ]]
+ then
+ continue
+ fi
+ dest="$skills_dir/$name"
+ if [[ -e "$dest" && ! -L "$dest" ]]
+ then
+ red "$dest is not a link; leaving it, skill $name not linked"
+ continue
+ fi
+ link "$skill" "$dest"
+ done
+ done
+done
diff --git a/link/ssh.sh b/link/ssh.sh
new file mode 100755
index 0000000..62efb52
--- /dev/null
+++ b/link/ssh.sh
@@ -0,0 +1,70 @@
+#!/bin/bash
+
+# ssh client config. Dry run by default; pass `yes` to apply.
+
+set -euo pipefail
+
+# shellcheck source-path=SCRIPTDIR
+# shellcheck source=lib.sh
+source "$(dirname "$0")/lib.sh" "$@"
+
+# ~/.ssh/config itself is the entry point: it reads ~/.config/ssh/config-fleet
+# first, if another layer has linked one in, then this host's
+# ~/.config/ssh/config-local -- see config/ssh/entry. Only the file is linked;
+# ~/.ssh stays a real directory, because what else lives in it (keys,
+# known_hosts, control and agent sockets) is this host's own.
+#
+# Each step below is skipped, loudly, rather than forced, because link() starts
+# with `rm -rf`:
+# - a symlinked ~/.ssh. On the Macs it once pointed into iCloud Drive, a
+# single directory shared by two machines, so replacing a file in it on one
+# deleted it on the other.
+# - a real ~/.ssh/config holding anything but `Include ~/.config/ssh/config`,
+# the one line each host was given by hand on 2026-09-07, before this file
+# was linked. Anything else is host-specific and belongs in config-local;
+# move it there first.
+ssh_dir="$HOME/.ssh"
+if [[ -L "$ssh_dir" ]]
+then
+ red "$ssh_dir is a symlink to $(readlink "$ssh_dir"); skipping ssh links -- make it a real local directory first"
+ exit 0
+fi
+
+if [[ -z "${is_dry_run}" && ! -d "$ssh_dir" ]]
+then
+ install -d -m 700 "$ssh_dir"
+fi
+
+ssh_config_extra=""
+if [[ -f "$ssh_dir/config" && ! -L "$ssh_dir/config" ]]
+then
+ ssh_config_extra="$(grep -vE '^[[:space:]]*(#|$)' "$ssh_dir/config" \
+ | grep -vxF 'Include ~/.config/ssh/config' || true)"
+fi
+if [[ -n "$ssh_config_extra" ]]
+then
+ red "$ssh_dir/config has host-specific lines; move them to ~/.config/ssh/config-local, then re-run"
+else
+ link "$config_dir/ssh/entry" "$ssh_dir/config"
+fi
+
+# Links this repo made while host blocks and their key pins were tracked here,
+# now that another layer owns them: only a dangling link in ~/.config/ssh that
+# points into this repo's config/ssh is removed -- never a real file, and never
+# a link someone else made. Compared as real paths: the link holds whatever
+# spelling this repo had when it was made, which on a machine whose checkout is
+# reached through a symlink is not this run's.
+ssh_repo_dir="$(realpath "$config_dir/ssh")"
+for stale in "$xdg_config_dir"/ssh/*
+do
+ if [[ -L "$stale" && ! -e "$stale" ]] \
+ && [[ "$(realpath "$(dirname "$(readlink "$stale")")" 2>/dev/null)" == "$ssh_repo_dir" ]]
+ then
+ if [[ -n "${is_dry_run}" ]]
+ then
+ yellow "$stale is a dangling link into this repo and will be removed"
+ else
+ rm "$stale"
+ fi
+ fi
+done