diff --git a/.claude-plugins/projman-marketplace/.claude-plugin/marketplace.json b/.claude-plugins/projman-marketplace/.claude-plugin/marketplace.json index 0d72a75..21c86a6 100644 --- a/.claude-plugins/projman-marketplace/.claude-plugin/marketplace.json +++ b/.claude-plugins/projman-marketplace/.claude-plugin/marketplace.json @@ -10,6 +10,12 @@ "version": "0.1.0", "description": "Sprint planning and project management with Gitea and Wiki.js integration", "source": "./../../projman" + }, + { + "name": "project-hygiene", + "version": "0.1.0", + "description": "Post-task cleanup hook that removes temp files, warns about unexpected root files, and manages orphans", + "source": "./../../project-hygiene" } ] } diff --git a/README.md b/README.md index e69de29..76fc4f9 100644 Binary files a/README.md and b/README.md differ diff --git a/project-hygiene/.claude-plugin/plugin.json b/project-hygiene/.claude-plugin/plugin.json new file mode 100644 index 0000000..90f5088 --- /dev/null +++ b/project-hygiene/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "project-hygiene", + "version": "0.1.0", + "description": "Post-task cleanup hook that removes temp files, warns about unexpected root files, and manages orphaned supporting files", + "author": { + "name": "Bandit Labs", + "email": "dev@banditlabs.io" + }, + "license": "MIT", + "keywords": ["cleanup", "hygiene", "automation", "hooks", "maintenance"], + "repository": "https://github.com/bandit-labs/project-hygiene", + "config": { + "default_shell": "bash", + "environment": { + "PLUGIN_HOME": "${CLAUDE_PLUGIN_ROOT}" + } + }, + "permissions": { + "file_access": ["read", "write"], + "shell_access": true + } +} diff --git a/project-hygiene/README.md b/project-hygiene/README.md new file mode 100644 index 0000000..abf5a5b --- /dev/null +++ b/project-hygiene/README.md @@ -0,0 +1,135 @@ +# project-hygiene + +Post-task cleanup hook plugin for Claude Code. Automatically cleans up temporary files, warns about unexpected files in the project root, and manages orphaned supporting files after task completion. + +## Features + +- **Delete temp files**: Removes `*.tmp`, `*.bak`, `__pycache__`, `.pytest_cache`, and other common temporary patterns +- **Root file warnings**: Alerts when unexpected files appear in project root +- **Orphan detection**: Identifies `test_*`, `debug_*`, `*_backup.*` and similar files that may have been left behind +- **Cleanup logging**: Records all actions to `.dev/logs/` +- **Configurable**: Project-local `.hygiene.json` for custom rules + +## Installation + +Add to your Claude Code configuration or install from the marketplace: + +```bash +claude plugin install project-hygiene +``` + +## How It Works + +The plugin registers a `task-completed` hook that runs after Claude completes any task. It: + +1. Scans for and deletes known temporary file patterns +2. Removes temporary directories (`__pycache__`, `.pytest_cache`, etc.) +3. Checks for unexpected files in project root and warns +4. Identifies orphaned supporting files (test files, debug scripts, backups) +5. Optionally moves orphans to `.dev/scratch/` for review +6. Logs all actions to `.dev/logs/hygiene-TIMESTAMP.log` + +## Configuration + +Create `.hygiene.json` in your project root to customize behavior: + +```json +{ + "move_orphans": true, + "allowed_root_files": [ + "custom-config.yaml", + "my-script.sh" + ], + "temp_patterns": [ + "*.cache", + "*.pid" + ], + "ignore_patterns": [ + "important_test_*.py", + "keep_this_backup.*" + ] +} +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `move_orphans` | boolean | `false` | Move orphaned files to `.dev/scratch/` instead of just warning | +| `allowed_root_files` | array | (see below) | Additional files allowed in project root | +| `temp_patterns` | array | `[]` | Additional temp file patterns to delete | +| `ignore_patterns` | array | `[]` | Files to never touch during cleanup | + +### Default Allowed Root Files + +The plugin recognizes common project files in root: +- Git files: `.git`, `.gitignore`, `.gitattributes` +- Config: `.editorconfig`, `.env*`, `.nvmrc`, etc. +- Documentation: `README.md`, `LICENSE`, `CHANGELOG.md`, `CLAUDE.md` +- Package managers: `package.json`, `requirements.txt`, `Cargo.toml`, `go.mod`, etc. +- Build configs: `Makefile`, `Dockerfile`, `docker-compose.yml`, `tsconfig.json`, etc. + +### Default Temp Patterns + +Automatically cleaned: +- `*.tmp`, `*.bak`, `*.swp`, `*.swo`, `*~` +- `.DS_Store`, `Thumbs.db` +- `*.log`, `*.orig`, `*.pyc`, `*.pyo` + +### Default Temp Directories + +Automatically removed: +- `__pycache__`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache` +- `node_modules/.cache`, `.next/cache`, `.nuxt/.cache`, `.turbo` +- `*.egg-info`, `.eggs` + +### Orphan Patterns + +Files flagged as orphans: +- `test_*.py` (standalone test files) +- `debug_*` (debug scripts) +- `*_backup.*`, `*_old.*`, `*_bak.*`, `*.backup` +- `temp_*`, `tmp_*` + +## Output + +After each task, you'll see output like: + +``` +[14:32:15] Starting project hygiene cleanup... + +[14:32:15] Cleaning temp files... +[14:32:15] DELETED: ./src/__pycache__/utils.cpython-311.pyc +[14:32:15] DELETED: ./temp.bak + +[14:32:15] Cleaning temp directories... +[14:32:15] DELETED DIR: ./src/__pycache__ + +[14:32:15] Checking root files... +[14:32:15] WARNING: Unexpected root file: random_notes.txt + +[14:32:15] Checking for orphaned files... +[14:32:15] ORPHAN: ./test_scratch.py +[14:32:15] ORPHAN: ./debug_api.py + +=== Cleanup Summary === + Deleted: 3 items + Warnings: 1 unexpected root files + Orphans: 2 files + Log file: .dev/logs/hygiene-20250110-143215.log +``` + +## Directory Structure + +``` +.dev/ +├── logs/ +│ └── hygiene-YYYYMMDD-HHMMSS.log +└── scratch/ # (if move_orphans enabled) + └── debug_api.py +``` + +## Requirements + +- Bash 4.0+ +- Optional: `jq` for JSON config parsing (falls back to defaults if not installed) diff --git a/project-hygiene/hooks/cleanup.sh b/project-hygiene/hooks/cleanup.sh new file mode 100755 index 0000000..5206948 --- /dev/null +++ b/project-hygiene/hooks/cleanup.sh @@ -0,0 +1,365 @@ +#!/bin/bash +# project-hygiene cleanup hook +# Runs after task completion to clean up temp files and manage orphans + +set -euo pipefail + +# Configuration +PROJECT_ROOT="${PROJECT_ROOT:-.}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$(realpath "$0")")")}" +CONFIG_FILE="${PROJECT_ROOT}/.hygiene.json" +LOG_DIR="${PROJECT_ROOT}/.dev/logs" +SCRATCH_DIR="${PROJECT_ROOT}/.dev/scratch" +LOG_FILE="${LOG_DIR}/hygiene-$(date +%Y%m%d-%H%M%S).log" + +# Default allowed root files (can be overridden by .hygiene.json) +DEFAULT_ALLOWED_ROOT=( + ".git" + ".gitignore" + ".gitattributes" + ".editorconfig" + ".env" + ".env.example" + ".env.local" + ".nvmrc" + ".node-version" + ".python-version" + ".ruby-version" + ".tool-versions" + "README.md" + "LICENSE" + "CHANGELOG.md" + "CONTRIBUTING.md" + "CLAUDE.md" + "package.json" + "package-lock.json" + "yarn.lock" + "pnpm-lock.yaml" + "Makefile" + "Dockerfile" + "docker-compose.yml" + "docker-compose.yaml" + "Cargo.toml" + "Cargo.lock" + "go.mod" + "go.sum" + "requirements.txt" + "setup.py" + "pyproject.toml" + "poetry.lock" + "Gemfile" + "Gemfile.lock" + "tsconfig.json" + "jsconfig.json" + ".eslintrc*" + ".prettierrc*" + "vite.config.*" + "webpack.config.*" + "rollup.config.*" + ".hygiene.json" +) + +# Temp file patterns to delete +TEMP_PATTERNS=( + "*.tmp" + "*.bak" + "*.swp" + "*.swo" + "*~" + ".DS_Store" + "Thumbs.db" + "*.log" + "*.orig" + "*.pyc" + "*.pyo" +) + +# Directory patterns to delete +TEMP_DIRS=( + "__pycache__" + ".pytest_cache" + ".mypy_cache" + ".ruff_cache" + "node_modules/.cache" + ".next/cache" + ".nuxt/.cache" + ".turbo" + "*.egg-info" + ".eggs" + "dist" + "build" +) + +# Orphan patterns to identify +ORPHAN_PATTERNS=( + "test_*.py" + "debug_*" + "*_backup.*" + "*_old.*" + "*_bak.*" + "*.backup" + "temp_*" + "tmp_*" +) + +# Initialize +DELETED_COUNT=0 +WARNED_COUNT=0 +ORPHAN_COUNT=0 +MOVE_ORPHANS=false + +# Logging function +log() { + local msg="[$(date +%H:%M:%S)] $1" + echo "$msg" + if [[ -f "$LOG_FILE" ]]; then + echo "$msg" >> "$LOG_FILE" + fi +} + +log_action() { + local action="$1" + local target="$2" + log " $action: $target" +} + +# Load project-local config if exists +load_config() { + if [[ -f "$CONFIG_FILE" ]]; then + log "Loading config from $CONFIG_FILE" + + # Check if move_orphans is enabled + if command -v jq &>/dev/null; then + MOVE_ORPHANS=$(jq -r '.move_orphans // false' "$CONFIG_FILE" 2>/dev/null || echo "false") + + # Load additional allowed root files + local extra_allowed + extra_allowed=$(jq -r '.allowed_root_files // [] | .[]' "$CONFIG_FILE" 2>/dev/null || true) + if [[ -n "$extra_allowed" ]]; then + while IFS= read -r file; do + DEFAULT_ALLOWED_ROOT+=("$file") + done <<< "$extra_allowed" + fi + + # Load additional temp patterns + local extra_temp + extra_temp=$(jq -r '.temp_patterns // [] | .[]' "$CONFIG_FILE" 2>/dev/null || true) + if [[ -n "$extra_temp" ]]; then + while IFS= read -r pattern; do + TEMP_PATTERNS+=("$pattern") + done <<< "$extra_temp" + fi + + # Load ignore patterns (files to never touch) + IGNORE_PATTERNS=() + local ignore + ignore=$(jq -r '.ignore_patterns // [] | .[]' "$CONFIG_FILE" 2>/dev/null || true) + if [[ -n "$ignore" ]]; then + while IFS= read -r pattern; do + IGNORE_PATTERNS+=("$pattern") + done <<< "$ignore" + fi + else + log "Warning: jq not installed, using default config" + fi + fi +} + +# Check if file should be ignored +should_ignore() { + local file="$1" + local basename + basename=$(basename "$file") + + for pattern in "${IGNORE_PATTERNS[@]:-}"; do + if [[ "$basename" == $pattern ]] || [[ "$file" == $pattern ]]; then + return 0 + fi + done + return 1 +} + +# Check if file is in allowed root list +is_allowed_root() { + local file="$1" + local basename + basename=$(basename "$file") + + for allowed in "${DEFAULT_ALLOWED_ROOT[@]}"; do + # Support wildcards in allowed patterns + if [[ "$basename" == $allowed ]]; then + return 0 + fi + done + return 1 +} + +# Check if file matches orphan pattern +is_orphan() { + local file="$1" + local basename + basename=$(basename "$file") + + for pattern in "${ORPHAN_PATTERNS[@]}"; do + if [[ "$basename" == $pattern ]]; then + return 0 + fi + done + return 1 +} + +# Setup directories +setup_dirs() { + mkdir -p "$LOG_DIR" + if [[ "$MOVE_ORPHANS" == "true" ]]; then + mkdir -p "$SCRATCH_DIR" + fi + + # Start log file + echo "=== Project Hygiene Cleanup ===" > "$LOG_FILE" + echo "Started: $(date)" >> "$LOG_FILE" + echo "Project: $PROJECT_ROOT" >> "$LOG_FILE" + echo "" >> "$LOG_FILE" +} + +# Delete temp files +cleanup_temp_files() { + log "Cleaning temp files..." + + for pattern in "${TEMP_PATTERNS[@]}"; do + while IFS= read -r -d '' file; do + if should_ignore "$file"; then + continue + fi + rm -f "$file" + log_action "DELETED" "$file" + ((DELETED_COUNT++)) + done < <(find "$PROJECT_ROOT" -name "$pattern" -type f -print0 2>/dev/null || true) + done +} + +# Delete temp directories +cleanup_temp_dirs() { + log "Cleaning temp directories..." + + for pattern in "${TEMP_DIRS[@]}"; do + while IFS= read -r -d '' dir; do + if should_ignore "$dir"; then + continue + fi + rm -rf "$dir" + log_action "DELETED DIR" "$dir" + ((DELETED_COUNT++)) + done < <(find "$PROJECT_ROOT" -name "$pattern" -type d -print0 2>/dev/null || true) + done +} + +# Warn about unexpected root files +check_root_files() { + log "Checking root files..." + + local unexpected_files=() + + while IFS= read -r -d '' file; do + local basename + basename=$(basename "$file") + + # Skip directories + [[ -d "$file" ]] && continue + + # Skip if in allowed list + is_allowed_root "$basename" && continue + + # Skip if should be ignored + should_ignore "$basename" && continue + + unexpected_files+=("$basename") + log_action "WARNING" "Unexpected root file: $basename" + ((WARNED_COUNT++)) + done < <(find "$PROJECT_ROOT" -maxdepth 1 -print0 2>/dev/null || true) + + if [[ ${#unexpected_files[@]} -gt 0 ]]; then + log "" + log "⚠️ Unexpected files in project root:" + for f in "${unexpected_files[@]}"; do + log " - $f" + done + fi +} + +# Identify and handle orphaned files +handle_orphans() { + log "Checking for orphaned files..." + + local orphan_files=() + + for pattern in "${ORPHAN_PATTERNS[@]}"; do + while IFS= read -r -d '' file; do + if should_ignore "$file"; then + continue + fi + + orphan_files+=("$file") + + if [[ "$MOVE_ORPHANS" == "true" ]]; then + local dest="${SCRATCH_DIR}/$(basename "$file")" + # Handle duplicates + if [[ -f "$dest" ]]; then + dest="${SCRATCH_DIR}/$(date +%Y%m%d%H%M%S)_$(basename "$file")" + fi + mv "$file" "$dest" + log_action "MOVED" "$file -> $dest" + else + log_action "ORPHAN" "$file" + fi + ((ORPHAN_COUNT++)) + done < <(find "$PROJECT_ROOT" -name "$pattern" -type f -print0 2>/dev/null || true) + done + + if [[ ${#orphan_files[@]} -gt 0 && "$MOVE_ORPHANS" != "true" ]]; then + log "" + log "📦 Orphaned files found (enable move_orphans in .hygiene.json to auto-move):" + for f in "${orphan_files[@]}"; do + log " - $f" + done + fi +} + +# Summary +print_summary() { + log "" + log "=== Cleanup Summary ===" + log " Deleted: $DELETED_COUNT items" + log " Warnings: $WARNED_COUNT unexpected root files" + log " Orphans: $ORPHAN_COUNT files" + if [[ "$MOVE_ORPHANS" == "true" ]]; then + log " Orphans moved to: $SCRATCH_DIR" + fi + log " Log file: $LOG_FILE" + log "" +} + +# Main +main() { + cd "$PROJECT_ROOT" || exit 1 + + load_config + setup_dirs + + log "Starting project hygiene cleanup..." + log "" + + cleanup_temp_files + cleanup_temp_dirs + check_root_files + handle_orphans + + print_summary + + # Exit with warning code if issues found + if [[ $WARNED_COUNT -gt 0 || $ORPHAN_COUNT -gt 0 ]]; then + exit 0 # Still success, but logged warnings + fi +} + +main "$@" diff --git a/project-hygiene/hooks/hooks.json b/project-hygiene/hooks/hooks.json new file mode 100644 index 0000000..7d922f1 --- /dev/null +++ b/project-hygiene/hooks/hooks.json @@ -0,0 +1,9 @@ +{ + "hooks": [ + { + "event": "task-completed", + "script": "${CLAUDE_PLUGIN_ROOT}/hooks/cleanup.sh", + "description": "Post-task cleanup: remove temp files, warn about unexpected root files, manage orphans" + } + ] +}