all repos — dot @ master

Shell, editor and terminal configuration, and the tools that go with them

link/claude.sh (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
#!/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.
#
# Two things a spec may carry beyond what Claude Code takes, both resolved here:
#
#   "${szecret:NAME}"  anywhere in a string value, replaced with `szecret get
#                      NAME` as the server is registered with Claude Code. For a
#                      remote server's Authorization header: Claude Code's own
#                      headersHelper is not run in a workspace without persisted
#                      trust, which includes $HOME, so a token fetched there
#                      would simply be missing. The secret ends up in
#                      ~/.claude.json, which Claude Code keeps mode 600. A
#                      secret that cannot be read skips that server, loudly.
#
#   "desktop"          a whole spec to give Claude Desktop instead of this one,
#                      e.g. a local stdio server where Claude Code gets a remote
#                      one. Claude Code never sees it.
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
      if ! code_spec="$(python3 -c 'import json, re, subprocess, sys
spec = json.loads(sys.argv[1])
spec.pop("desktop", None)

def secret(match):
    name = match.group(1)
    try:
        return subprocess.run(["szecret", "get", name], check=True, capture_output=True, text=True).stdout.strip()
    except (OSError, subprocess.CalledProcessError):
        sys.exit("szecret get %s failed" % name)

def expand(value):
    if isinstance(value, str):
        return re.sub(r"\$\{szecret:([A-Za-z0-9._-]+)\}", secret, value)
    if isinstance(value, dict):
        return {k: expand(v) for k, v in value.items()}
    if isinstance(value, list):
        return [expand(v) for v in value]
    return value

print(json.dumps(expand(spec)))' "$spec" 2>&1)"
      then
        red "MCP server (user scope) not registered: $name: $code_spec"
        continue
      fi
      green "MCP server (user scope): $name"
      claude mcp remove --scope user "$name" >/dev/null 2>&1 || true
      claude mcp add-json --scope user "$name" "$code_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)
    spec = spec.get("desktop", 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