Created
January 9, 2026 02:21
-
-
Save dan-palmer/3eb1bb47da8144f8c96c2feeac205998 to your computer and use it in GitHub Desktop.
slop-usage: Track AI spending across Claude Code, Factory Droid, and OpenAI Codex
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # AI Slop Usage Tracker | |
| # Aggregates usage from Claude Code, Factory Droid, and OpenAI Codex | |
| # | |
| # Usage: slop-usage [--daily|--monthly] [--current] | |
| set -e | |
| # Colors | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| BOLD='\033[1m' | |
| NC='\033[0m' | |
| # Parse arguments | |
| PERIOD="daily" | |
| CURRENT_ONLY=false | |
| TODAY_ONLY=false | |
| for arg in "$@"; do | |
| case "$arg" in | |
| --daily) PERIOD="daily" ;; | |
| --weekly) PERIOD="weekly" ;; | |
| --monthly) PERIOD="monthly" ;; | |
| --current) CURRENT_ONLY=true ;; | |
| --today) TODAY_ONLY=true; PERIOD="daily" ;; | |
| --help|-h) | |
| echo "Usage: slop-usage [--daily|--weekly|--monthly] [--current|--today]" | |
| echo "" | |
| echo "Options:" | |
| echo " --daily Show usage grouped by day (default)" | |
| echo " --weekly Show usage grouped by week (Monday start)" | |
| echo " --monthly Show usage grouped by month" | |
| echo " --current Only show current period (this week/month)" | |
| echo " --today Only show today's usage (from midnight)" | |
| exit 0 | |
| ;; | |
| *) | |
| echo "Unknown option: $arg" | |
| echo "Usage: slop-usage [--daily|--weekly|--monthly] [--current|--today]" | |
| exit 1 | |
| ;; | |
| esac | |
| done | |
| # Prompt for confirmation | |
| confirm() { | |
| local msg="$1" | |
| echo -en "${YELLOW}$msg [y/N] ${NC}" | |
| read -r response | |
| [[ "$response" =~ ^[Yy]$ ]] | |
| } | |
| # Check dependencies | |
| check_deps() { | |
| local missing=() | |
| local install_cmds=() | |
| if ! command -v npx &>/dev/null; then | |
| echo -e "${RED}Error: npx not found. Please install Node.js first:${NC}" | |
| echo " brew install node # macOS" | |
| echo " apt install nodejs # Ubuntu/Debian" | |
| exit 1 | |
| fi | |
| if ! command -v jq &>/dev/null; then | |
| missing+=("jq") | |
| if command -v brew &>/dev/null; then | |
| install_cmds+=("brew install jq") | |
| elif command -v apt-get &>/dev/null; then | |
| install_cmds+=("sudo apt-get install -y jq") | |
| fi | |
| fi | |
| if ! command -v bc &>/dev/null; then | |
| missing+=("bc") | |
| if command -v brew &>/dev/null; then | |
| install_cmds+=("brew install bc") | |
| elif command -v apt-get &>/dev/null; then | |
| install_cmds+=("sudo apt-get install -y bc") | |
| fi | |
| fi | |
| if [[ ${#missing[@]} -gt 0 ]]; then | |
| echo -e "${YELLOW}Missing dependencies: ${missing[*]}${NC}" | |
| if [[ ${#install_cmds[@]} -gt 0 ]]; then | |
| if confirm "Install missing dependencies?"; then | |
| for cmd in "${install_cmds[@]}"; do | |
| echo -e "${BLUE}Running: $cmd${NC}" | |
| eval "$cmd" | |
| done | |
| else | |
| echo -e "${RED}Cannot continue without dependencies.${NC}" | |
| exit 1 | |
| fi | |
| else | |
| echo -e "${RED}Please install manually: ${missing[*]}${NC}" | |
| exit 1 | |
| fi | |
| fi | |
| } | |
| # Setup Droid sessions symlinks if needed | |
| setup_droid_sessions() { | |
| local FACTORY_DIR="${HOME}/.factory" | |
| local SESSIONS_DIR="${FACTORY_DIR}/sessions" | |
| local DATA_DIR="${FACTORY_DIR}/sessions-data" | |
| [[ -d "$FACTORY_DIR" ]] || return 0 | |
| [[ -d "$SESSIONS_DIR" ]] || return 0 | |
| # If sessions-data doesn't exist, check if migration is needed | |
| if [[ ! -d "$DATA_DIR" ]]; then | |
| if find "$SESSIONS_DIR" -mindepth 2 -name "*.jsonl" -print -quit 2>/dev/null | grep -q .; then | |
| echo -e "${YELLOW}Setting up Droid sessions for better-ccusage compatibility...${NC}" | |
| mv "$SESSIONS_DIR" "$DATA_DIR" | |
| mkdir "$SESSIONS_DIR" | |
| for f in "$DATA_DIR"/*/*.jsonl "$DATA_DIR"/*/*.settings.json; do | |
| [[ -e "$f" ]] || continue | |
| rel_path="${f#$DATA_DIR/}" | |
| ln -s "../sessions-data/$rel_path" "$SESSIONS_DIR/$(basename "$f")" 2>/dev/null || true | |
| done | |
| for dir in "$DATA_DIR"/*/; do | |
| [[ -d "$dir" ]] || continue | |
| dirname=$(basename "$dir") | |
| ln -s "../sessions-data/$dirname" "$SESSIONS_DIR/$dirname" 2>/dev/null || true | |
| done | |
| echo -e "${GREEN}Droid sessions migrated.${NC}" | |
| fi | |
| else | |
| # Sync any new sessions that don't have symlinks yet | |
| for f in "$DATA_DIR"/*/*.jsonl "$DATA_DIR"/*/*.settings.json; do | |
| [[ -e "$f" ]] || continue | |
| base=$(basename "$f") | |
| if [[ ! -L "$SESSIONS_DIR/$base" ]]; then | |
| rel_path="${f#$DATA_DIR/}" | |
| ln -s "../sessions-data/$rel_path" "$SESSIONS_DIR/$base" 2>/dev/null || true | |
| fi | |
| done | |
| fi | |
| } | |
| # Main | |
| check_deps | |
| setup_droid_sessions | |
| # Get today's date in YYYY-MM-DD format | |
| TODAY_DATE=$(date +%Y-%m-%d) | |
| THIS_MONTH=$(date +%Y-%m) | |
| # Get Sunday of current week (week start - tools use Sunday-based weeks) | |
| day_of_week=$(date +%u) # 1=Monday, 7=Sunday | |
| if [[ "$day_of_week" -eq 7 ]]; then | |
| THIS_WEEK="$TODAY_DATE" | |
| else | |
| THIS_WEEK=$(date -v-${day_of_week}d +%Y-%m-%d 2>/dev/null || date -d "$day_of_week days ago" +%Y-%m-%d 2>/dev/null || echo "$TODAY_DATE") | |
| fi | |
| if $TODAY_ONLY; then | |
| echo -e "\n${BOLD}AI Slop Usage (today - ${TODAY_DATE})${NC}\n" | |
| elif $CURRENT_ONLY; then | |
| if [[ "$PERIOD" == "weekly" ]]; then | |
| echo -e "\n${BOLD}AI Slop Usage (this week - ${THIS_WEEK})${NC}\n" | |
| else | |
| echo -e "\n${BOLD}AI Slop Usage (this month - ${THIS_MONTH})${NC}\n" | |
| fi | |
| else | |
| echo -e "\n${BOLD}AI Slop Usage (${PERIOD})${NC}\n" | |
| fi | |
| echo "Fetching usage data..." | |
| # Temp files for parallel execution | |
| tmp_claude=$(mktemp) | |
| tmp_droid=$(mktemp) | |
| tmp_codex=$(mktemp) | |
| trap "rm -f $tmp_claude $tmp_droid $tmp_codex" EXIT | |
| # Get system timezone | |
| TZ_NAME=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||' || echo "UTC") | |
| # Check which tools have data available | |
| HAS_CLAUDE=false | |
| HAS_DROID=false | |
| HAS_CODEX=false | |
| [[ -d "${HOME}/.claude" ]] && HAS_CLAUDE=true | |
| [[ -d "${HOME}/.factory/sessions" ]] && HAS_DROID=true | |
| [[ -d "${HOME}/.codex" ]] && HAS_CODEX=true | |
| # Run available tools in parallel with timezone | |
| if $HAS_CLAUDE; then | |
| npx --yes ccusage@latest "$PERIOD" --json --timezone "$TZ_NAME" 2>/dev/null > "$tmp_claude" & | |
| pid1=$! | |
| else | |
| echo '{}' > "$tmp_claude" | |
| pid1="" | |
| fi | |
| if $HAS_DROID; then | |
| npx --yes better-ccusage@latest "$PERIOD" --json --timezone "$TZ_NAME" 2>/dev/null > "$tmp_droid" & | |
| pid2=$! | |
| else | |
| echo '{}' > "$tmp_droid" | |
| pid2="" | |
| fi | |
| if $HAS_CODEX && [[ "$PERIOD" != "weekly" ]]; then | |
| # Codex doesn't support weekly | |
| npx --yes @ccusage/codex "$PERIOD" --json --timezone "$TZ_NAME" 2>/dev/null > "$tmp_codex" & | |
| pid3=$! | |
| else | |
| echo '{}' > "$tmp_codex" | |
| pid3="" | |
| fi | |
| # Wait for running processes | |
| [[ -n "$pid1" ]] && wait $pid1 | |
| [[ -n "$pid2" ]] && wait $pid2 | |
| [[ -n "$pid3" ]] && wait $pid3 | |
| # Show which sources are available | |
| sources=() | |
| $HAS_CLAUDE && sources+=("Claude Code") | |
| $HAS_DROID && sources+=("Factory Droid") | |
| $HAS_CODEX && [[ "$PERIOD" != "weekly" ]] && sources+=("OpenAI Codex") | |
| if [[ ${#sources[@]} -eq 0 ]]; then | |
| echo -e "${RED}No usage data found. Install Claude Code, Factory Droid, or OpenAI Codex first.${NC}" | |
| exit 1 | |
| fi | |
| echo -e "${BLUE}Sources: ${sources[*]}${NC}" | |
| # Get date field name based on period | |
| case "$PERIOD" in | |
| monthly) DATE_FIELD="month" ;; | |
| weekly) DATE_FIELD="week" ;; | |
| *) DATE_FIELD="date" ;; | |
| esac | |
| # Build combined data with jq | |
| # Normalize codex dates (e.g., "Jan 2026" -> "2026-01") and merge all sources | |
| if $TODAY_ONLY || $CURRENT_ONLY; then | |
| # For --today: filter to today's date specifically | |
| # For --current: get last entry (this month) | |
| if $TODAY_ONLY; then | |
| filter_date="$TODAY_DATE" | |
| # Get entry matching today's date, or null | |
| claude_cost=$(jq -r "[.[\"$PERIOD\"] // [] | .[] | select(.date == \"$filter_date\")] | .[0].totalCost // 0" "$tmp_claude" 2>/dev/null || echo 0) | |
| droid_cost=$(jq -r "[.[\"$PERIOD\"] // [] | .[] | select(.date == \"$filter_date\")] | .[0].totalCost // 0" "$tmp_droid" 2>/dev/null || echo 0) | |
| # Codex uses different date format, need to check both | |
| codex_cost=$(jq -r "[.[\"$PERIOD\"] // [] | .[] | select(.date == \"$filter_date\" or .date == \"$(date +'%b %d, %Y')\")] | .[0].costUSD // 0" "$tmp_codex" 2>/dev/null || echo 0) | |
| period_label="$TODAY_DATE" | |
| elif [[ "$PERIOD" == "weekly" ]]; then | |
| filter_date="$THIS_WEEK" | |
| # Get entry for this week | |
| claude_cost=$(jq -r "[.[\"$PERIOD\"] // [] | .[] | select(.week == \"$filter_date\")] | .[0].totalCost // 0" "$tmp_claude" 2>/dev/null || echo 0) | |
| droid_cost=$(jq -r "[.[\"$PERIOD\"] // [] | .[] | select(.week == \"$filter_date\")] | .[0].totalCost // 0" "$tmp_droid" 2>/dev/null || echo 0) | |
| codex_cost=$(jq -r "[.[\"$PERIOD\"] // [] | .[] | select(.week == \"$filter_date\")] | .[0].costUSD // 0" "$tmp_codex" 2>/dev/null || echo 0) | |
| period_label="$THIS_WEEK" | |
| else | |
| filter_date="$THIS_MONTH" | |
| # Get last entry for this month | |
| claude_cost=$(jq -r "[.[\"$PERIOD\"] // [] | .[] | select(.month == \"$filter_date\")] | .[0].totalCost // 0" "$tmp_claude" 2>/dev/null || echo 0) | |
| droid_cost=$(jq -r "[.[\"$PERIOD\"] // [] | .[] | select(.month == \"$filter_date\")] | .[0].totalCost // 0" "$tmp_droid" 2>/dev/null || echo 0) | |
| # Codex uses different month format like "Jan 2026" | |
| codex_month=$(date +'%b %Y') | |
| codex_cost=$(jq -r "[.[\"$PERIOD\"] // [] | .[] | select(.month == \"$filter_date\" or .month == \"$codex_month\")] | .[0].costUSD // 0" "$tmp_codex" 2>/dev/null || echo 0) | |
| period_label="$THIS_MONTH" | |
| fi | |
| total=$(echo "$claude_cost + $droid_cost + $codex_cost" | bc -l 2>/dev/null || echo "0") | |
| echo "" | |
| printf "${BOLD}%-14s %12s %14s %13s %12s${NC}\n" "Period" "Claude Code" "Factory Droid" "OpenAI Codex" "Total" | |
| printf "%-14s %12s %14s %13s %12s\n" "--------------" "------------" "--------------" "-------------" "------------" | |
| printf "%-14s %11.2f %13.2f %12.2f %11.2f\n" "$period_label" "$claude_cost" "$droid_cost" "$codex_cost" "$total" | |
| echo "" | |
| else | |
| # Normalize dates to YYYY-MM-DD format for merging | |
| # Claude/Droid use: 2025-12-08 (daily) or 2025-12 (monthly) | |
| # Codex uses: Dec 08, 2025 (daily) or Jan 2026 (monthly) | |
| normalize_jq=' | |
| def normalize_date: | |
| if test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$") then . | |
| elif test("^[0-9]{4}-[0-9]{2}$") then . | |
| elif test("^[A-Z][a-z]{2} [0-9]{2}, [0-9]{4}$") then | |
| capture("^(?<m>[A-Z][a-z]{2}) (?<d>[0-9]{2}), (?<y>[0-9]{4})$") | | |
| {"Jan":"01","Feb":"02","Mar":"03","Apr":"04","May":"05","Jun":"06", | |
| "Jul":"07","Aug":"08","Sep":"09","Oct":"10","Nov":"11","Dec":"12"}[.m] as $mm | | |
| "\(.y)-\($mm)-\(.d)" | |
| elif test("^[A-Z][a-z]{2} [0-9]{4}$") then | |
| capture("^(?<m>[A-Z][a-z]{2}) (?<y>[0-9]{4})$") | | |
| {"Jan":"01","Feb":"02","Mar":"03","Apr":"04","May":"05","Jun":"06", | |
| "Jul":"07","Aug":"08","Sep":"09","Oct":"10","Nov":"11","Dec":"12"}[.m] as $mm | | |
| "\(.y)-\($mm)" | |
| else . | |
| end; | |
| [.[] | {key: (.'$DATE_FIELD' | normalize_date), value: .COST_FIELD}] | from_entries | |
| ' | |
| claude_json=$(jq -c "${normalize_jq/COST_FIELD/totalCost}" <<< "$(jq ".[\"$PERIOD\"] // []" "$tmp_claude")" 2>/dev/null || echo '{}') | |
| droid_json=$(jq -c "${normalize_jq/COST_FIELD/totalCost}" <<< "$(jq ".[\"$PERIOD\"] // []" "$tmp_droid")" 2>/dev/null || echo '{}') | |
| codex_json=$(jq -c "${normalize_jq/COST_FIELD/costUSD}" <<< "$(jq ".[\"$PERIOD\"] // []" "$tmp_codex")" 2>/dev/null || echo '{}') | |
| # Get all unique normalized dates, sorted | |
| all_dates=$(echo "$claude_json $droid_json $codex_json" | jq -rs '[.[] | keys] | flatten | unique | sort[]' 2>/dev/null) | |
| echo "" | |
| printf "${BOLD}%-14s %12s %14s %13s %12s${NC}\n" "Period" "Claude Code" "Factory Droid" "OpenAI Codex" "Total" | |
| printf "%-14s %12s %14s %13s %12s\n" "--------------" "------------" "--------------" "-------------" "------------" | |
| grand_claude=0 | |
| grand_droid=0 | |
| grand_codex=0 | |
| while IFS= read -r date; do | |
| [[ -z "$date" ]] && continue | |
| claude_cost=$(echo "$claude_json" | jq -r ".[\"$date\"] // 0") | |
| droid_cost=$(echo "$droid_json" | jq -r ".[\"$date\"] // 0") | |
| codex_cost=$(echo "$codex_json" | jq -r ".[\"$date\"] // 0") | |
| row_total=$(echo "$claude_cost + $droid_cost + $codex_cost" | bc -l 2>/dev/null || echo "0") | |
| grand_claude=$(echo "$grand_claude + $claude_cost" | bc -l) | |
| grand_droid=$(echo "$grand_droid + $droid_cost" | bc -l) | |
| grand_codex=$(echo "$grand_codex + $codex_cost" | bc -l) | |
| printf "%-14s %11.2f %13.2f %12.2f %11.2f\n" "$date" "$claude_cost" "$droid_cost" "$codex_cost" "$row_total" | |
| done <<< "$all_dates" | |
| grand_total=$(echo "$grand_claude + $grand_droid + $grand_codex" | bc -l) | |
| printf "%-14s %12s %14s %13s %12s\n" "--------------" "------------" "--------------" "-------------" "------------" | |
| printf "${BOLD}%-14s %11.2f %13.2f %12.2f %11.2f${NC}\n" "TOTAL" "$grand_claude" "$grand_droid" "$grand_codex" "$grand_total" | |
| echo "" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment