#!/usr/bin/env bash # gstack-analytics — personal usage dashboard from local JSONL # # Usage: # gstack-analytics # default: last 7 days # gstack-analytics 7d # last 7 days # gstack-analytics 30d # last 30 days # gstack-analytics all # all time # # Env overrides (for testing): # GSTACK_STATE_DIR — override ~/.gstack state directory set -uo pipefail STATE_DIR="${GSTACK_STATE_DIR:-$HOME/.gstack}" JSONL_FILE="$STATE_DIR/analytics/skill-usage.jsonl" # ─── Parse time window ─────────────────────────────────────── WINDOW="${1:-7d}" case "$WINDOW" in 7d) DAYS=7; LABEL="last 7 days" ;; 30d) DAYS=30; LABEL="last 30 days" ;; all) DAYS=0; LABEL="all time" ;; *) DAYS=7; LABEL="last 7 days" ;; esac # ─── Check for data ────────────────────────────────────────── if [ ! -f "$JSONL_FILE" ]; then echo "gstack usage — no data yet" echo "" echo "Usage data will appear here after you use gstack skills" echo "with telemetry enabled (gstack-config set telemetry anonymous)." exit 0 fi TOTAL_LINES="$(wc -l < "$JSONL_FILE" | tr -d ' ')" if [ "$TOTAL_LINES" = "0" ]; then echo "gstack usage — no data yet" exit 0 fi # ─── Filter by time window ─────────────────────────────────── if [ "$DAYS" -gt 0 ] 2>/dev/null; then # Calculate cutoff date if date -v-1d +%Y-%m-%d >/dev/null 2>&1; then # macOS date CUTOFF="$(date -v-${DAYS}d -u +%Y-%m-%dT%H:%M:%SZ)" else # GNU date CUTOFF="$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "2000-01-01T00:00:00Z")" fi # Filter: skill_run events (new format) OR basic skill events (old format, no event_type) # Old format: {"skill":"X","ts":"Y","repo":"Z"} (no event_type field) # New format: {"event_type":"skill_run","skill":"X","ts":"Y",...} FILTERED="$(awk -F'"' -v cutoff="$CUTOFF" ' /"ts":"/ { # Skip hook_fire events if (/"event":"hook_fire"/) next # Skip non-skill_run new-format events if (/"event_type":"/ && !/"event_type":"skill_run"/) next for (i=1; i<=NF; i++) { if ($i == "ts" && $(i+1) ~ /^:/) { ts = $(i+2) if (ts >= cutoff) { print; break } } } } ' "$JSONL_FILE")" else # All time: include skill_run events + old-format basic events, exclude hook_fire FILTERED="$(awk '/"ts":"/ && !/"event":"hook_fire"/' "$JSONL_FILE" | grep -v '"event_type":"upgrade_' 2>/dev/null || true)" fi if [ -z "$FILTERED" ]; then echo "gstack usage ($LABEL) — no skill runs found" exit 0 fi # ─── Aggregate by skill ────────────────────────────────────── # Extract skill names and count SKILL_COUNTS="$(echo "$FILTERED" | awk -F'"' ' /"skill":"/ { for (i=1; i<=NF; i++) { if ($i == "skill" && $(i+1) ~ /^:/) { skill = $(i+2) counts[skill]++ break } } } END { for (s in counts) print counts[s], s } ' | sort -rn)" # Count outcomes TOTAL="$(echo "$FILTERED" | wc -l | tr -d ' ')" SUCCESS="$(echo "$FILTERED" | grep -c '"outcome":"success"' || true)" SUCCESS="${SUCCESS:-0}"; SUCCESS="$(echo "$SUCCESS" | tr -d ' \n\r\t')" ERRORS="$(echo "$FILTERED" | grep -c '"outcome":"error"' || true)" ERRORS="${ERRORS:-0}"; ERRORS="$(echo "$ERRORS" | tr -d ' \n\r\t')" # Old format events have no outcome field — count them as successful NO_OUTCOME="$(echo "$FILTERED" | grep -vc '"outcome":' || true)" NO_OUTCOME="${NO_OUTCOME:-0}"; NO_OUTCOME="$(echo "$NO_OUTCOME" | tr -d ' \n\r\t')" SUCCESS=$(( SUCCESS + NO_OUTCOME )) # Calculate success rate if [ "$TOTAL" -gt 0 ] 2>/dev/null; then SUCCESS_RATE=$(( SUCCESS * 100 / TOTAL )) else SUCCESS_RATE=100 fi # ─── Calculate total duration ──────────────────────────────── TOTAL_DURATION="$(echo "$FILTERED" | awk -F'[:,]' ' /"duration_s"/ { for (i=1; i<=NF; i++) { if ($i ~ /"duration_s"/) { val = $(i+1) gsub(/[^0-9.]/, "", val) if (val+0 > 0) total += val } } } END { printf "%.0f", total } ')" # Format duration TOTAL_DURATION="${TOTAL_DURATION:-0}" if [ "$TOTAL_DURATION" -ge 3600 ] 2>/dev/null; then HOURS=$(( TOTAL_DURATION / 3600 )) MINS=$(( (TOTAL_DURATION % 3600) / 60 )) DUR_DISPLAY="${HOURS}h ${MINS}m" elif [ "$TOTAL_DURATION" -ge 60 ] 2>/dev/null; then MINS=$(( TOTAL_DURATION / 60 )) DUR_DISPLAY="${MINS}m" else DUR_DISPLAY="${TOTAL_DURATION}s" fi # ─── Render output ─────────────────────────────────────────── echo "gstack usage ($LABEL)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" # Find max count for bar scaling MAX_COUNT="$(echo "$SKILL_COUNTS" | head -1 | awk '{print $1}')" BAR_WIDTH=20 echo "$SKILL_COUNTS" | while read -r COUNT SKILL; do # Scale bar if [ "$MAX_COUNT" -gt 0 ] 2>/dev/null; then BAR_LEN=$(( COUNT * BAR_WIDTH / MAX_COUNT )) else BAR_LEN=1 fi [ "$BAR_LEN" -lt 1 ] && BAR_LEN=1 # Build bar BAR="" i=0 while [ "$i" -lt "$BAR_LEN" ]; do BAR="${BAR}█" i=$(( i + 1 )) done # Calculate avg duration for this skill AVG_DUR="$(echo "$FILTERED" | awk -v skill="$SKILL" ' index($0, "\"skill\":\"" skill "\"") > 0 { # Extract duration_s value using split on "duration_s": n = split($0, parts, "\"duration_s\":") if (n >= 2) { # parts[2] starts with the value, e.g. "142," gsub(/[^0-9.].*/, "", parts[2]) if (parts[2]+0 > 0) { total += parts[2]; count++ } } } END { if (count > 0) printf "%.0f", total/count; else print "0" } ')" # Format avg duration if [ "$AVG_DUR" -ge 60 ] 2>/dev/null; then AVG_DISPLAY="$(( AVG_DUR / 60 ))m" else AVG_DISPLAY="${AVG_DUR}s" fi printf " /%-20s %s %d runs (avg %s)\n" "$SKILL" "$BAR" "$COUNT" "$AVG_DISPLAY" done echo "" echo "Success rate: ${SUCCESS_RATE}% | Errors: ${ERRORS} | Total time: ${DUR_DISPLAY}" echo "Events: ${TOTAL} skill runs"