tokenhoggers 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tokenhoggers-0.1.0/LICENSE +21 -0
- tokenhoggers-0.1.0/PKG-INFO +138 -0
- tokenhoggers-0.1.0/README.md +120 -0
- tokenhoggers-0.1.0/collectors/__init__.py +0 -0
- tokenhoggers-0.1.0/collectors/cli/__init__.py +0 -0
- tokenhoggers-0.1.0/collectors/cli/auth.py +247 -0
- tokenhoggers-0.1.0/collectors/cli/config.py +44 -0
- tokenhoggers-0.1.0/collectors/cli/daemon.py +186 -0
- tokenhoggers-0.1.0/collectors/cli/main.py +242 -0
- tokenhoggers-0.1.0/collectors/cli/parsers/__init__.py +7 -0
- tokenhoggers-0.1.0/collectors/cli/parsers/base.py +80 -0
- tokenhoggers-0.1.0/collectors/cli/parsers/claude_code.py +239 -0
- tokenhoggers-0.1.0/collectors/cli/parsers/cursor.py +95 -0
- tokenhoggers-0.1.0/collectors/cli/parsers/gemini_cli.py +84 -0
- tokenhoggers-0.1.0/collectors/cli/parsers/ollama.py +46 -0
- tokenhoggers-0.1.0/collectors/cli/paths.py +58 -0
- tokenhoggers-0.1.0/collectors/cli/state.py +36 -0
- tokenhoggers-0.1.0/collectors/cli/sync.py +92 -0
- tokenhoggers-0.1.0/pyproject.toml +31 -0
- tokenhoggers-0.1.0/setup.cfg +4 -0
- tokenhoggers-0.1.0/tests/test_models.py +32 -0
- tokenhoggers-0.1.0/tests/test_storage.py +50 -0
- tokenhoggers-0.1.0/tokenhoggers.egg-info/PKG-INFO +138 -0
- tokenhoggers-0.1.0/tokenhoggers.egg-info/SOURCES.txt +25 -0
- tokenhoggers-0.1.0/tokenhoggers.egg-info/dependency_links.txt +1 -0
- tokenhoggers-0.1.0/tokenhoggers.egg-info/entry_points.txt +2 -0
- tokenhoggers-0.1.0/tokenhoggers.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Token Hoggers Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tokenhoggers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Track your AI usage across Claude Code, Cursor, ChatGPT, Gemini, and more
|
|
5
|
+
Author: Vikrant Tyagi
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AIPeek/token-hoggers
|
|
8
|
+
Project-URL: Repository, https://github.com/AIPeek/token-hoggers
|
|
9
|
+
Keywords: ai,analytics,leaderboard,claude,cursor,llm
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Token Hoggers
|
|
20
|
+
|
|
21
|
+
**Gamified, tool-agnostic AI usage leaderboard.** Track and compete on your AI usage across Claude Code, Cursor, Gemini, ChatGPT, Copilot, and more.
|
|
22
|
+
|
|
23
|
+
Inspired by [claude_rank](https://github.com/bicro/claude_rank) but tool-agnostic, with an RPG character system built on the **AMI (AI Maturity Index)** framework.
|
|
24
|
+
|
|
25
|
+
## How It Works
|
|
26
|
+
|
|
27
|
+
1. **Install the CLI** — `pip install tokenhoggers`
|
|
28
|
+
2. **Sign in** — Google OAuth at [tokenhoggers.com](https://web-system42.vercel.app)
|
|
29
|
+
3. **Sync** — `tokenhoggers sync` reads your local AI tool stats (counts only, never content)
|
|
30
|
+
4. **Compete** — See your rank, level, tier, and RPG stats on the leaderboard
|
|
31
|
+
|
|
32
|
+
## Privacy-First
|
|
33
|
+
|
|
34
|
+
We collect **counts and timestamps only**. Never prompt text, code, file paths, or API keys.
|
|
35
|
+
|
|
36
|
+
| Collected | Never Touched |
|
|
37
|
+
|---|---|
|
|
38
|
+
| Token counts (in/out) | Prompt/response text |
|
|
39
|
+
| Message & session counts | Project/repo names |
|
|
40
|
+
| Tool call counts | File paths, code, diffs |
|
|
41
|
+
| Model names | API keys, credentials |
|
|
42
|
+
| Timestamps | Working directories |
|
|
43
|
+
|
|
44
|
+
## RPG Character System
|
|
45
|
+
|
|
46
|
+
Built on the **AMI (AI Maturity Index)** — 4 dimensions that measure how you use AI, not just how much.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
MST (Mastery)
|
|
50
|
+
|
|
|
51
|
+
PRC ──────+────── VRS
|
|
52
|
+
|
|
|
53
|
+
END (Endurance)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Stat | Measures |
|
|
57
|
+
|---|---|
|
|
58
|
+
| **Mastery** | Power-user behavior (tool calls/session, model diversity) |
|
|
59
|
+
| **Precision** | Output per effort (efficiency) |
|
|
60
|
+
| **Versatility** | Multi-tool range |
|
|
61
|
+
| **Endurance** | Consistency (streaks, active days) |
|
|
62
|
+
|
|
63
|
+
**Tiers:** Bronze → Silver → Gold → Platinum → Diamond
|
|
64
|
+
|
|
65
|
+
## Supported Tools
|
|
66
|
+
|
|
67
|
+
| Tool | Data Source | Parser |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| Claude Code | `~/.claude/stats-cache.json` | CLI |
|
|
70
|
+
| Cursor | `state.vscdb` (SQLite, read-only) | CLI |
|
|
71
|
+
| Gemini CLI | `~/.gemini/tmp/*/logs.json` | CLI |
|
|
72
|
+
| ChatGPT | Web request counting | Browser Extension |
|
|
73
|
+
| Gemini Web | Web request counting | Browser Extension |
|
|
74
|
+
| Claude Web | Web request counting | Browser Extension |
|
|
75
|
+
|
|
76
|
+
## Quick Start
|
|
77
|
+
|
|
78
|
+
### CLI
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install tokenhoggers
|
|
82
|
+
|
|
83
|
+
# Check what data is available locally
|
|
84
|
+
tokenhoggers status
|
|
85
|
+
|
|
86
|
+
# Sign in at the web app, copy your token, then:
|
|
87
|
+
tokenhoggers config --url https://aaxkbwyolbhjgovmcael.supabase.co/functions/v1/sync
|
|
88
|
+
tokenhoggers config --token <YOUR_TOKEN>
|
|
89
|
+
tokenhoggers sync
|
|
90
|
+
|
|
91
|
+
# Re-sync all historical data
|
|
92
|
+
tokenhoggers sync --force
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Desktop App (macOS)
|
|
96
|
+
|
|
97
|
+
Menu bar app that shows your stats at a glance.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cd desktop/src-tauri
|
|
101
|
+
source ~/.cargo/env
|
|
102
|
+
cargo run
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Browser Extension
|
|
106
|
+
|
|
107
|
+
Tracks request counts on ChatGPT, Gemini, and Claude web (never captures content).
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
cd extension/icons && python3 generate.py
|
|
111
|
+
# Chrome → chrome://extensions → Developer Mode → Load unpacked → select extension/
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Architecture
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
collectors/cli/ Python CLI — parses local AI tool stats
|
|
118
|
+
web/ Frontend (vanilla HTML/JS/CSS) — hosted on Vercel
|
|
119
|
+
supabase/ Backend — PostgreSQL, Auth, Edge Functions, Realtime
|
|
120
|
+
desktop/ Tauri 2.x macOS menu bar app
|
|
121
|
+
extension/ Chrome MV3 browser extension
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Tech Stack
|
|
125
|
+
|
|
126
|
+
- **Backend:** Supabase (PostgreSQL + Auth + Edge Functions + Realtime)
|
|
127
|
+
- **Frontend:** Vanilla HTML/JS/CSS (Claude Rank-inspired design)
|
|
128
|
+
- **CLI:** Python 3.9+
|
|
129
|
+
- **Desktop:** Rust / Tauri 2.x
|
|
130
|
+
- **Extension:** Chrome MV3
|
|
131
|
+
|
|
132
|
+
## Contributing
|
|
133
|
+
|
|
134
|
+
PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Token Hoggers
|
|
2
|
+
|
|
3
|
+
**Gamified, tool-agnostic AI usage leaderboard.** Track and compete on your AI usage across Claude Code, Cursor, Gemini, ChatGPT, Copilot, and more.
|
|
4
|
+
|
|
5
|
+
Inspired by [claude_rank](https://github.com/bicro/claude_rank) but tool-agnostic, with an RPG character system built on the **AMI (AI Maturity Index)** framework.
|
|
6
|
+
|
|
7
|
+
## How It Works
|
|
8
|
+
|
|
9
|
+
1. **Install the CLI** — `pip install tokenhoggers`
|
|
10
|
+
2. **Sign in** — Google OAuth at [tokenhoggers.com](https://web-system42.vercel.app)
|
|
11
|
+
3. **Sync** — `tokenhoggers sync` reads your local AI tool stats (counts only, never content)
|
|
12
|
+
4. **Compete** — See your rank, level, tier, and RPG stats on the leaderboard
|
|
13
|
+
|
|
14
|
+
## Privacy-First
|
|
15
|
+
|
|
16
|
+
We collect **counts and timestamps only**. Never prompt text, code, file paths, or API keys.
|
|
17
|
+
|
|
18
|
+
| Collected | Never Touched |
|
|
19
|
+
|---|---|
|
|
20
|
+
| Token counts (in/out) | Prompt/response text |
|
|
21
|
+
| Message & session counts | Project/repo names |
|
|
22
|
+
| Tool call counts | File paths, code, diffs |
|
|
23
|
+
| Model names | API keys, credentials |
|
|
24
|
+
| Timestamps | Working directories |
|
|
25
|
+
|
|
26
|
+
## RPG Character System
|
|
27
|
+
|
|
28
|
+
Built on the **AMI (AI Maturity Index)** — 4 dimensions that measure how you use AI, not just how much.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
MST (Mastery)
|
|
32
|
+
|
|
|
33
|
+
PRC ──────+────── VRS
|
|
34
|
+
|
|
|
35
|
+
END (Endurance)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| Stat | Measures |
|
|
39
|
+
|---|---|
|
|
40
|
+
| **Mastery** | Power-user behavior (tool calls/session, model diversity) |
|
|
41
|
+
| **Precision** | Output per effort (efficiency) |
|
|
42
|
+
| **Versatility** | Multi-tool range |
|
|
43
|
+
| **Endurance** | Consistency (streaks, active days) |
|
|
44
|
+
|
|
45
|
+
**Tiers:** Bronze → Silver → Gold → Platinum → Diamond
|
|
46
|
+
|
|
47
|
+
## Supported Tools
|
|
48
|
+
|
|
49
|
+
| Tool | Data Source | Parser |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| Claude Code | `~/.claude/stats-cache.json` | CLI |
|
|
52
|
+
| Cursor | `state.vscdb` (SQLite, read-only) | CLI |
|
|
53
|
+
| Gemini CLI | `~/.gemini/tmp/*/logs.json` | CLI |
|
|
54
|
+
| ChatGPT | Web request counting | Browser Extension |
|
|
55
|
+
| Gemini Web | Web request counting | Browser Extension |
|
|
56
|
+
| Claude Web | Web request counting | Browser Extension |
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
### CLI
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install tokenhoggers
|
|
64
|
+
|
|
65
|
+
# Check what data is available locally
|
|
66
|
+
tokenhoggers status
|
|
67
|
+
|
|
68
|
+
# Sign in at the web app, copy your token, then:
|
|
69
|
+
tokenhoggers config --url https://aaxkbwyolbhjgovmcael.supabase.co/functions/v1/sync
|
|
70
|
+
tokenhoggers config --token <YOUR_TOKEN>
|
|
71
|
+
tokenhoggers sync
|
|
72
|
+
|
|
73
|
+
# Re-sync all historical data
|
|
74
|
+
tokenhoggers sync --force
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Desktop App (macOS)
|
|
78
|
+
|
|
79
|
+
Menu bar app that shows your stats at a glance.
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
cd desktop/src-tauri
|
|
83
|
+
source ~/.cargo/env
|
|
84
|
+
cargo run
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Browser Extension
|
|
88
|
+
|
|
89
|
+
Tracks request counts on ChatGPT, Gemini, and Claude web (never captures content).
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cd extension/icons && python3 generate.py
|
|
93
|
+
# Chrome → chrome://extensions → Developer Mode → Load unpacked → select extension/
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Architecture
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
collectors/cli/ Python CLI — parses local AI tool stats
|
|
100
|
+
web/ Frontend (vanilla HTML/JS/CSS) — hosted on Vercel
|
|
101
|
+
supabase/ Backend — PostgreSQL, Auth, Edge Functions, Realtime
|
|
102
|
+
desktop/ Tauri 2.x macOS menu bar app
|
|
103
|
+
extension/ Chrome MV3 browser extension
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Tech Stack
|
|
107
|
+
|
|
108
|
+
- **Backend:** Supabase (PostgreSQL + Auth + Edge Functions + Realtime)
|
|
109
|
+
- **Frontend:** Vanilla HTML/JS/CSS (Claude Rank-inspired design)
|
|
110
|
+
- **CLI:** Python 3.9+
|
|
111
|
+
- **Desktop:** Rust / Tauri 2.x
|
|
112
|
+
- **Extension:** Chrome MV3
|
|
113
|
+
|
|
114
|
+
## Contributing
|
|
115
|
+
|
|
116
|
+
PRs welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT — see [LICENSE](LICENSE).
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Browser-based OAuth login and token refresh for Token Hoggers CLI."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
11
|
+
from typing import Optional
|
|
12
|
+
from urllib.error import HTTPError
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
from collectors.cli.config import Config
|
|
16
|
+
|
|
17
|
+
SUPABASE_URL = "https://aaxkbwyolbhjgovmcael.supabase.co"
|
|
18
|
+
SUPABASE_ANON_KEY = (
|
|
19
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
|
|
20
|
+
"eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImFheGtid3lvbGJoamdvdm1jYWVsIiwi"
|
|
21
|
+
"cm9sZSI6ImFub24iLCJpYXQiOjE3NzQxODM4MTUsImV4cCI6MjA4OTc1OTgxNX0."
|
|
22
|
+
"nNDUcYm2xzpGW6ervqNQEIXQdENi1qYXpQo4Rp0JrI8"
|
|
23
|
+
)
|
|
24
|
+
SYNC_API_URL = f"{SUPABASE_URL}/functions/v1/sync"
|
|
25
|
+
|
|
26
|
+
_PORT_RANGE = range(19876, 19900)
|
|
27
|
+
_LOGIN_TIMEOUT = 120 # seconds
|
|
28
|
+
|
|
29
|
+
# HTML page served at /callback — reads hash fragment and POSTs tokens back
|
|
30
|
+
_CALLBACK_HTML = """<!DOCTYPE html>
|
|
31
|
+
<html>
|
|
32
|
+
<head>
|
|
33
|
+
<title>Token Hoggers</title>
|
|
34
|
+
<style>
|
|
35
|
+
body { font-family: -apple-system, system-ui, sans-serif; display: flex;
|
|
36
|
+
justify-content: center; align-items: center; min-height: 100vh;
|
|
37
|
+
margin: 0; background: #0f0f0f; color: #e0e0e0; }
|
|
38
|
+
.card { text-align: center; padding: 48px; border-radius: 16px;
|
|
39
|
+
background: #1a1a1a; border: 1px solid #333; max-width: 400px; }
|
|
40
|
+
h2 { margin: 0 0 8px; color: #fff; }
|
|
41
|
+
p { margin: 0; color: #888; font-size: 14px; }
|
|
42
|
+
.err { color: #ff6b6b; }
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<div class="card">
|
|
47
|
+
<div id="status"><h2>Completing login...</h2></div>
|
|
48
|
+
</div>
|
|
49
|
+
<script>
|
|
50
|
+
(function() {
|
|
51
|
+
var hash = window.location.hash.substring(1);
|
|
52
|
+
if (!hash) {
|
|
53
|
+
document.getElementById('status').innerHTML =
|
|
54
|
+
'<h2 class="err">Login failed</h2><p>No auth tokens received. Please try again.</p>';
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
var params = new URLSearchParams(hash);
|
|
58
|
+
var data = {
|
|
59
|
+
access_token: params.get('access_token'),
|
|
60
|
+
refresh_token: params.get('refresh_token'),
|
|
61
|
+
expires_in: parseInt(params.get('expires_in') || '3600', 10),
|
|
62
|
+
token_type: params.get('token_type')
|
|
63
|
+
};
|
|
64
|
+
if (!data.access_token) {
|
|
65
|
+
document.getElementById('status').innerHTML =
|
|
66
|
+
'<h2 class="err">Login failed</h2><p>Missing access token. Please try again.</p>';
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
fetch('/receive-tokens', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {'Content-Type': 'application/json'},
|
|
72
|
+
body: JSON.stringify(data)
|
|
73
|
+
}).then(function(r) { return r.json(); }).then(function() {
|
|
74
|
+
document.getElementById('status').innerHTML =
|
|
75
|
+
'<h2>Login successful!</h2><p>You can close this tab and return to your terminal.</p>';
|
|
76
|
+
}).catch(function(e) {
|
|
77
|
+
document.getElementById('status').innerHTML =
|
|
78
|
+
'<h2 class="err">Error</h2><p>' + e.message + '</p>';
|
|
79
|
+
});
|
|
80
|
+
})();
|
|
81
|
+
</script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _find_available_port() -> int:
|
|
87
|
+
"""Find an available port in the designated range."""
|
|
88
|
+
for port in _PORT_RANGE:
|
|
89
|
+
try:
|
|
90
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
91
|
+
s.bind(("127.0.0.1", port))
|
|
92
|
+
return port
|
|
93
|
+
except OSError:
|
|
94
|
+
continue
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"No available port in range {_PORT_RANGE.start}-{_PORT_RANGE.stop - 1}. "
|
|
97
|
+
"Close other applications or use: tokenhoggers config --token <JWT>"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def browser_login() -> dict:
|
|
102
|
+
"""Open browser for Google OAuth and capture tokens via local HTTP server.
|
|
103
|
+
|
|
104
|
+
Returns dict with access_token, refresh_token, expires_in.
|
|
105
|
+
Raises TimeoutError if user doesn't complete login within the timeout.
|
|
106
|
+
Raises RuntimeError on other failures.
|
|
107
|
+
"""
|
|
108
|
+
port = _find_available_port()
|
|
109
|
+
tokens = {}
|
|
110
|
+
token_event = threading.Event()
|
|
111
|
+
|
|
112
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
113
|
+
def do_GET(self):
|
|
114
|
+
if self.path.startswith("/callback"):
|
|
115
|
+
self.send_response(200)
|
|
116
|
+
self.send_header("Content-Type", "text/html")
|
|
117
|
+
self.end_headers()
|
|
118
|
+
self.wfile.write(_CALLBACK_HTML.encode())
|
|
119
|
+
else:
|
|
120
|
+
self.send_response(404)
|
|
121
|
+
self.end_headers()
|
|
122
|
+
|
|
123
|
+
def do_POST(self):
|
|
124
|
+
if self.path == "/receive-tokens":
|
|
125
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
126
|
+
body = json.loads(self.rfile.read(length))
|
|
127
|
+
tokens.update(body)
|
|
128
|
+
token_event.set()
|
|
129
|
+
self.send_response(200)
|
|
130
|
+
self.send_header("Content-Type", "application/json")
|
|
131
|
+
self.end_headers()
|
|
132
|
+
self.wfile.write(b'{"ok":true}')
|
|
133
|
+
else:
|
|
134
|
+
self.send_response(404)
|
|
135
|
+
self.end_headers()
|
|
136
|
+
|
|
137
|
+
def log_message(self, format, *args):
|
|
138
|
+
pass # suppress HTTP server logging
|
|
139
|
+
|
|
140
|
+
server = HTTPServer(("127.0.0.1", port), CallbackHandler)
|
|
141
|
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
142
|
+
server_thread.start()
|
|
143
|
+
|
|
144
|
+
auth_url = (
|
|
145
|
+
f"{SUPABASE_URL}/auth/v1/authorize"
|
|
146
|
+
f"?provider=google"
|
|
147
|
+
f"&redirect_to=http://localhost:{port}/callback"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
opened = webbrowser.open(auth_url)
|
|
151
|
+
if not opened:
|
|
152
|
+
print(f"\n Could not open browser. Please visit this URL manually:\n")
|
|
153
|
+
print(f" {auth_url}\n")
|
|
154
|
+
|
|
155
|
+
if not token_event.wait(timeout=_LOGIN_TIMEOUT):
|
|
156
|
+
server.shutdown()
|
|
157
|
+
raise TimeoutError("Login timed out. Please try again.")
|
|
158
|
+
|
|
159
|
+
server.shutdown()
|
|
160
|
+
|
|
161
|
+
if not tokens.get("access_token"):
|
|
162
|
+
raise RuntimeError("No access token received.")
|
|
163
|
+
|
|
164
|
+
return tokens
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def decode_jwt_exp(token: str) -> int:
|
|
168
|
+
"""Decode a JWT and return the exp (expiry) claim as a Unix timestamp.
|
|
169
|
+
|
|
170
|
+
Does NOT verify the signature — only used for local expiry checks.
|
|
171
|
+
Returns 0 if the token is malformed or has no exp claim.
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
parts = token.split(".")
|
|
175
|
+
if len(parts) != 3:
|
|
176
|
+
return 0
|
|
177
|
+
payload = parts[1]
|
|
178
|
+
# Add base64 padding
|
|
179
|
+
padding = 4 - len(payload) % 4
|
|
180
|
+
if padding != 4:
|
|
181
|
+
payload += "=" * padding
|
|
182
|
+
decoded = base64.urlsafe_b64decode(payload)
|
|
183
|
+
claims = json.loads(decoded)
|
|
184
|
+
return int(claims.get("exp", 0))
|
|
185
|
+
except Exception:
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def refresh_access_token(refresh_token: str) -> dict:
|
|
190
|
+
"""Exchange a refresh token for a new access token pair.
|
|
191
|
+
|
|
192
|
+
Returns dict with access_token, refresh_token, expires_in.
|
|
193
|
+
Raises RuntimeError if refresh fails (token revoked/expired).
|
|
194
|
+
"""
|
|
195
|
+
url = f"{SUPABASE_URL}/auth/v1/token?grant_type=refresh_token"
|
|
196
|
+
body = json.dumps({"refresh_token": refresh_token}).encode()
|
|
197
|
+
req = Request(
|
|
198
|
+
url,
|
|
199
|
+
data=body,
|
|
200
|
+
headers={
|
|
201
|
+
"apikey": SUPABASE_ANON_KEY,
|
|
202
|
+
"Content-Type": "application/json",
|
|
203
|
+
},
|
|
204
|
+
method="POST",
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
with urlopen(req, timeout=15) as resp:
|
|
208
|
+
data = json.loads(resp.read().decode())
|
|
209
|
+
return {
|
|
210
|
+
"access_token": data["access_token"],
|
|
211
|
+
"refresh_token": data["refresh_token"],
|
|
212
|
+
"expires_in": data.get("expires_in", 3600),
|
|
213
|
+
}
|
|
214
|
+
except HTTPError as e:
|
|
215
|
+
if e.code in (400, 401, 403):
|
|
216
|
+
raise RuntimeError(
|
|
217
|
+
"Session expired. Please run `tokenhoggers login` again."
|
|
218
|
+
)
|
|
219
|
+
raise RuntimeError(f"Token refresh failed (HTTP {e.code})")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def refresh_if_needed(config: Config) -> Config:
|
|
223
|
+
"""Check if the access token is expired and refresh it if possible.
|
|
224
|
+
|
|
225
|
+
Modifies and saves config in-place if a refresh occurs.
|
|
226
|
+
Returns the (possibly updated) config.
|
|
227
|
+
"""
|
|
228
|
+
if not config.token:
|
|
229
|
+
return config
|
|
230
|
+
|
|
231
|
+
exp = decode_jwt_exp(config.token)
|
|
232
|
+
now = int(time.time())
|
|
233
|
+
|
|
234
|
+
# Token still valid for more than 60 seconds
|
|
235
|
+
if exp > 0 and (exp - now) > 60:
|
|
236
|
+
return config
|
|
237
|
+
|
|
238
|
+
# No refresh token available (manual token setup)
|
|
239
|
+
if not config.refresh_token:
|
|
240
|
+
return config
|
|
241
|
+
|
|
242
|
+
tokens = refresh_access_token(config.refresh_token)
|
|
243
|
+
config.token = tokens["access_token"]
|
|
244
|
+
config.refresh_token = tokens["refresh_token"]
|
|
245
|
+
config.token_expiry = now + tokens.get("expires_in", 3600)
|
|
246
|
+
config.save()
|
|
247
|
+
return config
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""User configuration for the Token Hoggers CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from collectors.cli.paths import config_dir
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Config:
|
|
12
|
+
api_url: str = ""
|
|
13
|
+
token: str = ""
|
|
14
|
+
refresh_token: str = ""
|
|
15
|
+
token_expiry: int = 0
|
|
16
|
+
enable_fluency: bool = False
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def load(cls) -> "Config":
|
|
20
|
+
path = config_dir() / "config.json"
|
|
21
|
+
if path.exists():
|
|
22
|
+
data = json.loads(path.read_text())
|
|
23
|
+
return cls(
|
|
24
|
+
api_url=data.get("api_url", ""),
|
|
25
|
+
token=data.get("token", ""),
|
|
26
|
+
refresh_token=data.get("refresh_token", ""),
|
|
27
|
+
token_expiry=data.get("token_expiry", 0),
|
|
28
|
+
enable_fluency=data.get("enable_fluency", False),
|
|
29
|
+
)
|
|
30
|
+
return cls()
|
|
31
|
+
|
|
32
|
+
def save(self) -> None:
|
|
33
|
+
path = config_dir() / "config.json"
|
|
34
|
+
path.write_text(json.dumps({
|
|
35
|
+
"api_url": self.api_url,
|
|
36
|
+
"token": self.token,
|
|
37
|
+
"refresh_token": self.refresh_token,
|
|
38
|
+
"token_expiry": self.token_expiry,
|
|
39
|
+
"enable_fluency": self.enable_fluency,
|
|
40
|
+
}, indent=2))
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_configured(self) -> bool:
|
|
44
|
+
return bool(self.api_url and self.token)
|