#!/bin/sh
# Join this machine to a cl-memory instance: install the client, wire every coding agent found on
# the machine, and start capturing. --uninstall reverses the wiring; --purge also removes the
# client binary, the hook bundle, and the data directory.
#
#   curl -fsSL https://cl-memory-eng.westus3.cloudapp.azure.com/join.sh | AI_MEMORY_JOIN_TOKEN=aim_… sh
#
# or, from a checkout of this repository:
#
#   deploy/join.sh --token aim_… [--agent NAME]… [--server-url URL] [--scope user|project]
#   deploy/join.sh --uninstall [--agent NAME]… [--purge] [--scope user|project]
#
# The token is the person's own `aim_` API key, issued by an operator with
# `ai-memory api-key add --username <name> --label <machine>`. It is the only credential involved;
# nothing here needs Azure access or the root bearer, and the script never prints it.
#
# With no --agent the script wires every supported agent it finds: Claude Code, Codex, Cursor, and
# OpenCode. Re-running is safe — every step is idempotent and each installer backs up the config
# file it rewrites.
#
# Uninstalling needs no key and touches the server never: it removes the hook entries, the MCP
# registration, and the OpenCode plugin from each agent's own configuration, preserving the rest
# of every file (a timestamped .bak-uninstall-* is written beside each file it edits). Anything
# already sent stays on the server; an operator revokes the key to cut a machine off there.
# --purge additionally deletes ~/.local/bin/ai-memory, the hook bundle, and the data directory,
# discarding any queued-but-unsent events. Deleting a repository's .ai-memory.toml marker is the
# per-repository opt-out and needs no uninstall at all.
#
# POSIX sh on purpose: the published copy is piped straight into `sh`.
set -eu

VERSION="2.2.1"                                   # the pinned upstream release, as in deploy/docker-compose.yml
SERVER_URL="https://cl-memory-eng.westus3.cloudapp.azure.com"
SCOPE="user"
INSTALL_DIR="${HOME}/.local/bin"
DATA_DIR="${AI_MEMORY_DATA_DIR:-$HOME/.local/share/ai-memory}"
HOOKS_DIR="$HOME/.local/share/ai-memory-hooks"    # kept beside the data dir, never inside it:
                                                  # the installer stages into <data-dir>/hooks.
TOKEN="${AI_MEMORY_JOIN_TOKEN:-}"
AGENTS=""                                         # space-separated; empty means detect
MODE="install"                                    # install | uninstall
PURGE=""

die() { echo "error: $*" >&2; exit 1; }
say() { printf '%s\n' "$*"; }

# Every agent this script knows how to wire. The hook integration and the MCP client happen to
# share a name for all four; a future agent where they differ needs a second table.
supported="claude-code codex cursor open-code"

agent_label() {
  case "$1" in
    claude-code) echo "Claude Code" ;;
    codex)       echo "Codex CLI" ;;
    cursor)      echo "Cursor" ;;
    open-code)   echo "OpenCode" ;;
    *)           echo "$1" ;;
  esac
}

# Present on this machine? A binary on PATH or the agent's own config directory both count: Cursor
# installs its CLI under a name that varies by platform, and an agent whose config dir exists is
# one the person uses even when its launcher is not on this shell's PATH.
agent_present() {
  case "$1" in
    claude-code) command -v claude    >/dev/null 2>&1 || [ -d "$HOME/.claude" ] ;;
    codex)       command -v codex     >/dev/null 2>&1 || [ -d "$HOME/.codex" ] ;;
    cursor)      command -v cursor-agent >/dev/null 2>&1 || command -v cursor >/dev/null 2>&1 || [ -d "$HOME/.cursor" ] ;;
    open-code)   command -v opencode  >/dev/null 2>&1 || [ -d "${XDG_CONFIG_HOME:-$HOME/.config}/opencode" ] ;;
    *)           return 1 ;;
  esac
}

is_supported() {
  for _s in $supported; do [ "$_s" = "$1" ] && return 0; done
  return 1
}

# ---- uninstall helpers ------------------------------------------------------
# The upstream 2.2.1 installers write but never remove, so the reversal lives here. Every edit is
# preceded by a timestamped backup, and filters match on the "ai-memory" string the installers put
# in every hook command and MCP entry name, so unrelated configuration passes through untouched.

backup_file() {
  [ -f "$1" ] || return 0
  cp "$1" "$1.bak-uninstall-$(date +%Y%m%dT%H%M%S)" || true
}

