#!/usr/bin/env bash # Bootstrap or re-sync the OpenCode harness on this Mac. # # Idempotent — re-run after pulling changes to opencode.json or the # Phoenix bridge plugin. Each step checks before doing work. # # What this does: # 1. Verify Homebrew is present # 2. Install node, uv, opencode, jq (skips if already at latest) # 3. Pre-cache Playwright's chromium so the first MCP call is instant # 4. Install Serena (uv tool — LSP-backed code navigation MCP) # 5. Wire basic-memory's storage to the Obsidian vault's AI-memory folder # 6. Install github-mcp-server + check for GITHUB_PERSONAL_ACCESS_TOKEN # 7. Install task-master-ai (workflow MCP) # 8. Install the Phoenix bridge plugin's OTel deps # 9. Generate ~/.config/opencode/opencode.json from the repo's # opencode.json with relative plugin paths rewritten to absolute, # so opencode loads the plugin regardless of where it's launched. # # Usage: ./install.sh (from this directory) set -euo pipefail cd "$(dirname "$0")" HERE="$(pwd)" # --- Pretty printing --------------------------------------------------------- bold() { printf '\033[1m%s\033[0m\n' "$*"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } info() { printf ' → %s\n' "$*"; } warn() { printf ' \033[33m!\033[0m %s\n' "$*"; } fail() { printf ' \033[31m✗\033[0m %s\n' "$*"; exit 1; } # --- 1. Homebrew ------------------------------------------------------------- bold "[1/9] Homebrew" if ! command -v brew >/dev/null 2>&1; then fail "brew not found. Install from https://brew.sh, then re-run." fi ok "brew $(brew --version | head -1 | awk '{print $2}')" # --- 2. CLI deps ------------------------------------------------------------- bold "[2/9] CLI dependencies" brew_install_if_missing() { local pkg="$1" local bin="${2:-$1}" if command -v "$bin" >/dev/null 2>&1; then ok "$pkg already installed ($(command -v "$bin"))" else info "installing $pkg" brew install "$pkg" ok "$pkg installed" fi } brew_install_if_missing node node brew_install_if_missing uv uv brew_install_if_missing jq jq # opencode is in a tap; check the binary, not the formula name. if command -v opencode >/dev/null 2>&1; then ok "opencode already installed ($(command -v opencode))" else info "tapping sst/tap and installing opencode" brew install sst/tap/opencode ok "opencode installed" fi # --- 3. Playwright browsers -------------------------------------------------- bold "[3/9] Playwright browser cache" PW_CACHE="${HOME}/Library/Caches/ms-playwright" if [[ -d "$PW_CACHE" ]] && find "$PW_CACHE" -name "chrome" -o -name "Chromium*" 2>/dev/null | grep -q .; then ok "browsers already cached at $PW_CACHE" else info "downloading chromium (~200 MB) — first run only" npx -y @playwright/mcp@latest --help >/dev/null 2>&1 || true ok "browsers cached" fi # --- 4. Serena (LSP-backed semantic code navigation MCP) -------------------- # Installed once as a uv tool so opencode can launch it as `serena # start-mcp-server ...` without paying uvx's resolution cost on every # session start. --prerelease=allow is required because serena-agent # ships pre-1.0 versions. bold "[4/9] Serena MCP" if uv tool list 2>/dev/null | awk '{print $1}' | grep -qx 'serena-agent'; then ok "serena-agent already installed ($(serena --version 2>/dev/null | head -1 || echo 'version unknown'))" else info "installing serena-agent via uv tool (~30s first run)" uv tool install -p 3.13 serena-agent@latest --prerelease=allow ok "serena-agent installed" fi if ! command -v serena >/dev/null 2>&1; then warn "serena binary not on PATH — uv tool's bin dir may not be exported." warn "Add this to your shell rc: export PATH=\"\$HOME/.local/bin:\$PATH\"" fi # --- 5. basic-memory storage ------------------------------------------------ # basic-memory defaults its project home to ~/basic-memory. We point that # at a folder inside the Obsidian vault via symlink so the AI's notes # show up in Obsidian's graph and search. Symlink (not env var) chosen # because it's stable across basic-memory's evolving config schema. bold "[5/9] basic-memory storage" AI_MEM_PATH="${HOME}/Documents/obsidian/AI-memory" if [[ ! -d "$AI_MEM_PATH" ]]; then info "creating $AI_MEM_PATH" mkdir -p "$AI_MEM_PATH" ok "AI-memory directory created" else ok "AI-memory directory exists at $AI_MEM_PATH" fi if [[ -L "${HOME}/basic-memory" ]]; then link_target="$(readlink "${HOME}/basic-memory")" if [[ "$link_target" == "$AI_MEM_PATH" ]]; then ok "~/basic-memory already linked to AI-memory" else warn "~/basic-memory points to $link_target — leaving as-is. basic-memory MCP will write there, not to AI-memory." fi elif [[ -e "${HOME}/basic-memory" ]]; then warn "~/basic-memory exists and is not a symlink. Move or remove it for AI-memory linkage." else info "linking ~/basic-memory -> $AI_MEM_PATH" ln -s "$AI_MEM_PATH" "${HOME}/basic-memory" ok "symlink created" fi # --- 6. github-mcp-server (GitHub MCP, classic-PAT auto-scope-filtered) ----- # Homebrew formula tracks upstream releases; the binary is a Go single-file. # We launch it via opencode.json's mcp.github entry with --read-only and a # narrowed --toolsets allowlist; auto-scope-filtering on classic PATs (the # Jan 2026 GitHub feature) cuts ~23k tokens of tool-list overhead — significant # for a local 70B's effective context. Token itself is NOT in opencode.json # (it's git-tracked); github-mcp-server inherits GITHUB_PERSONAL_ACCESS_TOKEN # from the user's shell env. bold "[6/9] github-mcp-server" brew_install_if_missing github-mcp-server github-mcp-server if [[ -z "${GITHUB_PERSONAL_ACCESS_TOKEN:-}" ]]; then warn "GITHUB_PERSONAL_ACCESS_TOKEN is not set in your shell environment." warn " github-mcp-server will fail to connect on opencode startup." warn " Fix:" warn " 1. Create a classic PAT (starts with ghp_) at" warn " https://github.com/settings/tokens with the scopes you want" warn " exposed (auto-filtering hides tools whose scopes the PAT lacks)." warn " 2. Add to ~/.zshrc:" warn " export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxxxxxxxxxx" warn " 3. Re-source the shell (or open a new terminal) before launching opencode." else ok "GITHUB_PERSONAL_ACCESS_TOKEN is set in this shell" fi # --- 7. claude-task-master (workflow MCP, npm global) ----------------------- # task-master-ai: workflow/task-gate MCP. File-based (.taskmaster/ in each # project), no DB, no external service. opencode.json launches it via # `npx -y task-master-ai`; the global install also provides the `task-master` # CLI (`task-master init` to scaffold a project's tasks). bold "[7/9] claude-task-master" if command -v task-master >/dev/null 2>&1; then ok "task-master-ai already installed ($(task-master --version 2>/dev/null | head -1 || echo 'version unknown'))" else info "installing task-master-ai globally via npm" npm install -g task-master-ai ok "task-master-ai installed" fi # --- 8. Phoenix bridge plugin deps ------------------------------------------ bold "[8/9] Phoenix bridge plugin deps" if [[ -d ".opencode/plugin/node_modules" && -f ".opencode/plugin/package-lock.json" ]]; then # Re-run npm install if package.json is newer than the lockfile, otherwise skip. if [[ ".opencode/plugin/package.json" -nt ".opencode/plugin/package-lock.json" ]]; then info "package.json is newer than lockfile — running npm install" ( cd .opencode/plugin && npm install ) ok "deps updated" else ok "deps already installed" fi else info "installing OTel deps (one-time, ~40 MB)" ( cd .opencode/plugin && npm install ) ok "deps installed" fi # --- 9. Generate ~/.config/opencode/opencode.json --------------------------- # The repo's opencode.json uses relative plugin paths so it stays valid # in-place. Rewriting them to absolute paths here makes opencode find the # plugin regardless of which directory it was launched from. Re-run this # script after editing opencode.json. bold "[9/9] Deploy global config" mkdir -p "${HOME}/.config/opencode" src="${HERE}/opencode.json" dst="${HOME}/.config/opencode/opencode.json" # If the user previously had a symlink from the old install.sh, replace it. if [[ -L "$dst" ]]; then info "removing stale symlink at $dst" rm "$dst" fi # And the old .opencode dir symlink — no longer needed now that plugin # paths are absolute. if [[ -L "${HOME}/.config/opencode/.opencode" ]]; then info "removing stale ~/.config/opencode/.opencode symlink" rm "${HOME}/.config/opencode/.opencode" fi # Rewrite any relative path (./foo, ../foo) to an absolute path rooted at # this directory. Applies to both the top-level `plugin` array and to any # string inside `mcp..command[]` (used for serena's --context arg # pointing at serena-ide-trim.yml). Absolute paths and npm-package refs # pass through untouched. jq --arg here "$HERE" ' def rewrite($h): if type == "string" and (startswith("./") or startswith("../")) then ($h + "/" + ltrimstr("./") | gsub("/\\./"; "/")) else . end; .plugin = ((.plugin // []) | map(rewrite($here))) | .mcp = ( (.mcp // {}) | with_entries( if (.value | type) == "object" and (.value | has("command")) then .value.command |= map(rewrite($here)) else . end ) ) ' "$src" > "$dst.tmp" mv "$dst.tmp" "$dst" ok "wrote $dst" info "plugin paths resolved to:" jq -r '.plugin[]?' "$dst" | sed 's/^/ /' info "mcp.serena context resolved to:" jq -r '.mcp.serena.command | map(select(test("\\.yml$"))) | .[]?' "$dst" | sed 's/^/ /' echo bold "Done." cat <