diff --git a/setup/scripts/link-mcp b/setup/scripts/link-mcp index 711528c..8dbb6f6 100755 --- a/setup/scripts/link-mcp +++ b/setup/scripts/link-mcp @@ -18,9 +18,15 @@ # This is personal content, so it obeys the same interview decision link-user # does. A machine that never said yes registers nothing. # -# Only Claude Code is 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. +# 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 @@ -44,8 +50,12 @@ if [[ "$decision" != "yes" ]]; then exit 0 fi -if ! command -v claude >/dev/null 2>&1; then - log "Claude Code is not installed; nothing to register." +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 @@ -62,6 +72,8 @@ 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 @@ -103,6 +115,20 @@ while read -r name transport target token_var _rest; do 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 @@ -114,8 +140,6 @@ while read -r name transport target token_var _rest; do '.mcpServers[$n] // {} | {command, env: (if (.env // {}) == {} then null else .env end)}' \ "$HOME/.claude.json" 2>/dev/null || true)" else - auth="" - [[ "${args[0]:-}" == -H ]] && auth="${args[1]#Authorization: }" 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" \ @@ -146,5 +170,36 @@ while read -r name transport target token_var _rest; do fi done <"$SERVERS" -log "MCP servers: $registered registered, $skipped skipped." -log "Claude Code loads them at start, so restart a running session to pick them up." +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." diff --git a/user/README.md b/user/README.md index ad0e582..fcf14b3 100644 --- a/user/README.md +++ b/user/README.md @@ -12,9 +12,9 @@ and one file says where each piece goes. | Path | Goes to | Why | | --- | --- | --- | -| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. | -| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | Personal skills live once in the checkout. Both destinations use `linkdir` so Panama's shipped skills can live beside them. | -| `agents/rules/` | `~/.claude/rules` | | +| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md` | Three tools, one file. These were byte-identical copies before this, waiting to disagree. | +| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | Personal skills live once in the checkout. Both destinations use `linkdir` so Panama's shipped skills can live beside them. opencode reads both directories itself. | +| `agents/rules/` | `~/.claude/rules` | opencode loads these through `instructions` in its config, which `setup/scripts/link-mcp` writes. | | `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. | | `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. | diff --git a/user/manifest b/user/manifest index 97201b5..8acab1f 100644 --- a/user/manifest +++ b/user/manifest @@ -23,10 +23,11 @@ # Gabriel's agent instructions on their machine. # ── Agents ─────────────────────────────────────────────────────────────────── -# One file, two names. Claude Code reads CLAUDE.md and Codex reads AGENTS.md, -# and before this they were two byte-identical copies waiting to disagree. +# One file, three names. Claude Code reads CLAUDE.md, Codex and opencode read +# AGENTS.md, and before this they were byte-identical copies waiting to disagree. link agents/AGENTS.md ~/.claude/CLAUDE.md link agents/AGENTS.md ~/.codex/AGENTS.md +link agents/AGENTS.md ~/.config/opencode/AGENTS.md # Skills, at both paths that look for them. Each tracked skill is linked from # this checkout so edits stay shared and `panama update` offers to commit them.