Files

482 lines
17 KiB
Bash
Executable File

#!/bin/bash
# clawtap — CLI for managing AI coding sessions (Claude, Codex, Gemini)
#
# Usage:
# clawtap # Start server, show connection URLs
# clawtap new # New Claude session in tmux
# clawtap new --adapter codex # New Codex session
# clawtap -a # List active sessions (current project)
# clawtap -a --adapter gemini # List only Gemini sessions (current project)
# clawtap -A # List ALL active sessions (all projects)
# clawtap --continue # Resume most recent session
# clawtap --continue --adapter codex # Resume most recent Codex session
# clawtap --resume <session-id> # Resume a specific session
# clawtap hooks install --adapter claude # Install hooks for Claude only
# clawtap stop # Stop the server
#
# Sessions run inside tmux "clawtap".
# Mobile app auto-connects for real-time sync.
TMUX_SESSION="clawtap"
YOLO="--dangerously-skip-permissions"
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
SERVER_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PORT="${PORT:-3456}"
PID_FILE="$HOME/.clawtap/server.pid"
# --- Parse --adapter flag (before any command handling) ---
# Helper: set adapter variables from adapter name
set_adapter() {
case "$1" in
claude) ADAPTER="claude"; ADAPTER_CMD="claude"; YOLO="--dangerously-skip-permissions" ;;
codex) ADAPTER="codex"; ADAPTER_CMD="codex"; YOLO="--dangerously-bypass-approvals-and-sandbox" ;;
gemini) ADAPTER="gemini"; ADAPTER_CMD="gemini"; YOLO="--approval-mode yolo" ;;
esac
}
ADAPTER="claude"
ADAPTER_CMD="claude"
ADAPTER_EXPLICIT=false
prev_arg=""
for arg in "$@"; do
if [ "$prev_arg" = "--adapter" ]; then
ADAPTER_EXPLICIT=true
case "$arg" in
claude) set_adapter claude ;;
codex) set_adapter codex ;;
gemini) set_adapter gemini ;;
*) echo "Unknown adapter: $arg"; exit 1 ;;
esac
fi
prev_arg="$arg"
done
# Strip --adapter and its value from positional args
CLEANED_ARGS=()
skip_next=false
for arg in "$@"; do
if $skip_next; then skip_next=false; continue; fi
if [ "$arg" = "--adapter" ]; then skip_next=true; continue; fi
CLEANED_ARGS+=("$arg")
done
set -- "${CLEANED_ARGS[@]}"
# --- CLI flags (no server needed) ---
case "$1" in
--version|-v)
printf 'clawtap v%s\n' "$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$SERVER_DIR/package.json" | head -1)"
exit 0 ;;
--help|-h)
cat << 'HELP'
Usage: clawtap [options] [cli args...]
Commands:
new Start a new AI coding session in tmux
stop Stop the server (graceful cleanup)
hooks install Install hooks (all adapters, or use --adapter)
hooks uninstall Remove hooks (all adapters, or use --adapter)
cert Generate self-signed HTTPS certificate
Options:
-v, --version Show version
-h, --help Show this help
-a List active sessions (current project)
-A List ALL active sessions (all projects)
--adapter <name> Adapter: claude (default), codex, gemini
--resume <session-id> Resume a specific session by ID
--continue Resume the most recent session
<any args> Pass through to the adapter CLI
Examples:
clawtap new # New Claude session
clawtap new --adapter codex # New Codex session
clawtap -a --adapter gemini # List Gemini sessions (this project)
clawtap --continue --adapter codex # Resume latest Codex session
clawtap hooks install --adapter claude # Install hooks for Claude only
HELP
exit 0 ;;
stop)
if [ ! -f "$PID_FILE" ]; then
echo "Server is not running."
# Safety net: clean up hooks in case of previous ungraceful shutdown
npx tsx "$SCRIPT_DIR/hooks-cli.mjs" uninstall 2>/dev/null
exit 0
fi
PID=$(cat "$PID_FILE")
if ! kill -0 "$PID" 2>/dev/null; then
echo "Server process (PID $PID) not running. Cleaning up stale PID file."
rm -f "$PID_FILE"
npx tsx "$SCRIPT_DIR/hooks-cli.mjs" uninstall 2>/dev/null
exit 0
fi
echo "Stopping ClawTap server (PID $PID)..."
kill "$PID"
# Wait for graceful shutdown (up to 5s)
for i in $(seq 1 10); do
if ! kill -0 "$PID" 2>/dev/null; then
echo "Server stopped."
# Safety net: ensure hooks are cleaned up even if server didn't do it
npx tsx "$SCRIPT_DIR/hooks-cli.mjs" uninstall 2>/dev/null
exit 0
fi
sleep 0.5
done
echo "Server didn't stop gracefully, forcing..."
kill -9 "$PID" 2>/dev/null
rm -f "$PID_FILE"
# Graceful shutdown failed — force cleanup hooks
npx tsx "$SCRIPT_DIR/hooks-cli.mjs" uninstall 2>/dev/null
echo "Server killed."
exit 0 ;;
hooks)
if [ "$ADAPTER_EXPLICIT" = true ]; then
npx tsx "$SCRIPT_DIR/hooks-cli.mjs" "$2" "$ADAPTER"
else
npx tsx "$SCRIPT_DIR/hooks-cli.mjs" "$2"
fi
exit 0 ;;
cert)
CERT_DIR="$HOME/.clawtap"
CERT_FILE="$CERT_DIR/cert.pem"
KEY_FILE="$CERT_DIR/key.pem"
mkdir -p "$CERT_DIR"
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
echo "Certificate already exists at $CERT_DIR/"
echo " cert.pem $(openssl x509 -in "$CERT_FILE" -noout -enddate 2>/dev/null | sed 's/notAfter=/Expires: /')"
echo ""
read -p "Regenerate? (y/N) " REGEN
[ "$REGEN" != "y" ] && [ "$REGEN" != "Y" ] && exit 0
fi
echo "Generating self-signed certificate..."
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$KEY_FILE" \
-out "$CERT_FILE" \
-days 365 \
-subj "/CN=ClawTap" \
-addext "subjectAltName=IP:$(ipconfig getifaddr en0 2>/dev/null || echo '0.0.0.0')" \
2>/dev/null
if [ $? -ne 0 ]; then
echo "Failed to generate certificate. Is openssl installed?"
exit 1
fi
chmod 600 "$KEY_FILE"
echo ""
echo "Certificate generated:"
echo " $CERT_FILE"
echo " $KEY_FILE"
echo ""
echo "Restart the server to use HTTPS:"
echo " clawtap stop && clawtap"
echo ""
echo "To trust on your phone (iOS):"
echo " 1. Send ~/.clawtap/cert.pem to your phone (AirDrop, email, etc.)"
echo " 2. Open it → Install Profile"
echo " 3. Settings → General → About → Certificate Trust Settings"
echo " → Enable full trust for 'ClawTap'"
echo ""
echo "To trust on your phone (Android):"
echo " 1. Send ~/.clawtap/cert.pem to your phone"
echo " 2. Settings → Security → Install certificate → CA certificate"
exit 0 ;;
esac
# --- Server management ---
# Detect HTTPS mode
if [ -f "$HOME/.clawtap/cert.pem" ] && [ -f "$HOME/.clawtap/key.pem" ]; then
PROTOCOL="https"
CURL_OPTS="-k" # allow self-signed certs
else
PROTOCOL="http"
CURL_OPTS=""
fi
ensure_server() {
# Check if server is already running
if curl -sf $CURL_OPTS --connect-timeout 2 "$PROTOCOL://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
return 0
fi
# Fallback: check PID file (written by server on startup)
if [ -f "$PID_FILE" ]; then
SAVED_PID=$(cat "$PID_FILE")
if kill -0 "$SAVED_PID" 2>/dev/null; then
# Server process alive — retry health check with patience
for i in $(seq 1 5); do
if curl -sf $CURL_OPTS --connect-timeout 2 "$PROTOCOL://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
return 0
fi
sleep 0.5
done
echo "Server process running (PID $SAVED_PID) but not responding on port $PORT"
echo "Check: $HOME/.clawtap/server.log"
exit 1
else
# Stale PID file — process dead, clean up
rm -f "$PID_FILE"
fi
fi
# Password is required
if [ -z "$CLAWTAP_PASSWORD" ]; then
echo "ClawTap server not running."
echo ""
echo "Set a password and try again:"
echo " export CLAWTAP_PASSWORD=your-password"
echo " clawtap"
echo ""
echo "Or start the server separately:"
echo " CLAWTAP_PASSWORD=your-password npm start"
exit 1
fi
echo "Starting ClawTap server on port $PORT..."
CLAWTAP_PASSWORD="$CLAWTAP_PASSWORD" PORT="$PORT" \
nohup npx tsx "$SERVER_DIR/server/index.ts" >"$HOME/.clawtap/server.log" 2>&1 &
SERVER_PID=$!
# Wait for server to be ready (up to 10s)
for i in $(seq 1 20); do
if curl -sf $CURL_OPTS --connect-timeout 2 "$PROTOCOL://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
echo "ClawTap running on $PROTOCOL://0.0.0.0:$PORT (pid $SERVER_PID)"
return 0
fi
sleep 0.5
done
echo "Server failed to start. Check $HOME/.clawtap/server.log"
exit 1
}
ensure_server
# Localhost requests are trusted by the server — no token needed
require_auth() {
AUTH_TOKEN=""
}
# No args → just start server, print URLs, exit
if [ $# -eq 0 ]; then
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || echo "")
TS_HOST=$(tailscale status --self --json 2>/dev/null | \
python3 -c "import sys,json; print(json.load(sys.stdin)['Self']['DNSName'].rstrip('.'))" 2>/dev/null || echo "")
echo ""
echo "ClawTap server is running on port $PORT"
echo ""
echo " Open on your phone:"
if [ -n "$TS_HOST" ]; then echo " https://${TS_HOST} (Tailscale)"; fi
if [ -n "$LAN_IP" ]; then echo " ${PROTOCOL}://${LAN_IP}:${PORT} (LAN)"; fi
echo " http://localhost:${PORT} (this machine)"
echo ""
echo " New session: clawtap new [--adapter claude|codex|gemini]"
echo " List sessions: clawtap -a"
echo " Stop server: clawtap stop"
echo ""
exit 0
fi
# "new" subcommand → create tmux session (like old no-args behavior)
if [ "$1" = "new" ]; then
shift
fi
# Ensure tmux session exists
tmux has-session -t "$TMUX_SESSION" 2>/dev/null || \
tmux new-session -d -s "$TMUX_SESSION" -n main
# --- Attach mode (query server API for accurate adapter info) ---
if [ "$1" = "--attach" ] || [ "$1" = "-a" ] || [ "$1" = "-A" ]; then
ALL_MODE=false
[ "$1" = "-A" ] && ALL_MODE=true
require_auth
SESSIONS_JSON=$(curl -s $CURL_OPTS "$PROTOCOL://localhost:$PORT/api/active-sessions" \
-H "Authorization: Bearer $AUTH_TOKEN")
if ! echo "$SESSIONS_JSON" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
echo "Error: Failed to fetch sessions from server"
exit 1
fi
# Filter with Python: by adapter (if explicit), by cwd (if -a mode)
FILTERED=$(CWD="$(pwd)" ADAPTER="$ADAPTER" ADAPTER_EXPLICIT="$ADAPTER_EXPLICIT" ALL_MODE="$ALL_MODE" python3 -c "
import sys, json, os
sessions = json.load(sys.stdin)
adapter_filter = os.environ.get('ADAPTER') if os.environ.get('ADAPTER_EXPLICIT') == 'true' else None
cwd_filter = os.environ.get('CWD') if os.environ.get('ALL_MODE') == 'false' else None
results = []
for s in sessions:
if adapter_filter and s.get('adapter','') != adapter_filter:
continue
if cwd_filter and s.get('cwd','') != cwd_filter:
continue
results.append(s)
# Sort by lastActivity descending
results.sort(key=lambda x: x.get('lastActivity',''), reverse=True)
json.dump(results, sys.stdout)
" <<< "$SESSIONS_JSON" 2>/dev/null)
COUNT=$(echo "$FILTERED" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null)
if [ "$COUNT" = "0" ] || [ -z "$COUNT" ]; then
if [ "$ALL_MODE" = true ]; then
echo "No active sessions."
else
PROJECT_NAME=$(basename "$(pwd)")
HINT=""
[ "$ADAPTER_EXPLICIT" = true ] && HINT=" ($ADAPTER)"
echo "No active sessions${HINT} for project '$PROJECT_NAME'."
echo "Run 'clawtap -A' to see all projects, or 'clawtap new' to start a new session."
fi
exit 0
fi
if [ "$ALL_MODE" = true ]; then
echo "Active sessions (all projects):"
else
echo "Active sessions for $(basename "$(pwd)"):"
fi
[ "$ADAPTER_EXPLICIT" = true ] && echo " (filtered: $ADAPTER)"
echo ""
# Display sessions
DISPLAY_OUTPUT=$(ALL_MODE="$ALL_MODE" python3 -c "
import sys, json, os
sessions = json.load(sys.stdin)
home = os.path.expanduser('~')
colors = {'claude': '\033[33m[Claude]\033[0m', 'codex': '\033[32m[Codex]\033[0m', 'gemini': '\033[34m[Gemini]\033[0m'}
all_mode = os.environ.get('ALL_MODE') == 'true'
for i, s in enumerate(sessions, 1):
label = colors.get(s.get('adapter',''), '\033[90m[?]\033[0m')
sid = s.get('sessionId','?')
prompt = (s.get('firstPrompt','') or '')[:60]
cwd = s.get('cwd','')
if cwd.startswith(home):
cwd = '~' + cwd[len(home):]
print(f' {i}) {label} {sid}')
if all_mode and cwd:
print(f' Dir: {cwd}')
if prompt:
print(f' {prompt}')
print()
" <<< "$FILTERED" 2>/dev/null)
echo "$DISPLAY_OUTPUT"
# Build session ID array for selection
declare -a SESS_IDS
while IFS= read -r sid; do
[ -z "$sid" ] && continue
SESS_IDS+=("$sid")
done < <(echo "$FILTERED" | python3 -c "import sys,json; [print(s['sessionId']) for s in json.load(sys.stdin)]" 2>/dev/null)
read -p "Select (1-${#SESS_IDS[@]}): " CHOICE
if [ -n "$CHOICE" ] && [ "$CHOICE" -ge 1 ] 2>/dev/null && [ "$CHOICE" -le "${#SESS_IDS[@]}" ] 2>/dev/null; then
SELECTED="${SESS_IDS[$((CHOICE-1))]}"
tmux select-window -t "$TMUX_SESSION:$SELECTED"
tmux attach -t "$TMUX_SESSION"
else
echo "Cancelled."
fi
exit 0
fi
# --- Resume mode ---
if [ "$1" = "--resume" ] && [ -n "$2" ]; then
RESUME_ID="$2"
shift 2
require_auth
BODY=$(printf '%s\n%s\n%s' "$RESUME_ID" "$ADAPTER" "$(pwd)" | python3 -c 'import sys,json; s,a,c=sys.stdin.read().strip().split("\n"); print(json.dumps({"sessionId":s,"adapter":a,"cwd":c}))' 2>/dev/null)
RESULT=$(curl -s $CURL_OPTS -X POST "$PROTOCOL://localhost:$PORT/api/sessions/resume" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY")
SESSION_ID=$(echo "$RESULT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("sessionId",""))' 2>/dev/null)
if [ -z "$SESSION_ID" ] || [ "$SESSION_ID" = "null" ]; then
echo "Error: Failed to resume session"
echo "$RESULT"
exit 1
fi
# Check if window already exists (session might already be active)
if tmux list-windows -t "$TMUX_SESSION" -F '#{window_name}' 2>/dev/null | grep -q "^${SESSION_ID}$"; then
tmux select-window -t "$TMUX_SESSION:$SESSION_ID"
else
echo "Session resumed but window not found. Try refreshing."
fi
tmux attach -t "$TMUX_SESSION"
exit 0
# --- Continue mode ---
elif [ "$1" = "--continue" ]; then
shift
require_auth
# When --adapter is explicit, query API and pick most recent for that adapter
if [ "$ADAPTER_EXPLICIT" = true ]; then
SESSIONS_JSON=$(curl -s $CURL_OPTS "$PROTOCOL://localhost:$PORT/api/active-sessions?adapter=$ADAPTER" \
-H "Authorization: Bearer $AUTH_TOKEN")
LATEST=$(echo "$SESSIONS_JSON" | python3 -c "
import sys, json
sessions = json.load(sys.stdin)
if not sessions:
sys.exit(1)
sessions.sort(key=lambda x: x.get('lastActivity',''), reverse=True)
print(sessions[0]['sessionId'])
" 2>/dev/null)
if [ -z "$LATEST" ]; then
echo "No active $ADAPTER sessions to continue."
echo "Run 'clawtap -a --adapter $ADAPTER' to check, or 'clawtap new --adapter $ADAPTER' to start one."
exit 1
fi
else
LATEST=$(tmux list-windows -t "$TMUX_SESSION" -F '#{window_activity} #{window_name}' 2>/dev/null | grep -v " main$" | sort -rn | head -1 | awk '{print $2}')
fi
if [ -n "$LATEST" ]; then
# Check if the process in the pane is still running
PANE_CMD=$(tmux display -t "$TMUX_SESSION:$LATEST" -p '#{pane_current_command}' 2>/dev/null)
if [ "$PANE_CMD" = "zsh" ] || [ "$PANE_CMD" = "bash" ]; then
# CLI process exited, shell is showing — resume via API
BODY=$(printf '%s\n%s\n%s' "$LATEST" "$ADAPTER" "$(pwd)" | python3 -c 'import sys,json; s,a,c=sys.stdin.read().strip().split("\n"); print(json.dumps({"sessionId":s,"adapter":a,"cwd":c}))' 2>/dev/null)
curl -s $CURL_OPTS -X POST "${PROTOCOL}://localhost:${PORT}/api/sessions/resume" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" >/dev/null 2>&1
fi
tmux select-window -t "$TMUX_SESSION:$LATEST"
else
echo "No active sessions to continue."
echo "Run 'clawtap new' to start a new session."
exit 1
fi
tmux attach -t "$TMUX_SESSION"
exit 0
# --- New session ---
else
require_auth
BODY=$(printf '%s\n%s' "$ADAPTER" "$(pwd)" | python3 -c 'import sys,json; a,c=sys.stdin.read().strip().split("\n"); print(json.dumps({"adapter":a,"cwd":c}))' 2>/dev/null)
RESULT=$(curl -s $CURL_OPTS -X POST "$PROTOCOL://localhost:$PORT/api/sessions/start" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY")
SESSION_ID=$(echo "$RESULT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("sessionId",""))' 2>/dev/null)
if [ -z "$SESSION_ID" ] || [ "$SESSION_ID" = "null" ]; then
echo "Error: Failed to create session"
echo "$RESULT"
exit 1
fi
tmux select-window -t "$TMUX_SESSION:$SESSION_ID"
tmux attach -t "$TMUX_SESSION"
fi