This is page 1 of 2. Use http://codebase.md/data-goblin/claude-goblin?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── .python-version ├── CHANGELOG.md ├── docs │ ├── commands.md │ ├── images │ │ ├── dashboard.png │ │ ├── heatmap.png │ │ └── status-bar.png │ └── versions │ ├── 0.1.0.md │ ├── 0.1.1.md │ ├── 0.1.2.md │ └── 0.1.3.md ├── LICENSE ├── pyproject.toml ├── README.md └── src ├── __init__.py ├── aggregation │ ├── __init__.py │ ├── daily_stats.py │ └── usage_limits.py ├── cli.py ├── commands │ ├── __init__.py │ ├── delete_usage.py │ ├── export.py │ ├── help.py │ ├── limits.py │ ├── restore_backup.py │ ├── stats.py │ ├── status_bar.py │ ├── update_usage.py │ └── usage.py ├── config │ ├── __init__.py │ ├── settings.py │ └── user_config.py ├── data │ ├── __init__.py │ └── jsonl_parser.py ├── hooks │ ├── __init__.py │ ├── audio_tts.py │ ├── audio.py │ ├── manager.py │ ├── png.py │ ├── scripts │ │ └── audio_tts_hook.sh │ └── usage.py ├── models │ ├── __init__.py │ └── usage_record.py ├── storage │ ├── __init__.py │ └── snapshot_db.py ├── utils │ ├── __init__.py │ ├── _system.py │ └── text_analysis.py └── visualization ├── __init__.py ├── activity_graph.py ├── dashboard.py ├── export.py └── usage_bars.py ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.13 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | build/ 6 | dist/ 7 | wheels/ 8 | *.egg-info 9 | .eggs/ 10 | *.egg 11 | 12 | # Virtual environments 13 | .venv/ 14 | venv/ 15 | ENV/ 16 | env/ 17 | 18 | # IDE & AI tools 19 | .claude/ 20 | CLAUDE.md 21 | AGENTS.md 22 | .cursor/ 23 | .codex/ 24 | .gemini/ 25 | .vscode/ 26 | .idea/ 27 | *.swp 28 | *.swo 29 | *~ 30 | 31 | # Testing 32 | .pytest_cache/ 33 | .coverage 34 | htmlcov/ 35 | .tox/ 36 | .mypy_cache/ 37 | tests/ 38 | 39 | # OS 40 | .DS_Store 41 | Thumbs.db 42 | 43 | # Project-specific 44 | *.svg 45 | *.db 46 | *.db-journal 47 | main.py 48 | test_*.py 49 | RELEASING.md 50 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Claude Code Goblin 2 | 3 |  4 |  5 |  6 |  7 | 8 | Python command line tool to help with Claude Code utilities and Claude Code usage analytics and long-term tracking. 9 | 10 | 11 | **Quick Start:** Install with `pip install claude-goblin` and use `ccg --help` for commands or `ccg usage` to start tracking. Below are some examples of outputs that this command line can give you. 12 | 13 | > [!NOTE] 14 | > Both `claude-goblin` and `ccg` work interchangeably as command aliases. 15 | 16 | ## Example outputs 17 | 18 | **TUI Dashboard:** 19 | 20 |  21 | 22 | --- 23 | 24 | **MacOS status bar for usage limits:** 25 | 26 |  27 | 28 | --- 29 | 30 | **GitHub activity-style heatmap of annual usage:** 31 | 32 |  33 | 34 | --- 35 | 36 | 37 | > [!NOTE] 38 | > This tool was developed and tested on macOS (Python 3.13). Should work on Linux and Windows but is untested on those platforms. 39 | 40 | 41 | 42 | ## Features 43 | 44 | - Local snapshotting of Claude Code logs for analytics 45 | - Local snapshotting of usage limits from the Claude Code `/usage` command 46 | - Dashboard and stats of usage and limit history 47 | - Project anonymization for sharing screenshots (`--anon` flag) 48 | - Hook setup to automate data logging or analysis of Claude Code 49 | - Audio notifications for Claude Code completion, permission requests, and conversation compaction 50 | - Text-to-speech (TTS) notifications with customizable hook selection (macOS only) 51 | 52 | ## Installation 53 | 54 | ### From PyPI (recommended) 55 | ```bash 56 | # Install from PyPI 57 | pip install claude-goblin 58 | 59 | # Optional: Install export dependencies for PNG/SVG generation 60 | pip install "claude-goblin[export]" 61 | ``` 62 | 63 | ### From source 64 | ```bash 65 | # Clone the repository 66 | git clone https://github.com/data-goblin/claude-goblin.git 67 | cd claude-goblin 68 | 69 | # Install with pip 70 | pip install -e . 71 | 72 | # Optional: Install export dependencies 73 | pip install -e ".[export]" 74 | ``` 75 | 76 | ## First-Time Setup 77 | 78 | After installation, start tracking your Claude Code usage: 79 | 80 | ```bash 81 | # View your current usage dashboard 82 | ccg usage 83 | 84 | # (Optional) Enable automatic tracking with hooks 85 | ccg setup-hooks usage 86 | ``` 87 | 88 | **Note**: The `usage` command automatically saves your data to the historical database every time you run it. No manual setup required. 89 | 90 | ### Commands Explained 91 | 92 | - **`update-usage`**: Update historical database with latest data and fill in missing date gaps with empty records (use when you want continuous date coverage for the heatmap) 93 | 94 | For most users, just run `usage` regularly and it will handle data tracking automatically. Use `setup-hooks usage` to automate this completely. 95 | 96 | ## Commands 97 | 98 | | Command | Description | 99 | |---------|-------------| 100 | | **Dashboard & Analytics** | | 101 | | `ccg usage` | Show usage dashboard with KPI cards and breakdowns | 102 | | `ccg usage --live` | Auto-refresh dashboard every 5 seconds | 103 | | `ccg usage --fast` | Skip live limits for faster rendering | 104 | | `ccg usage --anon` | Anonymize project names (project-001, project-002, etc.) | 105 | | `ccg limits` | Show current usage limits (session, week, Opus) | 106 | | `ccg stats` | Show detailed statistics and cost analysis | 107 | | `ccg stats --fast` | Skip live limits for faster rendering | 108 | | `ccg status-bar [type]` | Launch macOS menu bar app (session\|weekly\|opus) | 109 | | **Export** | | 110 | | `ccg export` | Export yearly heatmap as PNG (default) | 111 | | `ccg export --svg` | Export as SVG image | 112 | | `ccg export --open` | Export and open the image | 113 | | `ccg export -y 2024` | Export specific year | 114 | | `ccg export -o output.png` | Specify output file path | 115 | | **Data Management** | | 116 | | `ccg update-usage` | Update historical database with latest data | 117 | | `ccg delete-usage --force` | Delete historical database (requires --force) | 118 | | `ccg restore-backup` | Restore from backup | 119 | | **Hooks (Advanced)** | | 120 | | `ccg setup-hooks usage` | Auto-track usage after each Claude response | 121 | | `ccg setup-hooks audio` | Play sounds for completion, permission & compaction | 122 | | `ccg setup-hooks audio-tts` | Speak notifications using TTS (macOS, multi-hook) | 123 | | `ccg setup-hooks png` | Auto-generate PNG after each response | 124 | | `ccg remove-hooks [type]` | Remove hooks (usage\|audio\|audio-tts\|png, or all) | 125 | 126 | ## Data Source 127 | 128 | Claude Goblin reads usage data from Claude Code's local session logs: 129 | ``` 130 | ~/.claude/projects/*.jsonl 131 | ``` 132 | 133 | **Important**: Claude Code retains session logs for approximately **30 days** (rolling window). There is no way to get other historical data without contacting Anthropic support. Claude Goblin solves this by: 134 | - Automatically saving data to an SQLite database (`~/.claude/usage/usage_history.db`) whenever you run `--usage` 135 | - Preserving historical data indefinitely 136 | - Merging current + historical data for complete analytics 137 | - Configuration to choose between saving detailed or aggregate data 138 | 139 | ## How It Works 140 | 141 | ```mermaid 142 | graph TD 143 | A[Claude Code] -->|writes| B[JSONL Files<br/>~/.claude/projects/*.jsonl] 144 | A -.->|triggers| H[Hooks] 145 | 146 | B --> ING{Ingestion<br/>--usage<br/>--update-usage} 147 | H -.->|automates| ING 148 | 149 | ING --> DB[(Database<br/>~/.claude/usage/usage_history.db)] 150 | 151 | DB --> CMD1{--usage} 152 | DB --> CMD2{--stats} 153 | DB --> CMD3{--export} 154 | 155 | CMD1 --> OUT1[TUI Dashboard] 156 | CMD2 --> OUT2[Summary Stats<br/>in Terminal] 157 | CMD3 --> OUT3[Annual Activity PNG] 158 | 159 | H -.->|automates| CMD3 160 | 161 | style A fill:#e0e0e0,stroke:#333,color:#000 162 | style B fill:#ff8800,stroke:#333,color:#000 163 | style DB fill:#4a9eff,stroke:#333,color:#fff 164 | style OUT1 fill:#90ee90,stroke:#333,color:#000 165 | style OUT2 fill:#90ee90,stroke:#333,color:#000 166 | style OUT3 fill:#90ee90,stroke:#333,color:#000 167 | style H fill:#ffeb3b,stroke:#333,color:#000 168 | ``` 169 | 170 | **Key Points:** 171 | - **JSONL files** are raw logs with a 30-day rolling window (older data disappears) 172 | - **Ingestion** step reads JSONL and saves to DB (with automatic deduplication via `UNIQUE` constraint) 173 | - **Database** is the single source of truth - all display commands read from here only 174 | - **Hooks** can automate ingestion after each Claude response 175 | 176 | ### Command Behavior 177 | 178 | **`ccg usage`** (Display + Ingestion) 179 | 1. **Ingestion**: Reads JSONL files from `~/.claude/projects/*.jsonl` and saves to DB 180 | 2. **Display**: Reads data from DB and renders dashboard 181 | 182 | **`ccg export`** (Display only) 183 | 1. Reads data from DB at `~/.claude/usage/usage_history.db` 184 | 2. Generates yearly heatmap 185 | 3. Exports to current directory as `claude-usage-<timestamp>.png` (or specify with `-o`) 186 | 187 | **`ccg stats`** (Display + Ingestion) 188 | 1. **Ingestion**: Reads JSONL files from `~/.claude/projects/*.jsonl` and saves to DB 189 | 2. **Display**: Reads data from DB and displays comprehensive statistics 190 | 191 | **`ccg update-usage`** (Ingestion only) 192 | 1. Reads JSONL files from `~/.claude/projects/*.jsonl` 193 | 2. Saves to DB at `~/.claude/usage/usage_history.db` (with automatic deduplication) 194 | 3. Fills in missing dates with empty records (ensures continuous heatmap) 195 | 196 | ### File Locations 197 | 198 | | File | Location | Purpose | 199 | |------|----------|---------| 200 | | **JSONL logs** | `~/.claude/projects/*.jsonl` | Current 30-day usage data from Claude Code | 201 | | **SQLite DB** | `~/.claude/usage/usage_history.db` | Historical usage data preserved indefinitely | 202 | | **Default exports** | `~/.claude/usage/claude-usage-<timestamp>.png` | PNG/SVG heatmaps (default location unless `-o` is used) | 203 | | **Hook exports** | `~/.claude/usage/claude-usage.png` | Default location for PNG hook auto-updates | 204 | 205 | ## --usage TUI dashboard 206 | 207 | Example TUI: 208 | 209 |  210 | 211 | ## --export Heatmap 212 | 213 | Export a GitHub-style yearly activity heatmap: 214 | 215 | ```bash 216 | ccg export --open 217 | ``` 218 | 219 | Example heatmap: 220 | 221 |  222 | 223 | ### --export Formats 224 | 225 | - **PNG** (default): `ccg export` 226 | 227 | ## --status-bar (macOS only) 228 | 229 | Launch a menu bar app showing your Claude Code usage limits: 230 | 231 | ```bash 232 | # Show weekly usage (default) 233 | ccg status-bar weekly 234 | 235 | # Show session usage 236 | ccg status-bar session 237 | 238 | # Show Opus weekly usage 239 | ccg status-bar opus 240 | ``` 241 | 242 | The menu bar displays "CC: XX%" and clicking it shows all three limits (Session, Weekly, Opus) with reset times. 243 | 244 | **Running in background:** 245 | - Use `&` to run in background: `ccg status-bar weekly &` 246 | - Use `nohup` to persist after terminal closes: `nohup ccg status-bar weekly > /dev/null 2>&1 &` 247 | 248 | Example: 249 | 250 |  251 | 252 | ## Hooks 253 | 254 | Claude Goblin can integrate with Claude Code's hook system to automate various tasks. Hooks trigger automatically based on Claude Code events. 255 | 256 | ### Available Hook Types 257 | 258 | #### Usage Hook 259 | Automatically tracks usage data after each Claude response: 260 | ```bash 261 | ccg setup-hooks usage 262 | ``` 263 | 264 | This adds a hook that runs `ccg update-usage --fast` after each Claude response, keeping your historical database up-to-date. 265 | 266 | #### Audio Hook 267 | Plays system sounds for three different events: 268 | ```bash 269 | ccg setup-hooks audio 270 | ``` 271 | 272 | You'll be prompted to select three sounds: 273 | 1. **Completion sound**: Plays when Claude finishes responding 274 | 2. **Permission sound**: Plays when Claude requests permission 275 | 3. **Compaction sound**: Plays before conversation compaction 276 | 277 | Supports macOS (10 built-in sounds), Windows, and Linux. 278 | 279 | #### Audio TTS Hook (macOS only) 280 | Speaks notifications aloud using macOS text-to-speech: 281 | ```bash 282 | ccg setup-hooks audio-tts 283 | ``` 284 | 285 | **Multi-hook selection** - Choose which events to speak: 286 | 1. Notification only (permission requests) - **[recommended]** 287 | 2. Stop only (when Claude finishes responding) 288 | 3. PreCompact only (before conversation compaction) 289 | 4. Notification + Stop 290 | 5. Notification + PreCompact 291 | 6. Stop + PreCompact 292 | 7. All three (Notification + Stop + PreCompact) 293 | 294 | You can also select from 7 different voices (Samantha, Alex, Daniel, Karen, Moira, Fred, Zarvox). 295 | 296 | **Example messages:** 297 | - Notification: Speaks the permission request message 298 | - Stop: "Claude finished responding" 299 | - PreCompact: "Auto compacting conversation" or "Manually compacting conversation" 300 | 301 | #### PNG Hook 302 | Auto-generates usage heatmap PNG after each Claude response: 303 | ```bash 304 | ccg setup-hooks png 305 | ``` 306 | 307 | Requires export dependencies: `pip install "claude-goblin[export]"` 308 | 309 | ### Removing Hooks 310 | 311 | ```bash 312 | # Remove specific hook type 313 | ccg remove-hooks usage 314 | ccg remove-hooks audio 315 | ccg remove-hooks audio-tts 316 | ccg remove-hooks png 317 | 318 | # Remove all Claude Goblin hooks 319 | ccg remove-hooks 320 | ``` 321 | 322 | ## Project Anonymization 323 | 324 | The `--anon` flag anonymizes project names when displaying usage data, perfect for sharing screenshots: 325 | 326 | ```bash 327 | ccg usage --anon 328 | ccg stats --anon 329 | ``` 330 | 331 | Projects are renamed to `project-001`, `project-002`, etc., ranked by total token usage (project-001 has the highest usage). 332 | 333 | ## Historical Data 334 | 335 | Claude Goblin automatically saves data every time you run `usage`. To manually manage: 336 | 337 | ```bash 338 | # View historical stats 339 | ccg stats 340 | 341 | # Update database with latest data and fill date gaps 342 | ccg update-usage 343 | 344 | # Delete all history 345 | ccg delete-usage -f 346 | ``` 347 | 348 | ## What It Tracks 349 | 350 | - **Tokens**: Input, output, cache creation, cache read (by model and project) 351 | - **Prompts**: User prompts and assistant responses 352 | - **Sessions**: Unique conversation threads 353 | - **Models**: Which Claude models you've used (Sonnet, Opus, Haiku) 354 | - **Projects**: Folders/directories where you've used Claude 355 | - **Time**: Daily activity patterns throughout the year 356 | - **Usage Limits**: Real-time session, weekly, and Opus limits 357 | 358 | It will also compute how much you would have had to pay if you used API pricing instead of a $200 Max plan. 359 | 360 | 361 | ## Technical Details 362 | 363 | ### Timezone Handling 364 | 365 | All timestamps in Claude Code's JSONL files seem to be stored in **UTC**. Claude Goblin should convert to your **local timezone** when grouping activity by date. This has only been tested with European CET. 366 | 367 | ### Cache Efficiency 368 | 369 | The token breakdown shows cache efficiency. High "Cache Read" percentages (80-90%+) mean Claude Code is effectively reusing context, which: 370 | - Speeds up responses 371 | - Can reduce costs on usage-based plans 372 | - Indicates good context management 373 | 374 | ## Requirements 375 | 376 | - Python >= 3.10 377 | - Claude Code (for generating usage data) 378 | - Rich >= 13.7.0 (terminal UI) 379 | - rumps >= 0.4.0 (macOS menu bar app, macOS only) 380 | - Pillow + CairoSVG (optional, for PNG/SVG export) 381 | 382 | ## License 383 | 384 | MIT License - see LICENSE file for details 385 | 386 | ## Contributing 387 | 388 | Contributions welcome! Please: 389 | 1. Fork the repository 390 | 2. Create a feature branch 391 | 3. Submit a pull request 392 | 393 | I don't have much time but I'll review PRs when I can. 394 | 395 | ## Troubleshooting 396 | 397 | ### "No Claude Code data found" 398 | - Ensure Claude Code is installed and you've used it at least once 399 | - Check that `~/.claude/projects/` exists and contains `.jsonl` files 400 | 401 | ### Limits showing "Could not parse usage data" 402 | - Run `claude` in a trusted folder first 403 | - Claude needs folder trust to display usage limits 404 | 405 | ### Export fails 406 | - Install export dependencies: `pip install -e ".[export]"` 407 | - For PNG: requires Pillow and CairoSVG 408 | 409 | ### Database errors 410 | - Try deleting and recreating: `ccg delete-usage --force` 411 | - Then run: `ccg usage` to rebuild from current data 412 | 413 | ## **AI Tools Disclaimer**: 414 | This project was developed with assistance from Claude Code. 415 | 416 | ## Credits 417 | 418 | Built with: 419 | - [Rich](https://github.com/Textualize/rich) - Terminal UI framework 420 | - [Pillow](https://python-pillow.org/) - Image processing (optional) 421 | - [CairoSVG](https://cairosvg.org/) - SVG to PNG conversion (optional) 422 | ``` -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /src/aggregation/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /src/data/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /src/models/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /src/visualization/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /src/hooks/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Hooks module 2 | ``` -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Utils module 2 | ``` -------------------------------------------------------------------------------- /src/commands/__init__.py: -------------------------------------------------------------------------------- ```python 1 | # Commands module 2 | ``` -------------------------------------------------------------------------------- /src/storage/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """Storage layer for historical usage snapshots.""" 2 | ``` -------------------------------------------------------------------------------- /docs/versions/0.1.0.md: -------------------------------------------------------------------------------- ```markdown 1 | # Version 0.1.0 2 | 3 | Initial release -- I'm too lazy to write all the initial release features and just using this to track feature adds. :) 4 | ``` -------------------------------------------------------------------------------- /docs/versions/0.1.1.md: -------------------------------------------------------------------------------- ```markdown 1 | # Version 0.1.1 2 | 3 | ## Features Added 4 | - Added `--status-bar` command for macOS menu bar integration 5 | - Menu bar shows real-time usage percentages (session, weekly, or opus) 6 | - Auto-refresh every 5 minutes 7 | 8 | ## Improvements 9 | - Enhanced documentation with status bar examples 10 | - Added background execution instructions for menu bar app 11 | ``` -------------------------------------------------------------------------------- /docs/versions/0.1.2.md: -------------------------------------------------------------------------------- ```markdown 1 | # Version 0.1.2 2 | 3 | ## Critical Bug Fixes 4 | - **Data Loss Fix**: Fixed critical bug in "full" storage mode where `INSERT OR REPLACE` was recalculating ALL daily_snapshots from current usage_records, causing data loss when JSONL files aged out (30-day window) 5 | - Now only updates dates that currently have records, preserving historical daily_snapshots forever 6 | 7 | ## Features Added 8 | - Migrated CLI from argparse to Typer for better command structure 9 | - New command syntax: `claude-goblin <command>` instead of `claude-goblin --<command>` 10 | - Old: `claude-goblin --usage` → New: `claude-goblin usage` 11 | - Old: `claude-goblin --stats` → New: `claude-goblin stats` 12 | - Old: `claude-goblin --export` → New: `claude-goblin export` 13 | - Updated hooks to use new command syntax automatically 14 | 15 | ## Improvements 16 | - Better command-line interface with clearer help messages 17 | - Improved documentation structure 18 | ``` -------------------------------------------------------------------------------- /src/config/settings.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | from pathlib import Path 3 | from typing import Final 4 | #endregion 5 | 6 | 7 | #region Constants 8 | # Claude data directory 9 | CLAUDE_DATA_DIR: Final[Path] = Path.home() / ".claude" / "projects" 10 | 11 | # Default refresh interval for dashboard (seconds) 12 | DEFAULT_REFRESH_INTERVAL: Final[int] = 5 13 | 14 | # Number of days to show in activity graph 15 | ACTIVITY_GRAPH_DAYS: Final[int] = 365 16 | 17 | # Graph dimensions 18 | GRAPH_WEEKS: Final[int] = 52 # 52 weeks = 364 days (close to 365) 19 | GRAPH_DAYS_PER_WEEK: Final[int] = 7 20 | #endregion 21 | 22 | 23 | #region Functions 24 | 25 | 26 | def get_claude_jsonl_files() -> list[Path]: 27 | """ 28 | Get all JSONL files from Claude's project data directory. 29 | 30 | Returns: 31 | List of Path objects pointing to JSONL files 32 | 33 | Raises: 34 | FileNotFoundError: If Claude data directory doesn't exist 35 | """ 36 | if not CLAUDE_DATA_DIR.exists(): 37 | raise FileNotFoundError( 38 | f"Claude data directory not found at {CLAUDE_DATA_DIR}. " 39 | "Make sure Claude Code has been run at least once." 40 | ) 41 | 42 | return list(CLAUDE_DATA_DIR.rglob("*.jsonl")) 43 | #endregion 44 | ``` -------------------------------------------------------------------------------- /src/hooks/scripts/audio_tts_hook.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | # Audio TTS Hook for Claude Code 3 | # Reads hook JSON from stdin and speaks it using macOS 'say' 4 | 5 | # Read JSON from stdin 6 | json_input=$(cat) 7 | 8 | # Extract the message content from the JSON 9 | # Try different fields depending on hook type 10 | message=$(echo "$json_input" | python3 -c " 11 | import sys 12 | import json 13 | try: 14 | data = json.load(sys.stdin) 15 | hook_type = data.get('hook_event_name', '') 16 | 17 | # Get appropriate message based on hook type 18 | if hook_type == 'Notification': 19 | msg = data.get('message', 'Claude requesting permission') 20 | elif hook_type == 'Stop': 21 | msg = 'Claude finished responding' 22 | elif hook_type == 'PreCompact': 23 | trigger = data.get('trigger', 'unknown') 24 | if trigger == 'auto': 25 | msg = 'Auto compacting conversation' 26 | else: 27 | msg = 'Manually compacting conversation' 28 | else: 29 | msg = data.get('message', 'Claude event') 30 | 31 | print(msg) 32 | except: 33 | print('Claude event') 34 | ") 35 | 36 | # Speak the message using macOS 'say' with selected voice (run in background to avoid blocking) 37 | echo "$message" | say -v Samantha & 38 | 39 | # Optional: Log for debugging 40 | # echo "$(date): TTS spoke: $message" >> ~/.claude/tts_hook.log 41 | ``` -------------------------------------------------------------------------------- /docs/versions/0.1.3.md: -------------------------------------------------------------------------------- ```markdown 1 | # Version 0.1.3 2 | 3 | ## Features Added 4 | - **`--fast` flag** for `usage` command: Skip all updates and read from database only for faster rendering 5 | - **`--fast` flag** for `export` command: Skip all updates and export directly from database 6 | - Shows last update timestamp when using `--fast` mode 7 | 8 | ## Improvements 9 | - Default export location changed to `~/.claude/usage/` (same as hook exports) 10 | - Export only saves to current directory when `-o` flag is explicitly used 11 | - Fixed command syntax in all error messages and hints (removed old `--command` style references) 12 | - Updated help text to use new command syntax throughout 13 | 14 | ## Documentation 15 | - Created `/docs/commands.md` with comprehensive command reference 16 | - Added separate sections for commands, flags, and arguments with proper syntax notation 17 | - Created `/docs/versions/` directory to track version-specific features 18 | - Reorganized README commands section into a clear table format 19 | - Updated all file location documentation to reflect new default export path 20 | 21 | ## Bug Fixes 22 | - Fixed dashboard tip showing incorrect syntax: `claude-goblin --export --open` → `claude-goblin export --open` 23 | - Fixed all references to old command syntax in: 24 | - `help.py` - Updated all command examples 25 | - `delete_usage.py` - Fixed deletion confirmation message 26 | - `hooks/manager.py` - Fixed setup and removal hints 27 | - `hooks/usage.py` - Fixed restore backup hint 28 | ``` -------------------------------------------------------------------------------- /src/commands/delete_usage.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import sys 3 | 4 | from rich.console import Console 5 | 6 | from src.storage.snapshot_db import ( 7 | DEFAULT_DB_PATH, 8 | get_database_stats, 9 | ) 10 | #endregion 11 | 12 | 13 | #region Functions 14 | 15 | 16 | def run(console: Console) -> None: 17 | """ 18 | Delete all historical usage data from the database. 19 | Requires -f or --force flag to prevent accidental deletion. 20 | 21 | Args: 22 | console: Rich console for output 23 | 24 | Flags: 25 | -f or --force: Required flag to confirm deletion 26 | """ 27 | force = "-f" in sys.argv or "--force" in sys.argv 28 | 29 | if not force: 30 | console.print("[red]WARNING: This will delete ALL historical usage data![/red]") 31 | console.print("[yellow]To confirm deletion, use: ccg delete-usage --force[/yellow]") 32 | return 33 | 34 | db_path = DEFAULT_DB_PATH 35 | 36 | if not db_path.exists(): 37 | console.print("[yellow]No historical database found.[/yellow]") 38 | return 39 | 40 | try: 41 | # Show stats before deletion 42 | db_stats = get_database_stats() 43 | if db_stats["total_records"] > 0: 44 | console.print("[cyan]Current database:[/cyan]") 45 | console.print(f" Records: {db_stats['total_records']:,}") 46 | console.print(f" Days: {db_stats['total_days']}") 47 | console.print(f" Range: {db_stats['oldest_date']} to {db_stats['newest_date']}\n") 48 | 49 | # Delete the database file 50 | db_path.unlink() 51 | console.print("[green]✓ Successfully deleted historical usage database[/green]") 52 | console.print(f"[dim]Deleted: {db_path}[/dim]") 53 | 54 | except Exception as e: 55 | console.print(f"[red]Error deleting database: {e}[/red]") 56 | 57 | 58 | #endregion 59 | ``` -------------------------------------------------------------------------------- /src/visualization/usage_bars.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | from datetime import datetime 3 | from rich.console import Console 4 | 5 | from src.aggregation.usage_limits import UsageLimits 6 | #endregion 7 | 8 | 9 | #region Functions 10 | 11 | 12 | def render_usage_limits(limits: UsageLimits, console: Console) -> None: 13 | """ 14 | Render usage limits as simple percentages with reset times. 15 | 16 | Displays: 17 | - Session: X% (resets at TIME) 18 | - Week: X% (resets on DATE) 19 | - Opus: X% (resets on DATE) [if applicable] 20 | 21 | Args: 22 | limits: UsageLimits object with usage data 23 | console: Rich console for output 24 | 25 | Common failure modes: 26 | - None values are handled gracefully 27 | - Percentages over 100% are shown as-is (no capping) 28 | """ 29 | console.print() 30 | 31 | # Session 32 | session_pct = limits.session_percentage 33 | reset_str = "" 34 | if limits.session_reset_time: 35 | local_time = limits.session_reset_time.astimezone() 36 | reset_str = local_time.strftime("%I:%M%p").lstrip('0') 37 | 38 | console.print(f"[bold cyan]Session:[/bold cyan] {session_pct:.0f}% [dim](resets {reset_str})[/dim]") 39 | 40 | # Week (all models) 41 | week_pct = limits.week_percentage 42 | week_reset_str = "" 43 | if limits.week_reset_time: 44 | local_time = limits.week_reset_time.astimezone() 45 | week_reset_str = local_time.strftime("%b %d").replace(' 0', ' ') 46 | 47 | console.print(f"[bold cyan]Week:[/bold cyan] {week_pct:.0f}% [dim](resets {week_reset_str})[/dim]") 48 | 49 | # Opus (only for Max plans) 50 | if limits.opus_limit > 0: 51 | opus_pct = limits.opus_percentage 52 | console.print(f"[bold cyan]Opus:[/bold cyan] {opus_pct:.0f}% [dim](resets {week_reset_str})[/dim]") 53 | 54 | console.print() 55 | 56 | 57 | #endregion 58 | ``` -------------------------------------------------------------------------------- /src/utils/_system.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import platform 3 | import subprocess 4 | from pathlib import Path 5 | from typing import Optional 6 | #endregion 7 | 8 | 9 | #region Functions 10 | 11 | 12 | def open_file(file_path: Path) -> None: 13 | """ 14 | Open a file with the default application (cross-platform). 15 | 16 | Args: 17 | file_path: Path to the file to open 18 | """ 19 | system = platform.system() 20 | try: 21 | if system == "Darwin": # macOS 22 | subprocess.run(["open", str(file_path)], check=False) 23 | elif system == "Windows": 24 | subprocess.run(["start", str(file_path)], shell=True, check=False) 25 | else: # Linux and others 26 | subprocess.run(["xdg-open", str(file_path)], check=False) 27 | except Exception: 28 | pass # Silently fail if opening doesn't work 29 | 30 | 31 | def get_sound_command(sound_name: str) -> Optional[str]: 32 | """ 33 | Get the command to play a sound (cross-platform). 34 | 35 | Args: 36 | sound_name: Name of the sound file (without extension) 37 | 38 | Returns: 39 | Command string to play the sound, or None if not supported 40 | """ 41 | system = platform.system() 42 | 43 | if system == "Darwin": # macOS 44 | return f"afplay /System/Library/Sounds/{sound_name}.aiff &" 45 | elif system == "Windows": 46 | # Windows Media Player command for playing system sounds 47 | return f'powershell -c "(New-Object Media.SoundPlayer \'C:\\Windows\\Media\\{sound_name}.wav\').PlaySync();" &' 48 | else: # Linux 49 | # Try to use paplay (PulseAudio) or aplay (ALSA) 50 | # Most Linux systems have one of these 51 | return f"(paplay /usr/share/sounds/freedesktop/stereo/{sound_name}.oga 2>/dev/null || aplay /usr/share/sounds/alsa/{sound_name}.wav 2>/dev/null) &" 52 | 53 | 54 | #endregion 55 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "claude-goblin" 3 | version = "0.1.5" 4 | description = "Python CLI for Claude Code utilities and usage tracking/analytics" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | license = {text = "MIT"} 8 | authors = [ 9 | {name = "Kurt Buhler"} 10 | ] 11 | keywords = ["claude", "claude-code", "usage", "analytics", "tui", "dashboard", "visualization"] 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: MIT License", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | "Topic :: Utilities", 23 | ] 24 | dependencies = [ 25 | "rich>=13.7.0", 26 | "typer>=0.9.0", 27 | "rumps>=0.4.0; sys_platform == 'darwin'", 28 | ] 29 | 30 | [project.urls] 31 | Homepage = "https://github.com/data-goblin/claude-goblin" 32 | Repository = "https://github.com/data-goblin/claude-goblin" 33 | Issues = "https://github.com/data-goblin/claude-goblin/issues" 34 | 35 | [project.optional-dependencies] 36 | export = [ 37 | "pillow>=10.0.0", 38 | "cairosvg>=2.7.0", 39 | ] 40 | 41 | [project.scripts] 42 | claude-goblin = "src.cli:main" 43 | ccg = "src.cli:main" 44 | 45 | [build-system] 46 | requires = ["hatchling"] 47 | build-backend = "hatchling.build" 48 | 49 | [tool.hatch.build.targets.wheel] 50 | packages = ["src"] 51 | 52 | [dependency-groups] 53 | dev = [ 54 | "pytest>=7.4.0", 55 | "pytest-cov>=4.1.0", 56 | "pytest-asyncio>=0.23.0", 57 | "pytest-faker>=2.0.0", 58 | "mypy>=1.7.0", 59 | ] 60 | 61 | [tool.pytest.ini_options] 62 | testpaths = ["tests"] 63 | python_files = ["test_*.py"] 64 | python_classes = ["Test*"] 65 | python_functions = ["test_*"] 66 | addopts = "-v --strict-markers" 67 | 68 | [tool.mypy] 69 | python_version = "3.10" 70 | strict = true 71 | warn_return_any = true 72 | warn_unused_configs = true 73 | disallow_untyped_defs = true 74 | ``` -------------------------------------------------------------------------------- /src/hooks/png.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | from pathlib import Path 3 | 4 | from rich.console import Console 5 | #endregion 6 | 7 | 8 | #region Functions 9 | 10 | 11 | def setup(console: Console, settings: dict, settings_path: Path) -> None: 12 | """ 13 | Set up the PNG auto-update hook. 14 | 15 | Args: 16 | console: Rich console for output 17 | settings: Settings dictionary to modify 18 | settings_path: Path to settings.json file 19 | """ 20 | # Ask for output path 21 | default_output = str(Path.home() / ".claude" / "usage" / "claude-usage.png") 22 | console.print("[bold cyan]Configure PNG auto-update:[/bold cyan]\n") 23 | console.print(f"[dim]Default output: {default_output}[/dim]") 24 | console.print("[dim]Enter custom path (or press Enter for default):[/dim] ", end="") 25 | 26 | try: 27 | user_input = input().strip() 28 | output_path = user_input if user_input else default_output 29 | except (EOFError, KeyboardInterrupt): 30 | console.print("\n[yellow]Cancelled[/yellow]") 31 | return 32 | 33 | # Create directory if it doesn't exist 34 | output_dir = Path(output_path).parent 35 | output_dir.mkdir(parents=True, exist_ok=True) 36 | 37 | hook_command = f"ccg export -o {output_path} > /dev/null 2>&1 &" 38 | 39 | # Remove existing PNG hooks 40 | original_count = len(settings["hooks"]["Stop"]) 41 | settings["hooks"]["Stop"] = [ 42 | hook for hook in settings["hooks"]["Stop"] 43 | if not is_hook(hook) 44 | ] 45 | png_hook_removed = len(settings["hooks"]["Stop"]) < original_count 46 | 47 | # Add new hook 48 | settings["hooks"]["Stop"].append({ 49 | "matcher": "*", 50 | "hooks": [{ 51 | "type": "command", 52 | "command": hook_command 53 | }] 54 | }) 55 | 56 | if png_hook_removed: 57 | console.print("[cyan]Replaced existing PNG auto-update hook[/cyan]") 58 | 59 | console.print(f"[green]✓ Successfully configured PNG auto-update hook[/green]") 60 | console.print("\n[bold]What this does:[/bold]") 61 | console.print(" • Exports PNG after each Claude response completes") 62 | console.print(f" • Overwrites: {output_path}") 63 | console.print(" • Runs silently in the background") 64 | 65 | 66 | def is_hook(hook) -> bool: 67 | """ 68 | Check if a hook is a PNG export hook. 69 | 70 | Recognizes both old-style (--export) and new-style (export) commands. 71 | 72 | Args: 73 | hook: Hook configuration dictionary 74 | 75 | Returns: 76 | True if this is a PNG export hook, False otherwise 77 | """ 78 | if not isinstance(hook, dict) or "hooks" not in hook: 79 | return False 80 | for h in hook.get("hooks", []): 81 | cmd = h.get("command", "") 82 | # Support both old-style (--export) and new-style (export) 83 | # Also support both claude-goblin and ccg aliases 84 | if (("claude-goblin --export" in cmd or "claude-goblin export" in cmd or 85 | "ccg --export" in cmd or "ccg export" in cmd) and "-o" in cmd): 86 | return True 87 | return False 88 | 89 | 90 | #endregion 91 | ``` -------------------------------------------------------------------------------- /src/models/usage_record.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | from typing import Optional 5 | #endregion 6 | 7 | 8 | #region Data Classes 9 | 10 | 11 | @dataclass(frozen=True) 12 | class TokenUsage: 13 | """ 14 | Represents token usage for a single API call. 15 | 16 | Attributes: 17 | input_tokens: Number of input tokens 18 | output_tokens: Number of output tokens 19 | cache_creation_tokens: Number of tokens written to cache 20 | cache_read_tokens: Number of tokens read from cache 21 | """ 22 | 23 | input_tokens: int 24 | output_tokens: int 25 | cache_creation_tokens: int 26 | cache_read_tokens: int 27 | 28 | @property 29 | def total_tokens(self) -> int: 30 | """Calculate total tokens across all categories.""" 31 | return ( 32 | self.input_tokens 33 | + self.output_tokens 34 | + self.cache_creation_tokens 35 | + self.cache_read_tokens 36 | ) 37 | 38 | 39 | @dataclass(frozen=True) 40 | class UsageRecord: 41 | """ 42 | Represents a single usage event from Claude Code. 43 | 44 | Attributes: 45 | timestamp: When the event occurred 46 | session_id: UUID of the conversation session 47 | message_uuid: UUID of the specific message 48 | message_type: Type of message ('user' or 'assistant') 49 | model: Model name (e.g., 'claude-sonnet-4-5-20250929') 50 | folder: Project folder path 51 | git_branch: Current git branch (if available) 52 | version: Claude Code version 53 | token_usage: Token usage details (None for user messages) 54 | content: Message content text (for analysis) 55 | char_count: Character count of message content 56 | """ 57 | 58 | timestamp: datetime 59 | session_id: str 60 | message_uuid: str 61 | message_type: str 62 | model: Optional[str] 63 | folder: str 64 | git_branch: Optional[str] 65 | version: str 66 | token_usage: Optional[TokenUsage] 67 | content: Optional[str] = None 68 | char_count: int = 0 69 | 70 | @property 71 | def date_key(self) -> str: 72 | """ 73 | Get date string in YYYY-MM-DD format for grouping. 74 | 75 | Converts UTC timestamp to local timezone before extracting date. 76 | This ensures activity is grouped by the user's local calendar day, 77 | not UTC days. For example, activity at 23:30 local time will be 78 | grouped into the correct local day, even though it may be a different 79 | UTC day. 80 | 81 | Returns: 82 | Date string in YYYY-MM-DD format (local timezone) 83 | """ 84 | local_timestamp = self.timestamp.astimezone() # Convert to local timezone 85 | return local_timestamp.strftime("%Y-%m-%d") 86 | 87 | @property 88 | def is_user_prompt(self) -> bool: 89 | """Check if this is a user prompt message.""" 90 | return self.message_type == "user" 91 | 92 | @property 93 | def is_assistant_response(self) -> bool: 94 | """Check if this is an assistant response message.""" 95 | return self.message_type == "assistant" 96 | #endregion 97 | ``` -------------------------------------------------------------------------------- /src/commands/restore_backup.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import os 3 | import shutil 4 | from datetime import datetime 5 | 6 | from rich.console import Console 7 | 8 | from src.storage.snapshot_db import ( 9 | DEFAULT_DB_PATH, 10 | get_database_stats, 11 | ) 12 | #endregion 13 | 14 | 15 | #region Functions 16 | 17 | 18 | def run(console: Console) -> None: 19 | """ 20 | Restore database from backup file. 21 | 22 | Restores the usage history database from a backup file (.db.bak). 23 | Creates a safety backup of the current database before restoring. 24 | 25 | Args: 26 | console: Rich console for output 27 | """ 28 | backup_path = DEFAULT_DB_PATH.parent / "usage_history.db.bak" 29 | 30 | if not backup_path.exists(): 31 | console.print("[yellow]No backup file found.[/yellow]") 32 | console.print(f"[dim]Expected location: {backup_path}[/dim]") 33 | return 34 | 35 | console.print("[bold cyan]Restore Database from Backup[/bold cyan]\n") 36 | console.print(f"[yellow]Backup file: {backup_path}[/yellow]") 37 | console.print(f"[yellow]This will replace: {DEFAULT_DB_PATH}[/yellow]") 38 | 39 | # Show backup file info 40 | backup_size = os.path.getsize(backup_path) 41 | backup_time = os.path.getmtime(backup_path) 42 | backup_date = datetime.fromtimestamp(backup_time).strftime("%Y-%m-%d %H:%M:%S") 43 | 44 | console.print(f"[dim]Backup size: {backup_size:,} bytes[/dim]") 45 | console.print(f"[dim]Backup date: {backup_date}[/dim]") 46 | console.print("") 47 | 48 | if DEFAULT_DB_PATH.exists(): 49 | console.print("[bold red]⚠️ WARNING: This will overwrite your current database![/bold red]") 50 | console.print("[yellow]Consider backing up your current database first.[/yellow]") 51 | console.print("") 52 | 53 | console.print("[cyan]Continue with restore? (yes/no):[/cyan] ", end="") 54 | 55 | try: 56 | confirm = input().strip().lower() 57 | if confirm not in ["yes", "y"]: 58 | console.print("[yellow]Restore cancelled[/yellow]") 59 | return 60 | except (EOFError, KeyboardInterrupt): 61 | console.print("\n[yellow]Restore cancelled[/yellow]") 62 | return 63 | 64 | try: 65 | # Create a backup of current DB if it exists 66 | if DEFAULT_DB_PATH.exists(): 67 | current_backup = DEFAULT_DB_PATH.parent / "usage_history.db.before_restore" 68 | shutil.copy2(DEFAULT_DB_PATH, current_backup) 69 | console.print(f"[dim]Current database backed up to: {current_backup}[/dim]") 70 | 71 | # Restore from backup 72 | shutil.copy2(backup_path, DEFAULT_DB_PATH) 73 | console.print(f"[green]✓ Database restored from backup[/green]") 74 | console.print(f"[dim]Restored: {DEFAULT_DB_PATH}[/dim]") 75 | 76 | # Show restored stats 77 | db_stats = get_database_stats() 78 | if db_stats["total_records"] > 0: 79 | console.print("") 80 | console.print("[cyan]Restored database contains:[/cyan]") 81 | console.print(f" Records: {db_stats['total_records']:,}") 82 | console.print(f" Days: {db_stats['total_days']}") 83 | console.print(f" Range: {db_stats['oldest_date']} to {db_stats['newest_date']}") 84 | 85 | except Exception as e: 86 | console.print(f"[red]Error restoring backup: {e}[/red]") 87 | 88 | 89 | #endregion 90 | ``` -------------------------------------------------------------------------------- /src/commands/help.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | from rich.console import Console 3 | #endregion 4 | 5 | 6 | #region Functions 7 | 8 | 9 | def run(console: Console) -> None: 10 | """ 11 | Display help message. 12 | 13 | Shows comprehensive usage information including: 14 | - Available commands and their flags 15 | - Key features of the tool 16 | - Data sources and storage locations 17 | - Recommended setup workflow 18 | 19 | Args: 20 | console: Rich console for output 21 | """ 22 | help_text = """ 23 | [bold cyan]Claude Goblin Usage Tracker[/bold cyan] 24 | 25 | Track and visualize your Claude Code usage with GitHub-style activity graphs. 26 | Automatically saves historical snapshots to preserve data beyond the 30-day rolling window. 27 | 28 | [bold]Usage:[/bold] 29 | ccg Show this help message 30 | ccg limits Show usage percentages (session, week, opus) 31 | ccg status-bar [type] Launch macOS menu bar app (session|weekly|opus) 32 | Defaults to weekly if type not specified 33 | ccg usage Show usage stats (single shot) 34 | ccg usage --live Show usage with auto-refresh 35 | ccg update-usage Update historical database with latest data 36 | ccg setup-hooks <type> Configure Claude Code hooks (usage|audio|png) 37 | ccg remove-hooks [type] Remove hooks (usage|audio|png, or all if not specified) 38 | ccg export Export heatmap as PNG image (default) 39 | Use --svg for SVG format 40 | Use --open to open after export 41 | Use -o FILE to specify output path 42 | Use --year YYYY to select year (default: current) 43 | ccg stats Show historical database statistics 44 | ccg restore-backup Restore database from backup (.db.bak file) 45 | ccg delete-usage -f Delete all historical data (requires --force) 46 | ccg help Show this help message 47 | 48 | [bold]Features:[/bold] 49 | • GitHub-style 365-day activity heatmap 50 | • Token usage breakdown (input, output, cache) 51 | • Session and prompt counts 52 | • Model and project folder breakdowns 53 | • Live auto-refresh dashboard 54 | • Automatic historical data preservation 55 | • Claude Code hooks integration for real-time tracking 56 | 57 | [bold]Data Sources:[/bold] 58 | Current (30 days): ~/.claude/projects/*.jsonl 59 | Historical: ~/.claude/usage/usage_history.db 60 | 61 | [bold]Recommended Setup:[/bold] 62 | 1. Run: ccg usage 63 | (View your dashboard and save initial snapshot) 64 | 2. Optional: ccg setup-hooks usage 65 | (Configure automatic tracking after each Claude response) 66 | 3. Optional: ccg setup-hooks audio 67 | (Play sound when Claude is ready for input) 68 | 69 | [bold]Exit:[/bold] 70 | Press Ctrl+C to exit 71 | 72 | [bold]Note:[/bold] 73 | Claude Code keeps a rolling 30-day window of logs. This tool automatically 74 | snapshots your data each time you run it, building a complete history over time. 75 | With hooks enabled, tracking happens automatically in the background. 76 | """ 77 | console.print(help_text) 78 | 79 | 80 | #endregion 81 | ``` -------------------------------------------------------------------------------- /src/config/user_config.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import json 3 | from pathlib import Path 4 | from typing import Optional 5 | #endregion 6 | 7 | 8 | #region Constants 9 | CONFIG_PATH = Path.home() / ".claude" / "goblin_config.json" 10 | #endregion 11 | 12 | 13 | #region Functions 14 | 15 | 16 | def load_config() -> dict: 17 | """ 18 | Load user configuration from disk. 19 | 20 | Returns: 21 | Configuration dictionary with user preferences 22 | """ 23 | if not CONFIG_PATH.exists(): 24 | return get_default_config() 25 | 26 | try: 27 | with open(CONFIG_PATH, "r") as f: 28 | return json.load(f) 29 | except (json.JSONDecodeError, IOError): 30 | return get_default_config() 31 | 32 | 33 | def save_config(config: dict) -> None: 34 | """ 35 | Save user configuration to disk. 36 | 37 | Args: 38 | config: Configuration dictionary to save 39 | 40 | Raises: 41 | IOError: If config cannot be written 42 | """ 43 | CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) 44 | 45 | with open(CONFIG_PATH, "w") as f: 46 | json.dump(config, f, indent=2) 47 | 48 | 49 | def get_default_config() -> dict: 50 | """ 51 | Get default configuration values. 52 | 53 | Returns: 54 | Default configuration dictionary 55 | """ 56 | return { 57 | "storage_mode": "aggregate", # "aggregate" or "full" 58 | "plan_type": "max_20x", # "pro", "max_5x", or "max_20x" 59 | "tracking_mode": "both", # "both", "tokens", or "limits" 60 | "version": "1.0" 61 | } 62 | 63 | 64 | def get_storage_mode() -> str: 65 | """ 66 | Get the current storage mode setting. 67 | 68 | Returns: 69 | Either "aggregate" or "full" 70 | """ 71 | config = load_config() 72 | return config.get("storage_mode", "aggregate") 73 | 74 | 75 | def set_storage_mode(mode: str) -> None: 76 | """ 77 | Set the storage mode. 78 | 79 | Args: 80 | mode: Either "aggregate" or "full" 81 | 82 | Raises: 83 | ValueError: If mode is not valid 84 | """ 85 | if mode not in ["aggregate", "full"]: 86 | raise ValueError(f"Invalid storage mode: {mode}. Must be 'aggregate' or 'full'") 87 | 88 | config = load_config() 89 | config["storage_mode"] = mode 90 | save_config(config) 91 | 92 | 93 | def get_plan_type() -> str: 94 | """ 95 | Get the current Claude Code plan type. 96 | 97 | Returns: 98 | One of "pro", "max_5x", or "max_20x" 99 | """ 100 | config = load_config() 101 | return config.get("plan_type", "max_20x") 102 | 103 | 104 | def set_plan_type(plan: str) -> None: 105 | """ 106 | Set the Claude Code plan type. 107 | 108 | Args: 109 | plan: One of "pro", "max_5x", or "max_20x" 110 | 111 | Raises: 112 | ValueError: If plan is not valid 113 | """ 114 | if plan not in ["pro", "max_5x", "max_20x"]: 115 | raise ValueError(f"Invalid plan type: {plan}. Must be 'pro', 'max_5x', or 'max_20x'") 116 | 117 | config = load_config() 118 | config["plan_type"] = plan 119 | save_config(config) 120 | 121 | 122 | def get_tracking_mode() -> str: 123 | """ 124 | Get the current tracking mode setting. 125 | 126 | Returns: 127 | One of "both", "tokens", or "limits" 128 | """ 129 | config = load_config() 130 | return config.get("tracking_mode", "both") 131 | 132 | 133 | def set_tracking_mode(mode: str) -> None: 134 | """ 135 | Set the tracking mode for data capture and visualization. 136 | 137 | Args: 138 | mode: One of "both", "tokens", or "limits" 139 | 140 | Raises: 141 | ValueError: If mode is not valid 142 | """ 143 | if mode not in ["both", "tokens", "limits"]: 144 | raise ValueError(f"Invalid tracking mode: {mode}. Must be 'both', 'tokens', or 'limits'") 145 | 146 | config = load_config() 147 | config["tracking_mode"] = mode 148 | save_config(config) 149 | 150 | 151 | #endregion 152 | ``` -------------------------------------------------------------------------------- /src/utils/text_analysis.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import re 3 | from typing import Optional 4 | #endregion 5 | 6 | 7 | #region Constants 8 | 9 | # Swear word patterns (comprehensive list with common misspellings) 10 | SWEAR_PATTERNS = [ 11 | # F-word variations 12 | r'\bf[u\*]c?k+(?:ing|ed|er|s)?\b', 13 | r'\bf+[aeiou]*c?k+\b', 14 | r'\bfck(?:ing|ed|er|s)?\b', 15 | r'\bfuk(?:ing|ed|er|s)?\b', 16 | r'\bphuck(?:ing|ed|er|s)?\b', 17 | 18 | # S-word variations 19 | r'\bsh[i\*]t+(?:ty|ting|ted|s)?\b', 20 | r'\bsht(?:ty|ting|ted|s)?\b', 21 | r'\bshyt(?:ty|ting|ted|s)?\b', 22 | r'\bcr[a\*]p+(?:py|ping|ped|s)?\b', 23 | 24 | # A-word variations 25 | r'\bass+h[o\*]le?s?\b', 26 | r'\ba+rse+(?:hole)?s?\b', 27 | 28 | # D-word variations 29 | r'\bd[a\*]mn+(?:ed|ing|s)?\b', 30 | r'\bd[a\*]m+(?:ed|ing|s)?\b', 31 | 32 | # B-word variations 33 | r'\bb[i\*]tch+(?:ing|ed|es|y)?\b', 34 | r'\bbstard+s?\b', 35 | 36 | # Other common variations 37 | r'\bhell+\b', 38 | r'\bpiss+(?:ed|ing|es)?\b', 39 | r'\bc[o\*]ck+(?:s)?\b', 40 | r'\bd[i\*]ck+(?:s|head)?\b', 41 | r'\btw[a\*]t+s?\b', 42 | ] 43 | 44 | # Specific phrase patterns 45 | PERFECT_PATTERNS = [ 46 | r'\bperfect!', 47 | r'\bperfect\.', 48 | r'\bexcellent!', 49 | r'\bexcellent\.', 50 | ] 51 | 52 | ABSOLUTELY_RIGHT_PATTERNS = [ 53 | r"\byou'?re?\s+absolutely\s+right\b", 54 | r"\byou\s+are\s+absolutely\s+right\b", 55 | ] 56 | 57 | # Politeness patterns 58 | THANK_PATTERNS = [ 59 | r'\bthank+(?:s|you|u)?\b', 60 | r'\bthn?x\b', 61 | r'\bty\b', 62 | r'\bthanku\b', 63 | r'\bthnk+s?\b', 64 | ] 65 | 66 | PLEASE_PATTERNS = [ 67 | r'\bplease\b', 68 | r'\bpl[sz]e?\b', 69 | r'\bples[ae]?\b', 70 | r'\bpls\b', 71 | ] 72 | 73 | #endregion 74 | 75 | 76 | #region Functions 77 | 78 | 79 | def count_swears(text: Optional[str]) -> int: 80 | """ 81 | Count swear words in text using comprehensive pattern matching. 82 | 83 | Args: 84 | text: Text to analyze 85 | 86 | Returns: 87 | Count of swear words found 88 | 89 | Reasons for failure: 90 | - None (returns 0 if text is None/empty) 91 | """ 92 | if not text: 93 | return 0 94 | 95 | text_lower = text.lower() 96 | count = 0 97 | 98 | for pattern in SWEAR_PATTERNS: 99 | matches = re.findall(pattern, text_lower) 100 | count += len(matches) 101 | 102 | return count 103 | 104 | 105 | def count_perfect_phrases(text: Optional[str]) -> int: 106 | """ 107 | Count instances of "Perfect!" in text. 108 | 109 | Args: 110 | text: Text to analyze 111 | 112 | Returns: 113 | Count of "Perfect!" phrases found 114 | """ 115 | if not text: 116 | return 0 117 | 118 | text_lower = text.lower() 119 | count = 0 120 | 121 | for pattern in PERFECT_PATTERNS: 122 | matches = re.findall(pattern, text_lower) 123 | count += len(matches) 124 | 125 | return count 126 | 127 | 128 | def count_absolutely_right_phrases(text: Optional[str]) -> int: 129 | """ 130 | Count instances of "You're absolutely right!" in text. 131 | 132 | Args: 133 | text: Text to analyze 134 | 135 | Returns: 136 | Count of "You're absolutely right!" phrases found 137 | """ 138 | if not text: 139 | return 0 140 | 141 | text_lower = text.lower() 142 | count = 0 143 | 144 | for pattern in ABSOLUTELY_RIGHT_PATTERNS: 145 | matches = re.findall(pattern, text_lower) 146 | count += len(matches) 147 | 148 | return count 149 | 150 | 151 | def count_thank_phrases(text: Optional[str]) -> int: 152 | """ 153 | Count instances of "thank you" and variations in text. 154 | 155 | Args: 156 | text: Text to analyze 157 | 158 | Returns: 159 | Count of thank you phrases found 160 | """ 161 | if not text: 162 | return 0 163 | 164 | text_lower = text.lower() 165 | count = 0 166 | 167 | for pattern in THANK_PATTERNS: 168 | matches = re.findall(pattern, text_lower) 169 | count += len(matches) 170 | 171 | return count 172 | 173 | 174 | def count_please_phrases(text: Optional[str]) -> int: 175 | """ 176 | Count instances of "please" and variations in text. 177 | 178 | Args: 179 | text: Text to analyze 180 | 181 | Returns: 182 | Count of please phrases found 183 | """ 184 | if not text: 185 | return 0 186 | 187 | text_lower = text.lower() 188 | count = 0 189 | 190 | for pattern in PLEASE_PATTERNS: 191 | matches = re.findall(pattern, text_lower) 192 | count += len(matches) 193 | 194 | return count 195 | 196 | 197 | def get_character_count(text: Optional[str]) -> int: 198 | """ 199 | Get character count of text. 200 | 201 | Args: 202 | text: Text to analyze 203 | 204 | Returns: 205 | Number of characters 206 | """ 207 | if not text: 208 | return 0 209 | 210 | return len(text) 211 | 212 | 213 | #endregion 214 | ``` -------------------------------------------------------------------------------- /src/commands/update_usage.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | from datetime import datetime, timedelta 3 | import sqlite3 4 | 5 | from rich.console import Console 6 | 7 | from src.commands.limits import capture_limits 8 | from src.config.settings import get_claude_jsonl_files 9 | from src.config.user_config import get_storage_mode, get_tracking_mode 10 | from src.data.jsonl_parser import parse_all_jsonl_files 11 | from src.storage.snapshot_db import ( 12 | DEFAULT_DB_PATH, 13 | get_database_stats, 14 | init_database, 15 | save_limits_snapshot, 16 | save_snapshot, 17 | ) 18 | #endregion 19 | 20 | 21 | #region Functions 22 | 23 | 24 | def run(console: Console) -> None: 25 | """ 26 | Update usage database and fill in gaps with empty records. 27 | 28 | This command: 29 | 1. Saves current usage data from JSONL files 30 | 2. Fills in missing days with zero-usage records 31 | 3. Ensures complete date coverage from earliest record to today 32 | 33 | Args: 34 | console: Rich console for output 35 | """ 36 | try: 37 | tracking_mode = get_tracking_mode() 38 | 39 | # Save current snapshot (tokens) 40 | if tracking_mode in ["both", "tokens"]: 41 | jsonl_files = get_claude_jsonl_files() 42 | if jsonl_files: 43 | records = parse_all_jsonl_files(jsonl_files) 44 | if records: 45 | saved_count = save_snapshot(records, storage_mode=get_storage_mode()) 46 | console.print(f"[green]Saved {saved_count} new token records[/green]") 47 | 48 | # Capture and save limits 49 | if tracking_mode in ["both", "limits"]: 50 | limits = capture_limits() 51 | if limits and "error" not in limits: 52 | save_limits_snapshot( 53 | session_pct=limits["session_pct"], 54 | week_pct=limits["week_pct"], 55 | opus_pct=limits["opus_pct"], 56 | session_reset=limits["session_reset"], 57 | week_reset=limits["week_reset"], 58 | opus_reset=limits["opus_reset"], 59 | ) 60 | console.print(f"[green]Saved limits snapshot (Session: {limits['session_pct']}%, Week: {limits['week_pct']}%, Opus: {limits['opus_pct']}%)[/green]") 61 | 62 | # Get database stats to determine date range 63 | db_stats = get_database_stats() 64 | if db_stats["total_records"] == 0: 65 | console.print("[yellow]No data to process.[/yellow]") 66 | return 67 | 68 | # Fill in gaps from oldest date to today 69 | init_database() 70 | conn = sqlite3.connect(DEFAULT_DB_PATH) 71 | 72 | try: 73 | cursor = conn.cursor() 74 | 75 | # Get all dates that have data 76 | cursor.execute("SELECT DISTINCT date FROM usage_records ORDER BY date") 77 | existing_dates = {row[0] for row in cursor.fetchall()} 78 | 79 | # Generate complete date range 80 | start_date = datetime.strptime(db_stats["oldest_date"], "%Y-%m-%d").date() 81 | end_date = datetime.now().date() 82 | 83 | current_date = start_date 84 | filled_count = 0 85 | 86 | while current_date <= end_date: 87 | date_str = current_date.strftime("%Y-%m-%d") 88 | 89 | if date_str not in existing_dates: 90 | # Insert empty daily snapshot for this date 91 | cursor.execute(""" 92 | INSERT OR IGNORE INTO daily_snapshots ( 93 | date, total_prompts, total_responses, total_sessions, total_tokens, 94 | input_tokens, output_tokens, cache_creation_tokens, 95 | cache_read_tokens, snapshot_timestamp 96 | ) VALUES (?, 0, 0, 0, 0, 0, 0, 0, 0, ?) 97 | """, (date_str, datetime.now().isoformat())) 98 | filled_count += 1 99 | 100 | current_date += timedelta(days=1) 101 | 102 | conn.commit() 103 | 104 | if filled_count > 0: 105 | console.print(f"[cyan]Filled {filled_count} empty days[/cyan]") 106 | 107 | # Show updated stats 108 | db_stats = get_database_stats() 109 | console.print( 110 | f"[green]Complete! Coverage: {db_stats['oldest_date']} to {db_stats['newest_date']}[/green]" 111 | ) 112 | 113 | finally: 114 | conn.close() 115 | 116 | except Exception as e: 117 | console.print(f"[red]Error updating usage: {e}[/red]") 118 | import traceback 119 | traceback.print_exc() 120 | 121 | 122 | #endregion 123 | ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.1.5] - 2025-10-13 9 | 10 | ### Added 11 | - Added `--fast` flag to `stats` command for faster rendering (skips all updates, reads from database) 12 | 13 | ### Fixed 14 | - Fixed missing limits updates in `stats` command - now automatically saves limits to database like other commands 15 | 16 | ## [0.1.4] - 2025-10-12 17 | 18 | ### Added 19 | - Added `--anon` flag to `usage` command to anonymize project names (displays as project-001, project-002, etc., ranked by token usage) 20 | - Added `PreCompact` hook support for audio notifications (plays sound before conversation compaction) 21 | - Added multi-hook selection for `audio-tts` setup (choose between Notification, Stop, PreCompact, or combinations) 22 | - Audio hook now supports three sounds: completion, permission requests, and conversation compaction 23 | 24 | ### Changed 25 | - `audio-tts` hook now supports configurable hook types (Notification only by default, with 7 selection options) 26 | - Audio hook setup now prompts for three sounds instead of two (added compaction sound) 27 | - TTS hook script intelligently handles different hook types with appropriate messages 28 | - Enhanced hook removal to properly clean up PreCompact hooks 29 | 30 | ### Fixed 31 | - Fixed `AttributeError` in `--anon` flag where `total_tokens` was accessed incorrectly on UsageRecord objects 32 | 33 | ## [0.1.3] - 2025-10-12 34 | 35 | ### Fixed 36 | - Fixed audio `Notification` hook format to properly trigger on permission requests (removed incorrect `matcher` field) 37 | - Fixed missing limits data in heatmap exports - `usage` command now automatically saves limits to database 38 | - Fixed double `claude` command execution - dashboard now uses cached limits from database instead of fetching live 39 | 40 | ### Changed 41 | - Improved status messages to show three distinct steps: "Updating usage data", "Updating usage limits", "Preparing dashboard" 42 | - Dashboard now displays limits from database after initial fetch, eliminating redundant API calls 43 | 44 | ### Added 45 | - Added `get_latest_limits()` function to retrieve most recent limits from database 46 | - Added `--fast` flag to `usage` command for faster dashboard rendering (skips all updates, reads directly from database) 47 | - Added `--fast` flag to `export` command for faster exports (skips all updates, reads directly from database) 48 | - Added database existence check for `--fast` mode with helpful error message 49 | - Added timestamp warning when using `--fast` mode showing last database update date 50 | 51 | ## [0.1.2] - 2025-10-11 52 | 53 | ### Added 54 | - Enhanced audio hook to support both `Stop` and `Notification` hooks 55 | - Completion sound: Plays when Claude finishes responding (`Stop` hook) 56 | - Permission sound: Plays when Claude requests permission (`Notification` hook) 57 | - User now selects two different sounds during `setup-hooks audio` for better distinction 58 | - Expanded macOS sound library from 5 to 10 sounds 59 | 60 | ### Changed 61 | - Updated `claude-goblin setup-hooks audio` to prompt for two sounds instead of one 62 | - Audio hook removal now cleans up both `Stop` and `Notification` hooks 63 | - Updated documentation to reflect dual audio notification capability 64 | 65 | ### Fixed 66 | - Fixed `NameError: name 'fast' is not defined` in usage command when `--fast` flag was used 67 | 68 | ## [0.1.1] - 2025-10-11 69 | 70 | ### Fixed 71 | - **CRITICAL**: Fixed data loss bug in "full" storage mode where `daily_snapshots` were being recalculated from scratch, causing historical data to be lost when JSONL files aged out (30-day window) 72 | - Now only updates `daily_snapshots` for dates that currently have records, preserving all historical data forever 73 | 74 | ### Changed 75 | - Migrated CLI from manual `sys.argv` parsing to `typer` for better UX and automatic help generation 76 | - Updated command syntax: `claude-goblin <command>` instead of `claude-goblin --<command>` 77 | - Old: `claude-goblin --usage` → New: `claude-goblin usage` 78 | - Old: `claude-goblin --stats` → New: `claude-goblin stats` 79 | - Old: `claude-goblin --export` → New: `claude-goblin export` 80 | - All other commands follow the same pattern 81 | - Updated hooks to use new command syntax (`claude-goblin update-usage` instead of `claude-goblin --update-usage`) 82 | - Improved help messages with examples and better descriptions 83 | 84 | ### Added 85 | - Added `typer>=0.9.0` as a dependency for CLI framework 86 | - Added backward compatibility in hooks to recognize both old and new command syntax 87 | 88 | ## [0.1.0] - 2025-10-10 89 | 90 | ### Added 91 | - Initial release 92 | - Usage tracking and analytics for Claude Code 93 | - GitHub-style activity heatmap visualization 94 | - TUI dashboard with real-time stats 95 | - Cost analysis and API pricing comparison 96 | - Export functionality (PNG/SVG) 97 | - Hook integration for automatic tracking 98 | - macOS menu bar app for usage monitoring 99 | - Support for both "aggregate" and "full" storage modes 100 | - Historical database preservation (SQLite) 101 | - Text analysis (politeness markers, phrase counting) 102 | - Model and project breakdown statistics 103 | ``` -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- ```markdown 1 | # Commands Reference 2 | 3 | Complete reference for all `claude-goblin` commands. 4 | 5 | ## Commands 6 | 7 | ### Dashboard & Analytics 8 | 9 | #### `claude-goblin usage` 10 | Show usage dashboard with KPI cards and breakdowns. 11 | 12 | Displays: 13 | - Total tokens, prompts, and sessions 14 | - Current usage limits (session, weekly, Opus) 15 | - Token breakdown by model 16 | - Token breakdown by project 17 | 18 | #### `claude-goblin limits` 19 | Show current usage limits (session, week, Opus). 20 | 21 | Displays current usage percentages and reset times for all three limit types. 22 | 23 | **Note:** Must be run from a trusted folder where Claude Code has been used. 24 | 25 | #### `claude-goblin stats` 26 | Show detailed statistics and cost analysis. 27 | 28 | Displays: 29 | - Summary: total tokens, prompts, responses, sessions, days tracked 30 | - Cost analysis: estimated API costs vs Max Plan costs 31 | - Averages: tokens per session/response, cost per session/response 32 | - Text analysis: prompt length, politeness markers, phrase counts 33 | - Usage by model: token distribution across different models 34 | 35 | #### `claude-goblin status-bar <type>` 36 | Launch macOS menu bar app (macOS only). 37 | 38 | Shows "CC: XX%" in your menu bar with auto-refresh every 5 minutes. 39 | 40 | **Arguments:** 41 | - `type` - Type of limit to display: `session`, `weekly`, or `opus` (default: `weekly`) 42 | 43 | ### Export 44 | 45 | #### `claude-goblin export` 46 | Export yearly heatmap as PNG or SVG. 47 | 48 | Generates a GitHub-style activity heatmap showing Claude Code usage throughout the year. 49 | 50 | ### Data Management 51 | 52 | #### `claude-goblin update-usage` 53 | Update historical database with latest data. 54 | 55 | This command: 56 | 1. Saves current usage data from JSONL files 57 | 2. Fills in missing days with zero-usage records 58 | 3. Ensures complete date coverage from earliest record to today 59 | 60 | Useful for ensuring continuous heatmap data without gaps. 61 | 62 | #### `claude-goblin delete-usage` 63 | Delete historical usage database. 64 | 65 | **WARNING:** This will permanently delete all historical usage data! 66 | 67 | A backup is automatically created before deletion. 68 | 69 | #### `claude-goblin restore-backup` 70 | Restore database from backup file. 71 | 72 | Restores the usage history database from `~/.claude/usage/usage_history.db.bak`. 73 | Creates a safety backup of the current database before restoring. 74 | 75 | ### Hooks (Advanced) 76 | 77 | #### `claude-goblin setup-hooks <type>` 78 | Setup Claude Code hooks for automation. 79 | 80 | **Arguments:** 81 | - `type` - Hook type to setup: `usage`, `audio`, or `png` 82 | 83 | Hook types: 84 | - `usage` - Auto-track usage after each Claude response 85 | - `audio` - Play sounds for completion and permission requests 86 | - `png` - Auto-update usage PNG after each Claude response 87 | 88 | #### `claude-goblin remove-hooks [type]` 89 | Remove Claude Code hooks configured by this tool. 90 | 91 | **Arguments:** 92 | - `type` (optional) - Hook type to remove: `usage`, `audio`, `png`, or omit to remove all 93 | 94 | ## Flags & Arguments 95 | 96 | ### Global Flags 97 | 98 | None currently available. 99 | 100 | ### Command-Specific Flags 101 | 102 | #### `usage` command 103 | - `--live` - Auto-refresh dashboard every 5 seconds 104 | - `--fast` - Skip live limits for faster rendering 105 | 106 | #### `export` command 107 | - `--svg` - Export as SVG instead of PNG 108 | - `--open` - Open file after export 109 | - `-y, --year <YYYY>` - Filter by year (default: current year) 110 | - `-o, --output <path>` - Output file path 111 | 112 | #### `delete-usage` command 113 | - `-f, --force` - Force deletion without confirmation (required) 114 | 115 | #### `status-bar` command 116 | Arguments: 117 | - `<type>` - Limit type: `session`, `weekly`, or `opus` (default: `weekly`) 118 | 119 | #### `setup-hooks` command 120 | Arguments: 121 | - `<type>` - Hook type: `usage`, `audio`, or `png` (required) 122 | 123 | #### `remove-hooks` command 124 | Arguments: 125 | - `[type]` - Hook type to remove: `usage`, `audio`, `png`, or omit for all (optional) 126 | 127 | ## Examples 128 | 129 | ```bash 130 | # View dashboard 131 | claude-goblin usage 132 | 133 | # View dashboard with auto-refresh 134 | claude-goblin usage --live 135 | 136 | # Export current year as PNG and open it 137 | claude-goblin export --open 138 | 139 | # Export specific year 140 | claude-goblin export -y 2024 141 | 142 | # Export as SVG to specific path 143 | claude-goblin export --svg -o ~/reports/usage.svg 144 | 145 | # Show current limits 146 | claude-goblin limits 147 | 148 | # Launch menu bar with weekly usage 149 | claude-goblin status-bar weekly 150 | 151 | # Setup automatic usage tracking 152 | claude-goblin setup-hooks usage 153 | 154 | # Setup audio notifications 155 | claude-goblin setup-hooks audio 156 | 157 | # Remove all hooks 158 | claude-goblin remove-hooks 159 | 160 | # Remove only audio hooks 161 | claude-goblin remove-hooks audio 162 | 163 | # Delete all historical data (with confirmation) 164 | claude-goblin delete-usage --force 165 | ``` 166 | 167 | ## File Locations 168 | 169 | | File | Location | Purpose | 170 | |------|----------|---------| 171 | | **JSONL logs** | `~/.claude/projects/*.jsonl` | Current 30-day usage data from Claude Code | 172 | | **SQLite DB** | `~/.claude/usage/usage_history.db` | Historical usage data preserved indefinitely | 173 | | **DB Backup** | `~/.claude/usage/usage_history.db.bak` | Automatic backup created before destructive operations | 174 | | **Default exports** | `~/.claude/usage/claude-usage-<timestamp>.png` | PNG/SVG heatmaps (default location unless `-o` is used) | 175 | | **Hook exports** | `~/.claude/usage/claude-usage.png` | Default location for PNG hook auto-updates | 176 | | **Settings** | `~/.claude/settings.json` | Claude Code settings including hooks configuration | 177 | ``` -------------------------------------------------------------------------------- /src/data/jsonl_parser.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import json 3 | from datetime import datetime 4 | from pathlib import Path 5 | from typing import Iterator, Optional 6 | 7 | from src.models.usage_record import TokenUsage, UsageRecord 8 | #endregion 9 | 10 | 11 | #region Functions 12 | 13 | 14 | def parse_jsonl_file(file_path: Path) -> Iterator[UsageRecord]: 15 | """ 16 | Parse a single JSONL file and yield UsageRecord objects. 17 | 18 | Extracts usage data from Claude Code session logs, including: 19 | - Token usage (input, output, cache creation, cache read) 20 | - Session metadata (model, folder, version, branch) 21 | - Timestamps and identifiers 22 | 23 | Args: 24 | file_path: Path to the JSONL file to parse 25 | 26 | Yields: 27 | UsageRecord objects for each assistant message with usage data 28 | 29 | Raises: 30 | FileNotFoundError: If the file doesn't exist 31 | json.JSONDecodeError: If the file contains invalid JSON 32 | """ 33 | if not file_path.exists(): 34 | raise FileNotFoundError(f"File not found: {file_path}") 35 | 36 | with open(file_path, "r", encoding="utf-8") as f: 37 | for line_num, line in enumerate(f, start=1): 38 | line = line.strip() 39 | if not line: 40 | continue 41 | 42 | try: 43 | data = json.loads(line) 44 | record = _parse_record(data) 45 | if record: 46 | yield record 47 | except json.JSONDecodeError as e: 48 | # Skip malformed lines but continue processing 49 | print(f"Warning: Skipping malformed JSON at {file_path}:{line_num}: {e}") 50 | continue 51 | 52 | 53 | def parse_all_jsonl_files(file_paths: list[Path]) -> list[UsageRecord]: 54 | """ 55 | Parse multiple JSONL files and return all usage records. 56 | 57 | Args: 58 | file_paths: List of paths to JSONL files 59 | 60 | Returns: 61 | List of all UsageRecord objects found across all files 62 | 63 | Raises: 64 | ValueError: If file_paths is empty 65 | """ 66 | if not file_paths: 67 | raise ValueError("No JSONL files provided to parse") 68 | 69 | records: list[UsageRecord] = [] 70 | for file_path in file_paths: 71 | try: 72 | records.extend(parse_jsonl_file(file_path)) 73 | except FileNotFoundError: 74 | print(f"Warning: File not found, skipping: {file_path}") 75 | except Exception as e: 76 | print(f"Warning: Error parsing {file_path}: {e}") 77 | 78 | return records 79 | 80 | 81 | def _parse_record(data: dict) -> Optional[UsageRecord]: 82 | """ 83 | Parse a single JSON record into a UsageRecord. 84 | 85 | Processes both user prompts and assistant responses. 86 | Skips system events and other message types. 87 | 88 | Args: 89 | data: Parsed JSON object from JSONL line 90 | 91 | Returns: 92 | UsageRecord for user or assistant messages, None otherwise 93 | """ 94 | message_type = data.get("type") 95 | 96 | # Only process user and assistant messages 97 | if message_type not in ("user", "assistant"): 98 | return None 99 | 100 | # Parse timestamp 101 | timestamp_str = data.get("timestamp") 102 | if not timestamp_str: 103 | return None 104 | 105 | timestamp = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) 106 | 107 | # Extract metadata (common to both user and assistant) 108 | session_id = data.get("sessionId", "unknown") 109 | message_uuid = data.get("uuid", "unknown") 110 | folder = data.get("cwd", "unknown") 111 | git_branch = data.get("gitBranch") 112 | version = data.get("version", "unknown") 113 | 114 | # Extract message data 115 | message = data.get("message", {}) 116 | model = message.get("model") 117 | 118 | # Filter out synthetic models (test/internal artifacts) 119 | if model == "<synthetic>": 120 | return None 121 | 122 | # Extract content for analysis 123 | content = None 124 | char_count = 0 125 | if isinstance(message.get("content"), str): 126 | content = message["content"] 127 | char_count = len(content) 128 | elif isinstance(message.get("content"), list): 129 | # Handle content blocks (concatenate text) 130 | text_parts = [] 131 | for block in message["content"]: 132 | if isinstance(block, dict) and block.get("type") == "text": 133 | text_parts.append(block.get("text", "")) 134 | content = "\n".join(text_parts) if text_parts else None 135 | char_count = len(content) if content else 0 136 | 137 | # Extract token usage (only available for assistant messages) 138 | token_usage = None 139 | if message_type == "assistant": 140 | usage_data = message.get("usage") 141 | if usage_data: 142 | cache_creation = usage_data.get("cache_creation", {}) 143 | cache_creation_tokens = ( 144 | cache_creation.get("cache_creation_input_tokens", 0) 145 | + cache_creation.get("ephemeral_5m_input_tokens", 0) 146 | + cache_creation.get("ephemeral_1h_input_tokens", 0) 147 | ) 148 | 149 | token_usage = TokenUsage( 150 | input_tokens=usage_data.get("input_tokens", 0), 151 | output_tokens=usage_data.get("output_tokens", 0), 152 | cache_creation_tokens=cache_creation_tokens, 153 | cache_read_tokens=usage_data.get("cache_read_input_tokens", 0), 154 | ) 155 | 156 | return UsageRecord( 157 | timestamp=timestamp, 158 | session_id=session_id, 159 | message_uuid=message_uuid, 160 | message_type=message_type, 161 | model=model, 162 | folder=folder, 163 | git_branch=git_branch, 164 | version=version, 165 | token_usage=token_usage, 166 | content=content, 167 | char_count=char_count, 168 | ) 169 | #endregion 170 | ``` -------------------------------------------------------------------------------- /src/commands/status_bar.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import sys 3 | import time 4 | import re 5 | from typing import Literal 6 | from rich.console import Console 7 | 8 | try: 9 | import rumps 10 | except ImportError: 11 | rumps = None 12 | #endregion 13 | 14 | 15 | #region Functions 16 | 17 | 18 | def _strip_timezone(reset_time: str) -> str: 19 | """ 20 | Remove timezone information from reset time string. 21 | 22 | Converts "in 2 hours (PST)" to "in 2 hours" 23 | Converts "Monday at 9:00 AM PST" to "Monday at 9:00 AM" 24 | 25 | Args: 26 | reset_time: Reset time string with optional timezone 27 | 28 | Returns: 29 | Reset time without timezone info 30 | """ 31 | # Remove timezone in parentheses: "(PST)", "(UTC)", etc. 32 | result = re.sub(r'\s*\([A-Z]{2,5}\)', '', reset_time) 33 | # Remove trailing timezone abbreviations: "PST", "UTC", etc. 34 | result = re.sub(r'\s+[A-Z]{2,5}$', '', result) 35 | return result.strip() 36 | 37 | 38 | def run(console: Console, limit_type: Literal["session", "weekly", "opus"]) -> None: 39 | """ 40 | Launch macOS menu bar app showing Claude Code usage percentage. 41 | 42 | Displays "CC: XX%" in the menu bar, updating every 5 minutes. 43 | The percentage shown depends on the limit_type argument: 44 | - session: Current session usage 45 | - weekly: Current week (all models) usage 46 | - opus: Current week (Opus only) usage 47 | 48 | Args: 49 | console: Rich console for output 50 | limit_type: Type of limit to display ("session", "weekly", or "opus") 51 | 52 | Raises: 53 | SystemExit: If not running on macOS or rumps is not available 54 | """ 55 | # Check platform 56 | if sys.platform != 'darwin': 57 | console.print("[red]Error: --status-bar is only available on macOS[/red]") 58 | sys.exit(1) 59 | 60 | # Check if rumps is available 61 | if rumps is None: 62 | console.print("[red]Error: rumps library not installed[/red]") 63 | console.print("[yellow]Install with: uv pip install rumps[/yellow]") 64 | sys.exit(1) 65 | 66 | # Import the capture function from limits 67 | from src.commands.limits import capture_limits 68 | 69 | class ClaudeStatusApp(rumps.App): 70 | """ 71 | macOS menu bar app for displaying Claude Code usage. 72 | 73 | Shows usage percentage in menu bar with format "CC: XX%" 74 | Updates every 5 minutes automatically. 75 | """ 76 | 77 | def __init__(self, limit_type: str): 78 | super(ClaudeStatusApp, self).__init__("CC: --", quit_button="Quit") 79 | self.limit_type = limit_type 80 | self.update_interval = 300 # 5 minutes in seconds 81 | 82 | # Set up menu items - will be populated in update_usage 83 | self.menu_refresh = rumps.MenuItem("Refresh Now", callback=self.manual_refresh) 84 | self.menu_session = rumps.MenuItem("Loading...") 85 | self.menu_weekly = rumps.MenuItem("Loading...") 86 | self.menu_opus = rumps.MenuItem("Loading...") 87 | 88 | self.menu.add(self.menu_refresh) 89 | self.menu.add(rumps.separator) 90 | self.menu.add(self.menu_session) 91 | self.menu.add(self.menu_weekly) 92 | self.menu.add(self.menu_opus) 93 | 94 | # Initial update 95 | self.update_usage() 96 | 97 | @rumps.timer(300) # Update every 5 minutes 98 | def update_usage(self, _: rumps.Timer | None = None) -> None: 99 | """ 100 | Update the menu bar display with current usage. 101 | 102 | Fetches latest usage data from Claude and updates the menu bar title. 103 | Called automatically every 5 minutes and on manual refresh. 104 | 105 | Args: 106 | _: Timer object (unused, required by rumps.timer decorator) 107 | """ 108 | limits = capture_limits() 109 | 110 | if limits is None: 111 | self.title = "CC: ??" 112 | self.menu_session.title = "Error: Could not fetch usage data" 113 | self.menu_weekly.title = "" 114 | self.menu_opus.title = "" 115 | return 116 | 117 | # Check for trust prompt error 118 | if "error" in limits: 119 | self.title = "CC: ??" 120 | self.menu_session.title = "Error: " + limits.get("message", "Unknown error") 121 | self.menu_weekly.title = "" 122 | self.menu_opus.title = "" 123 | return 124 | 125 | # Extract all three percentages and reset times 126 | session_pct = limits.get("session_pct", 0) 127 | week_pct = limits.get("week_pct", 0) 128 | opus_pct = limits.get("opus_pct", 0) 129 | 130 | session_reset = _strip_timezone(limits.get("session_reset", "Unknown")) 131 | week_reset = _strip_timezone(limits.get("week_reset", "Unknown")) 132 | opus_reset = _strip_timezone(limits.get("opus_reset", "Unknown")) 133 | 134 | # Update menu bar title based on selected limit type 135 | if self.limit_type == "session": 136 | pct = session_pct 137 | elif self.limit_type == "weekly": 138 | pct = week_pct 139 | elif self.limit_type == "opus": 140 | pct = opus_pct 141 | else: 142 | self.title = "CC: ??" 143 | self.menu_session.title = f"Error: Invalid limit type '{self.limit_type}'" 144 | self.menu_weekly.title = "" 145 | self.menu_opus.title = "" 146 | return 147 | 148 | # Update menu bar title 149 | self.title = f"CC: {pct}%" 150 | 151 | # Update all three menu items to show all limits 152 | self.menu_session.title = f"Session: {session_pct}% (resets {session_reset})" 153 | self.menu_weekly.title = f"Weekly: {week_pct}% (resets {week_reset})" 154 | self.menu_opus.title = f"Opus: {opus_pct}% (resets {opus_reset})" 155 | 156 | def manual_refresh(self, _: rumps.MenuItem) -> None: 157 | """ 158 | Handle manual refresh request from menu. 159 | 160 | Args: 161 | _: Menu item that triggered the callback (unused) 162 | """ 163 | self.update_usage() 164 | 165 | # Launch the app 166 | console.print(f"[green]Launching status bar app (showing {limit_type} usage)...[/green]") 167 | console.print("[dim]The app will appear in your menu bar as 'CC: XX%'[/dim]") 168 | console.print("[dim]Press Ctrl+C or select 'Quit' from the menu to stop[/dim]") 169 | 170 | app = ClaudeStatusApp(limit_type) 171 | app.run() 172 | 173 | 174 | #endregion 175 | ``` -------------------------------------------------------------------------------- /src/aggregation/daily_stats.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | from collections import defaultdict 3 | from dataclasses import dataclass 4 | from datetime import datetime, timedelta 5 | from typing import DefaultDict 6 | 7 | from src.models.usage_record import UsageRecord 8 | #endregion 9 | 10 | 11 | #region Data Classes 12 | 13 | 14 | @dataclass 15 | class DailyStats: 16 | """ 17 | Aggregated statistics for a single day. 18 | 19 | Attributes: 20 | date: Date in YYYY-MM-DD format 21 | total_prompts: Number of user prompts (user messages) 22 | total_responses: Number of assistant responses (assistant messages) 23 | total_sessions: Number of unique sessions 24 | total_tokens: Total token count across all categories 25 | input_tokens: Total input tokens 26 | output_tokens: Total output tokens 27 | cache_creation_tokens: Total cache creation tokens 28 | cache_read_tokens: Total cache read tokens 29 | models: Set of unique model names used 30 | folders: Set of unique project folders 31 | """ 32 | 33 | date: str 34 | total_prompts: int 35 | total_responses: int 36 | total_sessions: int 37 | total_tokens: int 38 | input_tokens: int 39 | output_tokens: int 40 | cache_creation_tokens: int 41 | cache_read_tokens: int 42 | models: set[str] 43 | folders: set[str] 44 | 45 | 46 | @dataclass 47 | class AggregatedStats: 48 | """ 49 | Complete statistics across all time periods. 50 | 51 | Attributes: 52 | daily_stats: Dictionary mapping date strings to DailyStats 53 | overall_totals: DailyStats object with totals across all dates 54 | """ 55 | 56 | daily_stats: dict[str, DailyStats] 57 | overall_totals: DailyStats 58 | #endregion 59 | 60 | 61 | #region Functions 62 | 63 | 64 | def aggregate_by_day(records: list[UsageRecord]) -> dict[str, DailyStats]: 65 | """ 66 | Aggregate usage records by day. 67 | 68 | Groups records by date and calculates totals for each metric. 69 | 70 | Args: 71 | records: List of usage records to aggregate 72 | 73 | Returns: 74 | Dictionary mapping date strings (YYYY-MM-DD) to DailyStats objects 75 | 76 | Raises: 77 | ValueError: If records list is empty 78 | """ 79 | if not records: 80 | return {} 81 | 82 | # Group records by date 83 | daily_data: DefaultDict[str, list[UsageRecord]] = defaultdict(list) 84 | for record in records: 85 | daily_data[record.date_key].append(record) 86 | 87 | # Aggregate statistics for each day 88 | daily_stats: dict[str, DailyStats] = {} 89 | for date, day_records in daily_data.items(): 90 | daily_stats[date] = _calculate_day_stats(date, day_records) 91 | 92 | return daily_stats 93 | 94 | 95 | def calculate_overall_stats(records: list[UsageRecord]) -> DailyStats: 96 | """ 97 | Calculate overall statistics across all records. 98 | 99 | Args: 100 | records: List of all usage records 101 | 102 | Returns: 103 | DailyStats object with totals across all time periods 104 | """ 105 | if not records: 106 | return DailyStats( 107 | date="all", 108 | total_prompts=0, 109 | total_responses=0, 110 | total_sessions=0, 111 | total_tokens=0, 112 | input_tokens=0, 113 | output_tokens=0, 114 | cache_creation_tokens=0, 115 | cache_read_tokens=0, 116 | models=set(), 117 | folders=set(), 118 | ) 119 | 120 | return _calculate_day_stats("all", records) 121 | 122 | 123 | def aggregate_all(records: list[UsageRecord]) -> AggregatedStats: 124 | """ 125 | Create complete aggregated statistics from usage records. 126 | 127 | Args: 128 | records: List of all usage records 129 | 130 | Returns: 131 | AggregatedStats object with daily and overall totals 132 | """ 133 | return AggregatedStats( 134 | daily_stats=aggregate_by_day(records), 135 | overall_totals=calculate_overall_stats(records), 136 | ) 137 | 138 | 139 | def get_date_range(daily_stats: dict[str, DailyStats], days: int = 365) -> list[str]: 140 | """ 141 | Get a list of dates for the specified range, ending today. 142 | 143 | Creates a continuous date range even if some days have no data. 144 | 145 | Args: 146 | daily_stats: Dictionary of daily statistics (used to determine if we have any data) 147 | days: Number of days to include in range (default: 365) 148 | 149 | Returns: 150 | List of date strings in YYYY-MM-DD format, from oldest to newest 151 | """ 152 | if not daily_stats: 153 | # If no data, return empty range 154 | return [] 155 | 156 | today = datetime.now().date() 157 | start_date = today - timedelta(days=days - 1) 158 | 159 | date_range = [] 160 | current_date = start_date 161 | while current_date <= today: 162 | date_range.append(current_date.strftime("%Y-%m-%d")) 163 | current_date += timedelta(days=1) 164 | 165 | return date_range 166 | 167 | 168 | def _calculate_day_stats(date: str, records: list[UsageRecord]) -> DailyStats: 169 | """ 170 | Calculate statistics for a single day's records. 171 | 172 | Args: 173 | date: Date string in YYYY-MM-DD format 174 | records: All usage records for this day 175 | 176 | Returns: 177 | DailyStats object with aggregated metrics 178 | """ 179 | unique_sessions = set() 180 | models = set() 181 | folders = set() 182 | 183 | total_prompts = 0 184 | total_responses = 0 185 | total_tokens = 0 186 | input_tokens = 0 187 | output_tokens = 0 188 | cache_creation_tokens = 0 189 | cache_read_tokens = 0 190 | 191 | for record in records: 192 | unique_sessions.add(record.session_id) 193 | if record.model: 194 | models.add(record.model) 195 | folders.add(record.folder) 196 | 197 | # Count message types separately 198 | if record.is_user_prompt: 199 | total_prompts += 1 200 | elif record.is_assistant_response: 201 | total_responses += 1 202 | 203 | # Token usage only available on assistant responses 204 | if record.token_usage: 205 | total_tokens += record.token_usage.total_tokens 206 | input_tokens += record.token_usage.input_tokens 207 | output_tokens += record.token_usage.output_tokens 208 | cache_creation_tokens += record.token_usage.cache_creation_tokens 209 | cache_read_tokens += record.token_usage.cache_read_tokens 210 | 211 | return DailyStats( 212 | date=date, 213 | total_prompts=total_prompts, 214 | total_responses=total_responses, 215 | total_sessions=len(unique_sessions), 216 | total_tokens=total_tokens, 217 | input_tokens=input_tokens, 218 | output_tokens=output_tokens, 219 | cache_creation_tokens=cache_creation_tokens, 220 | cache_read_tokens=cache_read_tokens, 221 | models=models, 222 | folders=folders, 223 | ) 224 | #endregion 225 | ``` -------------------------------------------------------------------------------- /src/hooks/usage.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import shutil 3 | from pathlib import Path 4 | 5 | from rich.console import Console 6 | 7 | from src.config.user_config import get_storage_mode, set_storage_mode 8 | from src.storage.snapshot_db import DEFAULT_DB_PATH 9 | #endregion 10 | 11 | 12 | #region Functions 13 | 14 | 15 | def setup(console: Console, settings: dict, settings_path: Path) -> None: 16 | """ 17 | Set up the usage tracking hook. 18 | 19 | Args: 20 | console: Rich console for output 21 | settings: Settings dictionary to modify 22 | settings_path: Path to settings.json file 23 | """ 24 | # Check current storage mode 25 | current_mode = get_storage_mode() 26 | 27 | # Ask user to choose storage mode 28 | console.print("[bold cyan]Choose storage mode:[/bold cyan]\n") 29 | console.print(" [bold]1. Aggregate (default)[/bold] - Daily totals only (smaller, faster)") 30 | console.print(" • Stores: date, prompts count, tokens totals") 31 | console.print(" • ~10-50 KB for a year of data") 32 | console.print(" • Good for: Activity tracking, usage trends\n") 33 | console.print(" [bold]2. Full Analytics[/bold] - Every individual message (larger, detailed)") 34 | console.print(" • Stores: every prompt with model, folder, timestamps") 35 | console.print(" • ~5-10 MB for a year of heavy usage") 36 | console.print(" • Good for: Detailed analysis, per-project breakdowns\n") 37 | 38 | if current_mode == "full": 39 | console.print(f"[dim]Current mode: Full Analytics[/dim]") 40 | else: 41 | console.print(f"[dim]Current mode: Aggregate[/dim]") 42 | 43 | console.print("[dim]Enter 1 or 2 (or press Enter for default):[/dim] ", end="") 44 | 45 | try: 46 | user_input = input().strip() 47 | if user_input == "2": 48 | storage_mode = "full" 49 | else: 50 | storage_mode = "aggregate" 51 | except (EOFError, KeyboardInterrupt): 52 | console.print("\n[yellow]Cancelled[/yellow]") 53 | return 54 | 55 | hook_command = "ccg update-usage > /dev/null 2>&1 &" 56 | 57 | # Check if already exists 58 | hook_exists = any(is_hook(hook) for hook in settings["hooks"]["Stop"]) 59 | 60 | # Warn if changing storage modes 61 | if current_mode != storage_mode and hook_exists: 62 | console.print("\n[bold yellow]⚠️ WARNING: Changing storage mode[/bold yellow]") 63 | console.print(f"[yellow]Current mode: {current_mode.title()}[/yellow]") 64 | console.print(f"[yellow]New mode: {storage_mode.title()}[/yellow]") 65 | console.print("") 66 | 67 | if current_mode == "full" and storage_mode == "aggregate": 68 | console.print("[yellow]• New data will only save daily totals (no individual messages)[/yellow]") 69 | console.print("[yellow]• Existing detailed records will remain but won't be updated[/yellow]") 70 | else: 71 | console.print("[yellow]• New data will save full details for each message[/yellow]") 72 | console.print("[yellow]• Historical aggregates will still be available[/yellow]") 73 | 74 | console.print("") 75 | console.print("[bold cyan]Would you like to create a backup of your database?[/bold cyan]") 76 | console.print(f"[dim]Database: {DEFAULT_DB_PATH}[/dim]") 77 | console.print("[dim]Backup will be saved as: usage_history.db.bak[/dim]") 78 | console.print("") 79 | console.print("[cyan]Create backup? (yes/no) [recommended: yes]:[/cyan] ", end="") 80 | 81 | try: 82 | backup_choice = input().strip().lower() 83 | if backup_choice in ["yes", "y"]: 84 | # Create backup 85 | backup_path = DEFAULT_DB_PATH.parent / "usage_history.db.bak" 86 | 87 | if DEFAULT_DB_PATH.exists(): 88 | shutil.copy2(DEFAULT_DB_PATH, backup_path) 89 | console.print(f"[green]✓ Backup created: {backup_path}[/green]") 90 | console.print(f"[dim]To restore: ccg restore-backup[/dim]") 91 | else: 92 | console.print("[yellow]No database file found to backup[/yellow]") 93 | except (EOFError, KeyboardInterrupt): 94 | console.print("\n[yellow]Cancelled[/yellow]") 95 | return 96 | 97 | console.print("") 98 | console.print("[cyan]Continue with mode change? (yes/no):[/cyan] ", end="") 99 | 100 | try: 101 | confirm = input().strip().lower() 102 | if confirm not in ["yes", "y"]: 103 | console.print(f"[yellow]Cancelled - keeping current mode ({current_mode})[/yellow]") 104 | return 105 | except (EOFError, KeyboardInterrupt): 106 | console.print("\n[yellow]Cancelled[/yellow]") 107 | return 108 | 109 | # Save storage mode preference 110 | set_storage_mode(storage_mode) 111 | 112 | if hook_exists: 113 | console.print(f"\n[yellow]Usage tracking hook already configured![/yellow]") 114 | console.print(f"[cyan]Storage mode updated to: {storage_mode}[/cyan]") 115 | return 116 | 117 | # Add hook 118 | settings["hooks"]["Stop"].append({ 119 | "matcher": "*", 120 | "hooks": [{ 121 | "type": "command", 122 | "command": hook_command 123 | }] 124 | }) 125 | 126 | console.print(f"[green]✓ Successfully configured usage tracking hook ({storage_mode} mode)[/green]") 127 | console.print("\n[bold]What this does:[/bold]") 128 | console.print(" • Runs after each Claude response completes") 129 | if storage_mode == "aggregate": 130 | console.print(" • Saves daily usage totals (lightweight)") 131 | else: 132 | console.print(" • Saves every individual message (detailed analytics)") 133 | console.print(" • Fills in gaps with empty records") 134 | console.print(" • Runs silently in the background") 135 | 136 | 137 | def is_hook(hook) -> bool: 138 | """ 139 | Check if a hook is a usage tracking hook. 140 | 141 | Recognizes both old-style (--update-usage) and new-style (update-usage) commands. 142 | 143 | Args: 144 | hook: Hook configuration dictionary 145 | 146 | Returns: 147 | True if this is a usage tracking hook, False otherwise 148 | """ 149 | if not isinstance(hook, dict) or "hooks" not in hook: 150 | return False 151 | for h in hook.get("hooks", []): 152 | command = h.get("command", "") 153 | # Support both old-style (--update-usage) and new-style (update-usage) 154 | # Also support both claude-goblin and ccg aliases 155 | if ("claude-goblin --update-usage" in command or "claude-goblin update-usage" in command or 156 | "ccg --update-usage" in command or "ccg update-usage" in command): 157 | return True 158 | return False 159 | 160 | 161 | #endregion 162 | ``` -------------------------------------------------------------------------------- /src/commands/limits.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import subprocess 3 | import re 4 | import os 5 | import pty 6 | import select 7 | import time 8 | from rich.console import Console 9 | #endregion 10 | 11 | 12 | #region Functions 13 | 14 | 15 | def _strip_ansi(text: str) -> str: 16 | """ 17 | Remove ANSI escape codes from text. 18 | 19 | Args: 20 | text: Text with ANSI codes 21 | 22 | Returns: 23 | Clean text without ANSI codes 24 | """ 25 | ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') 26 | return ansi_escape.sub('', text) 27 | 28 | 29 | def capture_limits() -> dict | None: 30 | """ 31 | Capture usage limits from `claude /usage` without displaying output. 32 | 33 | Returns: 34 | Dictionary with keys: session_pct, week_pct, opus_pct, 35 | session_reset, week_reset, opus_reset, or None if capture failed 36 | """ 37 | try: 38 | # Create a pseudo-terminal pair 39 | master, slave = pty.openpty() 40 | 41 | # Start claude /usage with the PTY 42 | process = subprocess.Popen( 43 | ['claude', '/usage'], 44 | stdin=slave, 45 | stdout=slave, 46 | stderr=slave, 47 | close_fds=True 48 | ) 49 | 50 | # Close slave in parent process (child keeps it open) 51 | os.close(slave) 52 | 53 | # Read output until we see complete data 54 | output = b'' 55 | start_time = time.time() 56 | max_wait = 10 57 | 58 | while time.time() - start_time < max_wait: 59 | # Check if data is available to read 60 | ready, _, _ = select.select([master], [], [], 0.1) 61 | 62 | if ready: 63 | try: 64 | chunk = os.read(master, 4096) 65 | if chunk: 66 | output += chunk 67 | 68 | # Check if we hit trust prompt early - no point waiting 69 | if b'Do you trust the files in this folder?' in output: 70 | # We got the trust prompt, stop waiting 71 | time.sleep(0.5) # Give it a bit more time to finish rendering 72 | break 73 | 74 | # Check if we have complete data 75 | # Look for the usage screen's exit message, not the loading screen's "esc to interrupt" 76 | if b'Current week (Opus)' in output and b'Esc to exit' in output: 77 | # Wait a tiny bit more to ensure all data is flushed 78 | time.sleep(0.2) 79 | # Try to read any remaining data 80 | try: 81 | while True: 82 | ready, _, _ = select.select([master], [], [], 0.05) 83 | if not ready: 84 | break 85 | chunk = os.read(master, 4096) 86 | if chunk: 87 | output += chunk 88 | except: 89 | pass 90 | break 91 | except OSError: 92 | break 93 | 94 | # Send ESC to exit cleanly 95 | try: 96 | os.write(master, b'\x1b') 97 | time.sleep(0.1) 98 | except: 99 | pass 100 | 101 | # Clean up 102 | try: 103 | process.terminate() 104 | process.wait(timeout=1) 105 | except: 106 | process.kill() 107 | 108 | os.close(master) 109 | 110 | # Decode output 111 | output_str = output.decode('utf-8', errors='replace') 112 | 113 | # Strip ANSI codes 114 | clean_output = _strip_ansi(output_str) 115 | 116 | # Check if we hit the trust prompt 117 | if 'Do you trust the files in this folder?' in clean_output: 118 | return { 119 | "error": "trust_prompt", 120 | "message": "Claude prompted for folder trust. Please run 'claude' in a trusted folder first, or cd to a project directory." 121 | } 122 | 123 | # Parse for percentages and reset times 124 | session_match = re.search(r'Current session.*?(\d+)%\s+used.*?Resets\s+(.+?)(?:\n|$)', clean_output, re.DOTALL) 125 | week_match = re.search(r'Current week \(all models\).*?(\d+)%\s+used.*?Resets\s+(.+?)(?:\n|$)', clean_output, re.DOTALL) 126 | opus_match = re.search(r'Current week \(Opus\).*?(\d+)%\s+used.*?Resets\s+(.+?)(?:\n|$)', clean_output, re.DOTALL) 127 | 128 | if session_match and week_match and opus_match: 129 | return { 130 | "session_pct": int(session_match.group(1)), 131 | "week_pct": int(week_match.group(1)), 132 | "opus_pct": int(opus_match.group(1)), 133 | "session_reset": session_match.group(2).strip(), 134 | "week_reset": week_match.group(2).strip(), 135 | "opus_reset": opus_match.group(2).strip(), 136 | } 137 | 138 | return None 139 | 140 | except Exception as e: 141 | # Debug: print the error to help diagnose issues 142 | import sys 143 | print(f"[DEBUG] capture_limits failed: {e}", file=sys.stderr) 144 | import traceback 145 | traceback.print_exc(file=sys.stderr) 146 | return None 147 | 148 | 149 | def run(console: Console) -> None: 150 | """ 151 | Show current usage limits by parsing `claude /usage` output. 152 | 153 | Uses Python's pty module to create a pseudo-terminal for capturing 154 | TUI output from `claude /usage`, then strips ANSI codes and extracts 155 | percentage values. 156 | 157 | Args: 158 | console: Rich console for output 159 | """ 160 | try: 161 | limits = capture_limits() 162 | 163 | console.print() 164 | 165 | if limits: 166 | # Check if it's an error response 167 | if "error" in limits: 168 | console.print(f"[yellow]{limits['message']}[/yellow]") 169 | else: 170 | console.print(f"[bold]Session:[/bold] [#ff8800]{limits['session_pct']}%[/#ff8800] (resets [not bold cyan]{limits['session_reset']}[/not bold cyan])") 171 | console.print(f"[bold]Week:[/bold] [#ff8800]{limits['week_pct']}%[/#ff8800] (resets [not bold cyan]{limits['week_reset']}[/not bold cyan])") 172 | console.print(f"[bold]Opus:[/bold] [#ff8800]{limits['opus_pct']}%[/#ff8800] (resets [not bold cyan]{limits['opus_reset']}[/not bold cyan])") 173 | else: 174 | console.print("[yellow]Could not parse usage data from 'claude /usage'[/yellow]") 175 | 176 | console.print() 177 | 178 | except FileNotFoundError: 179 | console.print("[red]Error: 'claude' command not found[/red]") 180 | except Exception as e: 181 | console.print(f"[red]Error: {e}[/red]") 182 | import traceback 183 | traceback.print_exc() 184 | 185 | 186 | #endregion 187 | ``` -------------------------------------------------------------------------------- /src/commands/export.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import sys 3 | from datetime import datetime 4 | from pathlib import Path 5 | 6 | from rich.console import Console 7 | 8 | from src.aggregation.daily_stats import aggregate_all 9 | from src.commands.limits import capture_limits 10 | from src.config.settings import get_claude_jsonl_files 11 | from src.config.user_config import get_tracking_mode, get_storage_mode 12 | from src.data.jsonl_parser import parse_all_jsonl_files 13 | from src.storage.snapshot_db import ( 14 | load_historical_records, 15 | get_limits_data, 16 | save_limits_snapshot, 17 | save_snapshot, 18 | get_database_stats, 19 | DEFAULT_DB_PATH, 20 | ) 21 | from src.utils._system import open_file 22 | #endregion 23 | 24 | 25 | #region Functions 26 | 27 | 28 | def run(console: Console) -> None: 29 | """ 30 | Export the heatmap to PNG or SVG. 31 | 32 | Exports a GitHub-style activity heatmap as an image file. 33 | Supports PNG (default) and SVG formats, with optional file opening. 34 | 35 | Args: 36 | console: Rich console for output 37 | 38 | Flags: 39 | svg: Export as SVG instead of PNG 40 | --open: Open file after export 41 | --fast: Skip updates, read directly from database (faster) 42 | --year YYYY or -y YYYY: Filter by year (default: current year) 43 | -o FILE or --output FILE: Specify output file path 44 | """ 45 | from src.visualization.export import export_heatmap_svg, export_heatmap_png 46 | 47 | # Check for --fast flag 48 | fast_mode = "--fast" in sys.argv 49 | 50 | # Determine format from arguments (PNG is default) 51 | format_type = "png" 52 | if "svg" in sys.argv: 53 | format_type = "svg" 54 | 55 | # Check for --open flag 56 | should_open = "--open" in sys.argv 57 | 58 | # Parse year filter (--year YYYY) 59 | year_filter = None 60 | for i, arg in enumerate(sys.argv): 61 | if arg in ["--year", "-y"] and i + 1 < len(sys.argv): 62 | try: 63 | year_filter = int(sys.argv[i + 1]) 64 | except ValueError: 65 | console.print(f"[red]Invalid year: {sys.argv[i + 1]}[/red]") 66 | return 67 | break 68 | 69 | # Default to current year if not specified 70 | if year_filter is None: 71 | year_filter = datetime.now().year 72 | 73 | # Determine output path 74 | output_file = None 75 | custom_output = False 76 | for i, arg in enumerate(sys.argv): 77 | if arg in ["-o", "--output"] and i + 1 < len(sys.argv): 78 | output_file = sys.argv[i + 1] 79 | custom_output = True 80 | break 81 | 82 | if not output_file: 83 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 84 | output_file = f"claude-usage-{timestamp}.{format_type}" 85 | 86 | # Use absolute path, or resolve based on whether -o flag was used 87 | output_path = Path(output_file) 88 | if not output_path.is_absolute(): 89 | if custom_output: 90 | # If -o flag was used, resolve relative to current working directory 91 | output_path = Path.cwd() / output_path 92 | else: 93 | # Default location: ~/.claude/usage/ 94 | default_dir = Path.home() / ".claude" / "usage" 95 | default_dir.mkdir(parents=True, exist_ok=True) 96 | output_path = default_dir / output_file 97 | 98 | try: 99 | # Check if database exists when using --fast 100 | if fast_mode and not DEFAULT_DB_PATH.exists(): 101 | console.print("[red]Error: Cannot use --fast flag without existing database.[/red]") 102 | console.print("[yellow]Run 'ccg usage' or 'ccg update-usage' first to create the database.[/yellow]") 103 | return 104 | 105 | # If fast mode, show warning with last update timestamp 106 | if fast_mode: 107 | db_stats = get_database_stats() 108 | if db_stats.get("newest_timestamp"): 109 | # Format ISO timestamp to be more readable 110 | timestamp_str = db_stats["newest_timestamp"] 111 | try: 112 | dt = datetime.fromisoformat(timestamp_str) 113 | formatted_time = dt.strftime("%Y-%m-%d %H:%M:%S") 114 | console.print(f"[bold red]⚠ Fast mode: Reading from last update ({formatted_time})[/bold red]") 115 | except (ValueError, AttributeError): 116 | console.print(f"[bold red]⚠ Fast mode: Reading from last update ({timestamp_str})[/bold red]") 117 | else: 118 | console.print("[bold red]⚠ Fast mode: Reading from database (no timestamp available)[/bold red]") 119 | 120 | # Update data unless in fast mode 121 | if not fast_mode: 122 | # Step 1: Update usage data 123 | with console.status("[bold #ff8800]Updating usage data...", spinner="dots", spinner_style="#ff8800"): 124 | jsonl_files = get_claude_jsonl_files() 125 | if jsonl_files: 126 | current_records = parse_all_jsonl_files(jsonl_files) 127 | if current_records: 128 | save_snapshot(current_records, storage_mode=get_storage_mode()) 129 | 130 | # Step 2: Update limits data (if enabled) 131 | tracking_mode = get_tracking_mode() 132 | if tracking_mode in ["both", "limits"]: 133 | with console.status("[bold #ff8800]Updating usage limits...", spinner="dots", spinner_style="#ff8800"): 134 | limits = capture_limits() 135 | if limits and "error" not in limits: 136 | save_limits_snapshot( 137 | session_pct=limits["session_pct"], 138 | week_pct=limits["week_pct"], 139 | opus_pct=limits["opus_pct"], 140 | session_reset=limits["session_reset"], 141 | week_reset=limits["week_reset"], 142 | opus_reset=limits["opus_reset"], 143 | ) 144 | 145 | # Load data from database 146 | with console.status(f"[bold #ff8800]Loading data for {year_filter}...", spinner="dots", spinner_style="#ff8800"): 147 | all_records = load_historical_records() 148 | 149 | if not all_records: 150 | console.print("[yellow]No usage data found in database. Run 'ccg usage' to ingest data first.[/yellow]") 151 | return 152 | 153 | stats = aggregate_all(all_records) 154 | 155 | # Load limits data and tracking mode 156 | limits_data = get_limits_data() 157 | tracking_mode = get_tracking_mode() 158 | 159 | console.print(f"[cyan]Exporting to {format_type.upper()}...[/cyan]") 160 | 161 | if format_type == "png": 162 | export_heatmap_png(stats, output_path, limits_data=limits_data, year=year_filter, tracking_mode=tracking_mode) 163 | else: 164 | export_heatmap_svg(stats, output_path, year=year_filter) 165 | 166 | console.print(f"[green]✓ Exported to: {output_path.absolute()}[/green]") 167 | 168 | # Open the file if --open flag is present 169 | if should_open: 170 | console.print(f"[cyan]Opening {format_type.upper()}...[/cyan]") 171 | open_file(output_path) 172 | 173 | except ImportError as e: 174 | console.print(f"[red]{e}[/red]") 175 | except Exception as e: 176 | console.print(f"[red]Error exporting: {e}[/red]") 177 | import traceback 178 | traceback.print_exc() 179 | 180 | 181 | #endregion 182 | ``` -------------------------------------------------------------------------------- /src/hooks/manager.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import json 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from rich.console import Console 7 | 8 | from src.hooks import usage, audio, png, audio_tts 9 | #endregion 10 | 11 | 12 | #region Functions 13 | 14 | 15 | def setup_hooks(console: Console, hook_type: Optional[str] = None) -> None: 16 | """ 17 | Set up Claude Code hooks for automation. 18 | 19 | Args: 20 | console: Rich console for output 21 | hook_type: Type of hook to set up ('usage', 'audio', 'png', or None for menu) 22 | """ 23 | settings_path = Path.home() / ".claude" / "settings.json" 24 | 25 | if hook_type is None: 26 | # Show menu 27 | console.print("[bold cyan]Available hooks to set up:[/bold cyan]\n") 28 | console.print(" [bold]usage[/bold] - Auto-track usage after each response") 29 | console.print(" [bold]audio[/bold] - Play sounds for completion & permission requests") 30 | console.print(" [bold]audio-tts[/bold] - Speak permission requests using TTS (macOS only)") 31 | console.print(" [bold]png[/bold] - Auto-update usage PNG after each response\n") 32 | console.print("Usage: ccg setup-hooks <type>") 33 | console.print("Example: ccg setup-hooks usage") 34 | return 35 | 36 | console.print(f"[bold cyan]Setting up {hook_type} hook[/bold cyan]\n") 37 | 38 | try: 39 | # Read existing settings 40 | if settings_path.exists(): 41 | with open(settings_path, "r") as f: 42 | settings = json.load(f) 43 | else: 44 | settings = {} 45 | 46 | # Initialize hooks structure 47 | if "hooks" not in settings: 48 | settings["hooks"] = {} 49 | 50 | if "Stop" not in settings["hooks"]: 51 | settings["hooks"]["Stop"] = [] 52 | 53 | if "Notification" not in settings["hooks"]: 54 | settings["hooks"]["Notification"] = [] 55 | 56 | # Delegate to specific hook module 57 | if hook_type == "usage": 58 | usage.setup(console, settings, settings_path) 59 | elif hook_type == "audio": 60 | audio.setup(console, settings, settings_path) 61 | elif hook_type == "audio-tts": 62 | audio_tts.setup(console, settings, settings_path) 63 | elif hook_type == "png": 64 | png.setup(console, settings, settings_path) 65 | else: 66 | console.print(f"[red]Unknown hook type: {hook_type}[/red]") 67 | console.print("Valid types: usage, audio, audio-tts, png") 68 | return 69 | 70 | # Write settings back 71 | with open(settings_path, "w") as f: 72 | json.dump(settings, f, indent=2) 73 | 74 | console.print("\n[dim]Hook location: ~/.claude/settings.json[/dim]") 75 | console.print(f"[dim]To remove: ccg remove-hooks {hook_type}[/dim]") 76 | 77 | except Exception as e: 78 | console.print(f"[red]Error setting up hooks: {e}[/red]") 79 | import traceback 80 | traceback.print_exc() 81 | 82 | 83 | def remove_hooks(console: Console, hook_type: Optional[str] = None) -> None: 84 | """ 85 | Remove Claude Code hooks configured by this tool. 86 | 87 | Args: 88 | console: Rich console for output 89 | hook_type: Type of hook to remove ('usage', 'audio', 'png', or None for all) 90 | """ 91 | settings_path = Path.home() / ".claude" / "settings.json" 92 | 93 | if not settings_path.exists(): 94 | console.print("[yellow]No Claude Code settings file found.[/yellow]") 95 | return 96 | 97 | console.print(f"[bold cyan]Removing hooks[/bold cyan]\n") 98 | 99 | try: 100 | # Read existing settings 101 | with open(settings_path, "r") as f: 102 | settings = json.load(f) 103 | 104 | if "hooks" not in settings: 105 | console.print("[yellow]No hooks configured.[/yellow]") 106 | return 107 | 108 | # Initialize hook lists if they don't exist 109 | if "Stop" not in settings["hooks"]: 110 | settings["hooks"]["Stop"] = [] 111 | if "Notification" not in settings["hooks"]: 112 | settings["hooks"]["Notification"] = [] 113 | if "PreCompact" not in settings["hooks"]: 114 | settings["hooks"]["PreCompact"] = [] 115 | 116 | original_stop_count = len(settings["hooks"]["Stop"]) 117 | original_notification_count = len(settings["hooks"]["Notification"]) 118 | original_precompact_count = len(settings["hooks"]["PreCompact"]) 119 | 120 | # Remove hooks based on type 121 | if hook_type == "usage": 122 | settings["hooks"]["Stop"] = [ 123 | hook for hook in settings["hooks"]["Stop"] 124 | if not usage.is_hook(hook) 125 | ] 126 | removed_type = "usage tracking" 127 | elif hook_type == "audio": 128 | settings["hooks"]["Stop"] = [ 129 | hook for hook in settings["hooks"]["Stop"] 130 | if not audio.is_hook(hook) 131 | ] 132 | settings["hooks"]["Notification"] = [ 133 | hook for hook in settings["hooks"]["Notification"] 134 | if not audio.is_hook(hook) 135 | ] 136 | settings["hooks"]["PreCompact"] = [ 137 | hook for hook in settings["hooks"]["PreCompact"] 138 | if not audio.is_hook(hook) 139 | ] 140 | removed_type = "audio notification" 141 | elif hook_type == "audio-tts": 142 | settings["hooks"]["Notification"] = [ 143 | hook for hook in settings["hooks"]["Notification"] 144 | if not audio_tts.is_hook(hook) 145 | ] 146 | settings["hooks"]["Stop"] = [ 147 | hook for hook in settings["hooks"]["Stop"] 148 | if not audio_tts.is_hook(hook) 149 | ] 150 | settings["hooks"]["PreCompact"] = [ 151 | hook for hook in settings["hooks"]["PreCompact"] 152 | if not audio_tts.is_hook(hook) 153 | ] 154 | removed_type = "audio TTS" 155 | elif hook_type == "png": 156 | settings["hooks"]["Stop"] = [ 157 | hook for hook in settings["hooks"]["Stop"] 158 | if not png.is_hook(hook) 159 | ] 160 | removed_type = "PNG auto-update" 161 | else: 162 | # Remove all our hooks 163 | settings["hooks"]["Stop"] = [ 164 | hook for hook in settings["hooks"]["Stop"] 165 | if not (usage.is_hook(hook) or audio.is_hook(hook) or png.is_hook(hook)) 166 | ] 167 | settings["hooks"]["Notification"] = [ 168 | hook for hook in settings["hooks"]["Notification"] 169 | if not (usage.is_hook(hook) or audio.is_hook(hook) or png.is_hook(hook) or audio_tts.is_hook(hook)) 170 | ] 171 | settings["hooks"]["PreCompact"] = [ 172 | hook for hook in settings["hooks"]["PreCompact"] 173 | if not (audio.is_hook(hook) or audio_tts.is_hook(hook)) 174 | ] 175 | removed_type = "all claude-goblin" 176 | 177 | removed_count = (original_stop_count - len(settings["hooks"]["Stop"])) + \ 178 | (original_notification_count - len(settings["hooks"]["Notification"])) + \ 179 | (original_precompact_count - len(settings["hooks"]["PreCompact"])) 180 | 181 | if removed_count == 0: 182 | console.print(f"[yellow]No {removed_type} hooks found to remove.[/yellow]") 183 | return 184 | 185 | # Write settings back 186 | with open(settings_path, "w") as f: 187 | json.dump(settings, f, indent=2) 188 | 189 | console.print(f"[green]✓ Removed {removed_count} {removed_type} hook(s)[/green]") 190 | console.print(f"[dim]Settings file: ~/.claude/settings.json[/dim]") 191 | 192 | except Exception as e: 193 | console.print(f"[red]Error removing hooks: {e}[/red]") 194 | import traceback 195 | traceback.print_exc() 196 | 197 | 198 | #endregion 199 | ``` -------------------------------------------------------------------------------- /src/commands/usage.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import sys 3 | import time 4 | from pathlib import Path 5 | 6 | from rich.console import Console 7 | 8 | from src.aggregation.daily_stats import aggregate_all 9 | from src.commands.limits import capture_limits 10 | from src.config.settings import ( 11 | DEFAULT_REFRESH_INTERVAL, 12 | get_claude_jsonl_files, 13 | ) 14 | from src.config.user_config import get_storage_mode, get_tracking_mode 15 | from src.data.jsonl_parser import parse_all_jsonl_files 16 | from src.storage.snapshot_db import ( 17 | get_database_stats, 18 | load_historical_records, 19 | save_limits_snapshot, 20 | save_snapshot, 21 | ) 22 | from src.visualization.dashboard import render_dashboard 23 | #endregion 24 | 25 | 26 | #region Functions 27 | 28 | 29 | def run(console: Console, live: bool = False, fast: bool = False, anon: bool = False) -> None: 30 | """ 31 | Handle the usage command. 32 | 33 | Loads Claude Code usage data and displays a dashboard with GitHub-style 34 | activity graph and statistics. Supports live refresh mode. 35 | 36 | Args: 37 | console: Rich console for output 38 | live: Enable auto-refresh mode (default: False) 39 | fast: Skip limits fetching for faster rendering (default: False) 40 | anon: Anonymize project names to project-001, project-002, etc (default: False) 41 | 42 | Exit: 43 | Exits with status 0 on success, 1 on error 44 | """ 45 | # Check sys.argv for backward compatibility (hooks still use old style) 46 | run_live = live or "--live" in sys.argv 47 | skip_limits = fast or "--fast" in sys.argv 48 | anonymize = anon or "--anon" in sys.argv 49 | 50 | try: 51 | with console.status("[bold #ff8800]Loading Claude Code usage data...", spinner="dots", spinner_style="#ff8800"): 52 | jsonl_files = get_claude_jsonl_files() 53 | 54 | if not jsonl_files: 55 | console.print( 56 | "[yellow]No Claude Code data found. " 57 | "Make sure you've used Claude Code at least once.[/yellow]" 58 | ) 59 | return 60 | 61 | console.print(f"[dim]Found {len(jsonl_files)} session files[/dim]", end="") 62 | 63 | # Run with or without live refresh 64 | if run_live: 65 | _run_live_dashboard(jsonl_files, console, skip_limits, anonymize) 66 | else: 67 | _display_dashboard(jsonl_files, console, skip_limits, anonymize) 68 | 69 | except FileNotFoundError as e: 70 | console.print(f"[red]Error: {e}[/red]") 71 | sys.exit(1) 72 | except KeyboardInterrupt: 73 | console.print("\n[cyan]Exiting...[/cyan]") 74 | sys.exit(0) 75 | except Exception as e: 76 | console.print(f"[red]Unexpected error: {e}[/red]") 77 | import traceback 78 | traceback.print_exc() 79 | sys.exit(1) 80 | 81 | 82 | def _run_live_dashboard(jsonl_files: list[Path], console: Console, skip_limits: bool = False, anonymize: bool = False) -> None: 83 | """ 84 | Run dashboard with auto-refresh. 85 | 86 | Args: 87 | jsonl_files: List of JSONL files to parse 88 | console: Rich console for output 89 | skip_limits: Skip limits fetching for faster rendering 90 | anonymize: Anonymize project names 91 | """ 92 | console.print( 93 | f"[dim]Auto-refreshing every {DEFAULT_REFRESH_INTERVAL} seconds. " 94 | "Press Ctrl+C to exit.[/dim]\n" 95 | ) 96 | 97 | while True: 98 | try: 99 | _display_dashboard(jsonl_files, console, skip_limits, anonymize) 100 | time.sleep(DEFAULT_REFRESH_INTERVAL) 101 | except KeyboardInterrupt: 102 | raise 103 | 104 | 105 | def _display_dashboard(jsonl_files: list[Path], console: Console, skip_limits: bool = False, anonymize: bool = False) -> None: 106 | """ 107 | Ingest JSONL data and display dashboard. 108 | 109 | This performs two steps: 110 | 1. Ingestion: Read JSONL files and save to DB (with deduplication) 111 | 2. Display: Read from DB and render dashboard 112 | 113 | Args: 114 | jsonl_files: List of JSONL files to parse 115 | console: Rich console for output 116 | skip_limits: Skip ALL updates, read directly from DB (fast mode) 117 | anonymize: Anonymize project names to project-001, project-002, etc 118 | """ 119 | from src.storage.snapshot_db import get_latest_limits, DEFAULT_DB_PATH, get_database_stats 120 | 121 | # Check if database exists when using --fast 122 | if skip_limits and not DEFAULT_DB_PATH.exists(): 123 | console.clear() 124 | console.print("[red]Error: Cannot use --fast flag without existing database.[/red]") 125 | console.print("[yellow]Run 'ccg usage' (without --fast) first to create the database.[/yellow]") 126 | return 127 | 128 | # Update data unless in fast mode 129 | if not skip_limits: 130 | # Step 1: Update usage data 131 | with console.status("[bold #ff8800]Updating usage data...", spinner="dots", spinner_style="#ff8800"): 132 | current_records = parse_all_jsonl_files(jsonl_files) 133 | 134 | # Save to database (with automatic deduplication via UNIQUE constraint) 135 | if current_records: 136 | save_snapshot(current_records, storage_mode=get_storage_mode()) 137 | 138 | # Step 2: Update limits data (if enabled) 139 | tracking_mode = get_tracking_mode() 140 | if tracking_mode in ["both", "limits"]: 141 | with console.status("[bold #ff8800]Updating usage limits...", spinner="dots", spinner_style="#ff8800"): 142 | limits = capture_limits() 143 | if limits and "error" not in limits: 144 | save_limits_snapshot( 145 | session_pct=limits["session_pct"], 146 | week_pct=limits["week_pct"], 147 | opus_pct=limits["opus_pct"], 148 | session_reset=limits["session_reset"], 149 | week_reset=limits["week_reset"], 150 | opus_reset=limits["opus_reset"], 151 | ) 152 | 153 | # Step 3: Prepare dashboard from database 154 | with console.status("[bold #ff8800]Preparing dashboard...", spinner="dots", spinner_style="#ff8800"): 155 | all_records = load_historical_records() 156 | 157 | # Get latest limits from DB (if we saved them above or if they exist) 158 | limits_from_db = get_latest_limits() 159 | 160 | if not all_records: 161 | console.clear() 162 | console.print( 163 | "[yellow]No usage data found in database. Run --update-usage to ingest data.[/yellow]" 164 | ) 165 | return 166 | 167 | # Clear screen before displaying dashboard 168 | console.clear() 169 | 170 | # Get date range for footer 171 | dates = sorted(set(r.date_key for r in all_records)) 172 | date_range = None 173 | if dates: 174 | date_range = f"{dates[0]} to {dates[-1]}" 175 | 176 | # Anonymize project names if requested 177 | if anonymize: 178 | all_records = _anonymize_projects(all_records) 179 | 180 | # Aggregate statistics 181 | stats = aggregate_all(all_records) 182 | 183 | # Render dashboard with limits from DB (no live fetch needed) 184 | render_dashboard(stats, all_records, console, skip_limits=True, clear_screen=False, date_range=date_range, limits_from_db=limits_from_db, fast_mode=skip_limits) 185 | 186 | 187 | def _anonymize_projects(records: list) -> list: 188 | """ 189 | Anonymize project folder names by ranking them by total tokens and replacing 190 | with project-001, project-002, etc (where project-001 is the highest usage). 191 | 192 | Args: 193 | records: List of UsageRecord objects 194 | 195 | Returns: 196 | List of UsageRecord objects with anonymized folder names 197 | """ 198 | from collections import defaultdict 199 | from dataclasses import replace 200 | 201 | # Calculate total tokens per project 202 | project_totals = defaultdict(int) 203 | for record in records: 204 | if record.token_usage: 205 | project_totals[record.folder] += record.token_usage.total_tokens 206 | 207 | # Sort projects by total tokens (descending) and create mapping 208 | sorted_projects = sorted(project_totals.items(), key=lambda x: x[1], reverse=True) 209 | project_mapping = { 210 | folder: f"project-{str(i+1).zfill(3)}" 211 | for i, (folder, _) in enumerate(sorted_projects) 212 | } 213 | 214 | # Replace folder names in records 215 | anonymized_records = [] 216 | for record in records: 217 | anonymized_records.append( 218 | replace(record, folder=project_mapping.get(record.folder, record.folder)) 219 | ) 220 | 221 | return anonymized_records 222 | 223 | 224 | #endregion 225 | ``` -------------------------------------------------------------------------------- /src/aggregation/usage_limits.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | from datetime import datetime, timedelta, timezone 3 | from typing import Optional 4 | from dataclasses import dataclass 5 | 6 | from src.models.usage_record import UsageRecord 7 | #endregion 8 | 9 | 10 | #region Constants 11 | # Known token limits per 5-hour session (from community research) 12 | SESSION_LIMITS = { 13 | "pro": 44_000, 14 | "max_5x": 88_000, 15 | "max_20x": 220_000, 16 | } 17 | 18 | # Weekly limits (estimated based on usage data) 19 | # These are approximate - Claude doesn't publish exact limits 20 | WEEKLY_LIMITS = { 21 | "pro": { 22 | "total": 300_000, # Rough estimate for total weekly tokens 23 | "opus": 0, # Pro doesn't get Opus access 24 | }, 25 | "max_5x": { 26 | "total": 1_500_000, # Rough estimate 27 | "opus": 150_000, # Switches at 20% usage 28 | }, 29 | "max_20x": { 30 | "total": 3_000_000, # Rough estimate 31 | "opus": 300_000, # Switches at 50% usage 32 | }, 33 | } 34 | #endregion 35 | 36 | 37 | #region Data Classes 38 | 39 | 40 | @dataclass 41 | class SessionUsage: 42 | """Usage data for a single 5-hour session.""" 43 | session_id: str 44 | start_time: datetime 45 | end_time: datetime 46 | total_tokens: int 47 | input_tokens: int 48 | output_tokens: int 49 | cache_creation_tokens: int 50 | cache_read_tokens: int 51 | records: list[UsageRecord] 52 | 53 | 54 | @dataclass 55 | class WeeklyUsage: 56 | """Usage data for a week (7 days).""" 57 | start_date: datetime 58 | end_date: datetime 59 | total_tokens: int 60 | opus_tokens: int 61 | sonnet_tokens: int 62 | haiku_tokens: int 63 | sessions: list[SessionUsage] 64 | 65 | 66 | @dataclass 67 | class UsageLimits: 68 | """Usage limits and current usage percentages.""" 69 | plan_type: str 70 | 71 | # Current session (5-hour window) 72 | current_session_tokens: int 73 | session_limit: int 74 | session_percentage: float 75 | session_reset_time: Optional[datetime] 76 | 77 | # Current week (7 days) 78 | current_week_tokens: int 79 | week_limit: int 80 | week_percentage: float 81 | week_reset_time: Optional[datetime] 82 | 83 | # Opus-specific (for Max plans) 84 | current_week_opus_tokens: int 85 | opus_limit: int 86 | opus_percentage: float 87 | #endregion 88 | 89 | 90 | #region Functions 91 | 92 | 93 | def get_current_session_usage( 94 | records: list[UsageRecord], 95 | session_window_hours: int = 5 96 | ) -> tuple[int, Optional[datetime]]: 97 | """ 98 | Calculate token usage for the current 5-hour session window. 99 | 100 | Claude's usage limits are based on rolling 5-hour windows. A session starts 101 | with the first message and expires 5 hours later. 102 | 103 | Args: 104 | records: List of usage records 105 | session_window_hours: Hours in the session window (default: 5) 106 | 107 | Returns: 108 | Tuple of (total_tokens, session_reset_time) 109 | 110 | Common failure modes: 111 | - Empty records list returns (0, None) 112 | - Records without timestamps are skipped 113 | """ 114 | if not records: 115 | return 0, None 116 | 117 | # Sort records by timestamp (most recent first) 118 | sorted_records = sorted( 119 | records, 120 | key=lambda r: r.timestamp, 121 | reverse=True 122 | ) 123 | 124 | # Find the most recent session 125 | now = datetime.now(timezone.utc) 126 | session_window = timedelta(hours=session_window_hours) 127 | 128 | # The current session started with the most recent message 129 | most_recent = sorted_records[0] 130 | session_start = most_recent.timestamp 131 | session_end = session_start + session_window 132 | 133 | # Calculate tokens used in this session window 134 | total_tokens = 0 135 | for record in sorted_records: 136 | # Ensure timezone-aware comparison 137 | record_time = record.timestamp 138 | if record_time.tzinfo is None: 139 | record_time = record_time.replace(tzinfo=timezone.utc) 140 | 141 | # Only count records within the current session window 142 | if session_start <= record_time <= now: 143 | if record.token_usage: 144 | total_tokens += record.token_usage.total_tokens 145 | else: 146 | # Records are sorted, so we can break early 147 | break 148 | 149 | return total_tokens, session_end 150 | 151 | 152 | def get_weekly_usage( 153 | records: list[UsageRecord], 154 | weeks_back: int = 0 155 | ) -> WeeklyUsage: 156 | """ 157 | Calculate token usage for the current or past week. 158 | 159 | Args: 160 | records: List of usage records 161 | weeks_back: Number of weeks to look back (0 = current week) 162 | 163 | Returns: 164 | WeeklyUsage object with token totals by model 165 | 166 | Common failure modes: 167 | - Empty records list returns WeeklyUsage with all zeros 168 | - Records without token_usage are skipped 169 | """ 170 | now = datetime.now(timezone.utc) 171 | 172 | # Calculate week boundaries 173 | # Week starts on Monday (isoweekday() returns 1 for Monday) 174 | days_since_monday = now.isoweekday() - 1 175 | week_start = (now - timedelta(days=days_since_monday + (weeks_back * 7))).replace( 176 | hour=0, minute=0, second=0, microsecond=0 177 | ) 178 | week_end = week_start + timedelta(days=7) 179 | 180 | # Filter records within the week 181 | total_tokens = 0 182 | opus_tokens = 0 183 | sonnet_tokens = 0 184 | haiku_tokens = 0 185 | 186 | for record in records: 187 | # Ensure timezone-aware comparison 188 | record_time = record.timestamp 189 | if record_time.tzinfo is None: 190 | record_time = record_time.replace(tzinfo=timezone.utc) 191 | 192 | if week_start <= record_time < week_end: 193 | if record.token_usage: 194 | tokens = record.token_usage.total_tokens 195 | total_tokens += tokens 196 | 197 | # Categorize by model 198 | if record.model and "opus" in record.model.lower(): 199 | opus_tokens += tokens 200 | elif record.model and "sonnet" in record.model.lower(): 201 | sonnet_tokens += tokens 202 | elif record.model and "haiku" in record.model.lower(): 203 | haiku_tokens += tokens 204 | 205 | return WeeklyUsage( 206 | start_date=week_start, 207 | end_date=week_end, 208 | total_tokens=total_tokens, 209 | opus_tokens=opus_tokens, 210 | sonnet_tokens=sonnet_tokens, 211 | haiku_tokens=haiku_tokens, 212 | sessions=[], 213 | ) 214 | 215 | 216 | def calculate_usage_limits( 217 | records: list[UsageRecord], 218 | plan_type: str = "max_20x" 219 | ) -> UsageLimits: 220 | """ 221 | Calculate usage limits and percentages for the current session and week. 222 | 223 | This function provides the same percentage calculations that Claude's /usage 224 | command shows, based on known plan limits. 225 | 226 | Args: 227 | records: List of usage records 228 | plan_type: One of "pro", "max_5x", "max_20x" 229 | 230 | Returns: 231 | UsageLimits object with current usage and percentages 232 | 233 | Common failure modes: 234 | - Invalid plan_type defaults to "max_20x" 235 | - Empty records list returns all zeros 236 | """ 237 | if plan_type not in SESSION_LIMITS: 238 | plan_type = "max_20x" 239 | 240 | # Get session usage 241 | session_tokens, session_reset = get_current_session_usage(records) 242 | session_limit = SESSION_LIMITS[plan_type] 243 | session_percentage = (session_tokens / session_limit * 100) if session_limit > 0 else 0.0 244 | 245 | # Get weekly usage 246 | weekly = get_weekly_usage(records) 247 | week_limit = WEEKLY_LIMITS[plan_type]["total"] 248 | week_percentage = (weekly.total_tokens / week_limit * 100) if week_limit > 0 else 0.0 249 | 250 | # Get Opus-specific usage 251 | opus_limit = WEEKLY_LIMITS[plan_type]["opus"] 252 | opus_percentage = (weekly.opus_tokens / opus_limit * 100) if opus_limit > 0 else 0.0 253 | 254 | # Calculate week reset time (next Monday at 00:00) 255 | now = datetime.now(timezone.utc) 256 | days_until_monday = (7 - now.isoweekday() + 1) % 7 257 | if days_until_monday == 0: 258 | days_until_monday = 7 259 | week_reset = (now + timedelta(days=days_until_monday)).replace( 260 | hour=0, minute=0, second=0, microsecond=0 261 | ) 262 | 263 | return UsageLimits( 264 | plan_type=plan_type, 265 | current_session_tokens=session_tokens, 266 | session_limit=session_limit, 267 | session_percentage=session_percentage, 268 | session_reset_time=session_reset, 269 | current_week_tokens=weekly.total_tokens, 270 | week_limit=week_limit, 271 | week_percentage=week_percentage, 272 | week_reset_time=week_reset, 273 | current_week_opus_tokens=weekly.opus_tokens, 274 | opus_limit=opus_limit, 275 | opus_percentage=opus_percentage, 276 | ) 277 | 278 | 279 | #endregion 280 | ``` -------------------------------------------------------------------------------- /src/commands/stats.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import sys 3 | from datetime import datetime 4 | 5 | from rich.console import Console 6 | 7 | from src.commands.limits import capture_limits 8 | from src.config.settings import get_claude_jsonl_files 9 | from src.config.user_config import get_storage_mode, get_tracking_mode 10 | from src.data.jsonl_parser import parse_all_jsonl_files 11 | from src.storage.snapshot_db import ( 12 | DEFAULT_DB_PATH, 13 | get_database_stats, 14 | get_text_analysis_stats, 15 | save_limits_snapshot, 16 | save_snapshot, 17 | ) 18 | #endregion 19 | 20 | 21 | #region Functions 22 | 23 | 24 | def run(console: Console, fast: bool = False) -> None: 25 | """ 26 | Show statistics about the historical database. 27 | 28 | Displays comprehensive statistics including: 29 | - Summary: total tokens, prompts, responses, sessions, days tracked 30 | - Cost analysis: estimated API costs vs Max Plan costs 31 | - Averages: tokens per session/response, cost per session/response 32 | - Text analysis: prompt length, politeness markers, phrase counts 33 | - Usage by model: token distribution across different models 34 | 35 | Args: 36 | console: Rich console for output 37 | fast: Skip updates, read directly from database (default: False) 38 | """ 39 | # Check for --fast flag in sys.argv for backward compatibility 40 | fast_mode = fast or "--fast" in sys.argv 41 | 42 | # Check if database exists when using --fast 43 | if fast_mode and not DEFAULT_DB_PATH.exists(): 44 | console.print("[red]Error: Cannot use --fast flag without existing database.[/red]") 45 | console.print("[yellow]Run 'ccg stats' (without --fast) first to create the database.[/yellow]") 46 | return 47 | 48 | # If fast mode, show warning with last update timestamp 49 | if fast_mode: 50 | db_stats_temp = get_database_stats() 51 | if db_stats_temp.get("newest_timestamp"): 52 | # Format ISO timestamp to be more readable 53 | timestamp_str = db_stats_temp["newest_timestamp"] 54 | try: 55 | dt = datetime.fromisoformat(timestamp_str) 56 | formatted_time = dt.strftime("%Y-%m-%d %H:%M:%S") 57 | console.print(f"[bold red]⚠ Fast mode: Reading from last update ({formatted_time})[/bold red]\n") 58 | except (ValueError, AttributeError): 59 | console.print(f"[bold red]⚠ Fast mode: Reading from last update ({timestamp_str})[/bold red]\n") 60 | else: 61 | console.print("[bold red]⚠ Fast mode: Reading from database (no timestamp available)[/bold red]\n") 62 | 63 | # Update data unless in fast mode 64 | if not fast_mode: 65 | # Step 1: Ingestion - parse JSONL and save to DB 66 | with console.status("[bold #ff8800]Updating database...", spinner="dots", spinner_style="#ff8800"): 67 | jsonl_files = get_claude_jsonl_files() 68 | if jsonl_files: 69 | current_records = parse_all_jsonl_files(jsonl_files) 70 | if current_records: 71 | save_snapshot(current_records, storage_mode=get_storage_mode()) 72 | 73 | # Step 2: Update limits data (if enabled) 74 | tracking_mode = get_tracking_mode() 75 | if tracking_mode in ["both", "limits"]: 76 | with console.status("[bold #ff8800]Updating usage limits...", spinner="dots", spinner_style="#ff8800"): 77 | limits = capture_limits() 78 | if limits and "error" not in limits: 79 | save_limits_snapshot( 80 | session_pct=limits["session_pct"], 81 | week_pct=limits["week_pct"], 82 | opus_pct=limits["opus_pct"], 83 | session_reset=limits["session_reset"], 84 | week_reset=limits["week_reset"], 85 | opus_reset=limits["opus_reset"], 86 | ) 87 | 88 | # Step 3: Display stats from DB 89 | db_stats = get_database_stats() 90 | 91 | if db_stats["total_records"] == 0 and db_stats["total_prompts"] == 0: 92 | console.print("[yellow]No historical data found. Run ccg usage to start tracking.[/yellow]") 93 | return 94 | 95 | console.print("[bold cyan]Claude Code Usage Statistics[/bold cyan]\n") 96 | 97 | # Summary Statistics 98 | console.print("[bold]Summary[/bold]") 99 | console.print(f" Total Tokens: {db_stats['total_tokens']:>15,}") 100 | console.print(f" Total Prompts: {db_stats['total_prompts']:>15,}") 101 | console.print(f" Total Responses: {db_stats['total_responses']:>15,}") 102 | console.print(f" Total Sessions: {db_stats['total_sessions']:>15,}") 103 | console.print(f" Days Tracked: {db_stats['total_days']:>15,}") 104 | console.print(f" Date Range: {db_stats['oldest_date']} to {db_stats['newest_date']}") 105 | 106 | # Cost Summary (if using API pricing) 107 | if db_stats['total_cost'] > 0: 108 | # Calculate actual months covered from date range 109 | start_date = datetime.strptime(db_stats['oldest_date'], "%Y-%m-%d") 110 | end_date = datetime.strptime(db_stats['newest_date'], "%Y-%m-%d") 111 | 112 | # Count unique months covered 113 | months_covered = set() 114 | current = start_date 115 | while current <= end_date: 116 | months_covered.add((current.year, current.month)) 117 | # Move to next month 118 | if current.month == 12: 119 | current = current.replace(year=current.year + 1, month=1, day=1) 120 | else: 121 | current = current.replace(month=current.month + 1, day=1) 122 | 123 | num_months = len(months_covered) 124 | plan_cost = num_months * 200.0 # $200/month Max Plan 125 | savings = db_stats['total_cost'] - plan_cost 126 | 127 | console.print(f"\n[bold]Cost Analysis[/bold]") 128 | console.print(f" Est. Cost (if using API): ${db_stats['total_cost']:>10,.2f}") 129 | console.print(f" Plan Cost: ${plan_cost:>14,.2f} ({num_months} month{'s' if num_months > 1 else ''} @ $200/mo)") 130 | 131 | if savings > 0: 132 | console.print(f" You Saved: ${savings:>14,.2f} (vs API)") 133 | else: 134 | overpaid = abs(savings) 135 | console.print(f" Plan Costs More: ${overpaid:>14,.2f}") 136 | console.print(f" [dim]Light usage - API would be cheaper[/dim]") 137 | 138 | # Averages 139 | console.print(f"\n[bold]Averages[/bold]") 140 | console.print(f" Tokens per Session: {db_stats['avg_tokens_per_session']:>15,}") 141 | console.print(f" Tokens per Response: {db_stats['avg_tokens_per_response']:>15,}") 142 | if db_stats['total_cost'] > 0: 143 | console.print(f" Cost per Session: ${db_stats['avg_cost_per_session']:>14,.2f}") 144 | console.print(f" Cost per Response: ${db_stats['avg_cost_per_response']:>14,.4f}") 145 | 146 | # Text Analysis (from current JSONL files) 147 | text_stats = get_text_analysis_stats() 148 | 149 | if text_stats["avg_user_prompt_chars"] > 0: 150 | console.print(f"\n[bold]Text Analysis[/bold]") 151 | console.print(f" Avg Prompt Length: {text_stats['avg_user_prompt_chars']:>15,} chars") 152 | console.print(f" User Swears: {text_stats['user_swears']:>15,}") 153 | console.print(f" Claude Swears: {text_stats['assistant_swears']:>15,}") 154 | console.print(f" User Thanks: {text_stats['user_thanks']:>15,}") 155 | console.print(f" User Please: {text_stats['user_please']:>15,}") 156 | console.print(f" Claude \"Perfect!\"/\"Excellent!\": {text_stats['perfect_count']:>10,}") 157 | console.print(f" Claude \"You're absolutely right!\": {text_stats['absolutely_right_count']:>6,}") 158 | 159 | # Tokens by Model 160 | if db_stats["tokens_by_model"]: 161 | console.print(f"\n[bold]Usage by Model[/bold]") 162 | for model, tokens in db_stats["tokens_by_model"].items(): 163 | percentage = (tokens / db_stats['total_tokens'] * 100) if db_stats['total_tokens'] > 0 else 0 164 | cost = db_stats["cost_by_model"].get(model, 0.0) 165 | if cost > 0: 166 | console.print(f" {model:30s} {tokens:>15,} ({percentage:5.1f}%) ${cost:>10,.2f}") 167 | else: 168 | console.print(f" {model:30s} {tokens:>15,} ({percentage:5.1f}%)") 169 | 170 | # Database Info 171 | console.print(f"\n[dim]Database: ~/.claude/usage/usage_history.db[/dim]") 172 | if db_stats["total_records"] > 0: 173 | console.print(f"[dim]Detail records: {db_stats['total_records']:,} (full analytics mode)[/dim]") 174 | else: 175 | console.print(f"[dim]Storage mode: aggregate (daily totals only)[/dim]") 176 | 177 | 178 | #endregion 179 | ``` -------------------------------------------------------------------------------- /src/hooks/audio.py: -------------------------------------------------------------------------------- ```python 1 | #region Imports 2 | import platform 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from rich.console import Console 7 | 8 | from src.utils._system import get_sound_command 9 | #endregion 10 | 11 | 12 | #region Functions 13 | 14 | 15 | def setup(console: Console, settings: dict, settings_path: Path) -> None: 16 | """ 17 | Set up the audio notification hook. 18 | 19 | Args: 20 | console: Rich console for output 21 | settings: Settings dictionary to modify 22 | settings_path: Path to settings.json file 23 | """ 24 | # Offer sound choices 25 | console.print("[bold cyan]Choose notification sounds:[/bold cyan]\n") 26 | console.print("[dim]You'll pick three sounds: completion, permission requests, and conversation compaction[/dim]\n") 27 | 28 | # Check if audio-tts hook exists 29 | if "Notification" in settings.get("hooks", {}): 30 | from src.hooks import audio_tts 31 | existing_tts_hooks = [hook for hook in settings["hooks"]["Notification"] if audio_tts.is_hook(hook)] 32 | if existing_tts_hooks: 33 | console.print("[yellow]⚠ Warning: You already have an audio TTS hook configured.[/yellow]") 34 | console.print("[yellow]Setting up audio will replace it with simple sound notifications.[/yellow]\n") 35 | console.print("[dim]Continue? (y/n):[/dim] ", end="") 36 | try: 37 | user_input = input().strip().lower() 38 | if user_input != "y": 39 | console.print("[yellow]Cancelled[/yellow]") 40 | return 41 | except (EOFError, KeyboardInterrupt): 42 | console.print("\n[yellow]Cancelled[/yellow]") 43 | return 44 | console.print() 45 | 46 | system = platform.system() 47 | if system == "Darwin": 48 | sounds = [ 49 | ("Glass", "Clear glass sound (recommended for completion)"), 50 | ("Ping", "Short ping sound (recommended for permission)"), 51 | ("Purr", "Soft purr sound"), 52 | ("Tink", "Quick tink sound"), 53 | ("Pop", "Pop sound"), 54 | ("Basso", "Low bass sound"), 55 | ("Blow", "Blow sound"), 56 | ("Bottle", "Bottle sound"), 57 | ("Frog", "Frog sound"), 58 | ("Funk", "Funk sound"), 59 | ] 60 | elif system == "Windows": 61 | sounds = [ 62 | ("Windows Notify", "Default notification"), 63 | ("Windows Ding", "Ding sound"), 64 | ("chimes", "Chimes sound"), 65 | ("chord", "Chord sound"), 66 | ("notify", "System notify"), 67 | ] 68 | else: # Linux 69 | sounds = [ 70 | ("complete", "Completion sound"), 71 | ("bell", "Bell sound"), 72 | ("message", "Message sound"), 73 | ("dialog-information", "Info dialog"), 74 | ("service-login", "Login sound"), 75 | ] 76 | 77 | # Choose completion sound 78 | console.print("[bold]Sound for when Claude finishes responding:[/bold]") 79 | for idx, (name, desc) in enumerate(sounds, 1): 80 | console.print(f" {idx}. {name} - {desc}") 81 | 82 | console.print("\n[dim]Enter number (default: 1):[/dim] ", end="") 83 | 84 | try: 85 | user_input = input().strip() 86 | if user_input == "": 87 | completion_sound = sounds[0][0] 88 | elif user_input.isdigit() and 1 <= int(user_input) <= len(sounds): 89 | completion_sound = sounds[int(user_input) - 1][0] 90 | else: 91 | console.print("[yellow]Invalid selection, using default[/yellow]") 92 | completion_sound = sounds[0][0] 93 | except (EOFError, KeyboardInterrupt): 94 | console.print("\n[yellow]Cancelled[/yellow]") 95 | return 96 | 97 | # Choose permission sound 98 | console.print("\n[bold]Sound for when Claude requests permission:[/bold]") 99 | for idx, (name, desc) in enumerate(sounds, 1): 100 | console.print(f" {idx}. {name} - {desc}") 101 | 102 | console.print("\n[dim]Enter number (default: 2):[/dim] ", end="") 103 | 104 | try: 105 | user_input = input().strip() 106 | if user_input == "": 107 | # Default to second sound if available 108 | permission_sound = sounds[1][0] if len(sounds) > 1 else sounds[0][0] 109 | elif user_input.isdigit() and 1 <= int(user_input) <= len(sounds): 110 | permission_sound = sounds[int(user_input) - 1][0] 111 | else: 112 | console.print("[yellow]Invalid selection, using default[/yellow]") 113 | permission_sound = sounds[1][0] if len(sounds) > 1 else sounds[0][0] 114 | except (EOFError, KeyboardInterrupt): 115 | console.print("\n[yellow]Cancelled[/yellow]") 116 | return 117 | 118 | # Choose compaction sound 119 | console.print("\n[bold]Sound for before conversation compaction:[/bold]") 120 | for idx, (name, desc) in enumerate(sounds, 1): 121 | console.print(f" {idx}. {name} - {desc}") 122 | 123 | console.print("\n[dim]Enter number (default: 3):[/dim] ", end="") 124 | 125 | try: 126 | user_input = input().strip() 127 | if user_input == "": 128 | # Default to third sound if available 129 | compaction_sound = sounds[2][0] if len(sounds) > 2 else sounds[0][0] 130 | elif user_input.isdigit() and 1 <= int(user_input) <= len(sounds): 131 | compaction_sound = sounds[int(user_input) - 1][0] 132 | else: 133 | console.print("[yellow]Invalid selection, using default[/yellow]") 134 | compaction_sound = sounds[2][0] if len(sounds) > 2 else sounds[0][0] 135 | except (EOFError, KeyboardInterrupt): 136 | console.print("\n[yellow]Cancelled[/yellow]") 137 | return 138 | 139 | completion_command = get_sound_command(completion_sound) 140 | permission_command = get_sound_command(permission_sound) 141 | compaction_command = get_sound_command(compaction_sound) 142 | 143 | if not completion_command or not permission_command or not compaction_command: 144 | console.print("[red]Audio hooks not supported on this platform[/red]") 145 | return 146 | 147 | # Initialize hook structures 148 | if "Stop" not in settings["hooks"]: 149 | settings["hooks"]["Stop"] = [] 150 | if "Notification" not in settings["hooks"]: 151 | settings["hooks"]["Notification"] = [] 152 | if "PreCompact" not in settings["hooks"]: 153 | settings["hooks"]["PreCompact"] = [] 154 | 155 | # Remove existing audio hooks 156 | stop_removed = len(settings["hooks"]["Stop"]) 157 | notification_removed = len(settings["hooks"]["Notification"]) 158 | precompact_removed = len(settings["hooks"]["PreCompact"]) 159 | 160 | settings["hooks"]["Stop"] = [ 161 | hook for hook in settings["hooks"]["Stop"] 162 | if not is_hook(hook) 163 | ] 164 | # Remove both regular audio hooks and TTS hooks 165 | from src.hooks import audio_tts 166 | settings["hooks"]["Notification"] = [ 167 | hook for hook in settings["hooks"]["Notification"] 168 | if not is_hook(hook) and not audio_tts.is_hook(hook) 169 | ] 170 | settings["hooks"]["PreCompact"] = [ 171 | hook for hook in settings["hooks"]["PreCompact"] 172 | if not is_hook(hook) and not audio_tts.is_hook(hook) 173 | ] 174 | 175 | stop_removed = stop_removed > len(settings["hooks"]["Stop"]) 176 | notification_removed = notification_removed > len(settings["hooks"]["Notification"]) 177 | precompact_removed = precompact_removed > len(settings["hooks"]["PreCompact"]) 178 | 179 | # Add new hooks 180 | settings["hooks"]["Stop"].append({ 181 | "matcher": "*", 182 | "hooks": [{ 183 | "type": "command", 184 | "command": completion_command 185 | }] 186 | }) 187 | 188 | settings["hooks"]["Notification"].append({ 189 | "hooks": [{ 190 | "type": "command", 191 | "command": permission_command 192 | }] 193 | }) 194 | 195 | settings["hooks"]["PreCompact"].append({ 196 | "hooks": [{ 197 | "type": "command", 198 | "command": compaction_command 199 | }] 200 | }) 201 | 202 | if stop_removed or notification_removed or precompact_removed: 203 | console.print("[cyan]Replaced existing audio notification hooks[/cyan]") 204 | 205 | console.print(f"[green]✓ Successfully configured audio notification hooks[/green]") 206 | console.print("\n[bold]What this does:[/bold]") 207 | console.print(f" • Completion sound ({completion_sound}): Plays when Claude finishes responding") 208 | console.print(f" • Permission sound ({permission_sound}): Plays when Claude requests permission") 209 | console.print(f" • Compaction sound ({compaction_sound}): Plays before conversation compaction") 210 | console.print(" • All hooks run in the background") 211 | 212 | 213 | def is_hook(hook) -> bool: 214 | """ 215 | Check if a hook is an audio notification hook. 216 | 217 | Args: 218 | hook: Hook configuration dictionary 219 | 220 | Returns: 221 | True if this is an audio notification hook, False otherwise 222 | """ 223 | if not isinstance(hook, dict) or "hooks" not in hook: 224 | return False 225 | for h in hook.get("hooks", []): 226 | cmd = h.get("command", "") 227 | if any(audio_cmd in cmd for audio_cmd in ["afplay", "powershell", "paplay", "aplay"]): 228 | return True 229 | return False 230 | 231 | 232 | #endregion 233 | ``` -------------------------------------------------------------------------------- /src/cli.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Claude Goblin CLI - Command-line interface using typer. 3 | 4 | Main entry point for all claude-goblin commands. 5 | """ 6 | from typing import Optional 7 | import typer 8 | from rich.console import Console 9 | 10 | from src.commands import ( 11 | usage, 12 | update_usage, 13 | stats, 14 | export, 15 | delete_usage, 16 | restore_backup, 17 | help as help_cmd, 18 | limits, 19 | status_bar, 20 | ) 21 | from src.hooks.manager import setup_hooks, remove_hooks 22 | 23 | 24 | # Create typer app 25 | app = typer.Typer( 26 | name="claude-goblin", 27 | help="Python CLI for Claude Code utilities and usage tracking/analytics", 28 | add_completion=False, 29 | no_args_is_help=True, 30 | ) 31 | 32 | # Create console for commands 33 | console = Console() 34 | 35 | 36 | @app.command(name="usage") 37 | def usage_command( 38 | live: bool = typer.Option(False, "--live", help="Auto-refresh dashboard every 5 seconds"), 39 | fast: bool = typer.Option(False, "--fast", help="Skip updates, read from database only (faster)"), 40 | anon: bool = typer.Option(False, "--anon", help="Anonymize project names to project-001, project-002, etc"), 41 | ): 42 | """ 43 | Show usage dashboard with KPI cards and breakdowns. 44 | 45 | Displays comprehensive usage statistics including: 46 | - Total tokens, prompts, and sessions 47 | - Current usage limits (session, weekly, Opus) 48 | - Token breakdown by model 49 | - Token breakdown by project 50 | 51 | Use --live for auto-refreshing dashboard. 52 | Use --fast to skip all updates and read from database only (requires existing database). 53 | Use --anon to anonymize project names (ranked by usage, project-001 is highest). 54 | """ 55 | usage.run(console, live=live, fast=fast, anon=anon) 56 | 57 | 58 | @app.command(name="stats") 59 | def stats_command( 60 | fast: bool = typer.Option(False, "--fast", help="Skip updates, read from database only (faster)"), 61 | ): 62 | """ 63 | Show detailed statistics and cost analysis. 64 | 65 | Displays comprehensive statistics including: 66 | - Summary: total tokens, prompts, responses, sessions, days tracked 67 | - Cost analysis: estimated API costs vs Max Plan costs 68 | - Averages: tokens per session/response, cost per session/response 69 | - Text analysis: prompt length, politeness markers, phrase counts 70 | - Usage by model: token distribution across different models 71 | 72 | Use --fast to skip all updates and read from database only (requires existing database). 73 | """ 74 | stats.run(console, fast=fast) 75 | 76 | 77 | @app.command(name="limits") 78 | def limits_command(): 79 | """ 80 | Show current usage limits (session, week, Opus). 81 | 82 | Displays current usage percentages and reset times for: 83 | - Session limit (resets after inactivity) 84 | - Weekly limit for all models (resets weekly) 85 | - Weekly Opus limit (resets weekly) 86 | 87 | Note: Must be run from a trusted folder where Claude Code has been used. 88 | """ 89 | limits.run(console) 90 | 91 | 92 | @app.command(name="export") 93 | def export_command( 94 | svg: bool = typer.Option(False, "--svg", help="Export as SVG instead of PNG"), 95 | open_file: bool = typer.Option(False, "--open", help="Open file after export"), 96 | fast: bool = typer.Option(False, "--fast", help="Skip updates, read from database only (faster)"), 97 | year: Optional[int] = typer.Option(None, "--year", "-y", help="Filter by year (default: current year)"), 98 | output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"), 99 | ): 100 | """ 101 | Export yearly heatmap as PNG or SVG. 102 | 103 | Generates a GitHub-style activity heatmap showing your Claude Code usage 104 | throughout the year. By default exports as PNG for the current year. 105 | 106 | Use --fast to skip all updates and read from database only (requires existing database). 107 | 108 | Examples: 109 | ccg export --open Export current year as PNG and open it 110 | ccg export --svg Export as SVG instead 111 | ccg export --fast Export from database without updating 112 | ccg export -y 2024 Export specific year 113 | ccg export -o ~/usage.png Specify output path 114 | """ 115 | # Pass parameters via sys.argv for backward compatibility with export command 116 | import sys 117 | if svg and "svg" not in sys.argv: 118 | sys.argv.append("svg") 119 | if open_file and "--open" not in sys.argv: 120 | sys.argv.append("--open") 121 | if fast and "--fast" not in sys.argv: 122 | sys.argv.append("--fast") 123 | if year is not None: 124 | if "--year" not in sys.argv and "-y" not in sys.argv: 125 | sys.argv.extend(["--year", str(year)]) 126 | if output is not None: 127 | if "--output" not in sys.argv and "-o" not in sys.argv: 128 | sys.argv.extend(["--output", output]) 129 | 130 | export.run(console) 131 | 132 | 133 | @app.command(name="update-usage") 134 | def update_usage_command(): 135 | """ 136 | Update historical database with latest data. 137 | 138 | This command: 139 | 1. Saves current usage data from JSONL files 140 | 2. Fills in missing days with zero-usage records 141 | 3. Ensures complete date coverage from earliest record to today 142 | 143 | Useful for ensuring continuous heatmap data without gaps. 144 | """ 145 | update_usage.run(console) 146 | 147 | 148 | @app.command(name="delete-usage") 149 | def delete_usage_command( 150 | force: bool = typer.Option(False, "--force", "-f", help="Force deletion without confirmation"), 151 | ): 152 | """ 153 | Delete historical usage database. 154 | 155 | WARNING: This will permanently delete all historical usage data! 156 | 157 | Requires --force flag to prevent accidental deletion. 158 | A backup is automatically created before deletion. 159 | 160 | Example: 161 | ccg delete-usage --force 162 | """ 163 | # Pass force flag via command module's own sys.argv check for backward compatibility 164 | import sys 165 | if force and "--force" not in sys.argv: 166 | sys.argv.append("--force") 167 | delete_usage.run(console) 168 | 169 | 170 | @app.command(name="restore-backup") 171 | def restore_backup_command(): 172 | """ 173 | Restore database from backup file. 174 | 175 | Restores the usage history database from a backup file (.db.bak). 176 | Creates a safety backup of the current database before restoring. 177 | 178 | Expected backup location: ~/.claude/usage/usage_history.db.bak 179 | """ 180 | restore_backup.run(console) 181 | 182 | 183 | @app.command(name="status-bar") 184 | def status_bar_command( 185 | limit_type: str = typer.Argument("weekly", help="Type of limit to display: session, weekly, or opus"), 186 | ): 187 | """ 188 | Launch macOS menu bar app (macOS only). 189 | 190 | Displays "CC: XX%" in your menu bar, showing current usage percentage. 191 | Updates automatically every 5 minutes. 192 | 193 | Arguments: 194 | limit_type: Which limit to display (session, weekly, or opus). Defaults to weekly. 195 | 196 | Examples: 197 | ccg status-bar weekly Show weekly usage (default) 198 | ccg status-bar session Show session usage 199 | ccg status-bar opus Show Opus weekly usage 200 | 201 | Running in background: 202 | nohup ccg status-bar weekly > /dev/null 2>&1 & 203 | """ 204 | if limit_type not in ["session", "weekly", "opus"]: 205 | console.print(f"[red]Error: Invalid limit type '{limit_type}'[/red]") 206 | console.print("[yellow]Valid types: session, weekly, opus[/yellow]") 207 | raise typer.Exit(1) 208 | 209 | status_bar.run(console, limit_type) 210 | 211 | 212 | @app.command(name="setup-hooks") 213 | def setup_hooks_command( 214 | hook_type: Optional[str] = typer.Argument(None, help="Hook type: usage, audio, audio-tts, or png"), 215 | ): 216 | """ 217 | Setup Claude Code hooks for automation. 218 | 219 | Available hooks: 220 | - usage: Auto-track usage after each Claude response 221 | - audio: Play sounds for completion, permission, and compaction (3 sounds) 222 | - audio-tts: Speak messages using TTS with hook selection (macOS only) 223 | - png: Auto-update usage PNG after each Claude response 224 | 225 | Examples: 226 | ccg setup-hooks usage Enable automatic usage tracking 227 | ccg setup-hooks audio Enable audio notifications (3 sounds) 228 | ccg setup-hooks audio-tts Enable TTS (choose which hooks) 229 | ccg setup-hooks png Enable automatic PNG exports 230 | """ 231 | setup_hooks(console, hook_type) 232 | 233 | 234 | @app.command(name="remove-hooks") 235 | def remove_hooks_command( 236 | hook_type: Optional[str] = typer.Argument(None, help="Hook type to remove: usage, audio, audio-tts, png, or leave empty for all"), 237 | ): 238 | """ 239 | Remove Claude Code hooks configured by this tool. 240 | 241 | Examples: 242 | ccg remove-hooks Remove all hooks 243 | ccg remove-hooks usage Remove only usage tracking hook 244 | ccg remove-hooks audio Remove only audio notification hook 245 | ccg remove-hooks audio-tts Remove only audio TTS hook 246 | ccg remove-hooks png Remove only PNG export hook 247 | """ 248 | remove_hooks(console, hook_type) 249 | 250 | 251 | @app.command(name="help", hidden=True) 252 | def help_command(): 253 | """ 254 | Show detailed help message. 255 | 256 | Displays comprehensive usage information including: 257 | - Available commands and their flags 258 | - Key features of the tool 259 | - Data sources and storage locations 260 | - Recommended setup workflow 261 | """ 262 | help_cmd.run(console) 263 | 264 | 265 | def main() -> None: 266 | """ 267 | Main CLI entry point for Claude Goblin Usage tracker. 268 | 269 | Loads Claude Code usage data and provides commands for viewing, 270 | analyzing, and exporting usage statistics. 271 | 272 | Usage: 273 | ccg --help Show available commands 274 | ccg usage Show usage dashboard 275 | ccg usage --live Show dashboard with auto-refresh 276 | ccg stats Show detailed statistics 277 | ccg export Export yearly heatmap 278 | 279 | Exit: 280 | Press Ctrl+C to exit 281 | """ 282 | app() 283 | 284 | 285 | if __name__ == "__main__": 286 | main() 287 | ```