← back to Checkup

scripts/checkup-audit.sh

173 lines

#!/usr/bin/env bash
# checkup-audit.sh — READ-ONLY environment audit for Claude Code.
# Gathers the raw data a /checkup needs, changes NOTHING. All interpretation
# and any change proposals are done by Claude reading this output against
# SKILL.md. Safe to run any time.
#
# Usage:
#   bash checkup-audit.sh              # audit ~/.claude + current project
#   bash checkup-audit.sh --deep       # also scan session logs for skill usage (slower)
#
# Output is a plain sectioned report on stdout. Exit code is always 0 unless a
# path is fundamentally wrong; findings are not errors.

set -uo pipefail

CLAUDE_HOME="${CLAUDE_HOME:-$HOME/.claude}"
DEEP=0
[ "${1:-}" = "--deep" ] && DEEP=1

# Thresholds (kept in one place so they're easy to tune)
ROOT_CLAUDEMD_WARN_BYTES=$((12 * 1024))   # root CLAUDE.md over ~12KB → recommend nesting
MEMORY_LIMIT_BYTES=$((24 * 1024))          # MEMORY.md soft limit the harness warns at
IDX_LINE_WARN_CHARS=200                     # MEMORY.md index line too long

hr(){ printf '\n=== %s ===\n' "$1"; }
kb(){ awk -v b="$1" 'BEGIN{printf "%.1fKB", b/1024}'; }
have(){ command -v "$1" >/dev/null 2>&1; }

echo "CHECKUP AUDIT  —  $(uname -n)  —  CLAUDE_HOME=$CLAUDE_HOME"
echo "(read-only; nothing changed)"

# ---------------------------------------------------------------------------
hr "CLAUDE CODE VERSION"
CUR="$(claude --version 2>/dev/null | awk '{print $1}')"
echo "installed: ${CUR:-unknown}"
if have npm; then
  LATEST="$(timeout 12 npm view @anthropic-ai/claude-code version 2>/dev/null)"
  echo "latest(npm): ${LATEST:-lookup-failed}"
  if [ -n "${LATEST:-}" ] && [ -n "${CUR:-}" ] && [ "$LATEST" != "$CUR" ]; then
    echo "FINDING: update available ($CUR -> $LATEST)  [GATED: version update]"
  fi
else
  echo "latest(npm): npm not on PATH — skip version check"
fi

# ---------------------------------------------------------------------------
hr "ROOT / GLOBAL CLAUDE.md (bloat → nest into sub-CLAUDE.md + skills)"
for f in "$CLAUDE_HOME/CLAUDE.md" "$PWD/CLAUDE.md" "$PWD/.claude/CLAUDE.md"; do
  [ -f "$f" ] || continue
  b=$(wc -c <"$f")
  flag=""
  [ "$b" -gt "$ROOT_CLAUDEMD_WARN_BYTES" ] && flag="  <-- FINDING: large, consider nesting  [AUTO w/ confirm: split]"
  printf '  %-55s %s%s\n' "$f" "$(kb "$b")" "$flag"
done

# ---------------------------------------------------------------------------
hr "LOCAL vs CHECKED-IN CLAUDE.md (dedup)"
# Local overrides live in CLAUDE.local.md (gitignored). Compare against the
# committed CLAUDE.md so duplicated lines can be pruned from the local copy.
if [ -f "$PWD/CLAUDE.local.md" ] && [ -f "$PWD/CLAUDE.md" ]; then
  dup=$(grep -Fxf "$PWD/CLAUDE.md" "$PWD/CLAUDE.local.md" 2>/dev/null | grep -c . )
  echo "  CLAUDE.local.md shares $dup identical line(s) with CLAUDE.md  [AUTO w/ confirm: prune dups from local]"
else
  echo "  no CLAUDE.local.md + CLAUDE.md pair in $PWD — nothing to dedup"
fi

# ---------------------------------------------------------------------------
hr "MEMORY.md (auto-memory index) SIZE"
# Find every MEMORY.md under the projects memory dirs.
while IFS= read -r m; do
  [ -f "$m" ] || continue
  b=$(wc -c <"$m")
  lines=$(grep -c '^- \[' "$m" 2>/dev/null || echo 0)
  longest=$(awk '{ if(length>L){L=length} } END{print L+0}' "$m")
  flag=""
  [ "$b" -gt "$MEMORY_LIMIT_BYTES" ] && flag="  <-- FINDING: over ${MEMORY_LIMIT_BYTES}B limit  [AUTO w/ confirm: trim index]"
  printf '  %s  %s  (%s index lines, longest line %s chars)%s\n' "$m" "$(kb "$b")" "$lines" "$longest" "$flag"
  if [ "$longest" -gt "$IDX_LINE_WARN_CHARS" ]; then
    echo "    -> index lines exceed ${IDX_LINE_WARN_CHARS} chars; move detail into topic files, keep index one-line."
  fi
done < <(find "$CLAUDE_HOME/projects" -maxdepth 3 -name MEMORY.md 2>/dev/null)

