Last active
January 14, 2026 13:10
-
-
Save kjanat/92fb86fa6f79ee9a1d40089062050da9 to your computer and use it in GitHub Desktop.
Gemini token-usage logger for interactive sessions.
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
| #!/usr/bin/env bash | |
| # | |
| # !!!!!!!!!!!!!! | |
| # SCRIPT IS BROKEN FOR NOW, NOT GOT NO TIME TO CHANGE, SORRY | |
| # MIGHT GET UPDATED IN A MONTH OR SO... :( | |
| # !!!!!!!!!!!!!! | |
| # | |
| # gemwrap: run gemini, mirror its output, and log token stats + git root. | |
| # Can also parse existing gemini output from stdin when used with --parse-only | |
| # | |
| # ╭─────────────────────────────────────────────────────────────────────────────╮ | |
| # │ GEMWRAP │ | |
| # │ │ | |
| # │ A wrapper for Google's Gemini CLI that logs token usage statistics to JSON │ | |
| # ╰─────────────────────────────────────────────────────────────────────────────╯ | |
| # | |
| # DESCRIPTION: | |
| # gemwrap is a transparent wrapper around Google's Gemini CLI tool that: | |
| # - Runs gemini normally, showing all output as usual (coding, debugging, etc.) | |
| # - Extracts token usage statistics from gemini's cumulative stats output | |
| # - Logs structured data to ~/.gemini/tokens_used.log with session tracking | |
| # - Includes git repository context (branch, commit, remote provider/repo info) | |
| # - Prevents duplicate entries using deterministic session IDs | |
| # - Can also parse historical gemini output via stdin for retroactive logging | |
| # | |
| # Google's Gemini CLI is an open-source AI agent that brings Gemini models | |
| # directly into your terminal. It's an agentic coding tool similar to Claude Code | |
| # or OpenAI's Codex CLI, designed for: | |
| # - Code understanding, writing, and debugging across large codebases | |
| # - File manipulation and command execution with natural language | |
| # - Multi-step workflows, testing, and git operations | |
| # - Integration with development environments via Model Context Protocol (MCP) | |
| # - Content generation, research, and task management beyond just coding | |
| # | |
| # INSTALLATION: | |
| # # Quick install to ~/bin (add ~/bin to PATH if needed) | |
| # curl -o ~/bin/gemwrap https://gist.github.com/kjanat/92fb86fa6f79ee9a1d40089062050da9/raw/gemwrap.sh | |
| # chmod +x ~/bin/gemwrap | |
| # | |
| # # System-wide install (requires sudo) | |
| # curl -o /usr/local/bin/gemwrap https://gist.github.com/kjanat/92fb86fa6f79ee9a1d40089062050da9/raw/gemwrap.sh | |
| # chmod +x /usr/local/bin/gemwrap | |
| # | |
| # # Or download to current directory | |
| # curl -O https://gist.github.com/kjanat/92fb86fa6f79ee9a1d40089062050da9/raw/gemwrap.sh | |
| # chmod +x gemwrap.sh | |
| # | |
| # DEPENDENCIES: | |
| # - gemini (Google's Gemini CLI - install from: https://github.com/google-gemini/gemini-cli) | |
| # - jq (for JSON processing) | |
| # - git (optional, for repository context) | |
| # | |
| # USAGE: | |
| # # Use exactly like gemini - all arguments are passed through | |
| # gemwrap "Help me debug this Python function" | |
| # gemwrap "Refactor this component for better performance" | |
| # gemwrap "Write tests for the user authentication module" | |
| # | |
| # # Parse existing gemini output from files or stdin | |
| # gemwrap --parse-only < old_session.txt | |
| # cat session1.txt session2.txt | gemwrap --parse-only | |
| # pbpaste | gemwrap --parse-only # macOS clipboard | |
| # | |
| # # Set up as alias to automatically log all gemini usage | |
| # alias gemini="gemwrap" | |
| # | |
| # EXAMPLES: | |
| # # Coding tasks (gemini's primary use case) | |
| # gemwrap "Fix the bug in auth.py and add error handling" | |
| # gemwrap "Create a React component for user profiles" | |
| # gemwrap "Optimize this database query and explain the changes" | |
| # | |
| # # Multi-step workflows | |
| # gemwrap "Run the tests, fix any failures, and commit the changes" | |
| # gemwrap "Analyze the git history and create a changelog" | |
| # | |
| # # Code analysis and explanations | |
| # gemwrap "Explain the architecture of this microservice" | |
| # gemwrap "Review this pull request for security issues" | |
| # | |
| # # Parse old gemini session output for token tracking | |
| # gemwrap --parse-only <<EOF | |
| # ╭─────────────────────────────────────╮ | |
| # │ Agent powering down. Goodbye! │ | |
| # │ Cumulative Stats (5 Turns) │ | |
| # │ Input Tokens 12,345 │ | |
| # │ Output Tokens 1,234 │ | |
| # │ Thoughts Tokens 567 │ | |
| # │ Total Tokens 14,146 │ | |
| # │ Total duration (API) 2m 5s │ | |
| # │ Total duration (wall) 15m 30s │ | |
| # ╰─────────────────────────────────────╯ | |
| # EOF | |
| # | |
| # # Debug mode for troubleshooting | |
| # GEMWRAP_DEBUG=1 gemwrap "help me with this error" | |
| # | |
| # OUTPUT: | |
| # Logs are written to: ~/.gemini/tokens_used.log | |
| # Each entry is a JSON object on its own line (JSONL format): | |
| # - session_id: Deterministic UUID based on token stats (prevents duplicates) | |
| # - timestamp: When the log entry was created | |
| # - session: {turns, command_args} | |
| # - tokens: {input, output, thoughts, total} | |
| # - duration: {api, wall} | |
| # - git: {project_root, branch, commit, provider, owner, repo} | |
| # | |
| # VIEW LOGS: | |
| # # Pretty print all logs (each line is a separate JSON object) | |
| # cat ~/.gemini/tokens_used.log | jq . | |
| # | |
| # # Show just token totals | |
| # cat ~/.gemini/tokens_used.log | jq '.tokens.total' | |
| # | |
| # # Group by repository | |
| # cat ~/.gemini/tokens_used.log | jq -s 'group_by(.git.git_repo)' | |
| # | |
| # # Convert to proper JSON array for analysis | |
| # cat ~/.gemini/tokens_used.log | jq -s . | |
| # | |
| # SOURCE: | |
| # Web: https://gist.github.com/kjanat/92fb86fa6f79ee9a1d40089062050da9 | |
| # Raw: https://gist.github.com/kjanat/92fb86fa6f79ee9a1d40089062050da9/raw/gemwrap.sh | |
| # | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| set -euo pipefail | |
| # Constants | |
| readonly SCRIPT_NAME="$(basename "$0")" | |
| readonly SCRIPT_VERSION="2.0.0" | |
| readonly SCRIPT_URL="https://gist.github.com/kjanat/92fb86fa6f79ee9a1d40089062050da9/raw/gemwrap.sh" | |
| readonly LOG_DIR="${HOME}/.gemini" | |
| readonly LOG_FILE="${LOG_DIR}/tokens_used.log" | |
| # Functions | |
| log_error() { | |
| echo "${SCRIPT_NAME}: error: $*" >&2 | |
| } | |
| log_debug() { | |
| [[ "${GEMWRAP_DEBUG:-}" == "1" ]] && echo "${SCRIPT_NAME}: debug: $*" >&2 | |
| } | |
| cleanup() { | |
| [[ -n "${tmp_file:-}" && -f "$tmp_file" ]] && rm -f "$tmp_file" | |
| } | |
| check_dependencies() { | |
| local missing_deps=() | |
| command -v gemini >/dev/null || missing_deps+=("gemini") | |
| command -v jq >/dev/null || missing_deps+=("jq") | |
| if [[ ${#missing_deps[@]} -gt 0 ]]; then | |
| log_error "missing required dependencies: ${missing_deps[*]}" | |
| exit 1 | |
| fi | |
| } | |
| get_git_info() { | |
| local git_info="" | |
| # Get git root | |
| local project_root="" | |
| if project_root=$(git rev-parse --show-toplevel 2>/dev/null); then | |
| log_debug "found git root: $project_root" | |
| git_info+='"project_root":"'$project_root'",' | |
| # Get branch | |
| local branch="" | |
| if branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null); then | |
| git_info+='"git_branch":"'$branch'",' | |
| fi | |
| # Get commit | |
| local commit="" | |
| if commit=$(git rev-parse --short HEAD 2>/dev/null); then | |
| git_info+='"git_commit":"'$commit'",' | |
| fi | |
| # Get remote info | |
| local remote_url="" | |
| if remote_url=$(git remote get-url origin 2>/dev/null); then | |
| log_debug "found remote URL: $remote_url" | |
| # Extract provider and repo name | |
| if [[ $remote_url =~ github\.com[/:](.*)/(.*)\.git ]]; then | |
| local repo_owner="${BASH_REMATCH[1]}" | |
| local repo_name="${BASH_REMATCH[2]}" | |
| git_info+='"git_provider":"github",' | |
| git_info+='"git_owner":"'$repo_owner'",' | |
| git_info+='"git_repo":"'$repo_name'",' | |
| elif [[ $remote_url =~ gitlab\.com[/:](.*)/(.*)\.git ]]; then | |
| local repo_owner="${BASH_REMATCH[1]}" | |
| local repo_name="${BASH_REMATCH[2]}" | |
| git_info+='"git_provider":"gitlab",' | |
| git_info+='"git_owner":"'$repo_owner'",' | |
| git_info+='"git_repo":"'$repo_name'",' | |
| elif [[ $remote_url =~ bitbucket\.org[/:](.*)/(.*)\.git ]]; then | |
| local repo_owner="${BASH_REMATCH[1]}" | |
| local repo_name="${BASH_REMATCH[2]}" | |
| git_info+='"git_provider":"bitbucket",' | |
| git_info+='"git_owner":"'$repo_owner'",' | |
| git_info+='"git_repo":"'$repo_name'",' | |
| else | |
| git_info+='"git_remote_url":"'$remote_url'",' | |
| fi | |
| fi | |
| fi | |
| echo "$git_info" | |
| } | |
| extract_turns() { | |
| local content="$1" | |
| local turns="" | |
| # Extract turn count from "Cumulative Stats (X Turns)" | |
| if [[ $content =~ Cumulative\ Stats\ \(([0-9]+)\ Turn ]]; then | |
| turns="${BASH_REMATCH[1]}" | |
| log_debug "found $turns turns" | |
| fi | |
| echo "$turns" | |
| } | |
| parse_stats_block() { | |
| local content="$1" | |
| local stats_json="" | |
| # Find the stats block | |
| local stats_block="" | |
| if [[ $content =~ (Cumulative\ Stats.*╰) ]]; then | |
| stats_block="${BASH_REMATCH[1]}" | |
| log_debug "found stats block" | |
| else | |
| log_debug "no stats block found" | |
| return 1 | |
| fi | |
| # Extract turn count | |
| local turns | |
| turns=$(extract_turns "$stats_block") | |
| # Parse individual stats | |
| local input_tokens output_tokens thoughts_tokens total_tokens | |
| local api_duration wall_duration | |
| [[ $stats_block =~ Input\ Tokens[[:space:]]+([0-9,]+) ]] && input_tokens="${BASH_REMATCH[1]}" | |
| [[ $stats_block =~ Output\ Tokens[[:space:]]+([0-9,]+) ]] && output_tokens="${BASH_REMATCH[1]}" | |
| [[ $stats_block =~ Thoughts\ Tokens[[:space:]]+([0-9,]+) ]] && thoughts_tokens="${BASH_REMATCH[1]}" | |
| [[ $stats_block =~ Total\ Tokens[[:space:]]+([0-9,]+) ]] && total_tokens="${BASH_REMATCH[1]}" | |
| [[ $stats_block =~ Total\ duration\ \(API\)[[:space:]]+([0-9hms ]+) ]] && api_duration="${BASH_REMATCH[1]}" | |
| [[ $stats_block =~ Total\ duration\ \(wall\)[[:space:]]+([0-9hms ]+) ]] && wall_duration="${BASH_REMATCH[1]}" | |
| # Generate deterministic session ID | |
| local session_id | |
| session_id=$(generate_session_id "$turns" "$input_tokens" "$output_tokens" "$thoughts_tokens" "$total_tokens" "$api_duration" "$wall_duration") | |
| # Build nested JSON structure | |
| local timestamp | |
| timestamp=$(date -Iseconds) | |
| local git_info | |
| git_info=$(get_git_info) | |
| # Remove trailing comma if present | |
| git_info="${git_info%,}" | |
| # Build the JSON with proper nesting | |
| stats_json=$(jq -n \ | |
| --arg session_id "$session_id" \ | |
| --arg timestamp "$timestamp" \ | |
| --arg turns "$turns" \ | |
| --arg input_tokens "$input_tokens" \ | |
| --arg output_tokens "$output_tokens" \ | |
| --arg thoughts_tokens "$thoughts_tokens" \ | |
| --arg total_tokens "$total_tokens" \ | |
| --arg api_duration "$api_duration" \ | |
| --arg wall_duration "$wall_duration" \ | |
| --arg command_args "${GEMWRAP_ARGS:-}" \ | |
| '{ | |
| session_id: $session_id, | |
| timestamp: $timestamp, | |
| session: { | |
| turns: ($turns | tonumber // null), | |
| command_args: $command_args | |
| }, | |
| tokens: { | |
| input: ($input_tokens // null), | |
| output: ($output_tokens // null), | |
| thoughts: ($thoughts_tokens // null), | |
| total: ($total_tokens // null) | |
| }, | |
| duration: { | |
| api: ($api_duration // null), | |
| wall: ($wall_duration // null) | |
| } | |
| }') | |
| # Add git info if available | |
| if [[ -n "$git_info" ]]; then | |
| # Parse git info and add to JSON | |
| local git_json="{$git_info}" | |
| stats_json=$(echo "$stats_json" | jq ". + {git: $git_json}") | |
| fi | |
| log_debug "parsed stats: input=$input_tokens, output=$output_tokens, turns=$turns" | |
| echo "$stats_json" | |
| } | |
| generate_session_id() { | |
| local turns="$1" input="$2" output="$3" thoughts="$4" total="$5" api_dur="$6" wall_dur="$7" | |
| # Create deterministic data string | |
| local data_string="${turns}|${input}|${output}|${thoughts}|${total}|${api_dur}|${wall_dur}" | |
| # Generate deterministic UUID using SHA256 hash | |
| # Use first 32 chars of hash and format as UUID | |
| local hash | |
| hash=$(echo -n "$data_string" | sha256sum | cut -d' ' -f1) | |
| # Format as UUID: 8-4-4-4-12 | |
| local uuid="${hash:0:8}-${hash:8:4}-${hash:12:4}-${hash:16:4}-${hash:20:12}" | |
| log_debug "generated session ID: $uuid for data: $data_string" | |
| echo "$uuid" | |
| } | |
| check_duplicate_entry() { | |
| local session_id="$1" | |
| # Return early if log file doesn't exist | |
| [[ ! -f "$LOG_FILE" ]] && return 0 | |
| log_debug "checking for existing session ID: $session_id" | |
| # Check if session_id already exists in log file | |
| if grep -q "\"session_id\":\"$session_id\"" "$LOG_FILE" 2>/dev/null; then | |
| log_debug "duplicate session ID found, skipping" | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| write_log_entry() { | |
| local json_data="$1" | |
| # Ensure log directory exists | |
| mkdir -p "$LOG_DIR" | |
| # Extract session ID from JSON | |
| local session_id | |
| session_id=$(echo "$json_data" | jq -r '.session_id') | |
| # Check for duplicates | |
| if ! check_duplicate_entry "$session_id"; then | |
| echo "Entry already exists (ID: $session_id), skipping duplicate" | |
| return 0 | |
| fi | |
| # Append to log file (JSONL format - one JSON object per line) | |
| echo "$json_data" >> "$LOG_FILE" | |
| log_debug "logged session $session_id to $LOG_FILE" | |
| echo "Token stats logged successfully" | |
| } | |
| parse_stdin_mode() { | |
| log_debug "parse-only mode: processing stdin" | |
| # Create temporary file for stdin content | |
| tmp_file=$(mktemp) || { | |
| log_error "failed to create temporary file" | |
| exit 1 | |
| } | |
| trap cleanup EXIT | |
| # Read all stdin into temporary file | |
| cat > "$tmp_file" | |
| if [[ ! -s "$tmp_file" ]]; then | |
| log_error "no input provided via stdin" | |
| exit 1 | |
| fi | |
| local content | |
| content=$(cat "$tmp_file") | |
| # Process the content | |
| local stats_json | |
| if stats_json=$(parse_stats_block "$content"); then | |
| write_log_entry "$stats_json" | |
| echo "Token stats logged successfully" | |
| else | |
| log_error "failed to parse stats from input" | |
| exit 1 | |
| fi | |
| } | |
| show_version() { | |
| echo "$SCRIPT_NAME version $SCRIPT_VERSION" | |
| echo "Source: https://gist.github.com/kjanat/92fb86fa6f79ee9a1d40089062050da9" | |
| } | |
| update_script() { | |
| log_debug "attempting to update script" | |
| local script_path | |
| script_path=$(realpath "$0") | |
| local backup_path="${script_path}.backup.$(date +%s)" | |
| echo "Updating $SCRIPT_NAME from GitHub..." | |
| echo "Current version: $SCRIPT_VERSION" | |
| echo "Script location: $script_path" | |
| # Create backup | |
| if ! cp "$script_path" "$backup_path"; then | |
| log_error "failed to create backup at $backup_path" | |
| exit 1 | |
| fi | |
| # Download new version with cache busting | |
| local temp_file | |
| temp_file=$(mktemp) | |
| local download_url="${SCRIPT_URL}?nocache=$(date +%s)" | |
| if ! curl -fsSL -H "Cache-Control: no-cache" -H "Pragma: no-cache" "$download_url" > "$temp_file"; then | |
| log_error "failed to download update from $SCRIPT_URL" | |
| rm -f "$temp_file" | |
| exit 1 | |
| fi | |
| # Verify it's a valid script | |
| if ! head -1 "$temp_file" | grep -q "^#!/"; then | |
| log_error "downloaded file doesn't appear to be a valid script" | |
| rm -f "$temp_file" | |
| exit 1 | |
| fi | |
| # Replace current script | |
| if ! mv "$temp_file" "$script_path"; then | |
| log_error "failed to replace script (check permissions)" | |
| rm -f "$temp_file" | |
| exit 1 | |
| fi | |
| # Restore executable permissions | |
| chmod +x "$script_path" | |
| echo "✓ Successfully updated $SCRIPT_NAME" | |
| echo "✓ Backup saved to: $backup_path" | |
| echo "✓ Run '$SCRIPT_NAME --version' to verify" | |
| } | |
| main() { | |
| # Handle special flags first | |
| case "${1:-}" in | |
| --version|-v) | |
| show_version | |
| exit 0 | |
| ;; | |
| --update) | |
| update_script | |
| exit 0 | |
| ;; | |
| --parse-only) | |
| shift | |
| parse_stdin_mode "$@" | |
| return | |
| ;; | |
| esac | |
| # Check dependencies | |
| check_dependencies | |
| # Create temporary file with proper cleanup | |
| tmp_file=$(mktemp) || { | |
| log_error "failed to create temporary file" | |
| exit 1 | |
| } | |
| trap cleanup EXIT | |
| log_debug "temporary file: $tmp_file" | |
| log_debug "gemini args: $*" | |
| # Run gemini, capturing both stdout and preserving exit code | |
| local gemini_exit_code=0 | |
| gemini "$@" | tee "$tmp_file" || gemini_exit_code=$? | |
| # Only process stats if gemini ran successfully | |
| if [[ $gemini_exit_code -eq 0 ]]; then | |
| log_debug "processing token stats" | |
| local content | |
| content=$(cat "$tmp_file") | |
| local stats_json | |
| if stats_json=$(parse_stats_block "$content"); then | |
| write_log_entry "$stats_json" | |
| else | |
| log_debug "no stats found to log" | |
| fi | |
| else | |
| log_error "gemini exited with code $gemini_exit_code" | |
| fi | |
| # Preserve gemini's exit code | |
| exit $gemini_exit_code | |
| } | |
| # Set environment variable for accessing original args | |
| export GEMWRAP_ARGS="$*" | |
| # Run main function | |
| main "$@" |
I can't find any stats block. I am using Gemini CLI v0.1.9.
gemwrap.sh: debug: no stats block found
gemwrap.sh: debug: no stats found to log
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This line
[[ "${GEMWRAP_DEBUG:-}" == "1" ]] && echo "${SCRIPT_NAME}: debug: $*" >&2will cause the script always failed if there is notGEMWRAP_DEBUGenvironment variable exist.Try this: