← 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."