# Remove the ai-memory entries from a settings file whose `.hooks` value maps event names to
# arrays of entries. Entries differ in shape per agent — Claude Code nests the command under
# .hooks[], Cursor and Codex keep a flat .command — so the filter sweeps the whole entry for any
# command mentioning ai-memory, then drops events left with empty arrays.
remove_ai_memory_hooks() {
  f="$1"
  [ -f "$f" ] && grep -q ai-memory "$f" || return 0
  command -v jq >/dev/null 2>&1 || {
    say "    jq not found: remove the ai-memory hook entries from $f by hand (a backup from the"
    say "    install may sit beside it)."
    return 0
  }
  backup_file "$f"
  jq '.hooks |= with_entries(
        .value |= map(select(([.. | objects | .command? // empty] | join(" ") | contains("ai-memory")) | not))
      )
      | .hooks |= with_entries(select(.value | length > 0))' "$f" > "$f.tmp" &&
    mv "$f.tmp" "$f"
  say "    removed the hook entries from $f"
}

# Delete $2."ai-memory" from a JSON file whose MCP servers sit under the object $2
# (mcpServers for Cursor, mcp for OpenCode).
remove_mcp_entry() {
  f="$1"; key="$2"
  [ -f "$f" ] && grep -q '"ai-memory"' "$f" || return 0
  command -v jq >/dev/null 2>&1 || {
    say "    jq not found: remove the ai-memory server from $f by hand."
    return 0
  }
  backup_file "$f"
  jq "del(.${key}.\"ai-memory\")" "$f" > "$f.tmp" && mv "$f.tmp" "$f"
  say "    removed the ai-memory server from $f"
}

# Drop every [mcp_servers.ai-memory*] table, and the keys under it, from a TOML file: skip from an
# ai-memory table header until the next header of any other kind.
remove_toml_mcp_table() {
  f="$1"
  [ -f "$f" ] && grep -q '^\[mcp_servers\.ai-memory' "$f" || return 0
  backup_file "$f"
  awk '/^\[mcp_servers\.ai-memory/ { skip = 1; next }
       /^\[/                       { skip = 0 }
       skip                        { next }
       { print }' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
  say "    removed the ai-memory server from $f"
}

remove_claude_mcp() {
  if command -v claude >/dev/null 2>&1; then
    # The blessed path; -s local covers a checkout the person scoped with --scope project (run
    # there for that checkout — local registrations are per directory).
    claude mcp remove ai-memory -s user  >/dev/null 2>&1 || true
    claude mcp remove ai-memory -s local >/dev/null 2>&1 || true
    say "    unregistered the ai-memory MCP server (user scope)"
  elif [ -f "$HOME/.claude.json" ] && grep -q '"ai-memory"' "$HOME/.claude.json"; then
    remove_mcp_entry "$HOME/.claude.json" mcpServers
  fi
}

remove_opencode_plugin() {
  plugin_dir="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins"
  [ -d "$plugin_dir" ] || return 0
  for f in "$plugin_dir"/ai-memory*.ts; do
    [ -f "$f" ] || continue
    if grep -q 'ai-memory install-hooks' "$f"; then
      rm -f "$f"
      say "    removed plugin $(basename "$f")"
    else
      say "    left $(basename "$f") alone: not an ai-memory-generated file"
    fi
  done
}

uninstall_agent() {
  a="$1"
  say "==> $(agent_label "$a")"
  case "$a" in
    claude-code)
      if [ "$SCOPE" = "project" ] && [ -n "$REPO_ROOT" ]; then
        remove_ai_memory_hooks "$REPO_ROOT/.claude/settings.local.json"
      else
        remove_ai_memory_hooks "$HOME/.claude/settings.json"
      fi
      remove_claude_mcp
      ;;
    codex)
      remove_ai_memory_hooks "$HOME/.codex/hooks.json"
      remove_toml_mcp_table "$HOME/.codex/config.toml"
      ;;
    cursor)
      remove_ai_memory_hooks "$HOME/.cursor/hooks.json"
      remove_mcp_entry "$HOME/.cursor/mcp.json" mcpServers
      ;;
    open-code)
      remove_opencode_plugin
      remove_mcp_entry "${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json" mcp
      ;;
  esac
}