# ---------------------------------------------------------------------------
hr "SKILLS (unused → archive to save context)"
SK="$CLAUDE_HOME/skills"
if [ -d "$SK" ]; then
  total=$(find "$SK" -maxdepth 1 -mindepth 1 -type d | wc -l | tr -d ' ')
  echo "  $total skill directories in $SK"
  echo "  10 least-recently-modified skill dirs (staleness proxy):"
  # Portable mtime sort (macOS stat -f, Linux stat -c fallback)
  if stat -f '%m %N' "$SK" >/dev/null 2>&1; then
    find "$SK" -maxdepth 1 -mindepth 1 -type d -exec stat -f '%m %N' {} \; 2>/dev/null \
      | sort -n | head -10 | while read -r ts path; do
          printf '    %s  %s\n' "$(date -r "$ts" '+%Y-%m-%d' 2>/dev/null)" "$(basename "$path")"; done
  else
    find "$SK" -maxdepth 1 -mindepth 1 -type d -printf '%T@ %p\n' 2>/dev/null \
      | sort -n | head -10 | while read -r ts path; do
          printf '    %s  %s\n' "$(date -d "@${ts%.*}" '+%Y-%m-%d' 2>/dev/null)" "$(basename "$path")"; done
  fi
  echo "  NOTE: mtime is a weak signal. Confirm with Steve before archiving any skill."
  if [ "$DEEP" = 1 ]; then
    echo "  --deep: skill names never seen in session logs (last 30d):"
    logs=$(find "$CLAUDE_HOME/projects" -name '*.jsonl' -mtime -30 2>/dev/null)
    if [ -n "$logs" ]; then
      for d in "$SK"/*/; do
        n=$(basename "$d")
        if ! grep -qhl "\"$n\"" $logs 2>/dev/null && ! grep -qh "/$n\b" $logs 2>/dev/null; then
          echo "    (never-invoked?) $n"
        fi
      done | head -40
      echo "    ...(truncated to 40; treat as CANDIDATES not proof)"
    else
      echo "    no recent session logs found"
    fi
  else
    echo "  (run with --deep to cross-check against 30d of session logs)"
  fi
else
  echo "  no skills dir at $SK"
fi

# ---------------------------------------------------------------------------
hr "MCP SERVERS (unused → remove to save context/startup)"
CJSON="$HOME/.claude.json"
if [ -f "$CJSON" ] && have jq; then
  echo "  top-level mcpServers:"
  jq -r '.mcpServers // {} | keys[]' "$CJSON" 2>/dev/null | sed 's/^/    /' || echo "    (none / parse failed)"
  echo "  per-project mcpServers counts:"
  jq -r '(.projects // {}) | to_entries[] | "    \(.key): \((.value.mcpServers // {}) | length) server(s)"' "$CJSON" 2>/dev/null | head -20
else
  echo "  $CJSON missing or jq not installed — inspect MCP config manually"
fi

# ---------------------------------------------------------------------------
hr "HOOKS (slow → disable)  [GATED: settings.json is classifier-blocked for Claude]"
for s in "$CLAUDE_HOME/settings.json" "$CLAUDE_HOME/settings.local.json"; do
  [ -f "$s" ] || continue
  echo "  $s ($(kb "$(wc -c <"$s")")):"
  if have jq; then
    jq -r '(.hooks // {}) | to_entries[] | "    \(.key): \(.value | length) matcher-group(s)"' "$s" 2>/dev/null || echo "    (no hooks / parse failed)"
    echo "    hook commands (eyeball for slow: loops, network, big scans):"
    jq -r '[.hooks // {} | .. | .command? // empty] | unique[]' "$s" 2>/dev/null | sed 's/^/      /' | head -30
  else
    grep -o '"command"[^,]*' "$s" 2>/dev/null | head -20 | sed 's/^/    /'
  fi
done
echo "  NOTE: '~/.claude/hooks-lite' (streamline mode) already exists to suppress slow SessionStart hooks."

# ---------------------------------------------------------------------------
hr "AUTO MODE + PERMISSIONS"
if have jq; then
  am=$(jq -r '(.autoMode // .permissions.defaultMode // "unset") | if type=="object" or type=="array" then "unset" else tostring end' "$CLAUDE_HOME/settings.json" 2>/dev/null)
  echo "  settings.json autoMode/defaultMode: ${am:-unset}   [GATED: settings.json]"
  allow=$(jq -r 'try ((.permissions.allow // []) | length)' "$CLAUDE_HOME/settings.json" 2>/dev/null)
  echo "  permissions.allow entries: ${allow:-0}"
fi
echo "  To pre-approve frequently-DENIED read-only commands, run the existing"
echo "  /fewer-permission-prompts skill — it scans session history and proposes an allowlist."

# ---------------------------------------------------------------------------
hr "STEVE GOODIES — keepawake + launchd health"
launchctl list 2>/dev/null | grep -q com.steve.keepawake \
  && echo "  keepawake guard: ALIVE" || echo "  keepawake guard: MISSING  [FINDING: repair per CLAUDE.md]"
ss=$(defaults -currentHost read com.apple.screensaver idleTime 2>/dev/null || echo unset)
echo "  screensaver idleTime: $ss  (expect 0)"
echo "  launchd com.steve.* jobs with non-zero last exit code:"
launchctl list 2>/dev/null | awk '$1!="-" && $2!="0" && $3 ~ /^com\.steve\./ {printf "    %s  exit=%s\n",$3,$2}' | head -20
echo "    (none listed above = all clean)"

hr "END OF AUDIT"
echo "Next: Claude reads this against SKILL.md, groups findings AUTO vs GATED,"
echo "confirms with Steve, applies AUTO items (git-committed), surfaces GATED as paste blocks."