diff options
Diffstat (limited to 'link/claude.sh')
| -rwxr-xr-x | link/claude.sh | 220 |
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 |
