diff options
Diffstat (limited to 'link')
| -rwxr-xr-x | link/claude.sh | 220 | ||||
| -rwxr-xr-x | link/hammerspoon.sh | 22 | ||||
| -rwxr-xr-x | link/icloud.sh | 23 | ||||
| -rwxr-xr-x | link/kwin.sh | 30 | ||||
| -rw-r--r-- | link/lib.sh | 122 | ||||
| -rwxr-xr-x | link/pi.sh | 231 | ||||
| -rwxr-xr-x | link/skills.sh | 129 | ||||
| -rwxr-xr-x | link/ssh.sh | 70 |
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 |
