UNPKG

claude-code-checkpoint

Version:

Automatic project snapshots for Claude Code - never lose your work again

214 lines (180 loc) 6.68 kB
export function generateCheckpointHook(config) { const { enableVoice, maxCheckpoints } = config; return `#!/bin/bash # Claude Code Checkpoint Hook # Generated by claude-code-checkpoint # This hook runs after every Claude operation # Configuration CHECKPOINT_BASE_DIR="$HOME/.claude/checkpoint" DATA_DIR="$CHECKPOINT_BASE_DIR/data" SCRIPTS_DIR="$CHECKPOINT_BASE_DIR/scripts" DEBUG_LOG="$HOME/.claude/checkpoint-debug.log" ENABLE_VOICE=${enableVoice ? 'true' : 'false'} MAX_CHECKPOINTS=${maxCheckpoints} # Function to log debug messages debug_log() { echo "[$(date)] $1" >> "$DEBUG_LOG" } # Start debug_log "Checkpoint hook started (npm version)" # Check if we're in a git repository if ! git rev-parse --git-dir > /dev/null 2>&1; then debug_log "Not in a git repository, skipping checkpoint" exit 0 fi # Get project name PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) PROJECT_NAME=$(basename "$PROJECT_ROOT") PROJECT_DATA_DIR="$DATA_DIR/$PROJECT_NAME" # Ensure directories exist mkdir -p "$PROJECT_DATA_DIR" # Check for changes using git status if [ -z "$(git status --porcelain 2>/dev/null)" ]; then debug_log "No changes detected, skipping checkpoint" exit 0 fi # Create state hash of current directory generate_state_hash() { find . -type f \\ -not -path "./.git/*" \\ -not -path "./node_modules/*" \\ -not -path "./dist/*" \\ -not -path "./build/*" \\ -not -path "./.next/*" \\ -not -name ".DS_Store" \\ -not -name "*.pyc" \\ -not -path "./__pycache__/*" \\ -exec stat -f "%m %z %N" {} \\; 2>/dev/null | \\ sort | shasum -a 256 | cut -d' ' -f1 } # Get current state CURRENT_STATE=$(generate_state_hash) LAST_STATE_FILE="$PROJECT_DATA_DIR/last-state.hash" # Check if state has changed if [ -f "$LAST_STATE_FILE" ]; then LAST_STATE=$(cat "$LAST_STATE_FILE") if [ "$CURRENT_STATE" = "$LAST_STATE" ]; then debug_log "No changes since last checkpoint (state unchanged)" exit 0 fi fi # Create checkpoint debug_log "Creating checkpoint for $PROJECT_NAME" # Get metadata METADATA_FILE="$PROJECT_DATA_DIR/metadata.json" if [ ! -f "$METADATA_FILE" ]; then echo '{"checkpoints":[]}' > "$METADATA_FILE" fi # Get next checkpoint ID NEXT_ID=$(jq '.checkpoints | length + 1' "$METADATA_FILE") CHECKPOINT_DIR="$PROJECT_DATA_DIR/checkpoint-$NEXT_ID" # Create checkpoint directory mkdir -p "$CHECKPOINT_DIR" # Generate smart description based on changes generate_description() { local prev_checkpoint="" local prev_id=$((NEXT_ID - 1)) if [ "$prev_id" -gt 0 ]; then prev_checkpoint="$PROJECT_DATA_DIR/checkpoint-$prev_id" fi if [ -d "$prev_checkpoint" ]; then # Compare against previous checkpoint CHANGED_FILES=$(diff -rq . "$prev_checkpoint" --exclude=.git --exclude=node_modules --exclude=.next --exclude=dist --exclude=build 2>/dev/null | \ grep -E "^Files .* differ$" | \ awk '{print $2}' | \ sed "s|^./||") NEW_FILES=$(diff -rq . "$prev_checkpoint" --exclude=.git --exclude=node_modules --exclude=.next --exclude=dist --exclude=build 2>/dev/null | \ grep "^Only in \\\\." | \ awk -F': ' '{print $2}') else # First checkpoint - compare against git CHANGED_FILES=$(git diff --name-only HEAD 2>/dev/null) NEW_FILES=$(git ls-files --others --exclude-standard 2>/dev/null) fi # Count changes CHANGED_COUNT=$(echo "$CHANGED_FILES" | grep -v '^$' | wc -l | tr -d ' ') NEW_COUNT=$(echo "$NEW_FILES" | grep -v '^$' | wc -l | tr -d ' ') TOTAL_COUNT=$((CHANGED_COUNT + NEW_COUNT)) if [ "$TOTAL_COUNT" -gt 0 ]; then # Get the most significant change if [ "$CHANGED_COUNT" -gt 0 ]; then FIRST_FILE=$(echo "$CHANGED_FILES" | head -1) ACTION="Updated" else FIRST_FILE=$(echo "$NEW_FILES" | head -1) ACTION="Added" fi FILENAME=$(basename "$FIRST_FILE") if [ "$TOTAL_COUNT" -eq 1 ]; then echo "$ACTION $FILENAME" else echo "Changed $TOTAL_COUNT files including $FILENAME" fi else echo "Claude operation completed" fi } # Generate description if [ -n "$CHECKPOINT_MANUAL_DESC" ]; then # Use manual description if provided DESCRIPTION="$CHECKPOINT_MANUAL_DESC" else # Auto-generate description based on changes DESCRIPTION=$(generate_description) fi debug_log "Generated description: $DESCRIPTION" # Copy files using rsync rsync -a \\ --exclude='.git' \\ --exclude='node_modules' \\ --exclude='dist' \\ --exclude='build' \\ --exclude='.next' \\ --exclude='__pycache__' \\ --exclude='*.pyc' \\ --exclude='.DS_Store' \\ --exclude='*.log' \\ --exclude='tmp' \\ --exclude='temp' \\ "$PROJECT_ROOT/" "$CHECKPOINT_DIR/" # Update metadata TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") jq --arg id "$NEXT_ID" \\ --arg desc "$DESCRIPTION" \\ --arg path "$CHECKPOINT_DIR" \\ --arg ts "$TIMESTAMP" \\ '.checkpoints += [{ "id": ($id | tonumber), "description": $desc, "path": $path, "timestamp": $ts, "session_id": "unknown" }]' "$METADATA_FILE" > "$METADATA_FILE.tmp" && mv "$METADATA_FILE.tmp" "$METADATA_FILE" # Save current state echo "$CURRENT_STATE" > "$LAST_STATE_FILE" # Enforce max checkpoints if set if [ "$MAX_CHECKPOINTS" -gt 0 ]; then CURRENT_COUNT=$(jq '.checkpoints | length' "$METADATA_FILE") if [ "$CURRENT_COUNT" -gt "$MAX_CHECKPOINTS" ]; then # Remove oldest checkpoints TO_REMOVE=$((CURRENT_COUNT - MAX_CHECKPOINTS)) debug_log "Removing $TO_REMOVE old checkpoints (max: $MAX_CHECKPOINTS)" # Get IDs to remove OLD_IDS=$(jq -r ".checkpoints | sort_by(.timestamp) | .[0:$TO_REMOVE] | .[].id" "$METADATA_FILE") for OLD_ID in $OLD_IDS; do # Remove checkpoint directory OLD_PATH=$(jq -r ".checkpoints[] | select(.id == $OLD_ID) | .path" "$METADATA_FILE") rm -rf "$OLD_PATH" # Remove from metadata jq "del(.checkpoints[] | select(.id == $OLD_ID))" "$METADATA_FILE" > "$METADATA_FILE.tmp" && mv "$METADATA_FILE.tmp" "$METADATA_FILE" done fi fi # Voice announcement if enabled (macOS only) if [ "$ENABLE_VOICE" = "true" ] && [ "$(uname)" = "Darwin" ]; then # Add 3.5s delay like the original system (sleep 3.5 && say "Checkpoint $NEXT_ID created") & fi debug_log "Checkpoint #$NEXT_ID created successfully" `; }