summaryrefslogtreecommitdiff
path: root/link/claude.sh
diff options
context:
space:
mode:
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