Skill: suhteevah/cronlint (version 1.0.1)

--- name: CronLint version: 1.0.0 description: "Scheduled task & cron job anti-pattern analyzer -- detects overlapping execution risks, timezone scheduling errors, missing error recovery, resource contention, lifecycle management issues, and observability gaps in cron jobs, schedulers, and periodic task code" homepage: https://cronlint.pages.dev metadata: { "openclaw": { "emoji": "\u23f0", "primaryEnv": "CRONLINT_LICENSE_KEY", "requires": { "bins": ["git", "bash", "python3", "jq"] }, "configPaths": ["~/.openclaw/openclaw.json"], "install": [ { "id": "lefthook", "kind": "brew", "formula": "lefthook", "bins": ["lefthook"], "label": "Install lefthook (git hooks manager)" } ], "os": ["darwin", "linux", "win32"] } } user-invocable: true disable-model-invocation: false --- # CronLint -- Scheduled Task & Cron Job Anti-Pattern Analyzer CronLint scans codebases for scheduled task and cron job anti-patterns: overlapping execution risks, timezone scheduling errors, missing error recovery, resource contention, lifecycle management issues, and observability gaps. It uses regex-based pattern matching against 90 scheduling-specific patterns across 6 categories, lefthook for git hook integration, and produces markdown reports with actionable remediation guidance. 100% local. Zero telemetry. **Note:** CronLint focuses on cron jobs, schedulers (node-cron, APScheduler, Quartz, Celery beat, Bull/BullMQ), setInterval/setTimeout scheduling, Kubernetes CronJobs, and periodic task code. It detects anti-patterns in scheduling logic, not HTTP cron endpoints. ## Commands ### Free Tier (No license required) #### `cronlint scan [file|directory]` One-shot scheduling quality scan of files or directories. **How to execute:** ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [target] ``` **What it does:** 1. Accepts a file path or directory (defaults to current directory) 2. Discovers all source files (skips .git, node_modules, binaries, images, .min.js) 3. Runs 30 scheduling patterns against each file (free tier limit) 4. Calculates a scheduling quality score (0-100) per file and overall 5. Grades: A (90-100), B (80-89), C (70-79), D (60-69), F (<60) 6. Outputs findings with: file, line number, check ID, severity, description, recommendation 7. Exit code 0 if score >= 70, exit code 1 if scheduling quality is poor 8. Free tier limited to first 30 patterns (OE + TZ categories) **Example usage scenarios:** - "Scan my code for cron job issues" -> runs `cronlint scan .` - "Check this file for scheduling anti-patterns" -> runs `cronlint scan src/scheduler.ts` - "Find overlapping cron execution risks" -> runs `cronlint scan src/` - "Audit timezone handling in scheduled tasks" -> runs `cronlint scan .` - "Check for missing error handling in cron jobs" -> runs `cronlint scan .` ### Pro Tier ($19/user/month -- requires CRONLINT_LICENSE_KEY) #### `cronlint scan --tier pro [file|directory]` Extended scan with 60 patterns covering overlap, timezone, error recovery, and resource contention. **How to execute:** ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [target] --tier pro ``` **What it does:** 1. Validates Pro+ license 2. Runs 60 scheduling patterns (OE, TZ, ER, RC categories) 3. Detects missing error recovery and retry logic in scheduled jobs 4. Identifies resource contention: every-minute crons, unbounded queries, no rate limiting 5. Full category breakdown reporting #### `cronlint scan --format json [directory]` Generate JSON output for CI/CD integration. ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --format json ``` #### `cronlint scan --format html [directory]` Generate HTML report for browser viewing. ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --format html ``` #### `cronlint scan --category OE [directory]` Filter scan to a specific check category (OE, TZ, ER, RC, LM, OB). ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --category OE ``` ### Team Tier ($39/user/month -- requires CRONLINT_LICENSE_KEY with team tier) #### `cronlint scan --tier team [directory]` Full scan with all 90 patterns across all 6 categories including lifecycle and observability. **How to execute:** ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --tier team ``` **What it does:** 1. Validates Team+ license 2. Runs all 90 patterns across 6 categories 3. Includes lifecycle management checks (graceful shutdown, orphaned tasks, stale entries) 4. Includes observability checks (missing metrics, no duration logging, no alerting) 5. Full category breakdown with per-file results #### `cronlint scan --verbose [directory]` Verbose output showing every matched line and pattern details. ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" --path [directory] --verbose ``` #### `cronlint status` Show license and configuration information. ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" status ``` #### `cronlint patterns` List all detection patterns with their IDs, severities, and descriptions. ```bash bash "<SKILL_DIR>/scripts/dispatcher.sh" patterns ``` ## Check Categories CronLint detects 90 scheduled task anti-patterns across 6 categories: | Category | Code | Patterns | Description | Severity Range | |----------|------|----------|-------------|----------------| | **Overlapping Execution** | OE | 15 | Missing locks, concurrent runs, no mutex, no pid file check | low -- critical | | **Timezone & Scheduling** | TZ | 15 | Hardcoded TZ, DST risks, UTC confusion, midnight pitfalls | low -- high | | **Error & Recovery** | ER | 15 | No try/catch, missing retry, silent failure, no dead letter queue | low -- critical | | **Resource Contention** | RC | 15 | Every-minute cron, no rate limit, unbounded queries, memory risk | low -- critical | | **Lifecycle Management** | LM | 15 | No graceful shutdown, orphaned tasks, stale entries, no health check | low -- high | | **Observability** | OB | 15 | No duration logging, no metrics, no alerting, no execution history | low -- high | ## Tier-Based Pattern Access | Tier | Patterns | Categories | |------|----------|------------| | **Free** | 30 | OE, TZ | | **Pro** | 60 | OE, TZ, ER, RC | | **Team** | 90 | OE, TZ, ER, RC, LM, OB | | **Enterprise** | 90 | OE, TZ, ER, RC, LM, OB + priority support | ## Scoring CronLint uses a deductive scoring system starting at 100 (perfect): | Severity | Point Deduction | Description | |----------|-----------------|-------------| | **Critical** | -25 per finding | Severe risk (overlapping execution, silent failure, resource abuse) | | **High** | -15 per finding | Significant problem (missing locks, DST scheduling, no error handling) | | **Medium** | -8 per finding | Moderate concern (hardcoded TZ, missing retry, no rate limiting) | | **Low** | -3 per finding | Informational / best practice suggestion | ### Grading Scale | Grade | Score Range | Meaning | |-------|-------------|---------| | **A** | 90-100 | Excellent scheduling quality | | **B** | 80-89 | Good scheduling with minor issues | | **C** | 70-79 | Acceptable but needs improvement | | **D** | 60-69 | Poor scheduling quality | | **F** | Below 60 | Critical scheduling problems | - **Pass threshold:** 70 (Grade C or better) - Exit code 0 = pass (score >= 70) - Exit code 1 = fail (score < 70) ## Configuration Users can configure CronLint in `~/.openclaw/openclaw.json`: ```json { "skills": { "entries": { "cronlint": { "enabled": true, "apiKey": "YOUR_LICENSE_KEY_HERE", "config": { "severityThreshold": "medium", "ignorePatterns": ["**/test/**", "**/fixtures/**", "**/*.test.*"], "ignoreChecks": [], "reportFormat": "text" } } } } } ``` ## Important Notes - **Free tier** works immediately with no configuration - **All scanning happens locally** -- no code is sent to external servers - **License validation is offline** -- no phone-home or network calls - Pattern matching only -- no AST parsing, no external dependencies beyond bash - Supports scanning all file types in a single pass - Git hooks use **lefthook** which must be installed (see install metadata above) - Exit codes: 0 = pass (score >= 70), 1 = fail (for CI/CD integration) - Output formats: text (default), json, html ## Error Handling - If lefthook is not installed and user tries hooks, prompt to install it - If license key is invalid or expired, show clear message with link to https://cronlint.pages.dev/renew - If a file is binary, skip it automatically with no warning - If no scannable files found in target, report clean scan with info message - If an invalid category is specified with --category, show available categories ## When to Use CronLint The user might say things like: - "Scan my code for cron job issues" - "Check my scheduled task logic" - "Find overlapping execution risks" - "Detect timezone problems in schedulers" - "Are there any cron anti-patterns?" - "Check for missing error handling in cron jobs" - "Audit my scheduling architecture" - "Find resource contention in batch jobs" - "Check for missing job observability" - "Scan for graceful shutdown issues" - "Run a scheduling quality audit" - "Generate a cron health report" - "Check if my jobs have overlap protection" - "Find silent failure patterns in scheduled tasks" - "Check my code for DST scheduling vulnerabilities"

path: config/lefthook.yml
content: # CronLint -- lefthook configuration
# Installed by: cronlint hooks install
# Documentation: https://cronlint.pages.dev/docs/hooks
#
# This configuration integrates CronLint with git hooks via lefthook.
# Pre-commit: scans staged files for scheduling issues (blocks on critical/high)
# Pre-push: runs full scan of the working tree before push

