#!/usr/bin/env bash # onboard-agentmemory.sh — bring ANY machine's OpenCode onto the ai-stack's # centralized agentmemory (shared brain) as a NATIVE remote MCP OAuth client. # # FIRST-TIMER ONE-LINER (from any machine, no clone, no secret, no mesh): # curl -fsSL https://static-ai-stack.dc7.io/install/agentmemory | bash -s -- install # # install [--global] [--url URL] # [--skills-git URL] [--skills-token TOK] [--no-skills] [--project DIR] # auth [--global] [--project DIR] trigger the browser OAuth login now # refresh [--project DIR] git-pull the latest shared skills # status [--global] [--project DIR] # uninstall [--global] [--project DIR] [--purge-skills] # # HOW AUTH WORKS (no shared secret, ever): # The memory server is fronted by an OAuth 2.1 MCP gateway (agentgateway) at # https://agentmemory-ai-stack.dc7.io/mcp. OpenCode is a conformant MCP OAuth client: # on first use it hits /mcp, gets 401 + WWW-Authenticate, discovers the auth server # (Pocket ID), opens a browser for a passkey login, then stores + auto-refreshes the # token in ~/.local/share/opencode/. No AGENTMEMORY_SECRET on the client. Access is # gated by your Pocket ID group + scope (memory:read / memory:write). # # Skills are pulled via a READ-ONLY git clone from the distribution repo (default # https://skills-ai-stack.dc7.io/skills.git) into ~/.cache/agentmemory/skills and wired # via opencode.json `skills.paths`. Publishing new skills is a TRUSTED action done from # the monorepo (`task skills:publish`) — NEVER from an onboarded client. # # DESIGN GUARANTEES # • Idempotent: re-running install never duplicates config or clobbers your setup. # • Surgical: only OUR keys are added; a pre-existing opencode.json is preserved # (and backed up). Uninstall restores it byte-for-byte when possible. # • Per-project by default (writes ./.opencode/); --global writes ~/.config/opencode/. # # Requires: bash, jq, curl. (opencode for the OAuth flow; git for skills.) # This script uses bashisms. Re-exec under bash when possible; for the piped one-liner # invoke with `bash -s` (NOT `sh`). if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1 \ && [ -f "$0" ] && [ "$0" != "sh" ] && [ "$0" != "-" ]; then exec bash "$0" "$@" fi set -euo pipefail # ── defaults ────────────────────────────────────────────────────────────────── # The PUBLIC OAuth MCP endpoint (the agentgateway resource server behind Caddy TLS). AGENTMEMORY_MCP_URL_DEFAULT="https://agentmemory-ai-stack.dc7.io/mcp" SKILLS_GIT_DEFAULT="https://skills-ai-stack.dc7.io/skills.git" # Where the self-updating loader + capture plugin are published (ETag'd static files) — # the single static host (docs/PLAN-static-ai-stack.md). ONBOARD_HOST_DEFAULT="https://static-ai-stack.dc7.io" CACHE_DIR="$HOME/.cache/agentmemory" SKILLS_CHECKOUT="$CACHE_DIR/skills" # git clone of the skills repo lives here # ── colors / logging ──────────────────────────────────────────────────────────── if [ -t 1 ]; then C_G=$'\033[32m'; C_Y=$'\033[33m'; C_R=$'\033[31m'; C_B=$'\033[1m'; C_0=$'\033[0m'; else C_G= C_Y= C_R= C_B= C_0=; fi log() { printf '%s%s%s\n' "$C_B" "$*" "$C_0"; } ok() { printf '%s✓%s %s\n' "$C_G" "$C_0" "$*"; } warn() { printf '%s!%s %s\n' "$C_Y" "$C_0" "$*" >&2; } die() { printf '%s✗%s %s\n' "$C_R" "$C_0" "$*" >&2; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; } # ── arg parsing ────────────────────────────────────────────────────────────────── CMD="${1:-}"; shift || true SCOPE="project" # project | global PROJECT_DIR="$PWD" AM_URL="$AGENTMEMORY_MCP_URL_DEFAULT" SKILLS_GIT="$SKILLS_GIT_DEFAULT" SKILLS_TOKEN="" NO_SKILLS=0 PURGE_SKILLS=0 CFG_DIR_PREEXISTED=1 while [ $# -gt 0 ]; do case "$1" in --global) SCOPE="global" ;; --project) PROJECT_DIR="${2:?}"; shift ;; --url) AM_URL="${2:?}"; shift ;; --skills-git) SKILLS_GIT="${2:?}"; shift ;; --skills-token) SKILLS_TOKEN="${2:?}"; shift ;; --no-skills) NO_SKILLS=1 ;; --purge-skills) PURGE_SKILLS=1 ;; -h|--help) CMD="help" ;; *) die "unknown arg: $1" ;; esac shift done # Resolve the config dir + a stable "ours" marker inside it. cfg_dir() { if [ "$SCOPE" = "global" ]; then echo "$HOME/.config/opencode"; else echo "$PROJECT_DIR/.opencode"; fi } CFG_DIR="$(cfg_dir)" CFG_JSON="$CFG_DIR/opencode.json" BACKUP="$CFG_DIR/.opencode.json.agentmemory-backup" # pre-existing config snapshot OURS_MARKER="$CFG_DIR/.agentmemory-managed" # lists what WE created # ── reachability gate (the gateway's UNAUTHENTICATED protected-resource metadata) ─ # A conformant probe of the OAuth surface: the RFC 9728 PRM doc is public and returns # 200 when the gateway is up. The /mcp endpoint itself returns 401 until authenticated # — that's success, not failure (it means the OAuth boundary is live). mem_reachable() { local base prm code base="${AM_URL%/mcp}" prm="$base/.well-known/oauth-protected-resource/mcp" code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 8 "$prm" 2>/dev/null || echo 000)" [ "$code" = "200" ] } # The /mcp endpoint returns 401 (challenge) when unauthenticated — a healthy sign. mem_challenges() { local code code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 8 \ -X POST -H 'content-type: application/json' \ --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' "$AM_URL" 2>/dev/null || echo 000)" [ "$code" = "401" ] } # ── plugin install: the SELF-UPDATING LOADER (fetched from the ai-stack, ETag'd) ── # Instead of pinning a copy of the capture plugin at install time, we install a tiny # LOADER that, on every OpenCode session start, conditionally GETs the latest capture # plugin from the ai-stack (If-None-Match/ETag → 304 when unchanged) and delegates to it. # So a new plugin version shipped by a deploy is picked up on the client's next launch — # event-like freshness, no polling loop, no daemon, no NATS. Falls back to bundling the # current capture plugin from npm only if the loader can't be fetched. fetch_capture_plugin() { local dest="$1" # Preferred: pull the maintained loader from the onboarding host (served with an ETag). if curl -fsSL --max-time 8 "${ONBOARD_HOST_DEFAULT}/agentmemory-loader.ts" -o "$dest" 2>/dev/null \ && grep -q 'export default' "$dest" 2>/dev/null; then return 0 fi # Fallback: bundle the current capture plugin directly from npm (static, no self-update). command -v npm >/dev/null 2>&1 || return 1 local tmp; tmp="$(mktemp -d)" ( cd "$tmp" npm pack @agentmemory/agentmemory >/dev/null 2>&1 || exit 1 local tgz; tgz="$(ls -1 ./*.tgz 2>/dev/null | head -1)"; [ -n "$tgz" ] || exit 1 tar -xzf "$tgz" >/dev/null 2>&1 || exit 1 local src="package/plugin/opencode/agentmemory-capture.ts" [ -f "$src" ] || exit 1 cp "$src" "$dest" grep -q '^export default AgentmemoryCapturePlugin' "$dest" \ || printf '\n// [onboard] OpenCode >=1.x loads plugins via the default export.\nexport default AgentmemoryCapturePlugin;\n' >> "$dest" ) local rc=$? rm -rf "$tmp" [ $rc -eq 0 ] && [ -f "$dest" ] } plugin_capture_rel=".opencode/plugin/agentmemory-capture.ts" # ── skills: read-only git clone/pull into a shared cache (works on ANY machine) ── sync_skills() { [ "$NO_SKILLS" = "1" ] && return 1 [ -n "$SKILLS_GIT" ] || return 1 command -v git >/dev/null 2>&1 || { warn "git not found — skipping skills (memory still works)"; return 1; } mkdir -p "$CACHE_DIR" local hdr="" [ -n "$SKILLS_TOKEN" ] && hdr="-c http.extraHeader=Authorization: Bearer $SKILLS_TOKEN" if [ -d "$SKILLS_CHECKOUT/.git" ]; then if [ -n "$hdr" ]; then git -c "http.extraHeader=Authorization: Bearer $SKILLS_TOKEN" -C "$SKILLS_CHECKOUT" pull --ff-only -q 2>/dev/null else git -C "$SKILLS_CHECKOUT" pull --ff-only -q 2>/dev/null; fi \ && ok "skills: pulled latest → $SKILLS_CHECKOUT" \ || warn "skills: pull failed (using existing checkout)" else if { [ -n "$hdr" ] && git -c "http.extraHeader=Authorization: Bearer $SKILLS_TOKEN" clone -q "$SKILLS_GIT" "$SKILLS_CHECKOUT" 2>/dev/null; } \ || { [ -z "$hdr" ] && git clone -q "$SKILLS_GIT" "$SKILLS_CHECKOUT" 2>/dev/null; }; then ok "skills: cloned → $SKILLS_CHECKOUT" else warn "skills: clone failed from $SKILLS_GIT (memory works; skills skipped)" return 1 fi fi return 0 } skills_paths_dir() { if [ -d "$SKILLS_CHECKOUT/skills" ]; then echo "$SKILLS_CHECKOUT/skills" elif [ -d "$SKILLS_CHECKOUT" ]; then echo "$SKILLS_CHECKOUT"; fi } write_config() { if [ ! -f "$OURS_MARKER" ]; then [ -d "$CFG_DIR" ] && CFG_DIR_PREEXISTED=1 || CFG_DIR_PREEXISTED=0 else CFG_DIR_PREEXISTED="$(sed -n 's/^dir_preexisted=//p' "$OURS_MARKER" 2>/dev/null || echo 1)" fi mkdir -p "$CFG_DIR" if [ ! -f "$OURS_MARKER" ]; then if [ -f "$CFG_JSON" ]; then cp "$CFG_JSON" "$BACKUP"; else echo '__none__' > "$BACKUP"; fi fi local base='{}' [ -f "$CFG_JSON" ] && base="$(cat "$CFG_JSON")" printf '%s' "$base" | jq -e . >/dev/null 2>&1 || die "existing $CFG_JSON is not valid JSON; aborting" # Skills path (globbed by opencode: **/SKILL.md under each path). local skills_path_json="null" sp sp="$(skills_paths_dir)" [ -n "$sp" ] && skills_path_json="[\"$sp\"]" # NATIVE REMOTE MCP: no command, no secret, no env. OpenCode auto-OAuths on first use # (browser passkey → Pocket ID → auto-refreshing token stored under ~/.local/share/ # opencode). The config carries ONLY the public /mcp URL. printf '%s' "$base" | jq \ --arg url "$AM_URL" \ --arg plugin "$plugin_capture_rel" \ --argjson skills "$skills_path_json" ' .mcp = (.mcp // {}) | .mcp.agentmemory = { "type": "remote", "url": $url, "enabled": true } | .plugin = ((.plugin // []) + [$plugin] | unique) | (if $skills != null then .skills = (.skills // {}) | .skills.paths = (((.skills.paths // []) + $skills) | unique) else . end) ' > "$CFG_JSON.tmp" mv "$CFG_JSON.tmp" "$CFG_JSON" [ "$skills_path_json" != "null" ] && ok "skills.paths → $sp" || warn "no skills checkout — skills not wired (memory works regardless)" # No secret embedded anymore → no per-scope .gitignore needed for it. Clean up any # redundant env file an OLD (secret-based) install may have dropped. rm -f "$CFG_DIR/agentmemory.env" 2>/dev/null || true # capture plugin (passive auto-record), version-matched from npm. Best-effort. mkdir -p "$CFG_DIR/plugin" fetch_capture_plugin "$CFG_DIR/plugin/agentmemory-capture.ts" || \ warn "could not fetch agentmemory-capture.ts (need npm/npx). MCP tools still work; passive auto-record is off until fetched." cat > "$OURS_MARKER" </dev/null; then rm -f "$CFG_JSON" ok "removed $CFG_JSON (we created it)" else cp "$BACKUP" "$CFG_JSON" ok "restored $CFG_JSON from pre-install backup" fi rm -f "$BACKUP" else if [ -f "$CFG_JSON" ]; then jq 'del(.mcp.agentmemory) | .plugin = ((.plugin // []) - ["'"$plugin_capture_rel"'"]) | del(."opencode-remote-config") | del(.skills) | if (.plugin == []) then del(.plugin) else . end | if (.mcp == {}) then del(.mcp) else . end' \ "$CFG_JSON" > "$CFG_JSON.tmp" && mv "$CFG_JSON.tmp" "$CFG_JSON" ok "surgically removed our keys from $CFG_JSON" fi fi local dir_preexisted; dir_preexisted="$(sed -n 's/^dir_preexisted=//p' "$OURS_MARKER" 2>/dev/null || echo 1)" rm -f "$CFG_DIR/agentmemory.env" "$CFG_DIR/plugin/agentmemory-capture.ts" "$OURS_MARKER" rm -f "$CFG_DIR/skills" "$CFG_DIR/agents" 2>/dev/null || true rmdir "$CFG_DIR/plugin" 2>/dev/null || true if [ "$dir_preexisted" = "0" ] && [ "$SCOPE" = "project" ]; then if [ ! -f "$CFG_DIR/package.json" ] || grep -q '@opencode-ai/plugin' "$CFG_DIR/package.json" 2>/dev/null; then rm -rf "$CFG_DIR/node_modules" "$CFG_DIR/package.json" "$CFG_DIR/package-lock.json" "$CFG_DIR/bun.lock" 2>/dev/null || true local gi="$CFG_DIR/.gitignore" if [ -f "$gi" ] && grep -qxE 'node_modules|package\.json|bun\.lock' "$gi" 2>/dev/null \ && ! grep -qvxE 'node_modules|package\.json|package-lock\.json|bun\.lock|\.gitignore' "$gi" 2>/dev/null; then rm -f "$gi" fi ok "swept OpenCode plugin artifacts (node_modules, package*.json)" fi fi [ "$SCOPE" = "project" ] && rmdir "$CFG_DIR" 2>/dev/null && ok "removed empty $CFG_DIR" || true } # ── commands ────────────────────────────────────────────────────────────────────── cmd_install() { need jq; need curl log "== install ($SCOPE) → $CFG_DIR ==" if mem_reachable; then ok "gateway reachable ($AM_URL — protected-resource metadata 200)" mem_challenges && ok "/mcp correctly challenges unauthenticated requests (401)" \ || warn "/mcp did not return 401 — verify the gateway's mcpAuthentication policy." else warn "gateway not reachable at $AM_URL. Writing config anyway; verify DNS/TLS + that" warn " roles/agentmemory-mcp-gateway + Caddy /mcp routes are deployed." fi command -v opencode >/dev/null 2>&1 || warn "opencode not found — install it to run the OAuth flow." sync_skills || true write_config log "next: authenticate (browser passkey), then use memory in opencode:" log " opencode mcp auth agentmemory" cmd_status } # Trigger the browser OAuth login explicitly (otherwise it happens on first memory use). cmd_auth() { command -v opencode >/dev/null 2>&1 || die "opencode not found — install it first." log "== auth agentmemory (browser passkey via Pocket ID) ==" ( [ "$SCOPE" = "project" ] && cd "$PROJECT_DIR"; opencode mcp auth agentmemory ) \ && ok "authenticated — token stored + auto-refreshing (~/.local/share/opencode)." \ || die "auth failed. Ensure you're in a group granting memory access and try again." } cmd_refresh() { need jq log "== refresh skills ==" if sync_skills; then if [ -f "$OURS_MARKER" ]; then local sp; sp="$(skills_paths_dir)" if [ -n "$sp" ] && [ -f "$CFG_JSON" ]; then jq --arg p "$sp" '.skills = (.skills // {}) | .skills.paths = [$p]' "$CFG_JSON" > "$CFG_JSON.tmp" && mv "$CFG_JSON.tmp" "$CFG_JSON" ok "skills.paths → $sp" fi fi ok "refresh complete ($(find "$(skills_paths_dir)" -name SKILL.md 2>/dev/null | wc -l | tr -d ' ') skills)." else warn "skills refresh skipped/failed." fi } cmd_status() { need jq log "== status ($SCOPE) ==" if mem_reachable; then printf ' gateway : reachable at %s ✓\n' "$AM_URL" mem_challenges && printf ' /mcp challenges unauthenticated (401) ✓\n' || printf ' /mcp did NOT return 401 — check mcpAuthentication\n' else printf ' gateway : NOT reachable at %s ✗\n' "$AM_URL" fi if [ -f "$OURS_MARKER" ]; then printf ' config : managed at %s\n' "$CFG_JSON" jq -e '.mcp.agentmemory.type == "remote"' "$CFG_JSON" >/dev/null 2>&1 \ && printf ' mcp.agentmemory = remote OAuth ✓\n' \ || printf ' mcp.agentmemory MISSING or not remote\n' else printf ' config : not installed in %s\n' "$CFG_DIR" fi # token presence (opencode stores it under the user data dir) local tok="$HOME/.local/share/opencode/auth.json" [ -f "$HOME/.local/share/opencode/mcp-auth.json" ] && tok="$HOME/.local/share/opencode/mcp-auth.json" if [ -f "$tok" ] && grep -qi agentmemory "$tok" 2>/dev/null; then printf ' token : present (auto-refreshing) ✓\n' else printf ' token : none yet — run: opencode mcp auth agentmemory (or just start opencode)\n' fi if [ -d "$SKILLS_CHECKOUT/.git" ]; then printf ' skills : %s SKILL.md @ %s\n' "$(find "$(skills_paths_dir)" -name SKILL.md 2>/dev/null | wc -l | tr -d ' ')" "$SKILLS_CHECKOUT" printf ' (re-run the installer with the "refresh" command to git pull)\n' else printf ' skills : not cloned (re-run the installer with the "refresh" command)\n' fi } cmd_uninstall() { need jq log "== uninstall ($SCOPE) → $CFG_DIR ==" restore_config # The self-updating loader caches the capture plugin here; clear it on uninstall. rm -rf "$CACHE_DIR/plugin" 2>/dev/null && ok "removed plugin cache $CACHE_DIR/plugin" || true if [ "$NO_SKILLS" = "1" ] || [ "${PURGE_SKILLS:-0}" = "1" ]; then rm -rf "$SKILLS_CHECKOUT" 2>/dev/null && ok "removed skills cache $SKILLS_CHECKOUT" || true fi ok "uninstall complete. (Revoke access anytime by leaving the Pocket ID group.)" } cmd_help() { sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//' } case "$CMD" in install) cmd_install ;; auth) cmd_auth ;; status) cmd_status ;; refresh) cmd_refresh ;; uninstall) cmd_uninstall ;; help|"" ) cmd_help ;; *) die "unknown command: $CMD (use install|auth|status|refresh|uninstall|help)" ;; esac