while [ $# -gt 0 ]; do
  case "$1" in
    --token)       TOKEN="${2:-}"; shift 2 ;;
    --agent)       is_supported "${2:-}" || die "unknown agent '${2:-}'. Supported: $supported"
                   AGENTS="$AGENTS ${2}"; shift 2 ;;
    --server-url)  SERVER_URL="${2:-}"; shift 2 ;;
    --scope)       SCOPE="${2:-}"; shift 2 ;;
    --install-dir) INSTALL_DIR="${2:-}"; shift 2 ;;
    --uninstall)   MODE="uninstall"; shift ;;
    --purge)       MODE="uninstall"; PURGE=1; shift ;;
    -h|--help)     sed -n '2,/^# POSIX sh on purpose/p' "$0"; exit 0 ;;
    *)             die "unknown argument: $1" ;;
  esac
done

case "$SCOPE" in user|project) ;; *) die "--scope takes 'user' or 'project', not '$SCOPE'" ;; esac

if [ "$MODE" = "uninstall" ]; then
  # No key, no server contact: undoing the wiring is a purely local operation.
  if [ -z "$AGENTS" ]; then
    for a in $supported; do
      if agent_present "$a"; then AGENTS="$AGENTS $a"; fi
    done
    [ -n "$AGENTS" ] || die "found none of: $supported. Name one with --agent if its config lives outside this home."
  fi
  say "==> unwiring$(for a in $AGENTS; do printf ' %s,' "$(agent_label "$a")"; done | sed 's/,$//')"
  for a in $AGENTS; do
    uninstall_agent "$a"
  done
  if [ -n "$PURGE" ]; then
    say "==> purging the client, the hook bundle, and the data directory"
    rm -rf "$DATA_DIR" "$HOOKS_DIR"
    rm -f "$INSTALL_DIR/ai-memory"
    say "    removed $INSTALL_DIR/ai-memory, $HOOKS_DIR, and $DATA_DIR (queued events discarded)"
  else
    say "==> kept $INSTALL_DIR/ai-memory and $DATA_DIR. Remove them, or repeat with --purge, to delete"
    say "    the client, the hook bundle, and any queued-but-unsent events."
  fi
  say ""
  say "done. Restart your agents to unload hooks they read at start. Repositories keep their"
  say ".ai-memory.toml markers; they are inert until you join again. What was already sent stays on"
  say "the server — ask an operator to revoke this machine's key to cut it off there."
  exit 0
fi

[ -n "$TOKEN" ] || die "no API key. Pipe the script with AI_MEMORY_JOIN_TOKEN=aim_… or pass --token aim_…"
command -v curl >/dev/null 2>&1 || die "curl is required."

# Project scope confines Claude Code's capture and MCP registration to one checkout. It is the
# narrow option; user scope is the default because allowlist capture below already makes silence
# the default everywhere, and because the other three agents keep their configuration per user and
# cannot be scoped to a checkout at all.
REPO_ROOT=""
if [ "$SCOPE" = "project" ]; then
  REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" ||
    die "run --scope project from inside a git checkout."
fi

sha256_of() {
  if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
  else shasum -a 256 "$1" | cut -d' ' -f1; fi
}

# 1. Check the key before installing anything, so a bad key fails in seconds rather than after a
#    download. An MCP `initialize` is the one call a non-root key can make that proves both that
#    the server is reachable and that the credential is accepted.
say "==> checking the key against $SERVER_URL"
code=$(curl -sS -m 20 -o /dev/null -w '%{http_code}' -X POST "$SERVER_URL/mcp" \
  -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"join.sh","version":"2"}}}')
case "$code" in
  200) say "    key accepted" ;;
  401) die "the server refused this key (401). Ask an operator to issue a new one." ;;
  *)   die "unexpected response from $SERVER_URL/mcp (HTTP $code)." ;;
esac

# 2. Decide what to wire. Detection keeps the common case to one command with no flags; an explicit
#    --agent overrides it, including for an agent this machine has not installed yet.
if [ -z "$AGENTS" ]; then
  for a in $supported; do
    if agent_present "$a"; then AGENTS="$AGENTS $a"; fi
  done
  [ -n "$AGENTS" ] || die "found none of: $supported. Install one, or name it with --agent."
  say "==> found$(for a in $AGENTS; do printf ' %s,' "$(agent_label "$a")"; done | sed 's/,$//')"
fi

# 3. Install the client and the vendored hook scripts from the pinned upstream release. The
#    installer needs the hooks bundle on disk; the binary alone is not enough.
if [ -x "$INSTALL_DIR/ai-memory" ] && "$INSTALL_DIR/ai-memory" --version 2>/dev/null | grep -q "$VERSION" \
   && [ -d "$HOOKS_DIR/claude-code" ]; then
  say "==> ai-memory $VERSION already installed"