pre-commit:
  parallel: true
  commands:
    cronlint-check:
      run: |
        CRONLINT_SKILL_DIR="${CRONLINT_SKILL_DIR:-$HOME/.openclaw/skills/cronlint}"
        if [[ -f "$CRONLINT_SKILL_DIR/scripts/analyzer.sh" ]]; then
          source "$CRONLINT_SKILL_DIR/scripts/patterns.sh"
          source "$CRONLINT_SKILL_DIR/scripts/analyzer.sh"
          hook_cronlint_check
        else
          echo "[CronLint] Skill not found at $CRONLINT_SKILL_DIR -- skipping scan"
        fi
      fail_text: |
        Scheduling issues detected in staged files!
        Run 'cronlint scan' to see details.
        Or 'git commit --no-verify' to skip (NOT recommended).

pre-push:
  parallel: true
  commands:
    cronlint-full-scan:
      run: |
        CRONLINT_SKILL_DIR="${CRONLINT_SKILL_DIR:-$HOME/.openclaw/skills/cronlint}"
        if [[ -f "$CRONLINT_SKILL_DIR/scripts/dispatcher.sh" ]]; then
          bash "$CRONLINT_SKILL_DIR/scripts/dispatcher.sh" --path . --format text
        else
          echo "[CronLint] Skill not found at $CRONLINT_SKILL_DIR -- skipping scan"
        fi
      fail_text: |
        CronLint detected scheduling quality issues in your codebase.
        Run 'cronlint scan --verbose' for detailed findings.
        Fix critical/high issues before pushing.


path: scripts/analyzer.sh
content: #!/usr/bin/env bash
# CronLint -- Core Analysis Engine
# Provides: file discovery, pattern scanning, score calculation, grades,
#           category breakdown, file-by-file results, tier-based filtering,
#           report generation, and multi-format output (text, json, html).
#
# This file is sourced by dispatcher.sh and by the lefthook pre-commit hook.
# Requires patterns.sh to be sourced first.

set -euo pipefail

# --- Colors (safe to re-declare; sourcing scripts may set these) --------------

RED="${RED:-\033[0;31m}"
GREEN="${GREEN:-\033[0;32m}"
YELLOW="${YELLOW:-\033[1;33m}"
BLUE="${BLUE:-\033[0;34m}"
CYAN="${CYAN:-\033[0;36m}"
MAGENTA="${MAGENTA:-\033[0;35m}"
BOLD="${BOLD:-\033[1m}"
DIM="${DIM:-\033[2m}"
NC="${NC:-\033[0m}"

SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
SKILL_DIR="${SKILL_DIR:-$(dirname "$SCRIPT_DIR")}"
CRONLINT_VERSION="${VERSION:-1.0.0}"

# Global findings array
# Each finding: FILE|LINE|SEVERITY|CHECK_ID|DESCRIPTION|RECOMMENDATION|MATCHED_TEXT
declare -a FINDINGS=()

# Global file tracking
declare -A FILE_FINDING_COUNTS=()

# ============================================================================
# File Discovery
# ============================================================================

# Check if a file should be skipped (binary, generated, vendor, etc.)
should_skip_file() {
  local filepath="$1"
  local basename_f
  basename_f=$(basename "$filepath")
  local basename_lower
  basename_lower=$(echo "$basename_f" | tr '[:upper:]' '[:lower:]')
  local ext="${basename_lower##*.}"

  # Skip binary/image/compiled file extensions
  case "$ext" in
    png|jpg|jpeg|gif|bmp|ico|svg|webp|mp3|mp4|avi|mov|mkv|wav|flac|ogg) return 0 ;;
    zip|tar|gz|bz2|xz|7z|rar|tgz) return 0 ;;
    exe|dll|so|dylib|o|a|class|pyc|pyo|wasm) return 0 ;;
    woff|woff2|ttf|eot|otf) return 0 ;;
    pdf|doc|docx|xls|xlsx|ppt|pptx) return 0 ;;
    lock) return 0 ;;  # package-lock.json, yarn.lock, etc.
    map) return 0 ;;   # source maps
  esac

  # Skip minified files
  case "$basename_lower" in
    *.min.js|*.min.css|*.min.mjs) return 0 ;;
    *.bundle.js|*.chunk.js) return 0 ;;
  esac

  # Skip known non-source files
  case "$basename_lower" in
    package-lock.json|yarn.lock|pnpm-lock.yaml|composer.lock|gemfile.lock|cargo.lock|poetry.lock) return 0 ;;
    .ds_store|thumbs.db|desktop.ini) return 0 ;;
  esac

  return 1
}

# Check if a path is in a directory that should be skipped
is_excluded_dir() {
  local filepath="$1"

  case "$filepath" in
    */.git/*|*/.git) return 0 ;;
    */node_modules/*|*/node_modules) return 0 ;;
    */vendor/*|*/vendor) return 0 ;;
    */.next/*|*/.next) return 0 ;;
    */dist/*|*/dist) return 0 ;;
    */build/*|*/build) return 0 ;;
    */coverage/*|*/coverage) return 0 ;;
    */__pycache__/*|*/__pycache__) return 0 ;;
    */.venv/*|*/.venv) return 0 ;;
    */venv/*|*/venv) return 0 ;;
    */.tox/*|*/.tox) return 0 ;;
    */.eggs/*|*/.eggs) return 0 ;;
    */.terraform/*|*/.terraform) return 0 ;;
    */.cache/*|*/.cache) return 0 ;;
    */target/*|*/target) return 0 ;;
  esac

  return 1
}

# Check if a file is ignored by .gitignore
is_gitignored() {
  local filepath="$1"

  if command -v git &>/dev/null; then
    if git rev-parse --git-dir &>/dev/null 2>&1; then
      if git check-ignore -q "$filepath" 2>/dev/null; then
        return 0
      fi
    fi
  fi

  return 1
}

# Find all scannable files in a directory tree
find_scannable_files() {
  local search_dir="$1"
  local -n _result_files="$2"
  _result_files=()

  if [[ -f "$search_dir" ]]; then
    if ! should_skip_file "$search_dir"; then
      _result_files+=("$search_dir")
    fi
    return 0
  fi

  while IFS= read -r -d '' file; do
    # Skip excluded directories
    is_excluded_dir "$file" && continue

    # Skip binary/generated files
    should_skip_file "$file" && continue

    # Skip gitignored files
    is_gitignored "$file" && continue

    _result_files+=("$file")
  done < <(find "$search_dir" -maxdepth 10 \
    \( -name ".git" -o -name "node_modules" -o -name "vendor" -o -name ".next" \
       -o -name "dist" -o -name "build" -o -name "coverage" -o -name "__pycache__" \
       -o -name ".venv" -o -name "venv" -o -name ".tox" -o -name ".terraform" \
       -o -name ".cache" -o -name "target" \) -prune -o \
    -type f -print0 2>/dev/null)
}

# ============================================================================
# Pattern Scanning
# ============================================================================

# Scan a single file against patterns for given categories.
# Appends findings to global FINDINGS array.
# Tracks per-file finding counts in FILE_FINDING_COUNTS.
#
# Arguments:
#   $1 -- filepath to scan
#   $2 -- space-separated list of category codes (e.g., "OE TZ ER RC")
#   $3 -- optional: specific category filter (only scan this category)
scan_file() {
  local filepath="$1"
  local categories="$2"
  local category_filter="${3:-}"

  local file_count_before=${#FINDINGS[@]}

  for category in $categories; do
    # If a specific category filter is set, skip non-matching categories
    if [[ -n "$category_filter" ]]; then
      local filter_upper
      filter_upper=$(echo "$category_filter" | tr '[:lower:]' '[:upper:]')
      if [[ "$category" != "$filter_upper" ]]; then
        continue
      fi
    fi

    local patterns_name
    patterns_name=$(get_cronlint_patterns_for_category "$category")

    if [[ -z "$patterns_name" ]]; then
      continue
    fi

    local -n _patterns_ref="$patterns_name"

    for entry in "${_patterns_ref[@]}"; do
      IFS='|' read -r regex severity check_id description recommendation <<< "$entry"

      # Use grep -nE to find matches with line numbers
      local matches
      matches=$(grep -nE "$regex" "$filepath" 2>/dev/null || true)

      if [[ -n "$matches" ]]; then
        while IFS= read -r match_line; do
          [[ -z "$match_line" ]] && continue
          local line_num="${match_line%%:*}"
          local matched_text="${match_line#*:}"

          # Trim whitespace
          matched_text=$(echo "$matched_text" | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')

          # Truncate long lines
          if [[ ${#matched_text} -gt 120 ]]; then
            matched_text="${matched_text:0:117}..."
          fi

          local finding="${filepath}|${line_num}|${severity}|${check_id}|${description}|${recommendation}|${matched_text}"
          FINDINGS+=("$finding")

        done <<< "$matches"
      fi
    done
  done

  # Track per-file counts
  local file_count_after=${#FINDINGS[@]}
  local file_new_findings=$((file_count_after - file_count_before))
  if [[ $file_new_findings -gt 0 ]]; then
    FILE_FINDING_COUNTS["$filepath"]=$file_new_findings
  fi
}

# ============================================================================
# Score Calculation & Grading
# ============================================================================

# Calculate a scheduling quality score based on findings.
# Score starts at 100 (clean) and decreases with each finding.
# Severity weights: critical=25, high=15, medium=8, low=3
calculate_score() {
  local score=100

  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r _file _line severity _check_id _desc _rec _text <<< "$finding"
    local points
    points=$(severity_to_points "$severity")
    score=$((score - points))
  done

  # Floor at 0
  if [[ $score -lt 0 ]]; then
    score=0
  fi

  echo "$score"
}

# Convert score to letter grade
# A: 90-100, B: 80-89, C: 70-79, D: 60-69, F: <60
score_to_grade() {
  local score="$1"
  if [[ $score -ge 90 ]]; then echo "A"
  elif [[ $score -ge 80 ]]; then echo "B"
  elif [[ $score -ge 70 ]]; then echo "C"
  elif [[ $score -ge 60 ]]; then echo "D"
  else echo "F"
  fi
}

# Grade color for terminal output
grade_color() {
  local grade="$1"
  case "$grade" in
    A) echo "$GREEN" ;;
    B) echo "$GREEN" ;;
    C) echo "$YELLOW" ;;
    D) echo "$YELLOW" ;;
    F) echo "$RED" ;;
    *) echo "$NC" ;;
  esac
}

