#!/usr/bin/env bash # onboard-agentmemory.sh — bring ANY machine's OpenCode onto the ai-stack's # centralized agentmemory (shared brain) over the existing Headscale mesh. # # FIRST-TIMER ONE-LINER (from any machine, no clone): # curl -fsSL https://get-agentmemory-ai-stack.dc7.io | bash -s -- install --secret # # install [--global] [--url URL] [--secret SECRET] [--skills-src PATH|file://…|git-url] # [--authkey KEY] [--login-server URL] [--rejoin] [--project DIR] # status [--global] [--project DIR] # uninstall [--global] [--project DIR] # # 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. # • Tailscale detect-and-skip: if the mesh is already up on the right tailnet we do # NOTHING to it, and uninstall only tears it down if WE installed it. # • Per-project by default (writes ./.opencode/); --global writes ~/.config/opencode/. # # Requires: bash, jq, curl. (tailscale only when the mesh isn't already up.) # This script uses bashisms. If run under a POSIX sh with a readable file path, # re-exec under bash. For the piped one-liner, invoke with `bash -s` (NOT `sh`): # curl -fsSL https://get-agentmemory-ai-stack.dc7.io | bash -s -- install --secret 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 ────────────────────────────────────────────────────────────────── LOGIN_SERVER_DEFAULT="https://headscale-ai-stack.dc7.io" MESH_TAG="tag:agentmemory" AGENTMEMORY_URL_DEFAULT="http://agentmemory.mesh.dc7.io:3111" # The skills library already lives in this monorepo; default to the on-disk path so a # machine that has the repo needs NO fetch (file:// symlink, changes instant). # ${BASH_SOURCE[0]} is unset when piped (curl | bash -s) → guard under set -u. _self="${BASH_SOURCE:-${0:-}}" REPO_ROOT="$(cd "$(dirname "$_self")/.." 2>/dev/null && pwd || echo "")" SKILLS_SRC_DEFAULT="" if [ -n "$REPO_ROOT" ] && [ -d "$REPO_ROOT/ansible-stacks/ai-stack/roles/shared/files/agents" ]; then SKILLS_SRC_DEFAULT="$REPO_ROOT/ansible-stacks/ai-stack/roles/shared/files/agents" fi MARKER_DIR="$HOME/.config/agentmemory" TS_MARKER="$MARKER_DIR/.installed-tailscale" # present ⇒ WE installed/joined tailscale # ── 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_URL_DEFAULT" AM_SECRET="" SKILLS_SRC="$SKILLS_SRC_DEFAULT" AUTHKEY="" LOGIN_SERVER="$LOGIN_SERVER_DEFAULT" REJOIN=0 while [ $# -gt 0 ]; do case "$1" in --global) SCOPE="global" ;; --project) PROJECT_DIR="${2:?}"; shift ;; --url) AM_URL="${2:?}"; shift ;; --secret) AM_SECRET="${2:?}"; shift ;; --skills-src) SKILLS_SRC="${2:?}"; shift ;; --authkey) AUTHKEY="${2:?}"; shift ;; --login-server) LOGIN_SERVER="${2:?}"; shift ;; --rejoin) REJOIN=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 # ── tailscale detect-and-skip ───────────────────────────────────────────────────── ts_state() { tailscale status --json 2>/dev/null || echo '{}'; } ensure_mesh() { local host tag_ok backend online control if command -v tailscale >/dev/null 2>&1; then local j; j="$(ts_state)" backend="$(printf '%s' "$j" | jq -r '.BackendState // empty')" online="$(printf '%s' "$j" | jq -r '.Self.Online // false')" control="$(printf '%s' "$j" | jq -r '.CurrentTailnet.Name // empty')" host="$(printf '%s' "$j" | jq -r '.Self.DNSName // .Self.HostName // empty' | sed 's/\.$//')" tag_ok="$(printf '%s' "$j" | jq -r --arg t "$MESH_TAG" '((.Self.Tags // []) | index($t)) != null')" if [ "$backend" = "Running" ] && [ "$online" = "true" ]; then # Already up. Is it the RIGHT tailnet? (login-server host substring match.) local want; want="$(printf '%s' "$LOGIN_SERVER" | sed -E 's#^https?://##; s#/.*$##')" if printf '%s' "$control" | grep -qi "$want" || [ -z "$control" ]; then ok "tailscale: already up on ${host:-mesh} — skipping install/up" if [ "$tag_ok" != "true" ]; then warn "node is NOT tagged $MESH_TAG yet — memory access (:3111) is gated on it." warn " Tag it server-side (from the ai-stack repo):" warn " task headscale:tag-node NAME=$(printf '%s' "$host" | cut -d. -f1) TAG=$MESH_TAG" fi return 0 fi # Up but on a different tailnet. if [ "$REJOIN" = "1" ]; then [ -n "$AUTHKEY" ] || die "--rejoin needs --authkey" warn "tailscale up on a different tailnet ($control); re-joining $LOGIN_SERVER…" tailscale up --login-server "$LOGIN_SERVER" --authkey "$AUTHKEY" --advertise-tags="$MESH_TAG" return 0 fi die "tailscale is up on a DIFFERENT tailnet ($control). Refusing to hijack it. Re-run with --rejoin --authkey to switch, or fix manually." fi # Installed but not up → bring it up (needs a key). [ -n "$AUTHKEY" ] || die "tailscale installed but not up; pass --authkey to join $LOGIN_SERVER" log "tailscale: joining $LOGIN_SERVER…" tailscale up --login-server "$LOGIN_SERVER" --authkey "$AUTHKEY" --advertise-tags="$MESH_TAG" : > "$MARKER_DIR/.joined-tailscale" 2>/dev/null || true return 0 fi # tailscale binary absent → install it. [ -n "$AUTHKEY" ] || die "tailscale not installed and no --authkey given. Install tailscale + pass --authkey." log "installing tailscale…" case "$(uname -s)" in Darwin) command -v brew >/dev/null 2>&1 && brew install tailscale || die "install tailscale (brew not found)";; Linux) curl -fsSL https://tailscale.com/install.sh | sh ;; *) die "unsupported OS for auto-install; install tailscale manually" ;; esac mkdir -p "$MARKER_DIR"; : > "$TS_MARKER" tailscale up --login-server "$LOGIN_SERVER" --authkey "$AUTHKEY" --advertise-tags="$MESH_TAG" } # ── reachability gate (the real success signal, independent of how we joined) ───── mem_reachable() { local code code="$(curl -fsS -o /dev/null -w '%{http_code}' --max-time 8 \ ${AM_SECRET:+-H "Authorization: Bearer $AM_SECRET"} \ "$AM_URL/agentmemory/livez" 2>/dev/null || echo 000)" [ "$code" = "200" ] || [ "$code" = "204" ] } # ── capture plugin fetch (version-matched, from npm, no global install) ─────────── fetch_capture_plugin() { local dest="$1" command -v npm >/dev/null 2>&1 || return 1 local tmp; tmp="$(mktemp -d)" ( cd "$tmp" # npm pack downloads the tarball without installing; extract just the plugin file. 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" # OpenCode >=1.x loads plugins via the default export (same shim the role applies). 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" ] } # ── JSON merge (surgical: add only OUR keys; preserve everything else) ──────────── plugin_capture_rel=".opencode/plugin/agentmemory-capture.ts" write_config() { mkdir -p "$CFG_DIR" # First-ever install for this scope: snapshot the pre-existing file (or note none). 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")" # Guard against invalid JSON in a pre-existing file. printf '%s' "$base" | jq -e . >/dev/null 2>&1 || die "existing $CFG_JSON is not valid JSON; aborting" local skills_repo_json # opencode-remote-config repositories entry: file:// for a local dir, else the URL. if [ -d "$SKILLS_SRC" ]; then skills_repo_json="{\"url\": \"file://$SKILLS_SRC\"}" elif [ -n "$SKILLS_SRC" ]; then skills_repo_json="{\"url\": \"$SKILLS_SRC\"}" else skills_repo_json="null" fi printf '%s' "$base" | jq \ --arg url "$AM_URL" \ --arg plugin "$plugin_capture_rel" \ --argjson skills "$skills_repo_json" ' # --- mcp.agentmemory (the shim) --- .mcp = (.mcp // {}) | .mcp.agentmemory = { "type": "local", "command": ["npx","-y","@agentmemory/mcp"], "environment": { "AGENTMEMORY_URL": $url, "AGENTMEMORY_SECRET": "{env:AGENTMEMORY_SECRET}", "AGENTMEMORY_FORCE_PROXY": "1" }, "enabled": true } | # --- plugins (dedup-append ours) --- .plugin = ((.plugin // []) + ["opencode-remote-config", $plugin] | unique) | # --- opencode-remote-config skills source --- (if $skills != null then ."opencode-remote-config" = (."opencode-remote-config" // {}) | ."opencode-remote-config".repositories = ( ((."opencode-remote-config".repositories // []) + [$skills]) | unique_by(.url) ) else . end) ' > "$CFG_JSON.tmp" mv "$CFG_JSON.tmp" "$CFG_JSON" # env file (chmod 600) sourced by the shim / your shell cat > "$CFG_DIR/agentmemory.env" < "$OURS_MARKER" </dev/null; then # We created opencode.json from scratch → remove it. rm -f "$CFG_JSON" ok "removed $CFG_JSON (we created it)" else # Restore the exact pre-existing file. cp "$BACKUP" "$CFG_JSON" ok "restored $CFG_JSON from pre-install backup" fi rm -f "$BACKUP" else # No backup (shouldn't happen) → surgical key removal as a fallback. if [ -f "$CFG_JSON" ]; then jq 'del(.mcp.agentmemory) | .plugin = ((.plugin // []) - ["opencode-remote-config", "'"$plugin_capture_rel"'"]) | del(."opencode-remote-config") | 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 rm -f "$CFG_DIR/agentmemory.env" "$CFG_DIR/plugin/agentmemory-capture.ts" "$OURS_MARKER" rmdir "$CFG_DIR/plugin" 2>/dev/null || true # If the .opencode dir is now empty (project scope, we created it), drop it. [ "$SCOPE" = "project" ] && rmdir "$CFG_DIR" 2>/dev/null && ok "removed empty $CFG_DIR" || true } teardown_tailscale() { if [ -f "$TS_MARKER" ]; then warn "we installed tailscale — bringing it down + logging out." tailscale down 2>/dev/null || true tailscale logout 2>/dev/null || true rm -f "$TS_MARKER" warn "left the tailscale binary installed; remove manually if desired (brew uninstall tailscale)." else ok "tailscale was pre-existing — leaving it untouched." fi } # ── commands ────────────────────────────────────────────────────────────────────── cmd_install() { need jq; need curl log "== install ($SCOPE) → $CFG_DIR ==" ensure_mesh if ! mem_reachable; then warn "agentmemory not reachable at $AM_URL (livez). Continuing to write config," warn "but verify tailnet + tag + secret. status: run '$0 status'." else ok "agentmemory reachable at $AM_URL" fi command -v npx >/dev/null 2>&1 || warn "npx/node not found — the @agentmemory/mcp shim needs it at runtime." write_config log "next: open OpenCode in this project; export AGENTMEMORY_SECRET or rely on agentmemory.env." cmd_status } cmd_status() { need jq log "== status ($SCOPE) ==" # tailscale if command -v tailscale >/dev/null 2>&1; then local j; j="$(ts_state)" printf ' tailscale : %s / online=%s / tailnet=%s / host=%s / tags=%s\n' \ "$(printf '%s' "$j" | jq -r '.BackendState // "?"')" \ "$(printf '%s' "$j" | jq -r '.Self.Online // false')" \ "$(printf '%s' "$j" | jq -r '.CurrentTailnet.Name // "?"')" \ "$(printf '%s' "$j" | jq -r '.Self.DNSName // .Self.HostName // "?"' | sed 's/\.$//')" \ "$(printf '%s' "$j" | jq -rc '.Self.Tags // []')" [ -f "$TS_MARKER" ] && printf ' (installed by this script)\n' || printf ' (pre-existing — untouched on uninstall)\n' else printf ' tailscale : not installed\n' fi # reachability + tool count if mem_reachable; then printf ' memory : reachable at %s ✓\n' "$AM_URL"; else printf ' memory : NOT reachable at %s ✗\n' "$AM_URL"; fi # config presence if [ -f "$OURS_MARKER" ]; then printf ' config : managed at %s\n' "$CFG_JSON" jq -e '.mcp.agentmemory' "$CFG_JSON" >/dev/null 2>&1 && printf ' mcp.agentmemory ✓\n' || printf ' mcp.agentmemory MISSING\n' printf ' skills src: %s\n' "${SKILLS_SRC:-}" else printf ' config : not installed in %s\n' "$CFG_DIR" fi } cmd_uninstall() { need jq log "== uninstall ($SCOPE) → $CFG_DIR ==" restore_config teardown_tailscale ok "uninstall complete." } cmd_help() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' } case "$CMD" in install) cmd_install ;; status) cmd_status ;; uninstall) cmd_uninstall ;; help|"" ) cmd_help ;; *) die "unknown command: $CMD (use install|status|uninstall|help)" ;; esac