token-tracker 0.2.4__tar.gz → 0.3.2__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.
Files changed (25) hide show
  1. {token_tracker-0.2.4 → token_tracker-0.3.2}/PKG-INFO +1 -1
  2. {token_tracker-0.2.4 → token_tracker-0.3.2}/README.md +2 -1
  3. {token_tracker-0.2.4 → token_tracker-0.3.2}/pyproject.toml +1 -1
  4. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/analyzer/cost.py +12 -3
  5. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/hooks.py +174 -48
  6. {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/PKG-INFO +1 -1
  7. {token_tracker-0.2.4 → token_tracker-0.3.2}/setup.cfg +0 -0
  8. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/__init__.py +0 -0
  9. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/__init__.py +0 -0
  10. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/claude.py +0 -0
  11. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/codex.py +0 -0
  12. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/rate_limits.py +0 -0
  13. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/registry.py +0 -0
  14. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/adapters/types.py +0 -0
  15. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/analyzer/__init__.py +0 -0
  16. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/analyzer/aggregator.py +0 -0
  17. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/analyzer/blocks.py +0 -0
  18. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/cli.py +0 -0
  19. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/ui/__init__.py +0 -0
  20. {token_tracker-0.2.4 → token_tracker-0.3.2}/src/ui/tables.py +0 -0
  21. {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/SOURCES.txt +0 -0
  22. {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/dependency_links.txt +0 -0
  23. {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/entry_points.txt +0 -0
  24. {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/requires.txt +0 -0
  25. {token_tracker-0.2.4 → token_tracker-0.3.2}/token_tracker.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: token-tracker
3
- Version: 0.2.4
3
+ Version: 0.3.2
4
4
  Summary: Track token usage across local AI agents (Claude Code, Codex)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: rich>=13.7
@@ -38,6 +38,7 @@
38
38
  - **成本分析** — 按会话、日、周、月维度的等效成本统计,多 Agent 按来源分组展示
39
39
  - **会话洞察** — 项目、模型、时长、消息数一览
40
40
  - **零配置** — 自动检测已安装的 Agent,直接读取本地数据
41
+ - **隐私安全** — 数据纯本地存储,不采集、不上传任何用户信息,极轻量无后顾之忧
41
42
 
42
43
  ## 安装
43
44
 
@@ -48,7 +49,7 @@ curl -sSL https://raw.githubusercontent.com/stormzhang/token-tracker/master/inst
48
49
  或者通过 pip:
49
50
 
50
51
  ```bash
51
- pip install token-tracker
52
+ pip install --force-reinstall token-tracker
52
53
  tt setup
53
54
  ```
54
55
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "token-tracker"
7
- version = "0.2.4"
7
+ version = "0.3.2"
8
8
  description = "Track token usage across local AI agents (Claude Code, Codex)"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
@@ -73,9 +73,18 @@ def _load_pricing() -> dict:
73
73
 
74
74
 
75
75
  def _fetch_and_cache() -> dict:
76
- req = urllib.request.Request(LITELLM_URL, headers={"User-Agent": "token-tracker/0.1"})
77
- with urllib.request.urlopen(req, timeout=10) as resp:
78
- data = json.loads(resp.read().decode())
76
+ import ssl
77
+ ctx = ssl.create_default_context()
78
+ try:
79
+ req = urllib.request.Request(LITELLM_URL, headers={"User-Agent": "token-tracker/0.1"})
80
+ with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
81
+ data = json.loads(resp.read().decode())
82
+ except ssl.SSLCertVerificationError:
83
+ ctx.check_hostname = False
84
+ ctx.verify_mode = ssl.CERT_NONE
85
+ req = urllib.request.Request(LITELLM_URL, headers={"User-Agent": "token-tracker/0.1"})
86
+ with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
87
+ data = json.loads(resp.read().decode())
79
88
 
80
89
  try:
81
90
  with open(CACHE_PATH, "w") as f:
@@ -10,7 +10,7 @@ CLAUDE_SETTINGS = os.path.expanduser("~/.claude/settings.json")
10
10
  HOOK_SCRIPT_PATH = os.path.expanduser("~/.claude/tt-statusline.py")
11
11
  CODEX_CONFIG = os.path.expanduser("~/.codex/config.toml")
12
12
  CODEX_BACKUP = os.path.expanduser("~/.codex/tt-backup.json")
13
- HOOK_VERSION = "1.1"
13
+ HOOK_VERSION = "1.4"
14
14
  _BACKUP_KEY = "tokenTracker"
15
15
  _PREV_SL_KEY = "previousStatusLine"
16
16
  _SL_REGEX = re.compile(r'status_line\s*=\s*\[.*?\]', re.DOTALL)
@@ -25,12 +25,12 @@ CODEX_STATUS_LINE = [
25
25
 
26
26
  HOOK_SCRIPT = r'''#!/usr/bin/env python3
27
27
  """Claude Code statusLine — 状态栏显示 + 数据持久化到 tt-status.json"""
28
- __version__ = "1.1"
29
- import json, os, sys, tempfile
28
+ __version__ = "1.4"
29
+ import json, os, re, subprocess, sys, tempfile
30
30
  from datetime import datetime, timezone
31
31
 
32
32
  STATUS_FILE = os.path.expanduser("~/.claude/tt-status.json")
33
- BAR = ("█", "░", 8)
33
+ ANSI_RE = re.compile(r'\033\[[0-9;]*m')
34
34
  C = {
35
35
  "green": "\033[32m", "yellow": "\033[33m", "red": "\033[31m",
36
36
  "cyan": "\033[36m", "blue": "\033[34m", "magenta": "\033[35m",
@@ -38,6 +38,24 @@ C = {
38
38
  }
39
39
 
40
40
 
41
+ def vlen(s):
42
+ return len(ANSI_RE.sub("", s))
43
+
44
+
45
+ def get_width():
46
+ try:
47
+ return max(1, os.get_terminal_size(2).columns - 4)
48
+ except Exception:
49
+ pass
50
+ import fcntl, struct, termios
51
+ try:
52
+ with open('/dev/tty', 'r') as tty:
53
+ res = fcntl.ioctl(tty, termios.TIOCGWINSZ, b'\x00' * 8)
54
+ return max(1, struct.unpack('hh', res[:4])[1] - 4)
55
+ except Exception:
56
+ return 116
57
+
58
+
41
59
  def color_by_pct(pct):
42
60
  return C["green"] if pct < 50 else C["yellow"] if pct < 80 else C["red"]
43
61
 
@@ -48,69 +66,177 @@ def fmt_tokens(n):
48
66
  return str(n)
49
67
 
50
68
 
51
- def progress_bar(value):
52
- filled_char, empty_char, width = BAR
69
+ def progress_bar(value, bar_width=8):
70
+ filled_char, empty_char = "█", "░"
53
71
  if value is None:
54
- return empty_char * width + " n/a"
72
+ return empty_char * bar_width + " n/a"
55
73
  pct = max(0.0, min(100.0, float(value)))
56
- filled = round(pct / 100 * width)
57
- return f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}{empty_char * (width - filled)} {pct:.0f}%"
74
+ filled = round(pct / 100 * bar_width)
75
+ return f"{color_by_pct(pct)}{filled_char * filled}{C['reset']}{empty_char * (bar_width - filled)} {pct:.0f}%"
76
+
77
+
78
+ def fmt_duration(seconds):
79
+ if seconds >= 86400:
80
+ d, rem = int(seconds // 86400), int(seconds % 86400)
81
+ return f"{d}d{rem // 3600}h"
82
+ if seconds >= 3600:
83
+ h, m = int(seconds // 3600), int((seconds % 3600) // 60)
84
+ return f"{h}h{m}m"
85
+ if seconds >= 60:
86
+ return f"{int(seconds // 60)}min"
87
+ return f"{int(seconds)}s"
88
+
89
+
90
+ def git_branch(cwd):
91
+ try:
92
+ branch = subprocess.check_output(
93
+ ["git", "branch", "--show-current"], cwd=cwd,
94
+ stderr=subprocess.DEVNULL, text=True, timeout=2,
95
+ ).strip()
96
+ except Exception:
97
+ return ""
98
+ if not branch:
99
+ return ""
100
+ try:
101
+ dirty = subprocess.check_output(
102
+ ["git", "status", "--porcelain", "--untracked-files=no"], cwd=cwd,
103
+ stderr=subprocess.DEVNULL, text=True, timeout=2,
104
+ ).strip()
105
+ if dirty:
106
+ branch += "*"
107
+ except Exception:
108
+ pass
109
+ return branch
58
110
 
59
111
 
60
- def save_data(data):
61
- data["_received_at"] = datetime.now(timezone.utc).isoformat()
112
+ def save_data(data, now):
113
+ data["_received_at"] = now.isoformat()
114
+ tmp = None
62
115
  try:
63
116
  fd, tmp = tempfile.mkstemp(dir=os.path.dirname(STATUS_FILE), suffix=".tmp")
64
117
  with os.fdopen(fd, "w") as f:
65
118
  json.dump(data, f)
66
119
  os.replace(tmp, STATUS_FILE)
67
120
  except OSError:
68
- pass
121
+ if tmp:
122
+ try:
123
+ os.unlink(tmp)
124
+ except OSError:
125
+ pass
69
126
 
70
127
 
71
- def render(data):
72
- parts = []
128
+ def render(data, now):
129
+ W = get_width()
130
+ ctx = data.get("context_window") or {}
131
+ bar_w = 8 if W >= 100 else 6 if W >= 60 else 4
73
132
 
74
- project = data.get("workspace", {}).get("project_dir", "")
75
- if project:
76
- parts.append(f"{C['cyan']}{os.path.basename(project)}{C['reset']}")
133
+ # --- Line 1: Project | 5h | 7d | CTX ---
134
+ line1 = []
77
135
 
78
- rl = data.get("rate_limits", {})
79
- has_rl = False
136
+ project = (data.get("workspace") or {}).get("project_dir", "")
137
+ if project:
138
+ name = os.path.basename(project)
139
+ branch = git_branch(project)
140
+ if branch:
141
+ line1.append(f"{C['green']}{name}{C['reset']}({C['magenta']}{branch}{C['reset']})")
142
+ else:
143
+ line1.append(f"{C['green']}{name}{C['reset']}")
144
+
145
+ rl = data.get("rate_limits") or {}
146
+ rl_parts = []
80
147
  for key, label in [("five_hour", "5h"), ("seven_day", "7d")]:
81
- pct = rl.get(key, {}).get("used_percentage")
148
+ entry = rl.get(key) or {}
149
+ pct = entry.get("used_percentage")
82
150
  if pct is not None:
83
- has_rl = True
84
- parts.append(f"{C['blue']}{label}:{C['reset']}{progress_bar(pct)}")
85
-
86
- if not has_rl:
87
- cost = data.get("cost", {})
88
- usd = cost.get("total_cost_usd")
89
- if usd is not None:
90
- parts.append(f"{C['blue']}Cost:{C['reset']}{C['peach']}${usd:.2f}{C['reset']}")
91
-
92
- ctx = data.get("context_window", {})
151
+ reset_str = ""
152
+ resets_at = entry.get("resets_at")
153
+ if resets_at:
154
+ remain = int(resets_at) - int(now.timestamp())
155
+ if remain > 0:
156
+ reset_str = f" {C['dim']}({fmt_duration(remain)}){C['reset']}"
157
+ rl_parts.append((
158
+ f"{C['blue']}{label}:{C['reset']}{progress_bar(pct, bar_w)}{reset_str}",
159
+ f"{C['blue']}{label}:{C['reset']}{progress_bar(pct, bar_w)}",
160
+ f"{C['blue']}{label}:{C['reset']}{pct:.0f}%",
161
+ ))
162
+
163
+ ctx_parts = []
93
164
  if ctx.get("used_percentage") is not None:
94
165
  size = ctx.get("context_window_size", 0)
95
- pct = ctx["used_percentage"]
96
- cc = C["green"] if pct < 40 else C["yellow"] if pct < 60 else C["red"]
97
- parts.append(f"{cc}{fmt_tokens(size)} CTX: {pct:.0f}%{C['reset']}")
166
+ ctx_parts = [
167
+ f"{C['blue']}{fmt_tokens(size)} Context:{C['reset']}{progress_bar(ctx['used_percentage'], bar_w)}",
168
+ f"{C['blue']}{fmt_tokens(size)} CTX:{C['reset']}{ctx['used_percentage']:.0f}%",
169
+ ]
170
+
171
+ # 尝试完整版(带进度条+reset time)
172
+ full = line1 + [p[0] for p in rl_parts] + (ctx_parts[:1] if ctx_parts else [])
173
+ candidate = " | ".join(full)
174
+ if vlen(candidate) <= W:
175
+ line1 = full
176
+ else:
177
+ # 去掉 reset time
178
+ no_reset = line1 + [p[1] for p in rl_parts] + (ctx_parts[:1] if ctx_parts else [])
179
+ candidate = " | ".join(no_reset)
180
+ if vlen(candidate) <= W:
181
+ line1 = no_reset
182
+ else:
183
+ # 去掉进度条,只留百分比
184
+ minimal = line1 + [p[2] for p in rl_parts] + (ctx_parts[1:2] if ctx_parts else [])
185
+ line1 = minimal
186
+
187
+ # --- Line 2: Tokens + Cache + Cost ---
188
+ line2 = []
98
189
 
99
190
  total_in = ctx.get("total_input_tokens", 0)
100
191
  total_out = ctx.get("total_output_tokens", 0)
101
- cache = ctx.get("current_usage", {}).get("cache_read_input_tokens", 0)
192
+ curr_usage = (ctx.get("current_usage") or {})
193
+ turn_in_total = curr_usage.get("input_tokens", 0) + curr_usage.get("cache_creation_input_tokens", 0)
194
+ turn_out = curr_usage.get("output_tokens", 0)
195
+ turn_str = f" {C['dim']}(本轮: in {fmt_tokens(turn_in_total)}, out {fmt_tokens(turn_out)}){C['reset']}"
102
196
  if total_in or total_out:
103
- parts.append(f"{C['peach']}Tokens: {fmt_tokens(total_in)} {fmt_tokens(total_out)}↓ cached:{fmt_tokens(cache)}{C['reset']}")
104
-
105
- model_name = data.get("model", {}).get("display_name", "")
197
+ tok_full = f"{C['peach']}Tokens: in {fmt_tokens(total_in)}, out {fmt_tokens(total_out)}{turn_str}"
198
+ tok_short = f"{C['peach']}Tokens: in {fmt_tokens(total_in)}, out {fmt_tokens(total_out)}{C['reset']}"
199
+ line2.append(tok_full)
200
+ cache_read = curr_usage.get("cache_read_input_tokens", 0)
201
+ if cache_read > 0:
202
+ line2.append(f"{C['cyan']}Cached: {fmt_tokens(cache_read)}{C['reset']}")
203
+
204
+ cost = data.get("cost") or {}
205
+ usd = cost.get("total_cost_usd")
206
+ if usd is not None:
207
+ line2.append(f"{C['magenta']}Cost: ${usd:.2f}{C['reset']}")
208
+
209
+ # 宽度不够时隐藏本轮数据
210
+ if vlen(" | ".join(line2)) > W and (total_in or total_out):
211
+ line2[0] = tok_short
212
+ if vlen(" | ".join(line2)) > W:
213
+ line2 = line2[1:]
214
+
215
+ # --- Line 3: Duration + Model ---
216
+ line3 = []
217
+
218
+ duration_ms = cost.get("total_duration_ms")
219
+ duration_part = ""
220
+ if duration_ms and duration_ms > 0:
221
+ duration_part = f"{C['dim']}{C['magenta']}会话时长: {fmt_duration(duration_ms / 1000)}{C['reset']}"
222
+ line3.append(duration_part)
223
+
224
+ model_name = (data.get("model") or {}).get("display_name", "")
106
225
  if model_name:
107
- effort = data.get("effort", {}).get("level", "")
226
+ effort = (data.get("effort") or {}).get("level", "")
108
227
  if effort:
109
- model_name += f"{C['dim']}/{effort}{C['reset']}"
110
- parts.append(f"{C['magenta']}{model_name}{C['reset']}")
228
+ model_name += f"/{effort}"
229
+ fast = data.get("fast_mode")
230
+ model_name += f"/{'fast' if fast else 'nofast'}"
231
+ line3.append(f"{C['dim']}{C['magenta']}{model_name}{C['reset']}")
111
232
 
112
- if parts:
113
- print(" | ".join(parts))
233
+ # 宽度不够时隐藏会话时长
234
+ if vlen(" | ".join(line3)) > W and duration_part:
235
+ line3 = [p for p in line3 if p != duration_part]
236
+
237
+ output = [" | ".join(line) for line in (line1, line2, line3) if line]
238
+ if output:
239
+ print("\n".join(output))
114
240
 
115
241
 
116
242
  def main():
@@ -121,8 +247,10 @@ def main():
121
247
  data = json.loads(raw)
122
248
  except Exception:
123
249
  return
124
- save_data(data)
125
- render(data)
250
+
251
+ now = datetime.now(timezone.utc)
252
+ save_data(data, now)
253
+ render(data, now)
126
254
 
127
255
 
128
256
  if __name__ == "__main__":
@@ -218,9 +346,7 @@ def setup(auto: bool = False) -> None:
218
346
 
219
347
 
220
348
  def _setup_claude() -> None:
221
- with open(HOOK_SCRIPT_PATH, "w", encoding="utf-8") as f:
222
- f.write(HOOK_SCRIPT)
223
- os.chmod(HOOK_SCRIPT_PATH, os.stat(HOOK_SCRIPT_PATH).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
349
+ update_hook()
224
350
 
225
351
  settings: dict = {}
226
352
  if os.path.exists(CLAUDE_SETTINGS):
@@ -232,7 +358,7 @@ def _setup_claude() -> None:
232
358
  console.print(f"[yellow]检测到已有 statusLine,备份后替换[/yellow]")
233
359
  settings.setdefault(_BACKUP_KEY, {})[_PREV_SL_KEY] = existing
234
360
 
235
- settings["statusLine"] = {"type": "command", "command": HOOK_SCRIPT_PATH}
361
+ settings["statusLine"] = {"type": "command", "command": f"python3 {HOOK_SCRIPT_PATH}"}
236
362
 
237
363
  with open(CLAUDE_SETTINGS, "w", encoding="utf-8") as f:
238
364
  json.dump(settings, f, indent=2, ensure_ascii=False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: token-tracker
3
- Version: 0.2.4
3
+ Version: 0.3.2
4
4
  Summary: Track token usage across local AI agents (Claude Code, Codex)
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: rich>=13.7
File without changes
File without changes