# Severity color for terminal output
severity_color() {
  local severity="$1"
  case "$severity" in
    critical) echo "$RED" ;;
    high)     echo "$MAGENTA" ;;
    medium)   echo "$YELLOW" ;;
    low)      echo "$CYAN" ;;
    *)        echo "$NC" ;;
  esac
}

# ============================================================================
# Severity Counting
# ============================================================================

# Count findings by severity level
count_by_severity() {
  local target_severity="$1"
  local count=0
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r _ _ severity _ _ _ _ <<< "$finding"
    if [[ "$severity" == "$target_severity" ]]; then
      count=$((count + 1))
    fi
  done
  echo "$count"
}

# Count findings by category prefix
count_by_category() {
  local target_prefix="$1"
  local count=0
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r _ _ _ check_id _ _ _ <<< "$finding"
    local prefix="${check_id%%-*}"
    if [[ "$prefix" == "$target_prefix" ]]; then
      count=$((count + 1))
    fi
  done
  echo "$count"
}

# ============================================================================
# Text Output
# ============================================================================

# Print findings to stdout in text format
print_findings_text() {
  local verbose="${1:-false}"

  local count=0
  local crit_count high_count med_count low_count
  crit_count=$(count_by_severity "critical")
  high_count=$(count_by_severity "high")
  med_count=$(count_by_severity "medium")
  low_count=$(count_by_severity "low")

  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r file line severity check_id description recommendation matched_text <<< "$finding"
    count=$((count + 1))

    local sev_col
    sev_col=$(severity_color "$severity")

    echo -e "  ${sev_col}${severity^^}${NC} ${DIM}${check_id}${NC} ${file}:${line}"
    echo -e "    ${description}"
    if [[ "$verbose" == "true" ]]; then
      echo -e "    ${DIM}Matched: ${matched_text}${NC}"
    fi
    echo -e "    ${DIM}Fix: ${recommendation}${NC}"
    echo ""
  done

  echo -e "${BOLD}--- Summary ---${NC}"
  echo -e "  Total findings: ${BOLD}$count${NC}"
  if [[ $crit_count -gt 0 ]]; then
    echo -e "  ${RED}Critical: $crit_count${NC}"
  fi
  if [[ $high_count -gt 0 ]]; then
    echo -e "  ${MAGENTA}High: $high_count${NC}"
  fi
  if [[ $med_count -gt 0 ]]; then
    echo -e "  ${YELLOW}Medium: $med_count${NC}"
  fi
  if [[ $low_count -gt 0 ]]; then
    echo -e "  ${CYAN}Low: $low_count${NC}"
  fi
}

# Print category breakdown in text format
print_category_breakdown_text() {
  echo ""
  echo -e "${BOLD}--- Category Breakdown ---${NC}"

  for cat in OE TZ ER RC LM OB; do
    local cat_count
    cat_count=$(count_by_category "$cat")
    if [[ $cat_count -gt 0 ]]; then
      local label
      label=$(get_cronlint_category_label "$cat")
      echo -e "  ${BOLD}$cat${NC} ($label): $cat_count findings"
    fi
  done
}

