|
#!/usr/bin/env bash
#
# clean-claude-cache.sh — Clean Claude Code cache files
#
# Usage:
# ./clean-claude-cache.sh # Dry-run (preview only, no deletion)
# ./clean-claude-cache.sh -f # Actually delete
# ./clean-claude-cache.sh -f --aggressive # Delete everything including sessions & history
# ./clean-claude-cache.sh -f --project-only # Only clean current project's .claude/
# ./clean-claude-cache.sh -f --global-only # Only clean ~/.claude/ (not project-level)
#
# Safety: Default mode is dry-run. Use -f to actually perform deletion.
#
set -euo pipefail
# ─── Colors ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m' # No Color
# ─── Defaults ─────────────────────────────────────────────────────────────────
FORCE=false
AGGRESSIVE=false
PROJECT_ONLY=false
GLOBAL_ONLY=false
VERBOSE=false
CLAude_DIR="$HOME/.claude"
TOTAL_FREED=0
# ─── Help ─────────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${BOLD}clean-claude-cache.sh${NC} — Clean Claude Code cache files
${BOLD}USAGE${NC}
$(basename "$0") [OPTIONS]
${BOLD}OPTIONS${NC}
-f, --force Actually delete files (default: dry-run)
-a, --aggressive Also clean sessions, history, and usage data
-p, --project-only Only clean project-level .claude/ directory
-g, --global-only Only clean global ~/.claude/ directory
-v, --verbose Show detailed file listing
-h, --help Show this help message
${BOLD}EXAMPLES${NC}
$(basename "$0") # Preview what would be cleaned
$(basename "$0") -f # Clean safe items only
$(basename "$0") -f --aggressive # Deep clean everything
$(basename "$0") -f --project-only # Clean only current project cache
${BOLD}SAFE CLEANUP${NC} (default -f mode)
• Cache files, debug logs, paste cache, downloads, stats cache
• File edit history, shell snapshots, session environments (>3 days old)
• Stale task files (>3 days), plan files (>7 days), backups (>3 days)
• Empty project directories (only sessions-index.json, no real data)
${BOLD}AGGRESSIVE CLEANUP${NC} (--aggressive, adds)
• Session metadata (>7 days old)
• Command history (history.jsonl — full delete)
• Usage data (>7 days old)
• Scheduled tasks
${BOLD}PRESERVED${NC} (never deleted)
• Settings (settings.json, settings.local.json)
• Skills and agents
• Project memories (*/memory/)
• IDE configuration
• Harness rules
EOF
exit 0
}
# ─── Parse Arguments ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--force) FORCE=true; shift ;;
-a|--aggressive) AGGRESSIVE=true; shift ;;
-p|--project-only) PROJECT_ONLY=true; shift ;;
-g|--global-only) GLOBAL_ONLY=true; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
-h|--help) usage ;;
*) echo -e "${RED}Unknown option: $1${NC}"; usage ;;
esac
done
if [[ "$PROJECT_ONLY" == true && "$GLOBAL_ONLY" == true ]]; then
echo -e "${RED}Error: --project-only and --global-only are mutually exclusive${NC}"
exit 1
fi
# ─── Helper Functions ─────────────────────────────────────────────────────────
# Calculate directory size in bytes
dir_size() {
local path="$1"
if [[ -e "$path" ]]; then
du -sk "$path" 2>/dev/null | cut -f1
else
echo 0
fi
}
# Format bytes to human-readable
human_size() {
local kb="$1"
if [[ "$kb" -ge 1048576 ]]; then
echo "$(echo "scale=1; $kb / 1048576" | bc)G"
elif [[ "$kb" -ge 1024 ]]; then
echo "$(echo "scale=1; $kb / 1024" | bc)M"
else
echo "${kb}K"
fi
}
# Clean a directory or file
clean_item() {
local path="$1"
local label="$2"
local category="$3" # "safe" or "aggressive"
# Skip if aggressive-only item but not in aggressive mode
if [[ "$category" == "aggressive" && "$AGGRESSIVE" != true ]]; then
return 0
fi
if [[ ! -e "$path" ]]; then
return 0
fi
local size_kb
size_kb=$(dir_size "$path")
local size_hr
size_hr=$(human_size "$size_kb")
if [[ "$FORCE" == true ]]; then
rm -rf "$path"
echo -e " ${GREEN}? Deleted${NC} ${DIM}${label}${NC} (${size_hr})"
else
echo -e " ${YELLOW}? Would delete${NC} ${DIM}${label}${NC} (${size_hr})"
fi
TOTAL_FREED=$((TOTAL_FREED + size_kb))
}
# Clean old files in a directory (older than N days)
clean_old_files() {
local dir="$1"
local label="$2"
local days="$3"
local category="$4"
if [[ "$category" == "aggressive" && "$AGGRESSIVE" != true ]]; then
return 0
fi
if [[ ! -d "$dir" ]]; then
return 0
fi
local count
count=$(find "$dir" -mindepth 1 -maxdepth 1 -mtime +"$days" 2>/dev/null | wc -l | tr -d ' ')
if [[ "$count" -eq 0 ]]; then
return 0
fi
local size_kb=0
while IFS= read -r f; do
local fsize
fsize=$(dir_size "$f")
size_kb=$((size_kb + fsize))
done < <(find "$dir" -mindepth 1 -maxdepth 1 -mtime +"$days" 2>/dev/null)
local size_hr
size_hr=$(human_size "$size_kb")
if [[ "$FORCE" == true ]]; then
find "$dir" -mindepth 1 -maxdepth 1 -mtime +"$days" -exec rm -rf {} +
echo -e " ${GREEN}? Deleted ${count} old items${NC} ${DIM}${label} (>${days}d)${NC} (${size_hr})"
else
echo -e " ${YELLOW}? Would delete ${count} old items${NC} ${DIM}${label} (>${days}d)${NC} (${size_hr})"
fi
TOTAL_FREED=$((TOTAL_FREED + size_kb))
}
# ─── Header ───────────────────────────────────────────────────────────────────
echo ""
echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${CYAN}║ Claude Code Cache Cleaner ║${NC}"
echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════╝${NC}"
echo ""
if [[ "$FORCE" != true ]]; then
echo -e "${YELLOW}? DRY-RUN MODE — No files will be deleted. Use -f to apply.${NC}"
else
echo -e "${RED}? FORCE MODE — Files WILL be deleted!${NC}"
fi
if [[ "$AGGRESSIVE" == true ]]; then
echo -e "${RED}? AGGRESSIVE — Including sessions, history, and usage data.${NC}"
fi
echo ""
# ─── Global Cleanup ───────────────────────────────────────────────────────────
if [[ "$PROJECT_ONLY" != true ]]; then
echo -e "${BOLD}???? Global Cache: ${CLAude_DIR}${NC}"
echo -e "${DIM}─────────────────────────────────────────────────${NC}"
# ── Safe to delete entirely ──
clean_item "$CLAude_DIR/cache" "Cache files" "safe"
clean_item "$CLAude_DIR/debug" "Debug logs" "safe"
clean_item "$CLAude_DIR/paste-cache" "Paste cache" "safe"
clean_item "$CLAude_DIR/downloads" "Downloads" "safe"
clean_item "$CLAude_DIR/stats-cache.json" "Stats cache" "safe"
# ── Clean contents (preserve directory) — only files older than 3 days ──
# Using time-based cleanup to avoid deleting files from active sessions
clean_old_files "$CLAude_DIR/file-history" "File edit history" 3 "safe"
clean_old_files "$CLAude_DIR/shell-snapshots" "Shell snapshots" 3 "safe"
clean_old_files "$CLAude_DIR/session-env" "Session environments" 3 "safe"
clean_old_files "$CLAude_DIR/backups" "Backup files" 3 "safe"
clean_old_files "$CLAude_DIR/tasks" "Task history" 3 "safe"
clean_old_files "$CLAude_DIR/plans" "Plan files" 7 "safe"
# ── Aggressive-only — also time-limited to protect active sessions ──
clean_old_files "$CLAude_DIR/sessions" "Session metadata" 7 "aggressive"
clean_item "$CLAude_DIR/history.jsonl" "Command history" "aggressive"
clean_old_files "$CLAude_DIR/usage-data" "Usage data" 7 "aggressive"
# ── Clean stale projects ──
# NOTE: Claude Code encodes project paths with a complex scheme that doesn't
# simply map "/" to "-". Paths with CJK characters, spaces, or dots produce
# encoding patterns like "----" that cannot be reliably reversed.
# Instead, we only clean project dirs that have ONLY a sessions-index.json
# (meaning no real session data was ever written — just an empty index).
echo ""
echo -e "${DIM} Checking for empty/unused project directories...${NC}"
if [[ -d "$CLAude_DIR/projects" ]]; then
while IFS= read -r proj_dir; do
# Count total files in the project directory
file_count=$(find "$proj_dir" -type f 2>/dev/null | wc -l | tr -d ' ')
# If only 1 file and it's sessions-index.json → never had real sessions
if [[ "$file_count" -le 1 ]]; then
has_only_index=$([[ -f "${proj_dir}/sessions-index.json" ]] && echo "yes" || echo "no")
if [[ "$has_only_index" == "yes" ]]; then
clean_item "$proj_dir" "Empty project: $(basename "$proj_dir")" "safe"
fi
fi
done < <(find "$CLAude_DIR/projects" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
fi
# ── Clean old files in large directories ──
echo ""
echo -e "${DIM} Checking for old files (>${STALE_DAYS:-30} days)...${NC}"
# Clean stale scheduled tasks (not from current session)
if [[ -f "$CLAude_DIR/scheduled_tasks.json" ]]; then
clean_item "$CLAude_DIR/scheduled_tasks.json" "Scheduled tasks" "aggressive"
fi
echo ""
fi
# ─── Project-Level Cleanup ────────────────────────────────────────────────────
if [[ "$GLOBAL_ONLY" != true ]]; then
# Find all project-level .claude directories
echo -e "${BOLD}???? Project-Level Cache${NC}"
echo -e "${DIM}─────────────────────────────────────────────────${NC}"
# Current project
if [[ -d ".claude" ]]; then
echo -e " ${CYAN}Current project:${NC} $(pwd)/.claude/"
# Clean worktrees
if [[ -d ".claude/worktrees" ]]; then
worktree_count=$(find .claude/worktrees -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
if [[ "$worktree_count" -gt 0 ]]; then
# Check if any worktrees are still registered in git
stale_worktrees=0
while IFS= read -r wt_dir; do
if ! git worktree list 2>/dev/null | grep -q "$(basename "$wt_dir")"; then
stale_worktrees=$((stale_worktrees + 1))
fi
done < <(find .claude/worktrees -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
if [[ "$stale_worktrees" -gt 0 ]]; then
echo -e " ${DIM} Found ${stale_worktrees} stale worktree(s) (not registered in git)${NC}"
# Only clean stale worktrees in force mode
if [[ "$FORCE" == true ]]; then
while IFS= read -r wt_dir; do
if ! git worktree list 2>/dev/null | grep -q "$(basename "$wt_dir")"; then
rm -rf "$wt_dir"
echo -e " ${GREEN}? Deleted stale worktree${NC} ${DIM}$(basename "$wt_dir")${NC}"
fi
done < <(find .claude/worktrees -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
else
echo -e " ${YELLOW}? Would clean stale worktrees${NC}"
fi
fi
fi
fi
# Clean project-level scheduled tasks
clean_item ".claude/scheduled_tasks.json" "Project scheduled tasks" "aggressive"
# Clean project-level session/task artifacts (but preserve memory/ and skills/)
for subdir in .claude/tasks .claude/plans .claude/session-env; do
if [[ -d "$subdir" ]]; then
clean_old_files "$subdir" "$(basename "$subdir")" 3 "safe"
fi
done
else
echo -e " ${DIM}No .claude/ directory in current project${NC}"
fi
echo ""
fi
# ─── Summary ──────────────────────────────────────────────────────────────────
echo -e "${BOLD}══════════════════════════════════════════════════${NC}"
total_hr=$(human_size "$TOTAL_FREED")
if [[ "$FORCE" == true ]]; then
echo -e "${GREEN}${BOLD} Freed: ${total_hr}${NC}"
else
echo -e "${YELLOW}${BOLD} Would free: ${total_hr}${NC}"
echo -e ""
echo -e " Run with ${BOLD}-f${NC} to apply: $(basename "$0") -f"
if [[ "$AGGRESSIVE" != true ]]; then
echo -e " Add ${BOLD}--aggressive${NC} for deeper clean: $(basename "$0") -f --aggressive"
fi
fi
echo -e "${BOLD}══════════════════════════════════════════════════${NC}"
echo ""
# ─── Preserved Items Info ─────────────────────────────────────────────────────
echo -e "${DIM}Preserved (not deleted):${NC}"
echo -e "${DIM} • Settings files (settings.json, settings.local.json)${NC}"
echo -e "${DIM} • Skills, agents, and plugins${NC}"
echo -e "${DIM} • Project memories (*/memory/)${NC}"
echo -e "${DIM} • IDE configuration${NC}"
echo -e "${DIM} • Harness rules${NC}"
echo ""
|