Files
Panama/setup/scripts/link-mcp
T
Gabriel Brown 34bc1663ed Give opencode the same agent instructions, rules and MCP servers as Claude Code
opencode already reads skills from ~/.claude/skills and ~/.agents/skills, so
those needed nothing. The rest did.

The manifest now links agents/AGENTS.md to ~/.config/opencode/AGENTS.md as
well, beside the Claude Code and Codex names.

opencode keeps its MCP servers in its own JSON config, with no file to point
a symlink at, so link-mcp now writes them there too. It builds one entry per
row of user/agents/mcp/servers with the same tokens it gives Claude Code,
and adds ~/.claude/rules/*.md under instructions, since opencode does not
read that directory by itself. Only those two keys change. The file is
written private and swapped in whole because it now holds tokens, and a
config with JSONC comments is left alone with a warning rather than
rewritten without them.
2026-10-07 01:06:44 -04:00

206 lines
8.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# MCP servers, registered with the agent runtimes on this machine.
#
# An MCP server is a URL plus a bearer token (or, for a stdio server, a launcher
# script plus a token), and the token is the whole reason
# this is a stage rather than a manifest line. Panama is a public repository, so
# the tokens cannot live in it, and neither runtime keeps its server list in a
# file that could be symlinked anyway: Codex writes them into config.toml beside
# dozens of unrelated settings, and Claude Code into ~/.claude.json. There is no
# file to point at, so this registers them through the runtime's own CLI.
#
# What is tracked is user/agents/mcp/servers, which names each server and which
# variable carries its token. What is not tracked is user/agents/mcp/env, which
# holds the tokens. A new machine gets the servers by dropping its own env file
# beside the tracked one and re-running ./install.
#
# This is personal content, so it obeys the same interview decision link-user
# does. A machine that never said yes registers nothing.
#
# Claude Code and opencode are handled. Codex stores its servers inside
# config.toml, and rewriting a section of somebody's live TOML is a worse
# failure mode than leaving two lines for them to paste once.
#
# opencode has no CLI for this either, but its config is plain JSON, so this
# stage merges two keys into it with jq: one mcp entry per server row, and the
# rules directory under instructions, because opencode reads ~/.claude/CLAUDE.md
# and ~/.claude/skills on its own but not ~/.claude/rules. Every other key in
# that file is left as it was.
set -euo pipefail
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*" >&2; }
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
MCP_DIR="$PANAMA_PATH/user/agents/mcp"
SERVERS="$MCP_DIR/servers"
ENV_FILE="$MCP_DIR/env"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama"
DECISION="$STATE_DIR/user-content"
[[ -r "$SERVERS" ]] || { log "No MCP server list; nothing to register."; exit 0; }
# The same gate link-user uses, read the same way, so one answer governs all
# personal content rather than two stages disagreeing about it.
decision="$([[ -r "$DECISION" ]] && cat "$DECISION" || printf 'no')"
if [[ "$decision" != "yes" ]]; then
log "Personal content is not enabled on this machine; no MCP servers registered."
exit 0
fi
has_claude=false
has_opencode=false
command -v claude >/dev/null 2>&1 && has_claude=true
command -v opencode >/dev/null 2>&1 && has_opencode=true
if ! $has_claude && ! $has_opencode; then
log "Neither Claude Code nor opencode is installed; nothing to register."
exit 0
fi
# Tokens are optional. A machine without the env file still registers any server
# that needs no header, and says which ones it skipped rather than failing.
if [[ -r "$ENV_FILE" ]]; then
set -a
# shellcheck source=/dev/null
. "$ENV_FILE"
set +a
else
warn "No $ENV_FILE; servers needing a token will be skipped."
fi
registered=0
skipped=0
# The opencode mcp object, built up one server at a time.
opencode_mcp='{}'
while read -r name transport target token_var _rest; do
case "${name:-}" in ''|'#'*) continue ;; esac
if [[ -z "${transport:-}" || -z "${target:-}" ]]; then
warn "Ignoring malformed row for '$name'."
continue
fi
token=""
if [[ -n "${token_var:-}" ]]; then
token="${!token_var:-}"
if [[ -z "$token" ]]; then
warn "Skipping $name: $token_var is not set in $ENV_FILE."
skipped=$((skipped + 1))
continue
fi
fi
# An http server's token normally travels as an Authorization header. One
# that only reads it from the query string (Penpot's userToken) marks the
# spot with {TOKEN} in its URL instead, and gets no header. A stdio server's
# target is a launcher under user/agents/mcp/, run from this checkout so
# every machine runs the same script, and its token arrives as MCP_TOKEN.
url="$target"
args=()
if [[ "$transport" == stdio ]]; then
launcher="$MCP_DIR/$target"
if [[ ! -x "$launcher" ]]; then
warn "Skipping $name: $launcher is not an executable launcher."
skipped=$((skipped + 1))
continue
fi
[[ -n "$token" ]] && args=(-e "MCP_TOKEN=$token")
elif [[ -n "$token" ]]; then
if [[ "$url" == *'{TOKEN}'* ]]; then
url="${url//\{TOKEN\}/$token}"
else
args=(-H "Authorization: $token")
fi
fi
auth=""
[[ "${args[0]:-}" == -H ]] && auth="${args[1]#Authorization: }"
if [[ "$transport" == stdio ]]; then
entry="$(jq -cn --arg c "$launcher" --arg t "$token" \
'{type: "local", command: [$c]} + (if $t == "" then {} else {environment: {MCP_TOKEN: $t}} end)')"
else
entry="$(jq -cn --arg u "$url" --arg a "$auth" \
'{type: "remote", url: $u} + (if $a == "" then {} else {headers: {Authorization: $a}} end)')"
fi
opencode_mcp="$(jq -c --arg n "$name" --argjson e "$entry" '.[$n] = $e' <<<"$opencode_mcp")"
$has_claude || continue
# A server already registered exactly as this row would register it is left
# alone. Removing and re-adding it would throw away the OAuth session of a
# server that signs in on its own (bill-tracker, atlassian), so every
# upgrade would sign them out again.
if [[ "$transport" == stdio ]]; then
want="$(jq -cn --arg c "$launcher" --arg t "$token" \
'{command: $c, env: (if $t == "" then null else {MCP_TOKEN: $t} end)}')"
have="$(jq -c --arg n "$name" \
'.mcpServers[$n] // {} | {command, env: (if (.env // {}) == {} then null else .env end)}' \
"$HOME/.claude.json" 2>/dev/null || true)"
else
want="$(jq -cn --arg u "$url" --arg a "$auth" \
'{url: $u, auth: (if $a == "" then null else $a end)}')"
have="$(jq -c --arg n "$name" \
'.mcpServers[$n] // {} | {url, auth: (.headers.Authorization // null)}' \
"$HOME/.claude.json" 2>/dev/null || true)"
fi
if [[ -n "$have" && "$have" == "$want" ]]; then
log "Already registered $name."
registered=$((registered + 1))
continue
fi
# Re-registering is how a new server or a rotated token reaches the
# runtime. Removing first avoids the CLI refusing a name it already knows.
# Neither call may print the token, so both are quiet unless they fail.
claude mcp remove "$name" -s user >/dev/null 2>&1 || true
if [[ "$transport" == stdio ]]; then
add=(claude mcp add "$name" -s user "${args[@]}" -- "$launcher")
else
add=(claude mcp add --transport "$transport" "$name" "$url" "${args[@]}" -s user)
fi
if "${add[@]}" >/dev/null 2>&1; then
log "Registered $name."
registered=$((registered + 1))
else
warn "Could not register $name."
skipped=$((skipped + 1))
fi
done <"$SERVERS"
if $has_claude; then
log "MCP servers: $registered registered, $skipped skipped."
log "Claude Code loads them at start, so restart a running session to pick them up."
fi
$has_opencode || exit 0
# opencode reads opencode.jsonc or opencode.json, whichever this machine has.
# The file holds tokens once this runs, so it is written private and swapped in
# whole, the way ~/.claude.json already holds them for Claude Code.
OPENCODE_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode"
opencode_config="$OPENCODE_DIR/opencode.jsonc"
[[ -e "$opencode_config" ]] || opencode_config="$OPENCODE_DIR/opencode.json"
rules="$HOME/.claude/rules/*.md"
mkdir -p "$OPENCODE_DIR"
current='{"$schema": "https://opencode.ai/config.json"}'
[[ -e "$opencode_config" ]] && current="$(cat "$opencode_config")"
# jq cannot read JSONC comments. A file that has them is left alone rather
# than rewritten without them.
if ! merged="$(jq --argjson mcp "$opencode_mcp" --arg rules "$rules" \
'.mcp = ((.mcp // {}) + $mcp) | .instructions = (((.instructions // []) - [$rules]) + [$rules])' \
<<<"$current" 2>/dev/null)"; then
warn "Could not parse $opencode_config as JSON; opencode MCP servers not written."
exit 0
fi
tmp="$(mktemp "$OPENCODE_DIR/.opencode-config.XXXXXX")"
chmod 600 "$tmp"
printf '%s\n' "$merged" >"$tmp"
mv "$tmp" "$opencode_config"
log "opencode: $(jq -r 'length' <<<"$opencode_mcp") MCP servers and the rules written to $opencode_config."