# Print file-by-file results
print_file_results_text() {
  if [[ ${#FILE_FINDING_COUNTS[@]} -eq 0 ]]; then
    return
  fi

  echo ""
  echo -e "${BOLD}--- Files with Findings ---${NC}"

  for file in "${!FILE_FINDING_COUNTS[@]}"; do
    echo -e "  ${FILE_FINDING_COUNTS[$file]} findings in ${DIM}$file${NC}"
  done
}

# Print score and grade in text format
print_score_text() {
  local score="$1"
  local grade
  grade=$(score_to_grade "$score")
  local gc
  gc=$(grade_color "$grade")

  echo ""
  echo -e "${BOLD}Scheduling Quality Score: ${gc}${score}/100${NC} (Grade: ${gc}${BOLD}${grade}${NC})"

  if [[ $score -ge 70 ]]; then
    echo -e "${GREEN}PASS${NC} -- Score is above threshold (70)"
  else
    echo -e "${RED}FAIL${NC} -- Score is below threshold (70)"
  fi
}

# ============================================================================
# JSON Output
# ============================================================================

# Generate JSON output for all findings
generate_json_output() {
  local target="$1"
  local files_scanned="$2"
  local score="$3"
  local grade="$4"
  local pass_status="$5"

  local crit_count high_count med_count low_count
  crit_count=$(count_by_severity "critical")
  high_count=$(count_by_severity "high")
  med_count=$(count_by_severity "medium")
  low_count=$(count_by_severity "low")

  local date_str
  date_str=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%Y-%m-%d 2>/dev/null || echo "unknown")

  # Build findings JSON array
  local findings_json=""
  local idx=0
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r file line severity check_id description recommendation matched_text <<< "$finding"

    # Escape special characters for JSON
    local esc_desc esc_rec esc_match esc_file
    esc_file=$(echo "$file" | sed 's/\\/\\\\/g; s/"/\\"/g')
    esc_desc=$(echo "$description" | sed 's/\\/\\\\/g; s/"/\\"/g')
    esc_rec=$(echo "$recommendation" | sed 's/\\/\\\\/g; s/"/\\"/g')
    esc_match=$(echo "$matched_text" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')

    [[ $idx -gt 0 ]] && findings_json="${findings_json},"
    findings_json="${findings_json}
    {
      \"file\": \"${esc_file}\",
      \"line\": ${line},
      \"severity\": \"${severity}\",
      \"check_id\": \"${check_id}\",
      \"category\": \"${check_id%%-*}\",
      \"description\": \"${esc_desc}\",
      \"recommendation\": \"${esc_rec}\",
      \"matched_text\": \"${esc_match}\"
    }"
    idx=$((idx + 1))
  done

  # Build category breakdown
  local categories_json=""
  local cat_idx=0
  for cat in OE TZ ER RC LM OB; do
    local cat_count
    cat_count=$(count_by_category "$cat")
    local label
    label=$(get_cronlint_category_label "$cat")

    [[ $cat_idx -gt 0 ]] && categories_json="${categories_json},"
    categories_json="${categories_json}
    {
      \"code\": \"${cat}\",
      \"name\": \"${label}\",
      \"count\": ${cat_count}
    }"
    cat_idx=$((cat_idx + 1))
  done

  cat <<JSON_EOF
{
  "tool": "CronLint",
  "version": "${CRONLINT_VERSION}",
  "timestamp": "${date_str}",
  "target": "${target}",
  "score": ${score},
  "grade": "${grade}",
  "pass": ${pass_status},
  "summary": {
    "files_scanned": ${files_scanned},
    "total_findings": ${#FINDINGS[@]},
    "critical": ${crit_count},
    "high": ${high_count},
    "medium": ${med_count},
    "low": ${low_count}
  },
  "categories": [${categories_json}
  ],
  "findings": [${findings_json}
  ]
}
JSON_EOF
}

# ============================================================================
# HTML Output
# ============================================================================

# Generate HTML report output
generate_html_output() {
  local target="$1"
  local files_scanned="$2"
  local score="$3"
  local grade="$4"

  local crit_count high_count med_count low_count
  crit_count=$(count_by_severity "critical")
  high_count=$(count_by_severity "high")
  med_count=$(count_by_severity "medium")
  low_count=$(count_by_severity "low")

  local date_str
  date_str=$(date +%Y-%m-%d 2>/dev/null || echo "unknown")

  # Determine grade color
  local grade_css_color
  case "$grade" in
    A|B) grade_css_color="#22c55e" ;;
    C)   grade_css_color="#eab308" ;;
    D)   grade_css_color="#f97316" ;;
    F)   grade_css_color="#ef4444" ;;
    *)   grade_css_color="#6b7280" ;;
  esac

  # Build findings table rows
  local findings_rows=""
  local idx=0
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r file line severity check_id description recommendation matched_text <<< "$finding"
    idx=$((idx + 1))

    local sev_color
    case "$severity" in
      critical) sev_color="#ef4444" ;;
      high)     sev_color="#a855f7" ;;
      medium)   sev_color="#eab308" ;;
      low)      sev_color="#06b6d4" ;;
      *)        sev_color="#6b7280" ;;
    esac

    # HTML-escape text
    local esc_file esc_desc esc_rec
    esc_file=$(echo "$file" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
    esc_desc=$(echo "$description" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')
    esc_rec=$(echo "$recommendation" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g')

    findings_rows="${findings_rows}
      <tr>
        <td>${idx}</td>
        <td><code>${esc_file}:${line}</code></td>
        <td><span style=\"color:${sev_color};font-weight:bold\">${severity^^}</span></td>
        <td><code>${check_id}</code></td>
        <td>${esc_desc}</td>
        <td><em>${esc_rec}</em></td>
      </tr>"
  done

  cat <<HTML_EOF
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CronLint Report -- ${target}</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 1100px; margin: 0 auto; padding: 2rem; background: #f8fafc; color: #1e293b; }
    h1 { color: #0f172a; border-bottom: 3px solid #f59e0b; padding-bottom: 0.5rem; }
    h2 { color: #334155; margin-top: 2rem; }
    .score-badge { display: inline-block; padding: 0.5rem 1.5rem; border-radius: 0.5rem; color: white; font-size: 1.5rem; font-weight: bold; background: ${grade_css_color}; }
    .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin: 1rem 0; }
    .summary-card { background: white; padding: 1rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); text-align: center; }
    .summary-card .number { font-size: 2rem; font-weight: bold; }
    .summary-card .label { color: #64748b; font-size: 0.875rem; }
    table { width: 100%; border-collapse: collapse; margin: 1rem 0; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.1); border-radius: 0.5rem; overflow: hidden; }
    th { background: #1e293b; color: white; padding: 0.75rem; text-align: left; font-size: 0.875rem; }
    td { padding: 0.75rem; border-bottom: 1px solid #e2e8f0; font-size: 0.875rem; }
    tr:hover { background: #f1f5f9; }
    code { background: #e2e8f0; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.8rem; }
    .footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 0.75rem; }
  </style>
</head>
<body>
  <h1>CronLint Report</h1>
  <p>Target: <code>${target}</code> | Date: ${date_str} | Version: ${CRONLINT_VERSION}</p>

  <div class="score-badge">${score}/100 (${grade})</div>

  <h2>Summary</h2>
  <div class="summary-grid">
    <div class="summary-card"><div class="number">${files_scanned}</div><div class="label">Files Scanned</div></div>
    <div class="summary-card"><div class="number">${#FINDINGS[@]}</div><div class="label">Total Findings</div></div>
    <div class="summary-card"><div class="number" style="color:#ef4444">${crit_count}</div><div class="label">Critical</div></div>
    <div class="summary-card"><div class="number" style="color:#a855f7">${high_count}</div><div class="label">High</div></div>
    <div class="summary-card"><div class="number" style="color:#eab308">${med_count}</div><div class="label">Medium</div></div>
    <div class="summary-card"><div class="number" style="color:#06b6d4">${low_count}</div><div class="label">Low</div></div>
  </div>

  <h2>Category Breakdown</h2>
  <table>
    <tr><th>Category</th><th>Code</th><th>Findings</th></tr>
    <tr><td>Overlapping Execution</td><td>OE</td><td>$(count_by_category "OE")</td></tr>
    <tr><td>Timezone & Scheduling</td><td>TZ</td><td>$(count_by_category "TZ")</td></tr>
    <tr><td>Error & Recovery</td><td>ER</td><td>$(count_by_category "ER")</td></tr>
    <tr><td>Resource Contention</td><td>RC</td><td>$(count_by_category "RC")</td></tr>
    <tr><td>Lifecycle Management</td><td>LM</td><td>$(count_by_category "LM")</td></tr>
    <tr><td>Observability</td><td>OB</td><td>$(count_by_category "OB")</td></tr>
  </table>

  <h2>Findings</h2>
  <table>
    <tr><th>#</th><th>Location</th><th>Severity</th><th>Check</th><th>Description</th><th>Recommendation</th></tr>
    ${findings_rows}
  </table>

  <div class="footer">
    Generated by <a href="https://cronlint.pages.dev">CronLint</a> v${CRONLINT_VERSION} on ${date_str}.
  </div>
</body>
</html>
HTML_EOF
}

# ============================================================================
# Markdown Report Generation
# ============================================================================

# Generate a markdown report using the template file
generate_markdown_report() {
  local target="$1"
  local files_scanned="$2"

  local score
  score=$(calculate_score)
  local grade
  grade=$(score_to_grade "$score")
  local date_str
  date_str=$(date +%Y-%m-%d 2>/dev/null || echo "unknown")

  # Count severities
  local crit_count high_count med_count low_count
  crit_count=$(count_by_severity "critical")
  high_count=$(count_by_severity "high")
  med_count=$(count_by_severity "medium")
  low_count=$(count_by_severity "low")

  local total_issues=${#FINDINGS[@]}

  # Build category breakdown
  local cat_breakdown=""
  for cat in OE TZ ER RC LM OB; do
    local cat_count
    cat_count=$(count_by_category "$cat")
    local label
    label=$(get_cronlint_category_label "$cat")
    cat_breakdown="${cat_breakdown}| ${label} (${cat}) | ${cat_count} |"$'\n'
  done

  # Build findings table
  local findings_table=""
  local idx=0
  for finding in "${FINDINGS[@]}"; do
    idx=$((idx + 1))
    IFS='|' read -r file line severity check_id description recommendation matched_text <<< "$finding"
    findings_table="${findings_table}| ${idx} | ${file} | ${line} | ${severity} | ${check_id} | ${description} |"$'\n'
  done

  # Build recommendations
  local recommendations=""
  if [[ $crit_count -gt 0 ]]; then
    recommendations="${recommendations}1. **CRITICAL:** ${crit_count} critical findings -- address immediately (overlapping execution, silent failures, resource abuse)"$'\n'
  fi
  if [[ $high_count -gt 0 ]]; then
    recommendations="${recommendations}2. **HIGH:** ${high_count} high-severity findings -- fix scheduling gaps and error handling"$'\n'
  fi
  if [[ $med_count -gt 0 ]]; then
    recommendations="${recommendations}3. **MEDIUM:** ${med_count} medium findings -- add timezone safety, retry logic, resource limits"$'\n'
  fi
  if [[ $low_count -gt 0 ]]; then
    recommendations="${recommendations}4. **LOW:** ${low_count} low findings -- review best practices and observability gaps"$'\n'
  fi

  # Get project name
  local project_name
  project_name=$(basename "$(cd "$target" 2>/dev/null && pwd)" 2>/dev/null || echo "$target")

  # Read template and substitute
  local template_file="$SKILL_DIR/templates/report.md.tmpl"
  if [[ ! -f "$template_file" ]]; then
    echo -e "${RED}[CronLint]${NC} Report template not found: $template_file" >&2
    return 1
  fi

  local report
  report=$(cat "$template_file")
  report="${report//\{\{PROJECT\}\}/$project_name}"
  report="${report//\{\{VERSION\}\}/$CRONLINT_VERSION}"
  report="${report//\{\{DATE\}\}/$date_str}"
  report="${report//\{\{SCORE\}\}/$score}"
  report="${report//\{\{GRADE\}\}/$grade}"
  report="${report//\{\{FILES_SCANNED\}\}/$files_scanned}"
  report="${report//\{\{TOTAL_ISSUES\}\}/$total_issues}"
  report="${report//\{\{CRITICAL_COUNT\}\}/$crit_count}"
  report="${report//\{\{HIGH_COUNT\}\}/$high_count}"
  report="${report//\{\{MEDIUM_COUNT\}\}/$med_count}"
  report="${report//\{\{LOW_COUNT\}\}/$low_count}"
  report="${report//\{\{CATEGORY_BREAKDOWN\}\}/$cat_breakdown}"
  report="${report//\{\{FINDINGS_TABLE\}\}/$findings_table}"
  report="${report//\{\{RECOMMENDATIONS\}\}/$recommendations}"

  echo "$report"
}

# ============================================================================
# Main Scan Function
# ============================================================================

# do_cronlint_scan <target> <tier_level> <format> <verbose> <category_filter>
#
# Arguments:
#   target          -- file or directory to scan
#   tier_level      -- numeric tier (0=free, 1=pro, 2=team, 3=enterprise)
#   format          -- output format: text, json, html
#   verbose         -- "true" or "false"
#   category_filter -- optional category code to filter (e.g., "OE")
do_cronlint_scan() {
  local target="${1:-.}"
  local tier_level="${2:-0}"
  local format="${3:-text}"
  local verbose="${4:-false}"
  local category_filter="${5:-}"

  FINDINGS=()
  FILE_FINDING_COUNTS=()

  # Determine which categories are available for this tier
  local categories
  categories=$(get_cronlint_categories_for_tier "$tier_level")

  # Validate category filter if provided
  if [[ -n "$category_filter" ]]; then
    local filter_upper
    filter_upper=$(echo "$category_filter" | tr '[:lower:]' '[:upper:]')
    if ! is_valid_cronlint_category "$filter_upper"; then
      echo -e "${RED}[CronLint]${NC} Invalid category: ${BOLD}$category_filter${NC}" >&2
      echo -e "  Valid categories: OE, TZ, ER, RC, LM, OB" >&2
      return 1
    fi

    # Check if the filtered category is available for this tier
    local cat_available=false
    for available_cat in $categories; do
      if [[ "$available_cat" == "$filter_upper" ]]; then
        cat_available=true
        break
      fi
    done

    if [[ "$cat_available" == false ]]; then
      echo -e "${RED}[CronLint]${NC} Category ${BOLD}$filter_upper${NC} is not available on your current tier." >&2
      echo -e "  Available categories for your tier: ${BOLD}$categories${NC}" >&2
      echo -e "  Upgrade at: ${CYAN}https://cronlint.pages.dev/pricing${NC}" >&2
      return 1
    fi
  fi

  # Discover files
  local -a files=()
  find_scannable_files "$target" files

  local total_files=${#files[@]}

  if [[ $total_files -eq 0 ]]; then
    if [[ "$format" == "json" ]]; then
      generate_json_output "$target" 0 100 "A" "true"
    elif [[ "$format" == "html" ]]; then
      generate_html_output "$target" 0 100 "A"
    else
      echo -e "${GREEN}[CronLint]${NC} No scannable files found in ${BOLD}$target${NC}"
      echo -e "  Clean scan -- no files to check."
    fi
    return 0
  fi

  # Only print progress info for text format
  if [[ "$format" == "text" ]]; then
    local pattern_count=0
    for cat in $categories; do
      local cnt
      cnt=$(cronlint_category_count "$cat")
      pattern_count=$((pattern_count + cnt))
    done

    echo -e "${BLUE}[CronLint]${NC} Scanning ${BOLD}$total_files${NC} files with ${BOLD}$pattern_count${NC} patterns..."
    if [[ -n "$category_filter" ]]; then
      echo -e "  ${DIM}Category filter: ${category_filter^^}${NC}"
    fi
    echo ""
  fi

  # Scan each file
  for filepath in "${files[@]}"; do
    scan_file "$filepath" "$categories" "$category_filter"
  done

  # Calculate results
  local score
  score=$(calculate_score)
  local grade
  grade=$(score_to_grade "$score")
  local pass_status
  if [[ $score -ge 70 ]]; then
    pass_status="true"
  else
    pass_status="false"
  fi

  # Output results in requested format
  case "$format" in
    json)
      generate_json_output "$target" "$total_files" "$score" "$grade" "$pass_status"
      ;;
    html)
      generate_html_output "$target" "$total_files" "$score" "$grade"
      ;;
    text|*)
      if [[ ${#FINDINGS[@]} -eq 0 ]]; then
        echo -e "${GREEN}${BOLD}No scheduling issues detected!${NC}"
        echo ""
        echo -e "Scheduling Quality Score: ${GREEN}${BOLD}100/100${NC} (Grade: ${GREEN}${BOLD}A${NC})"
        echo -e "${GREEN}PASS${NC}"
        return 0
      fi

      print_findings_text "$verbose"
      print_category_breakdown_text
      print_file_results_text
      print_score_text "$score"
      ;;
  esac

  # Return exit code based on score
  if [[ $score -lt 70 ]]; then
    return 1
  fi
  return 0
}

# ============================================================================
# Hook Integration
# ============================================================================

# Called by lefthook pre-commit hook
hook_cronlint_check() {
  local tier_level="${1:-0}"

  # Get list of staged files
  local staged_files
  staged_files=$(git diff --cached --name-only --diff-filter=ACMR 2>/dev/null || true)

  if [[ -z "$staged_files" ]]; then
    return 0
  fi

  FINDINGS=()
  FILE_FINDING_COUNTS=()

  # Determine categories for tier
  local categories
  categories=$(get_cronlint_categories_for_tier "$tier_level")

  local file_count=0
  while IFS= read -r filepath; do
    [[ -z "$filepath" ]] && continue
    [[ ! -f "$filepath" ]] && continue

    # Skip excluded dirs and binary files
    is_excluded_dir "$filepath" && continue
    should_skip_file "$filepath" && continue

    scan_file "$filepath" "$categories" ""
    file_count=$((file_count + 1))
  done <<< "$staged_files"

  if [[ ${#FINDINGS[@]} -eq 0 ]]; then
    echo -e "${GREEN}[CronLint]${NC} No scheduling issues in $file_count staged files."
    return 0
  fi

  # Check if any critical or high findings exist
  local has_blocking=false
  for finding in "${FINDINGS[@]}"; do
    IFS='|' read -r _ _ severity _ _ _ _ <<< "$finding"
    if [[ "$severity" == "critical" || "$severity" == "high" ]]; then
      has_blocking=true
      break
    fi
  done

  echo -e "${RED}${BOLD}[CronLint] Scheduling issues detected in staged files!${NC}"
  echo ""
  print_findings_text "false"

  local score
  score=$(calculate_score)
  print_score_text "$score"

  if [[ "$has_blocking" == true ]]; then
    echo ""
    echo -e "${RED}Commit blocked.${NC} Fix scheduling issues before committing."
    echo -e "  Run: ${CYAN}cronlint scan${NC} for details"
    echo -e "  Skip: ${DIM}git commit --no-verify${NC} (not recommended)"
    return 1
  fi

  return 0
}


path: scripts/dispatcher.sh
content: #!/usr/bin/env bash
# CronLint -- Main CLI Dispatcher (Entry Point)
# Usage: dispatcher.sh [options]
#
# Options:
#   --path <path>          File or directory to scan (default: .)
#   --format <fmt>         Output format: text, json, html (default: text)
#   --tier <tier>          Override tier: free, pro, team, enterprise
#   --license-key <key>    Provide license key directly
#   --verbose              Show detailed per-match output
#   --category <cat>       Filter to specific category: OE, TZ, ER, RC, LM, OB
#   --help, -h             Show help
#   --version, -v          Show version
#   status                 Show license and configuration info
#   patterns               List all detection patterns

set -euo pipefail

# --- Script location ----------------------------------------------------------

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
VERSION="1.0.0"

# --- Colors -------------------------------------------------------------------

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'

# --- Logging helpers ----------------------------------------------------------

log_info()    { echo -e "${BLUE}[CronLint]${NC} $*"; }
log_success() { echo -e "${GREEN}[CronLint]${NC} $*"; }
log_warn()    { echo -e "${YELLOW}[CronLint]${NC} $*"; }
log_error()   { echo -e "${RED}[CronLint]${NC} $*" >&2; }

# --- Version & Help -----------------------------------------------------------

show_version() {
  echo -e "${BOLD}CronLint${NC} v${VERSION}"
  echo "  Scheduled task & cron job anti-pattern analyzer"
  echo "  https://cronlint.pages.dev"
}

show_help() {
  show_version
  echo ""
  echo -e "${BOLD}USAGE${NC}"
  echo "  dispatcher.sh [options]"
  echo "  dispatcher.sh status"
  echo "  dispatcher.sh patterns"
  echo ""
  echo -e "${BOLD}OPTIONS${NC}"
  echo -e "  --path <path>          ${DIM}File or directory to scan (default: .)${NC}"
  echo -e "  --format <fmt>         ${DIM}Output: text, json, html (default: text)${NC}"
  echo -e "  --tier <tier>          ${DIM}Override: free, pro, team, enterprise${NC}"
  echo -e "  --license-key <key>    ${DIM}Provide license key directly${NC}"
  echo -e "  --verbose              ${DIM}Detailed per-match output${NC}"
  echo -e "  --category <cat>       ${DIM}Filter: OE, TZ, ER, RC, LM, OB${NC}"
  echo -e "  --help, -h             ${DIM}Show this help${NC}"
  echo -e "  --version, -v          ${DIM}Show version${NC}"
  echo ""
  echo -e "${BOLD}COMMANDS${NC}"
  echo -e "  scan                   ${DIM}Run scheduling anti-pattern scan (default)${NC}"
  echo -e "  status                 ${DIM}Show license and configuration info${NC}"
  echo -e "  patterns               ${DIM}List all detection patterns${NC}"
  echo -e "  hooks install          ${DIM}Install lefthook git hooks${NC}"
  echo -e "  hooks uninstall        ${DIM}Remove lefthook git hooks${NC}"
  echo -e "  report                 ${DIM}Generate markdown report${NC}"
  echo ""
  echo -e "${BOLD}TIER SYSTEM${NC}"
  echo -e "  ${GREEN}FREE${NC}        30 patterns  OE, TZ categories"
  echo -e "  ${CYAN}PRO${NC}  \$19/mo  60 patterns  OE, TZ, ER, RC"
  echo -e "  ${MAGENTA}TEAM${NC} \$39/mo  90 patterns  OE, TZ, ER, RC, LM, OB"
  echo -e "  ${BOLD}ENTERPRISE${NC}   90 patterns  All + priority support"
  echo ""
  echo -e "${BOLD}CHECK CATEGORIES${NC}"
  echo "  OE  Overlapping Execution -- missing locks, concurrent runs, no mutex"
  echo "  TZ  Timezone & Scheduling -- hardcoded TZ, DST risks, UTC confusion"
  echo "  ER  Error & Recovery      -- no try/catch, missing retry, silent failure"
  echo "  RC  Resource Contention   -- every-minute cron, no rate limit, unbounded"
  echo "  LM  Lifecycle Management  -- no graceful shutdown, orphaned tasks, stale"
  echo "  OB  Observability         -- no duration logging, no metrics, no alerting"
  echo ""
  echo -e "${BOLD}SCORING${NC}"
  echo "  100 = perfect, 0 = worst"
  echo "  Critical: -25, High: -15, Medium: -8, Low: -3"
  echo "  A(90+), B(80-89), C(70-79), D(60-69), F(<60)"
  echo "  Pass threshold: 70 (exit 0=pass, 1=fail)"
  echo ""
  echo -e "${BOLD}EXAMPLES${NC}"
  echo "  dispatcher.sh --path ."
  echo "  dispatcher.sh --path src/ --format json"
  echo "  dispatcher.sh --path . --category OE --verbose"
  echo "  dispatcher.sh --path . --format html > report.html"
  echo ""
  echo -e "Get a license at ${CYAN}https://cronlint.pages.dev/pricing${NC}"
}

# --- Tier hierarchy -----------------------------------------------------------

declare -A TIER_LEVELS=(
  [free]=0
  [pro]=1
  [team]=2
  [enterprise]=3
)

# --- License integration ------------------------------------------------------

# Source license module and determine the effective tier.
# Priority: --tier flag > --license-key flag > env var > config file > free
resolve_tier() {
  local tier_override="$1"
  local license_key_flag="$2"

  # If tier is explicitly overridden, validate license for that tier
  if [[ -n "$tier_override" ]]; then
    local required_level="${TIER_LEVELS[$tier_override]:-0}"
    if [[ $required_level -gt 0 ]]; then
      # Need to validate license
      source "$SCRIPT_DIR/license.sh"

      # If a key was provided via flag, set it in env
      if [[ -n "$license_key_flag" ]]; then
        export CRONLINT_LICENSE_KEY="$license_key_flag"
      fi

      if ! check_cronlint_license "$tier_override"; then
        exit 1
      fi
    fi
    echo "$required_level"
    return
  fi

  # Auto-detect tier from license
  source "$SCRIPT_DIR/license.sh"

  # If a key was provided via flag, set it in env
  if [[ -n "$license_key_flag" ]]; then
    export CRONLINT_LICENSE_KEY="$license_key_flag"
  fi

  local detected_tier
  detected_tier=$(get_cronlint_tier)
  echo "${TIER_LEVELS[$detected_tier]:-0}"
}

# --- Pattern listing ----------------------------------------------------------

do_list_patterns() {
  source "$SCRIPT_DIR/patterns.sh"

  echo -e "${BOLD}--- CronLint Detection Patterns ---${NC}"
  echo ""
  echo -e "${BOLD}$(cronlint_pattern_count) patterns across 6 categories${NC}"
  echo ""

  cronlint_list_patterns "all"
}

# --- Status display -----------------------------------------------------------

do_show_status() {
  show_version
  echo ""
  source "$SCRIPT_DIR/license.sh"
  show_cronlint_status
}

# --- Hooks management ---------------------------------------------------------

do_hooks_install() {
  if ! command -v lefthook &>/dev/null; then
    log_error "lefthook not installed."
    echo "  Install: brew install lefthook"
    return 1
  fi

  if ! git rev-parse --git-dir &>/dev/null 2>&1; then
    log_error "Not inside a git repository."
    return 1
  fi

  local repo_root
  repo_root=$(git rev-parse --show-toplevel)

  log_info "Installing hooks in ${BOLD}$repo_root${NC}"

  local config="$repo_root/lefthook.yml"

  if [[ -f "$config" ]]; then
    if grep -q "cronlint" "$config" 2>/dev/null; then
      log_success "Hooks already configured."
      return 0
    fi

    # Append to existing config
    cat >> "$config" <<'HOOKS'

# --- CronLint hooks ----------------------------------------
    cronlint-check:
      run: |
        CRONLINT_SKILL_DIR="${CRONLINT_SKILL_DIR:-$HOME/.openclaw/skills/cronlint}"
        if [[ -f "$CRONLINT_SKILL_DIR/scripts/analyzer.sh" ]]; then
          source "$CRONLINT_SKILL_DIR/scripts/patterns.sh"
          source "$CRONLINT_SKILL_DIR/scripts/analyzer.sh"
          hook_cronlint_check
        else
          echo "[CronLint] Skill not found at $CRONLINT_SKILL_DIR -- skipping scan"
        fi
      fail_text: |
        Scheduling issues detected in staged files!
        Run 'cronlint scan' to see details.
        Or 'git commit --no-verify' to skip (NOT recommended).
HOOKS
    echo -e "${GREEN}+${NC} Appended CronLint hooks to existing lefthook.yml"
  else
    cp "$SKILL_DIR/config/lefthook.yml" "$config"
    echo -e "${GREEN}+${NC} Created lefthook.yml"
  fi

  (cd "$repo_root" && lefthook install)
  echo ""
  log_success "Hooks installed! Staged files will be scanned for scheduling issues on every commit."
}

do_hooks_uninstall() {
  if ! git rev-parse --git-dir &>/dev/null 2>&1; then
    log_error "Not inside a git repository."
    return 1
  fi

  local repo_root
  repo_root=$(git rev-parse --show-toplevel)
  local config="$repo_root/lefthook.yml"

  if [[ -f "$config" ]]; then
    if grep -q "cronlint" "$config" 2>/dev/null; then
      local tmp
      tmp=$(mktemp)
      sed '/# --- CronLint hooks/,/fail_text:.*$/d' "$config" > "$tmp"
      grep -v "cronlint" "$tmp" > "$config" 2>/dev/null || mv "$tmp" "$config"
      rm -f "$tmp"
      echo -e "${GREEN}+${NC} Removed CronLint hooks"
    else
      log_warn "No CronLint hooks found"
    fi
  else
    log_warn "No lefthook.yml found"
  fi
}

# --- Main scan dispatch -------------------------------------------------------

do_scan() {
  local target="$1"
  local tier_level="$2"
  local format="$3"
  local verbose="$4"
  local category_filter="$5"

  # Source dependencies
  source "$SCRIPT_DIR/patterns.sh"
  source "$SCRIPT_DIR/analyzer.sh"

  # Run the scan
  do_cronlint_scan "$target" "$tier_level" "$format" "$verbose" "$category_filter"
}

# --- CLI Argument Parsing -----------------------------------------------------

main() {
  local scan_path="."
  local format="text"
  local tier_override=""
  local license_key_flag=""
  local verbose="false"
  local category_filter=""
  local command=""

  # Parse arguments
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --path)
        shift
        scan_path="${1:-.}"
        ;;
      --format)
        shift
        format="${1:-text}"
        ;;
      --tier)
        shift
        tier_override="${1:-}"
        ;;
      --license-key)
        shift
        license_key_flag="${1:-}"
        ;;
      --verbose)
        verbose="true"
        ;;
      --category)
        shift
        category_filter="${1:-}"
        ;;
      --help|-h|help)
        show_help
        exit 0
        ;;
      --version|-v)
        show_version
        exit 0
        ;;
      status)
        command="status"
        ;;
      patterns)
        command="patterns"
        ;;
      scan)
        # "scan" is the default; just consume the word
        ;;
      check|audit)
        # Aliases for scan
        ;;
      hooks)
        shift
        local subcmd="${1:-}"
        case "$subcmd" in
          install)
            source "$SCRIPT_DIR/license.sh"
            if [[ -n "$license_key_flag" ]]; then
              export CRONLINT_LICENSE_KEY="$license_key_flag"
            fi
            check_cronlint_license "pro" || exit 1
            do_hooks_install
            exit $?
            ;;
          uninstall)
            do_hooks_uninstall
            exit $?
            ;;
          *)
            log_error "Unknown hooks subcommand: $subcmd"
            echo "  Usage: dispatcher.sh hooks [install|uninstall]"
            exit 1
            ;;
        esac
        ;;
      report)
        shift || true
        local report_target="${1:-.}"
        source "$SCRIPT_DIR/license.sh"
        if [[ -n "$license_key_flag" ]]; then
          export CRONLINT_LICENSE_KEY="$license_key_flag"
        fi
        check_cronlint_license "pro" || exit 1
        source "$SCRIPT_DIR/patterns.sh"
        source "$SCRIPT_DIR/analyzer.sh"
        local tier_num
        tier_num=$(get_tier_level "$(get_cronlint_tier)")
        local categories
        categories=$(get_cronlint_categories_for_tier "$tier_num")
        # Discover and scan files
        local -a files=()
        find_scannable_files "$report_target" files
        for filepath in "${files[@]}"; do
          scan_file "$filepath" "$categories" ""
        done
        generate_markdown_report "$report_target" "${#files[@]}"
        exit 0
        ;;
      -*)
        log_error "Unknown option: $1"
        echo ""
        show_help
        exit 1
        ;;
      *)
        # Treat as path if no command matched
        if [[ -z "$command" && ("$1" == "." || -e "$1") ]]; then
          scan_path="$1"
        else
          log_error "Unknown command: $1"
          echo ""
          show_help
          exit 1
        fi
        ;;
    esac
    shift || true
  done

  # Handle special commands
  case "${command:-}" in
    status)
      do_show_status
      exit 0
      ;;
    patterns)
      do_list_patterns
      exit 0
      ;;
  esac

  # Validate format
  case "$format" in
    text|json|html) ;;
    *)
      log_error "Invalid format: $format. Use text, json, or html."
      exit 1
      ;;
  esac

  # Validate tier override if provided
  if [[ -n "$tier_override" ]]; then
    case "$tier_override" in
      free|pro|team|enterprise) ;;
      *)
        log_error "Invalid tier: $tier_override. Use free, pro, team, or enterprise."
        exit 1
        ;;
    esac
  fi

  # Resolve tier level
  local tier_level
  tier_level=$(resolve_tier "$tier_override" "$license_key_flag")

  # Show banner for text output
  if [[ "$format" == "text" ]]; then
    show_version
    echo ""

    # Show tier info
    local tier_name="Free"
    case "$tier_level" in
      0) tier_name="Free (30 patterns)" ;;
      1) tier_name="Pro (60 patterns)" ;;
      2) tier_name="Team (90 patterns)" ;;
      3) tier_name="Enterprise (90 patterns)" ;;
    esac
    echo -e "  Tier: ${BOLD}$tier_name${NC}"
    echo ""
  fi

  # Run scan
  do_scan "$scan_path" "$tier_level" "$format" "$verbose" "$category_filter"
  exit $?
}

