claude-code-checkpoint
Version:
Automatic project snapshots for Claude Code - never lose your work again
214 lines (180 loc) • 6.68 kB
JavaScript
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"
`;
}