From 13cc065ec014b86369dfe4ad60d1a43fa06fd251 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Thu, 17 Sep 2026 19:45:23 +0100 Subject: dotfiles Shell, editor and terminal configuration, the tools that go with them, and szecret, an age-based secret manager whose store lives elsewhere. History restarts here. What came before was not written to be read by anyone else, and carried machine names, addresses and an encrypted secret store; it is kept privately instead. What is left is meant to be safe on any machine, including one whose owner is not me: see CLAUDE.md for the rule it is written to. --- link/claude.sh | 220 +++++++++++++++++++++++++++++++++++++++++++++++++ link/hammerspoon.sh | 22 +++++ link/icloud.sh | 23 ++++++ link/kwin.sh | 30 +++++++ link/lib.sh | 122 +++++++++++++++++++++++++++ link/pi.sh | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++++ link/skills.sh | 129 +++++++++++++++++++++++++++++ link/ssh.sh | 70 ++++++++++++++++ 8 files changed, 847 insertions(+) create mode 100755 link/claude.sh create mode 100755 link/hammerspoon.sh create mode 100755 link/icloud.sh create mode 100755 link/kwin.sh create mode 100644 link/lib.sh create mode 100755 link/pi.sh create mode 100755 link/skills.sh create mode 100755 link/ssh.sh (limited to 'link') 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 + +# namespec, 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 ":"; 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 -- cgit v1.2.3