else
  case "$(uname -s)" in Linux) os=linux ;; Darwin) os=macos ;; *) die "unsupported OS: $(uname -s)" ;; esac
  case "$(uname -m)" in x86_64|amd64) arch=x86_64 ;; arm64|aarch64) arch=aarch64 ;; *) die "unsupported CPU: $(uname -m)" ;; esac
  asset="ai-memory-${os}-${arch}.tar.gz"
  base="https://github.com/akitaonrails/ai-memory/releases/download/v${VERSION}"
  tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
  say "==> downloading $asset"
  curl -sSL -o "$tmp/$asset" "$base/$asset"
  curl -sSL -o "$tmp/$asset.sha256" "$base/$asset.sha256"
  want="$(cut -d' ' -f1 < "$tmp/$asset.sha256")"
  got="$(sha256_of "$tmp/$asset")"
  [ "$want" = "$got" ] || die "checksum mismatch for $asset; refusing to install."
  say "    checksum verified"
  mkdir -p "$tmp/x" "$INSTALL_DIR" "$HOOKS_DIR"
  tar xzf "$tmp/$asset" -C "$tmp/x"
  install -m 0755 "$tmp/x/ai-memory" "$INSTALL_DIR/ai-memory"
  cp -R "$tmp/x/hooks/." "$HOOKS_DIR/"
  say "    installed $INSTALL_DIR/ai-memory and the hook bundle"
fi
AIM="$INSTALL_DIR/ai-memory"
case ":$PATH:" in *":$INSTALL_DIR:"*) ;; *) say "    note: $INSTALL_DIR is not on your PATH" ;; esac

# 4. Wire each agent: lifecycle hooks for the write path, MCP registration for the read path.
#    --apply writes the key into two 0600 files in the data directory rather than into any agent's
#    settings file, and backs up whatever config it rewrites. --as-user is deliberately omitted: in
#    2.2.1 it rejects an --auth-token that is present, and it is only an operator-visible label —
#    the server resolves attribution from the key itself.
restart_needed=""
for a in $AGENTS; do
  label="$(agent_label "$a")"
  say "==> $label"

  set -- --agent "$a" --apply --server-url "$SERVER_URL" --auth-token "$TOKEN" \
         --capture-mode allowlist --hooks-dir "$HOOKS_DIR"
  if [ "$a" = "claude-code" ] && [ -n "$REPO_ROOT" ]; then
    mkdir -p "$REPO_ROOT/.claude"
    set -- "$@" --config-file "$REPO_ROOT/.claude/settings.local.json"
  fi
  "$AIM" --data-dir "$DATA_DIR" install-hooks "$@" 2>&1 |
    grep -vE '^\s*$|ai-memory starting|capture-policy capability' | sed 's/^/    /' || true

  if [ "$a" = "claude-code" ] && [ -n "$REPO_ROOT" ] && command -v claude >/dev/null 2>&1; then
    # Project scope has no install-mcp equivalent: `claude mcp add -s local` is the only way to
    # register the server for one checkout rather than the whole machine.
    claude mcp remove ai-memory -s local >/dev/null 2>&1 || true
    claude mcp add --transport http ai-memory "$SERVER_URL/mcp" \
      -s local --header "Authorization: Bearer $TOKEN" >/dev/null
    say "    registered the memory server for this checkout"
  else
    "$AIM" --data-dir "$DATA_DIR" install-mcp --client "$a" --apply \
      --server-url "$SERVER_URL" --auth-token "$TOKEN" 2>&1 |
      grep -vE '^\s*$|ai-memory starting' | sed 's/^/    /' || true
  fi

  case "$a" in
    cursor|open-code) restart_needed="$restart_needed $label" ;;
  esac
done

say ""
say "done. Capture is in allowlist mode: a repository sends nothing until it carries a two-line"
say ".ai-memory.toml naming its workspace and project. Everything else on this machine stays silent."
# Cursor and OpenCode read their integration at start, so a running one is still uninstrumented.
if [ -n "$restart_needed" ]; then
  say "Restart $(printf '%s' "$restart_needed" | sed 's/^ //; s/ / and /') to load the new plugin."
fi
say "Browse the memories at $SERVER_URL/web — that needs a password account, not this key."
