#
tokens: 47157/50000 44/49 files (page 1/2)
lines: on (toggle) GitHub
raw markdown copy reset
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 | ![Python](https://img.shields.io/badge/python-3.10%2B-blue?logo=python&logoColor=white)
  4 | ![Claude Code](https://img.shields.io/badge/Claude%20Code-required-orange?logo=anthropic)
  5 | ![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey)
  6 | ![License](https://img.shields.io/badge/license-MIT-green)
  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 | ![Example TUI dashboard](docs/images/dashboard.png)
 21 | 
 22 | ---
 23 | 
 24 | **MacOS status bar for usage limits:**
 25 | 
 26 | ![Example status bar](docs/images/status-bar.png)
 27 | 
 28 | ---
 29 | 
 30 | **GitHub activity-style heatmap of annual usage:**
 31 | 
 32 | ![Example heatmap](docs/images/heatmap.png)
 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 | ![Example TUI dashboard](docs/images/dashboard.png)
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 | ![Yearly activity heatmap](docs/images/heatmap.png)
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 | ![example status bar](docs/images/status-bar.png)
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 | 
```
Page 1/2FirstPrevNextLast