#!/usr/bin/env bash # retention.sh — AI Agent Memory for Claude Code, Cursor, OpenClaw # Usage: curl -sL retention.sh/install.sh | bash # Platform-aware: set RETENTION_PLATFORM=cursor|openclaw|claude-code before piping # Backed by Tests Assured set -euo pipefail CYAN='\033[0;36m' GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' BOLD='\033[1m' NC='\033[0m' # ── Platform selection ─────────────────────────────────────────────────────── PLATFORM="${RETENTION_PLATFORM:-claude-code}" case "$PLATFORM" in cursor) MCP_FILE=".cursor/mcp.json" AGENT_NAME="Cursor" ;; openclaw) MCP_FILE=".openclaw/mcp.json" AGENT_NAME="OpenClaw" ;; *) PLATFORM="claude-code" MCP_FILE=".mcp.json" AGENT_NAME="Claude Code" ;; esac RETENTION_VERSION="0.4.0" echo "" echo -e "${CYAN}${BOLD} retention.sh${NC} ${YELLOW}v${RETENTION_VERSION}${NC}" echo -e " ${CYAN}AI Agent Memory — backed by Tests Assured${NC}" echo -e " ${CYAN}Platform: ${AGENT_NAME}${NC}" echo "" # Check for existing install — show what's new INSTALL_DIR_CHECK="$HOME/.ta-studio" if [ -f "$INSTALL_DIR_CHECK/proxy.py" ]; then PREV_VERSION="" if [ -f "$INSTALL_DIR_CHECK/.version" ]; then PREV_VERSION=$(cat "$INSTALL_DIR_CHECK/.version" 2>/dev/null || true) fi if [ "$PREV_VERSION" = "$RETENTION_VERSION" ]; then echo -e " ${GREEN}✓ Already at v${RETENTION_VERSION} — re-running to refresh config${NC}" elif [ -n "$PREV_VERSION" ]; then echo -e " ${YELLOW}↑ Upgrading: v${PREV_VERSION} → v${RETENTION_VERSION}${NC}" echo -e " ${CYAN} What's new:${NC}" echo -e " ${CYAN} • ta.sitemap — interactive site map with screenshots${NC}" echo -e " ${CYAN} • ta.qa_check — one-shot QA scan with verdict${NC}" echo -e " ${CYAN} • ta.ux_audit — 21-rule UX audit${NC}" echo -e " ${CYAN} • .claude/rules/retention.md — auto-QA rules${NC}" echo -e " ${CYAN} • /retention-qa slash command${NC}" echo "" else echo -e " ${YELLOW}↑ Updating existing install to v${RETENTION_VERSION}${NC}" fi fi # Step 1: Collect email (auto-detect from git, fall back to prompt) if [ -z "${RETENTION_EMAIL:-}" ]; then RETENTION_EMAIL=$(git config user.email 2>/dev/null || true) fi if [ -n "$RETENTION_EMAIL" ]; then echo -e " ${GREEN}✓ Email: ${RETENTION_EMAIL}${NC}" else printf " Enter your email: " read -r RETENTION_EMAIL fi if [ -z "$RETENTION_EMAIL" ]; then echo -e " ${RED}✗ Email is required. Set RETENTION_EMAIL or configure git.${NC}" exit 1 fi # Step 2: Generate token echo -e " ${YELLOW}→ Generating token for ${RETENTION_EMAIL}...${NC}" TOKEN_RESPONSE="" for token_attempt in 1 2 3; do TOKEN_RESPONSE=$(curl -sf --max-time 15 -X POST "https://exuberant-ferret-263.convex.site/api/mcp/generate-token" \ -H "Content-Type: application/json" \ -d "{\"email\": \"${RETENTION_EMAIL}\", \"platform\": \"${PLATFORM}\"}" 2>/dev/null || true) if [ -n "$TOKEN_RESPONSE" ]; then break fi if [ "$token_attempt" -lt 3 ]; then echo -e " ${YELLOW} Token server slow, retrying ($token_attempt/3)...${NC}" sleep 2 fi done if [ -z "$TOKEN_RESPONSE" ]; then echo -e " ${RED}✗ Could not reach token server after 3 attempts.${NC}" echo -e " ${RED} This usually means a temporary network issue. Try again in a moment.${NC}" echo -e " ${RED} If this persists, contact: support@testsassured.com${NC}" exit 1 fi # Extract token (works with python, node, or grep) TOKEN="" if command -v python3 &>/dev/null; then TOKEN=$(echo "$TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true) elif command -v node &>/dev/null; then TOKEN=$(echo "$TOKEN_RESPONSE" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token||'')}catch{console.log('')}})" 2>/dev/null || true) else TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4 || true) fi if [ -z "$TOKEN" ]; then # Check if response contains an error message ERROR_MSG="" if command -v python3 &>/dev/null; then ERROR_MSG=$(echo "$TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error',''))" 2>/dev/null || true) fi if [ -n "$ERROR_MSG" ]; then echo -e " ${RED}✗ Token error: ${ERROR_MSG}${NC}" else echo -e " ${RED}✗ Could not extract token from response.${NC}" echo -e " ${RED} Raw response: $TOKEN_RESPONSE${NC}" fi echo -e " ${RED} If this persists, contact: support@testsassured.com${NC}" exit 1 fi echo -e " ${GREEN}✓ Token: ${TOKEN}${NC}" # Step 3: Install proxy INSTALL_DIR="$HOME/.ta-studio" mkdir -p "$INSTALL_DIR" # Primary: Vercel CDN (instant, no cold start) # Fallback: Render backend (may have 30s cold start on free tier) PROXY_URL_PRIMARY="https://test-studio-xi.vercel.app/retention-config/proxy.txt" PROXY_URL_FALLBACK="https://tests-assured-backend.onrender.com/mcp/setup/proxy.py" echo -e " ${YELLOW}→ Downloading MCP proxy...${NC}" PROXY_OK=false # Try Vercel first (fast CDN) if curl -sf --max-time 10 "$PROXY_URL_PRIMARY" -o "$INSTALL_DIR/proxy.py" 2>/dev/null; then PROXY_OK=true fi # Fallback to Render backend if [ "$PROXY_OK" = "false" ]; then echo -e " ${YELLOW} Primary CDN unavailable, trying backend...${NC}" for attempt in 1 2 3; do if curl -sf --max-time 40 "$PROXY_URL_FALLBACK" -o "$INSTALL_DIR/proxy.py" 2>/dev/null; then PROXY_OK=true break fi if [ "$attempt" -lt 3 ]; then echo -e " ${YELLOW} Backend cold start — retrying ($attempt/3)...${NC}" sleep 5 fi done fi if [ "$PROXY_OK" = "false" ]; then echo -e " ${RED}✗ Could not download MCP proxy.${NC}" echo -e " ${RED} Check your internet connection or try again in a moment.${NC}" echo -e " ${RED} If this persists, contact: support@testsassured.com${NC}" exit 1 fi # Step 3b: Download local engines (QA + mobile — no cloud dependency) CRAWL_URL="https://test-studio-xi.vercel.app/retention-config/local_crawl.txt" MOBILE_URL="https://test-studio-xi.vercel.app/retention-config/local_mobile.txt" if curl -sf --max-time 10 "$CRAWL_URL" -o "$INSTALL_DIR/local_crawl.py" 2>/dev/null; then echo -e " ${GREEN}✓ Local crawl engine installed${NC}" else echo -e " ${YELLOW}⚠ Local crawl engine skipped (QA tools will use cloud fallback)${NC}" fi if curl -sf --max-time 10 "$MOBILE_URL" -o "$INSTALL_DIR/local_mobile.py" 2>/dev/null; then echo -e " ${GREEN}✓ Mobile QA engine installed${NC}" else echo -e " ${YELLOW}⚠ Mobile engine skipped (mobile tools will be unavailable)${NC}" fi # Step 3c: Detect local tools (Playwright, ADB, iOS Simulator) if command -v playwright &>/dev/null || python3 -c "import playwright" 2>/dev/null; then echo -e " ${GREEN}✓ Playwright — full browser crawling enabled${NC}" else echo -e " ${YELLOW}⚠ Playwright not found — ta.qa_check uses HTTP-only mode${NC}" echo -e " ${CYAN} For full browser crawling: pip install playwright && playwright install chromium${NC}" fi if command -v adb &>/dev/null; then ADB_DEVICES=$(adb devices 2>/dev/null | grep -c "device$" || true) echo -e " ${GREEN}✓ ADB detected — Android mobile QA enabled (${ADB_DEVICES} device(s))${NC}" fi if command -v xcrun &>/dev/null && xcrun simctl list devices 2>/dev/null | grep -q "Booted"; then echo -e " ${GREEN}✓ iOS Simulator detected — iOS mobile QA enabled${NC}" elif command -v xcrun &>/dev/null; then echo -e " ${YELLOW}⚠ Xcode found but no simulator running — boot one for iOS QA${NC}" fi # Verify proxy is a Python file (basic integrity check — not a 404/error page) if ! head -1 "$INSTALL_DIR/proxy.py" | grep -q "^#\|^import\|^from\|^\"\"\""; then echo -e " ${RED}✗ Downloaded file doesn't look like a Python script.${NC}" echo -e " ${RED} The server may be returning an error page. Try again later.${NC}" rm -f "$INSTALL_DIR/proxy.py" exit 1 fi echo -e " ${GREEN}✓ MCP proxy installed to ${INSTALL_DIR}/proxy.py${NC}" # Step 4: Write MCP config (platform-aware path) MCP_JSON="{ \"mcpServers\": { \"ta-studio\": { \"command\": \"python3\", \"args\": [\"$INSTALL_DIR/proxy.py\"], \"env\": { \"TA_STUDIO_URL\": \"https://tests-assured-backend.onrender.com\", \"TA_MCP_TOKEN\": \"$TOKEN\" } } } }" # Create parent directory for platform-specific paths (e.g. .cursor/, .openclaw/) MCP_DIR=$(dirname "$MCP_FILE") if [ "$MCP_DIR" != "." ]; then mkdir -p "$MCP_DIR" fi if [ -f "$MCP_FILE" ]; then BACKUP="${MCP_FILE}.retention" echo -e " ${YELLOW}⚠ ${MCP_FILE} already exists — writing to ${BACKUP} instead${NC}" echo "$MCP_JSON" > "$BACKUP" echo -e " ${GREEN}✓ Config written to ${BACKUP} — merge manually${NC}" else echo "$MCP_JSON" > "$MCP_FILE" echo -e " ${GREEN}✓ Config written to ${MCP_FILE}${NC}" fi # Step 4b: Ensure .mcp.json is gitignored (token should never be committed) if [ -f ".gitignore" ]; then if ! grep -q "^\.mcp\.json$" .gitignore 2>/dev/null; then echo "" >> .gitignore echo "# MCP token — do not commit" >> .gitignore echo ".mcp.json" >> .gitignore echo -e " ${GREEN}✓ Added .mcp.json to .gitignore${NC}" fi fi # Step 4c: Install retention.sh QA rules for Claude Code RULES_DIR=".claude/rules" if [ ! -f "$RULES_DIR/retention.md" ]; then mkdir -p "$RULES_DIR" RULES_URL="https://test-studio-xi.vercel.app/retention-config/rules.md" if curl -sf --max-time 10 "$RULES_URL" -o "$RULES_DIR/retention.md" 2>/dev/null; then echo -e " ${GREEN}✓ QA rules installed to ${RULES_DIR}/retention.md${NC}" fi fi # Step 5: Join team if invite code provided RETENTION_TEAM="${RETENTION_TEAM:-}" DASHBOARD_URL="https://test-studio-xi.vercel.app/memory/team" if [ -n "$RETENTION_TEAM" ]; then echo -e " ${YELLOW}→ Joining team ${RETENTION_TEAM}...${NC}" JOIN_RESPONSE=$(curl -sf -X POST "https://exuberant-ferret-263.convex.site/api/team/join" \ -H "Content-Type: application/json" \ -d "{\"token\": \"${TOKEN}\", \"inviteCode\": \"${RETENTION_TEAM}\"}" 2>/dev/null || true) if [ -n "$JOIN_RESPONSE" ]; then TEAM_NAME="" if command -v python3 &>/dev/null; then TEAM_NAME=$(echo "$JOIN_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('name',''))" 2>/dev/null || true) DASHBOARD_URL=$(echo "$JOIN_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('dashboardUrl','${DASHBOARD_URL}'))" 2>/dev/null || true) fi if [ -n "$TEAM_NAME" ]; then echo -e " ${GREEN}✓ Joined team: ${TEAM_NAME}${NC}" else echo -e " ${YELLOW}⚠ Team join response: ${JOIN_RESPONSE}${NC}" fi else echo -e " ${YELLOW}⚠ Could not join team — check your invite code${NC}" fi fi # Step 6: Install PostToolUse hook for agent analytics # Logs tool names + timing to ~/.retention/activity.jsonl (privacy-scrubbed, local-only) CLAUDE_SETTINGS="$HOME/.claude/settings.json" RETENTION_DIR="$HOME/.retention" mkdir -p "$RETENTION_DIR" if [ "$AGENT_NAME" = "Claude Code" ] && command -v python3 &>/dev/null; then # Check if hook already exists HOOK_EXISTS=$(python3 -c " import json,pathlib p=pathlib.Path('$CLAUDE_SETTINGS') if not p.exists(): print('no'); exit() s=json.loads(p.read_text()) for e in s.get('hooks',{}).get('PostToolUse',[]): for h in e.get('hooks',[]): if 'retention' in h.get('command',''): print('yes'); exit() print('no') " 2>/dev/null || echo "no") if [ "$HOOK_EXISTS" = "no" ]; then python3 -c " import json,pathlib p=pathlib.Path('$CLAUDE_SETTINGS') s=json.loads(p.read_text()) if p.exists() else {} hooks=s.setdefault('hooks',{}) post=hooks.setdefault('PostToolUse',[]) post.append({'matcher':'.*','hooks':[{'type':'command','command':\"python3 -c \\\"import sys,json,pathlib,datetime,hashlib;d=json.load(sys.stdin);p=pathlib.Path.home()/'.retention'/'activity.jsonl';p.parent.mkdir(exist_ok=True);tn=d.get('tool_name','');ti=d.get('tool_input',{});si={k:('[REDACTED]' if any(s in k.lower() for s in ['password','secret','key','token','credential']) else f'*{pathlib.PurePosixPath(str(v)).suffix}' if k in ('file_path','path') else str(v)[:20] if len(str(v))<=20 else f'[{len(str(v))}c]') for k,v in ti.items()} if isinstance(ti,dict) else {};e={'ts':datetime.datetime.now(datetime.timezone.utc).isoformat(),'source':'claude-code-hook','tool_name':tn,'tool_input':si,'session_id':d.get('session_id','')};f=open(p,'a');f.write(json.dumps(e)+'\\\\n');f.close()\\\"\"}]}) p.parent.mkdir(parents=True,exist_ok=True) p.write_text(json.dumps(s,indent=2)+'\n') " 2>/dev/null if [ $? -eq 0 ]; then echo -e " ${GREEN}✓ PostToolUse analytics hook installed${NC}" echo -e " Tool names + timing logged to ~/.retention/activity.jsonl" echo -e " No file contents, API keys, or personal data collected." fi else echo -e " ${GREEN}✓ PostToolUse analytics hook already installed${NC}" fi fi # Save version for future update detection echo "$RETENTION_VERSION" > "$INSTALL_DIR/.version" echo "" echo -e " ${GREEN}${BOLD}✓ Retention installed (v${RETENTION_VERSION}).${NC}" echo "" echo -e " ${BOLD}Next steps:${NC}" echo -e " 1. Restart ${AGENT_NAME} (or your MCP-compatible agent)" echo -e " 2. Type ${CYAN}/mcp${NC} to confirm ${BOLD}ta-studio${NC} is listed" echo -e " 3. Ask your agent: ${CYAN}ta.system_check${NC}" if [ -n "$RETENTION_TEAM" ]; then echo "" echo -e " ${BOLD}Team dashboard:${NC}" echo -e " ${CYAN}${DASHBOARD_URL}${NC}" fi echo "" echo -e " ${BOLD}Create or join a team:${NC}" echo -e " Create: ask your agent ${CYAN}ta.team.create${NC}" echo -e " Join: ${CYAN}RETENTION_TEAM= curl -sL retention.sh/install.sh | bash${NC}" echo "" echo -e " ${CYAN}retention.sh${NC} — backed by Tests Assured" echo ""