summaryrefslogtreecommitdiff
path: root/link/claude.sh
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-09-17 19:45:23 +0100
committerIgor Soarez <igor@soarez.org>2026-09-17 19:45:23 +0100
commit13cc065ec014b86369dfe4ad60d1a43fa06fd251 (patch)
tree684690c61b7b59758c2aac132c65b81c86de0608 /link/claude.sh
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.
Diffstat (limited to 'link/claude.sh')
-rwxr-xr-xlink/claude.sh220
1 files changed, 220 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