main "$@"


path: scripts/license.sh
content: #!/usr/bin/env bash
# CronLint -- License Validation Module
# Offline JWT-based license validation with 3 base64 decode fallbacks.
# Validates product claim, tier level, and expiry date.

set -euo pipefail

# --- Colors -------------------------------------------------------------------

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'

# --- Configuration ------------------------------------------------------------

OPENCLAW_CONFIG="${HOME}/.openclaw/openclaw.json"
CRONLINT_LICENSE_KEY="${CRONLINT_LICENSE_KEY:-}"

# --- Tier Hierarchy -----------------------------------------------------------
# free=0, pro=1, team=2, enterprise=3
# Higher tier level grants access to more patterns and features.

declare -A TIER_LEVELS=(
  [free]=0
  [pro]=1
  [team]=2
  [enterprise]=3
)

# --- Key Retrieval ------------------------------------------------------------

# Retrieve the CronLint license key from environment variable or config file.
# Priority: ENV var > openclaw.json (python3 > node > jq fallbacks)
get_cronlint_key() {
  # 1. Check environment variable
  if [[ -n "$CRONLINT_LICENSE_KEY" ]]; then
    echo "$CRONLINT_LICENSE_KEY"
    return 0
  fi

  # 2. Check openclaw.json config file
  if [[ -f "$OPENCLAW_CONFIG" ]]; then
    local key=""

    # Try python3 first
    if command -v python3 &>/dev/null; then
      key=$(python3 -c "
import json
try:
    with open('$OPENCLAW_CONFIG') as f:
        cfg = json.load(f)
    print(cfg.get('skills', {}).get('entries', {}).get('cronlint', {}).get('apiKey', ''))
except: pass
" 2>/dev/null)

    # Try node second
    elif command -v node &>/dev/null; then
      key=$(node -e "
try {
  const cfg = require('$OPENCLAW_CONFIG');
  console.log(cfg?.skills?.entries?.cronlint?.apiKey || '');
} catch(e) {}
" 2>/dev/null)

    # Try jq third
    elif command -v jq &>/dev/null; then
      key=$(jq -r '.skills.entries.cronlint.apiKey // ""' "$OPENCLAW_CONFIG" 2>/dev/null)
    fi

    if [[ -n "${key:-}" ]]; then
      echo "$key"
      return 0
    fi
  fi

  return 1
}

# --- JWT Decode ---------------------------------------------------------------

# Decode the payload (second segment) of a JWT token.
# Uses 3 fallback methods for base64 decoding:
#   1. base64 -d   (GNU/Linux coreutils)
#   2. base64 -D   (BSD/macOS)
#   3. openssl base64 -d  (universal fallback)
#
# Handles URL-safe base64 (tr '_-' '/+') and padding normalization.
decode_jwt_payload() {
  local token="$1"

  # --- JWT Structure Validation ---
  # A valid JWT must have exactly 3 dot-separated parts: header.payload.signature
  local dot_count
  dot_count=$(echo "$token" | tr -cd '.' | wc -c)
  if [[ "$dot_count" -ne 2 ]]; then
    echo "[JWT] Malformed [REDACTED_SECRET] 3 parts (header.payload.signature), got $((dot_count + 1))" >&2
    return 1
  fi

  local header payload signature
  header=$(echo "$token" | cut -d. -f1)
  payload=$(echo "$token" | cut -d. -f2)
  signature=$(echo "$token" | cut -d. -f3)

  # Signature must be non-empty
  if [[ -z "$signature" ]]; then
    echo "[JWT] Rejected: signature segment is empty (unsigned token)" >&2
    return 1
  fi

  # --- Signature Verification (HMAC-SHA256 via openssl, if available) ---
  # NOTE: Full cryptographic verification requires the shared secret. When
  # CLAWHUB_JWT_SECRET is set, we verify the HMAC-SHA256 signature. Without
  # the secret, we still enforce structural integrity (3-part, non-empty sig)
  # which blocks trivially forged tokens (e.g., alg:none attacks).
  if [[ -n "${CLAWHUB_JWT_SECRET:-}" ]] && command -v openssl &>/dev/null; then
    local signing_input="${header}.${payload}"
    local expected_sig
    expected_sig=$(printf '%s' "$signing_input" \
      | openssl dgst -sha256 -hmac "$CLAWHUB_JWT_SECRET" -binary 2>/dev/null \
      | openssl base64 -e 2>/dev/null \
      | tr '/+' '_-' | tr -d '=')
    if [[ "$expected_sig" != "$signature" ]]; then
      echo "[JWT] Signature verification failed" >&2
      return 1
    fi
  fi

  # --- Decode Payload ---
  local padded="$payload"
  local mod=$((${#payload} % 4))
  if [[ $mod -eq 2 ]]; then padded="${payload}=="; fi
  if [[ $mod -eq 3 ]]; then padded="${payload}="; fi

  local decoded
  decoded=$(echo "$padded" | tr '_-' '/+' | base64 -d 2>/dev/null || \
            echo "$padded" | tr '_-' '/+' | base64 -D 2>/dev/null || \
            echo "$padded" | tr '_-' '/+' | openssl base64 -d 2>/dev/null) || {
    echo "[JWT] Failed to decode payload" >&2
    return 1
  }

  # --- Expiry Check ---
  # Check exp claim inline to catch expired tokens at decode time
  local exp_val=""
  if command -v python3 &>/dev/null; then
    exp_val=$(python3 -c "import json; print(json.loads('$decoded').get('exp', ''))" 2>/dev/null || true)
  elif command -v node &>/dev/null; then
    exp_val=$(node -e "console.log(JSON.parse('$decoded').exp || '')" 2>/dev/null || true)
  elif command -v jq &>/dev/null; then
    exp_val=$(echo "$decoded" | jq -r '.exp // ""' 2>/dev/null || true)
  fi

  if [[ -n "$exp_val" && "$exp_val" != "null" ]]; then
    local now
    now=$(date +%s)
    if [[ "$now" -gt "$exp_val" ]]; then
      echo "[JWT] Token expired (exp=$exp_val, now=$now)" >&2
      return 1
    fi
  fi

  echo "$decoded"
}

# --- Field Extraction ---------------------------------------------------------

# Extract a single field from a JSON string.
# Uses python3 > node > jq > grep fallbacks.
extract_field() {
  local json="$1" field="$2"

  if command -v python3 &>/dev/null; then
    python3 -c "import json; print(json.loads('$json').get('$field', ''))" 2>/dev/null
  elif command -v node &>/dev/null; then
    node -e "console.log(JSON.parse('$json')['$field'] || '')" 2>/dev/null
  elif command -v jq &>/dev/null; then
    echo "$json" | jq -r ".$field // \"\"" 2>/dev/null
  else
    # Last resort: simple grep extraction
    echo "$json" | grep -oP "\"$field\"\s*:\s*\"?\K[^\",$}]+" 2>/dev/null || echo ""
  fi
}

# --- License Validation -------------------------------------------------------

# Validate a license key by decoding its JWT payload and checking the product
# field. Returns the decoded payload JSON on success.
validate_license() {
  local key="$1"
  local payload

  if ! payload=$(decode_jwt_payload "$key"); then
    return 1
  fi

  local product
  product=$(extract_field "$payload" "product")

  # Validate product matches cronlint
  if [[ "$product" != "cronlint" && -n "$product" ]]; then
    return 1
  fi

  echo "$payload"
  return 0
}

# --- Full License Check -------------------------------------------------------

# Perform a complete license check for a required tier.
# Validates: key presence, JWT decode, product match, tier level, expiry.
# Prints user-friendly error messages on failure.
check_cronlint_license() {
  local required_tier="${1:-pro}"
  local required_level="${TIER_LEVELS[$required_tier]:-1}"

  # Step 1: Retrieve the license key
  local key
  if ! key=$(get_cronlint_key) || [[ -z "$key" ]]; then
    echo -e "${RED}${BOLD}--- CronLint License Required ---${NC}"
    echo ""
    echo -e "This feature requires a ${BOLD}${required_tier}${NC} license."
    echo ""
    echo -e "Get your license at: ${CYAN}${BOLD}https://cronlint.pages.dev/pricing${NC}"
    echo ""
    echo "Then configure it:"
    echo -e "  ${DIM}# In ~/.openclaw/openclaw.json:${NC}"
    echo -e "  ${CYAN}\"skills\": { \"entries\": { \"cronlint\": { \"apiKey\": \"YOUR_KEY\" } } }${NC}"
    echo ""
    echo -e "  ${DIM}# Or set the environment variable:${NC}"
    echo -e "  ${CYAN}export CRONLINT_LICENSE_KEY=\"YOUR_KEY\"${NC}"
    return 1
  fi

  # Step 2: Decode JWT payload
  local payload
  if ! payload=$(decode_jwt_payload "$key"); then
    echo -e "${RED}[CronLint]${NC} Invalid license key format."
    echo -e "Get a valid license at: ${CYAN}https://cronlint.pages.dev/pricing${NC}"
    return 1
  fi

  # Step 3: Extract and validate fields
  local product tier expiry
  product=$(extract_field "$payload" "product")
  tier=$(extract_field "$payload" "tier")
  expiry=$(extract_field "$payload" "exp")

  # Step 4: Validate product
  if [[ -n "$product" && "$product" != "cronlint" ]]; then
    echo -e "${RED}[CronLint]${NC} License is for product '${BOLD}$product${NC}', not cronlint."
    echo -e "Get a CronLint license at: ${CYAN}https://cronlint.pages.dev/pricing${NC}"
    return 1
  fi

  # Step 5: Validate tier level
  local actual_level="${TIER_LEVELS[$tier]:-0}"
  if [[ "$actual_level" -lt "$required_level" ]]; then
    echo -e "${RED}[CronLint]${NC} This feature requires ${BOLD}$required_tier${NC} tier. You have: ${BOLD}$tier${NC}"
    echo -e "Upgrade at: ${CYAN}https://cronlint.pages.dev/upgrade${NC}"
    return 1
  fi

  # Step 6: Check expiry
  if [[ -n "$expiry" ]]; then
    local now
    now=$(date +%s)
    if [[ "$now" -gt "$expiry" ]]; then
      echo -e "${RED}[CronLint]${NC} License expired. Renew at: ${CYAN}https://cronlint.pages.dev/renew${NC}"
      return 1
    fi
  fi

  return 0
}

# --- Tier Detection -----------------------------------------------------------

# Get the current tier from the license key.
# Returns "free" if no valid key is found.
get_cronlint_tier() {
  local key
  if ! key=$(get_cronlint_key) || [[ -z "$key" ]]; then
    echo "free"
    return 0
  fi

  local payload
  if ! payload=$(decode_jwt_payload "$key" 2>/dev/null); then
    echo "free"
    return 0
  fi

  local tier
  tier=$(extract_field "$payload" "tier")
  echo "${tier:-free}"
}

# --- Tier Level Lookup --------------------------------------------------------

# Convert a tier name to its numeric level for comparison.
get_tier_level() {
  local tier="${1:-free}"
  echo "${TIER_LEVELS[$tier]:-0}"
}

# --- Status Display -----------------------------------------------------------

# Display license status information to the user.
show_cronlint_status() {
  local key
  if ! key=$(get_cronlint_key) || [[ -z "$key" ]]; then
    echo -e "License: ${YELLOW}Free tier${NC}"
    echo -e "  Patterns: 30 (OE, TZ categories)"
    echo -e "  Upgrade at: ${CYAN}https://cronlint.pages.dev/pricing${NC}"
    return
  fi

  local payload tier expiry seats product
  payload=$(decode_jwt_payload "$key" 2>/dev/null) || {
    echo -e "License: ${RED}Invalid key${NC}"
    return
  }
  product=$(extract_field "$payload" "product")
  tier=$(extract_field "$payload" "tier")
  expiry=$(extract_field "$payload" "exp")
  seats=$(extract_field "$payload" "seats")

  # Validate product
  if [[ -n "$product" && "$product" != "cronlint" ]]; then
    echo -e "License: ${RED}Wrong product ($product)${NC}"
    echo -e "  This key is for $product, not CronLint."
    echo -e "  Get a CronLint license at: ${CYAN}https://cronlint.pages.dev/pricing${NC}"
    return
  fi

  # Determine status
  local now status_color status_text
  now=$(date +%s)
  status_color="$GREEN"; status_text="Active"
  if [[ -n "$expiry" && "$now" -gt "$expiry" ]]; then
    status_color="$RED"; status_text="Expired"
  fi

  # Format expiry date
  local expiry_date="Never"
  if [[ -n "$expiry" ]]; then
    expiry_date=$(date -d "@$expiry" +%Y-%m-%d 2>/dev/null || \
                  date -r "$expiry" +%Y-%m-%d 2>/dev/null || \
                  echo "unknown")
  fi

  echo -e "License: ${status_color}${BOLD}${tier^^}${NC} (${status_color}${status_text}${NC})"
  echo -e "  Product: cronlint"
  echo -e "  Seats: ${seats:-unlimited}"
  echo -e "  Expires: $expiry_date"
  echo ""

  case "$tier" in
    pro)
      echo -e "  Patterns: 60 (OE, TZ, ER, RC categories)"
      echo -e "  Formats: text, json, html"
      echo -e "  Upgrade to Team for: LM, OB categories + full 90 patterns"
      ;;
    team)
      echo -e "  Patterns: 90 (all 6 categories)"
      echo -e "  Formats: text, json, html"
      echo -e "  All features available"
      ;;
    enterprise)
      echo -e "  Patterns: 90 (all 6 categories)"
      echo -e "  Formats: text, json, html"
      echo -e "  All features + priority support"
      ;;
  esac
}


path: scripts/scan.sh
content: #!/usr/bin/env bash
# scan.sh — Minimal wrapper that sources patterns and runs the scan
# Generated by generate-skills.sh — do not edit manually
#
# Usage: bash scan.sh [file|directory]
#   Defaults to scanning current directory if no argument given.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Source patterns for cronlint
source "$SCRIPT_DIR/patterns.sh"

# Delegate to main entry point
exec bash "$SCRIPT_DIR/dispatcher.sh" scan "${@:-.}"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)