claude-calc 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.
- claude_calc-0.1.0/LICENSE +21 -0
- claude_calc-0.1.0/PKG-INFO +76 -0
- claude_calc-0.1.0/README.md +57 -0
- claude_calc-0.1.0/claude_calc/__init__.py +2 -0
- claude_calc-0.1.0/claude_calc/cli.py +46 -0
- claude_calc-0.1.0/claude_calc/costlib.py +144 -0
- claude_calc-0.1.0/claude_calc/report.py +49 -0
- claude_calc-0.1.0/claude_calc/server.py +58 -0
- claude_calc-0.1.0/claude_calc/static/app.js +275 -0
- claude_calc-0.1.0/claude_calc/static/chart.umd.min.js +1 -0
- claude_calc-0.1.0/claude_calc/static/index.html +112 -0
- claude_calc-0.1.0/claude_calc/static/style.css +185 -0
- claude_calc-0.1.0/claude_calc.egg-info/PKG-INFO +76 -0
- claude_calc-0.1.0/claude_calc.egg-info/SOURCES.txt +17 -0
- claude_calc-0.1.0/claude_calc.egg-info/dependency_links.txt +1 -0
- claude_calc-0.1.0/claude_calc.egg-info/entry_points.txt +2 -0
- claude_calc-0.1.0/claude_calc.egg-info/top_level.txt +1 -0
- claude_calc-0.1.0/pyproject.toml +37 -0
- claude_calc-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hassan Ibrahim
|
|
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,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claude-calc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Price your Claude Code session logs and view the spend in a local dashboard
|
|
5
|
+
Author-email: Hassan Ibrahim <eng.hibrahem@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hibrahem/claude-calc
|
|
8
|
+
Project-URL: Issues, https://github.com/hibrahem/claude-calc/issues
|
|
9
|
+
Keywords: claude,claude-code,anthropic,cost,tokens,dashboard
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# claude-calc
|
|
21
|
+
|
|
22
|
+
See what your Claude Code sessions would cost at pay-per-token API rates.
|
|
23
|
+
|
|
24
|
+
It reads the session logs Claude Code keeps in `~/.claude/projects`, prices
|
|
25
|
+
every API message, and shows the result in a local dashboard: spend over time,
|
|
26
|
+
cost per model split by token type, and projects you can expand down to
|
|
27
|
+
individual sessions.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install claude-calc
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
or `pip install claude-calc`. Python 3.9 or newer, no other dependencies.
|
|
36
|
+
|
|
37
|
+
## Use
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
claude-calc
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Starts the dashboard on http://localhost:8765 and opens it in your browser.
|
|
44
|
+
Logs are rescanned on every page load and on the Rescan button.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
claude-calc report
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Prints a plain-text summary instead.
|
|
51
|
+
|
|
52
|
+
Options:
|
|
53
|
+
|
|
54
|
+
| Flag | Meaning |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `--port 9000` | serve on a different port |
|
|
57
|
+
| `--no-browser` | don't open a browser tab |
|
|
58
|
+
| `--projects-dir DIR` | read logs from somewhere other than `~/.claude/projects` |
|
|
59
|
+
|
|
60
|
+
## Pricing
|
|
61
|
+
|
|
62
|
+
Rates are `$/MTok` in `PRICES` inside `claude_calc/costlib.py`, as
|
|
63
|
+
`(input, output, cache_read, cache_write_5m, cache_write_1h)`. Messages are
|
|
64
|
+
deduplicated by API message id, and cache writes are billed at the 5-minute
|
|
65
|
+
or 1-hour rate depending on which the message used. Models with no rate are
|
|
66
|
+
skipped and listed at the bottom of the page.
|
|
67
|
+
|
|
68
|
+
## Develop
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/hibrahem/claude-calc
|
|
72
|
+
cd claude-calc
|
|
73
|
+
python3 -m claude_calc.cli
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Releases publish to PyPI from GitHub Actions when a `v*` tag is pushed.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# claude-calc
|
|
2
|
+
|
|
3
|
+
See what your Claude Code sessions would cost at pay-per-token API rates.
|
|
4
|
+
|
|
5
|
+
It reads the session logs Claude Code keeps in `~/.claude/projects`, prices
|
|
6
|
+
every API message, and shows the result in a local dashboard: spend over time,
|
|
7
|
+
cost per model split by token type, and projects you can expand down to
|
|
8
|
+
individual sessions.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pipx install claude-calc
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
or `pip install claude-calc`. Python 3.9 or newer, no other dependencies.
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
claude-calc
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Starts the dashboard on http://localhost:8765 and opens it in your browser.
|
|
25
|
+
Logs are rescanned on every page load and on the Rescan button.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
claude-calc report
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Prints a plain-text summary instead.
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
|
|
35
|
+
| Flag | Meaning |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `--port 9000` | serve on a different port |
|
|
38
|
+
| `--no-browser` | don't open a browser tab |
|
|
39
|
+
| `--projects-dir DIR` | read logs from somewhere other than `~/.claude/projects` |
|
|
40
|
+
|
|
41
|
+
## Pricing
|
|
42
|
+
|
|
43
|
+
Rates are `$/MTok` in `PRICES` inside `claude_calc/costlib.py`, as
|
|
44
|
+
`(input, output, cache_read, cache_write_5m, cache_write_1h)`. Messages are
|
|
45
|
+
deduplicated by API message id, and cache writes are billed at the 5-minute
|
|
46
|
+
or 1-hour rate depending on which the message used. Models with no rate are
|
|
47
|
+
skipped and listed at the bottom of the page.
|
|
48
|
+
|
|
49
|
+
## Develop
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/hibrahem/claude-calc
|
|
53
|
+
cd claude-calc
|
|
54
|
+
python3 -m claude_calc.cli
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Releases publish to PyPI from GitHub Actions when a `v*` tag is pushed.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None):
|
|
9
|
+
p = argparse.ArgumentParser(
|
|
10
|
+
prog="claude-calc",
|
|
11
|
+
description="Price your Claude Code session logs and view the spend in a local dashboard.",
|
|
12
|
+
)
|
|
13
|
+
p.add_argument("--version", action="version", version=f"claude-calc {__version__}")
|
|
14
|
+
p.add_argument("--projects-dir", metavar="DIR",
|
|
15
|
+
help="folder holding the session logs (default: ~/.claude/projects)")
|
|
16
|
+
sub = p.add_subparsers(dest="cmd")
|
|
17
|
+
|
|
18
|
+
s = sub.add_parser("serve", help="start the dashboard (default)")
|
|
19
|
+
s.add_argument("-p", "--port", type=int, default=8765)
|
|
20
|
+
s.add_argument("--no-browser", action="store_true", help="don't open a browser tab")
|
|
21
|
+
|
|
22
|
+
sub.add_parser("report", help="print a plain-text summary")
|
|
23
|
+
|
|
24
|
+
args = p.parse_args(argv)
|
|
25
|
+
projects_dir = os.path.expanduser(args.projects_dir) if args.projects_dir else None
|
|
26
|
+
if projects_dir and not os.path.isdir(projects_dir):
|
|
27
|
+
p.error(f"no such directory: {projects_dir}")
|
|
28
|
+
|
|
29
|
+
if args.cmd == "report":
|
|
30
|
+
from .report import report
|
|
31
|
+
report(projects_dir)
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
from .server import serve
|
|
35
|
+
port = getattr(args, "port", 8765)
|
|
36
|
+
open_browser = not getattr(args, "no_browser", False)
|
|
37
|
+
try:
|
|
38
|
+
serve(port=port, open_browser=open_browser, projects_dir=projects_dir)
|
|
39
|
+
except OSError as e:
|
|
40
|
+
print(f"Could not start on port {port}: {e.strerror}. Try --port <other>.", file=sys.stderr)
|
|
41
|
+
return 1
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
sys.exit(main())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Scan Claude Code session logs and price every API message.
|
|
2
|
+
|
|
3
|
+
Shared by the CLI (cost.py) and the web portal (serve.py).
|
|
4
|
+
"""
|
|
5
|
+
import collections
|
|
6
|
+
import glob
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
# $/MTok: (input, output, cache_read, cache_write_5m, cache_write_1h)
|
|
11
|
+
PRICES = {
|
|
12
|
+
"claude-fable-5-1": (10, 50, 0.25, 12.5, 20),
|
|
13
|
+
"claude-fable-5": (10, 50, 1.00, 12.5, 20),
|
|
14
|
+
"claude-opus-5": (5, 25, 0.50, 6.25, 10),
|
|
15
|
+
"claude-opus-4-8": (5, 25, 0.50, 6.25, 10),
|
|
16
|
+
"claude-sonnet-5": (2, 10, 0.20, 2.5, 4),
|
|
17
|
+
"claude-haiku-4-5": (1, 5, 0.10, 1.25, 2),
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
PROJECTS_DIR = os.path.expanduser("~/.claude/projects")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def price(model):
|
|
24
|
+
if model.startswith("claude-haiku-4-5"):
|
|
25
|
+
return PRICES["claude-haiku-4-5"]
|
|
26
|
+
return PRICES.get(model)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _first_text(content):
|
|
30
|
+
"""Return the first plain-text piece of a user message, or ''."""
|
|
31
|
+
if isinstance(content, str):
|
|
32
|
+
return content
|
|
33
|
+
if isinstance(content, list):
|
|
34
|
+
for part in content:
|
|
35
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
36
|
+
return part.get("text", "")
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _display_project(cwd, dirname):
|
|
41
|
+
home = os.path.expanduser("~")
|
|
42
|
+
if cwd:
|
|
43
|
+
if cwd.startswith(home + "/"):
|
|
44
|
+
return cwd[len(home) + 1:]
|
|
45
|
+
return cwd
|
|
46
|
+
# Fallback: undo the "-Users-name-..." encoding of the directory name.
|
|
47
|
+
encoded_home = home.replace("/", "-")
|
|
48
|
+
if dirname.startswith(encoded_home + "-"):
|
|
49
|
+
return dirname[len(encoded_home) + 1:].replace("-", "/")
|
|
50
|
+
return dirname
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load(projects_dir=PROJECTS_DIR):
|
|
54
|
+
"""Return {"rows": [...], "sessions": {...}, "unknown": {...}, "pricing": {...}}.
|
|
55
|
+
|
|
56
|
+
rows: one record per deduplicated assistant API message.
|
|
57
|
+
sessions: sessionId -> {"project", "started", "title"}.
|
|
58
|
+
unknown: model -> total tokens for models with no price.
|
|
59
|
+
"""
|
|
60
|
+
seen = {}
|
|
61
|
+
sessions = {}
|
|
62
|
+
pattern = os.path.join(projects_dir, "**", "*.jsonl")
|
|
63
|
+
for path in glob.glob(pattern, recursive=True):
|
|
64
|
+
dirname = os.path.basename(os.path.dirname(path))
|
|
65
|
+
with open(path, errors="replace") as fh:
|
|
66
|
+
for line in fh:
|
|
67
|
+
try:
|
|
68
|
+
d = json.loads(line)
|
|
69
|
+
except ValueError:
|
|
70
|
+
continue
|
|
71
|
+
kind = d.get("type")
|
|
72
|
+
sid = d.get("sessionId")
|
|
73
|
+
if kind == "user" and sid and not d.get("isSidechain"):
|
|
74
|
+
meta = sessions.setdefault(sid, {
|
|
75
|
+
"project": _display_project(d.get("cwd"), dirname),
|
|
76
|
+
"started": d.get("timestamp", ""),
|
|
77
|
+
"title": "",
|
|
78
|
+
})
|
|
79
|
+
if not meta["title"]:
|
|
80
|
+
text = _first_text((d.get("message") or {}).get("content"))
|
|
81
|
+
text = " ".join(text.split())
|
|
82
|
+
if text and not text.startswith("<"):
|
|
83
|
+
meta["title"] = text[:140]
|
|
84
|
+
continue
|
|
85
|
+
if kind != "assistant":
|
|
86
|
+
continue
|
|
87
|
+
m = d.get("message") or {}
|
|
88
|
+
u = m.get("usage")
|
|
89
|
+
mid = m.get("id")
|
|
90
|
+
if not u or not mid:
|
|
91
|
+
continue
|
|
92
|
+
seen[mid] = (d, u, m.get("model", ""), dirname)
|
|
93
|
+
|
|
94
|
+
rows = []
|
|
95
|
+
unknown = collections.Counter()
|
|
96
|
+
for mid, (d, u, model, dirname) in seen.items():
|
|
97
|
+
pr = price(model)
|
|
98
|
+
inp = u.get("input_tokens", 0) or 0
|
|
99
|
+
out = u.get("output_tokens", 0) or 0
|
|
100
|
+
cr = u.get("cache_read_input_tokens", 0) or 0
|
|
101
|
+
cc = u.get("cache_creation") or {}
|
|
102
|
+
w5 = cc.get("ephemeral_5m_input_tokens")
|
|
103
|
+
w1 = cc.get("ephemeral_1h_input_tokens")
|
|
104
|
+
if w5 is None and w1 is None:
|
|
105
|
+
w5 = u.get("cache_creation_input_tokens", 0)
|
|
106
|
+
w1 = 0
|
|
107
|
+
w5 = w5 or 0
|
|
108
|
+
w1 = w1 or 0
|
|
109
|
+
if pr is None:
|
|
110
|
+
unknown[model] += inp + out + cr + w5 + w1
|
|
111
|
+
continue
|
|
112
|
+
sid = d.get("sessionId")
|
|
113
|
+
ts = d.get("timestamp", "")
|
|
114
|
+
meta = sessions.setdefault(sid, {
|
|
115
|
+
"project": _display_project(d.get("cwd"), dirname),
|
|
116
|
+
"started": ts,
|
|
117
|
+
"title": "",
|
|
118
|
+
})
|
|
119
|
+
if ts and (not meta["started"] or ts < meta["started"]):
|
|
120
|
+
meta["started"] = ts
|
|
121
|
+
rows.append({
|
|
122
|
+
"ts": ts,
|
|
123
|
+
"model": model,
|
|
124
|
+
"session": sid,
|
|
125
|
+
"in": inp, "out": out, "cr": cr, "w5": w5, "w1": w1,
|
|
126
|
+
"c_in": inp * pr[0] / 1e6,
|
|
127
|
+
"c_out": out * pr[1] / 1e6,
|
|
128
|
+
"c_cr": cr * pr[2] / 1e6,
|
|
129
|
+
"c_w": (w5 * pr[3] + w1 * pr[4]) / 1e6,
|
|
130
|
+
})
|
|
131
|
+
for r in rows:
|
|
132
|
+
r["cost"] = r["c_in"] + r["c_out"] + r["c_cr"] + r["c_w"]
|
|
133
|
+
for k in ("c_in", "c_out", "c_cr", "c_w", "cost"):
|
|
134
|
+
r[k] = round(r[k], 6)
|
|
135
|
+
rows.sort(key=lambda r: r["ts"])
|
|
136
|
+
# Only keep sessions that have priced rows.
|
|
137
|
+
used = {r["session"] for r in rows}
|
|
138
|
+
sessions = {k: v for k, v in sessions.items() if k in used}
|
|
139
|
+
return {
|
|
140
|
+
"rows": rows,
|
|
141
|
+
"sessions": sessions,
|
|
142
|
+
"unknown": {k: v for k, v in unknown.items() if v},
|
|
143
|
+
"pricing": {k: list(v) for k, v in PRICES.items()},
|
|
144
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Plain-text spend report, the same summary the original script printed."""
|
|
2
|
+
import collections
|
|
3
|
+
|
|
4
|
+
from .costlib import load
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def report(projects_dir=None):
|
|
8
|
+
data = load(projects_dir) if projects_dir else load()
|
|
9
|
+
rows = data["rows"]
|
|
10
|
+
sessions = data["sessions"]
|
|
11
|
+
|
|
12
|
+
by_model = collections.defaultdict(lambda: collections.Counter())
|
|
13
|
+
by_proj = collections.defaultdict(float)
|
|
14
|
+
by_month = collections.defaultdict(float)
|
|
15
|
+
by_session = collections.defaultdict(float)
|
|
16
|
+
for r in rows:
|
|
17
|
+
c = by_model[r["model"]]
|
|
18
|
+
c["msgs"] += 1
|
|
19
|
+
for k in ("in", "out", "cr", "w5", "w1", "c_in", "c_out", "c_cr", "c_w", "cost"):
|
|
20
|
+
c[k] += r[k]
|
|
21
|
+
by_proj[sessions[r["session"]]["project"]] += r["cost"]
|
|
22
|
+
by_month[r["ts"][:7]] += r["cost"]
|
|
23
|
+
by_session[r["session"]] += r["cost"]
|
|
24
|
+
|
|
25
|
+
total = sum(c["cost"] for c in by_model.values())
|
|
26
|
+
print(f"Deduped API messages priced: {len(rows)}")
|
|
27
|
+
print(f"\nTOTAL: ${total:,.2f}\n")
|
|
28
|
+
if not rows:
|
|
29
|
+
print("No priced messages found.")
|
|
30
|
+
return
|
|
31
|
+
print(f"{'model':28}{'msgs':>7}{'input':>12}{'output':>11}{'cache_rd':>13}{'cw_5m':>11}{'cw_1h':>12}{'cost':>11}")
|
|
32
|
+
for mdl, c in sorted(by_model.items(), key=lambda x: -x[1]['cost']):
|
|
33
|
+
print(f"{mdl:28}{c['msgs']:7d}{c['in']:12,d}{c['out']:11,d}{c['cr']:13,d}{c['w5']:11,d}{c['w1']:12,d}{c['cost']:11,.2f}")
|
|
34
|
+
print("\nCost split by token type:")
|
|
35
|
+
for k, lab in [("c_in", "uncached input"), ("c_out", "output"), ("c_cr", "cache reads"), ("c_w", "cache writes")]:
|
|
36
|
+
v = sum(c[k] for c in by_model.values())
|
|
37
|
+
print(f" {lab:16}${v:9,.2f} ({v/total*100:4.1f}%)")
|
|
38
|
+
print("\nBy month:")
|
|
39
|
+
for k in sorted(by_month):
|
|
40
|
+
print(f" {k} ${by_month[k]:9,.2f}")
|
|
41
|
+
print("\nBy project:")
|
|
42
|
+
for k, v in sorted(by_proj.items(), key=lambda x: -x[1]):
|
|
43
|
+
print(f" ${v:9,.2f} {k}")
|
|
44
|
+
print("\nTop 10 sessions:")
|
|
45
|
+
for k, v in sorted(by_session.items(), key=lambda x: -x[1])[:10]:
|
|
46
|
+
s = sessions[k]
|
|
47
|
+
print(f" ${v:8,.2f} {s['started'][:10]} {k[:8]} {s['project']}")
|
|
48
|
+
if data["unknown"]:
|
|
49
|
+
print("\nSkipped (unpriced) models, total tokens:", data["unknown"])
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Local web portal for Claude Code spend."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from functools import partial
|
|
8
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
|
|
10
|
+
from .costlib import load
|
|
11
|
+
|
|
12
|
+
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Handler(SimpleHTTPRequestHandler):
|
|
16
|
+
def __init__(self, *a, projects_dir=None, **kw):
|
|
17
|
+
self.projects_dir = projects_dir
|
|
18
|
+
super().__init__(*a, directory=STATIC, **kw)
|
|
19
|
+
|
|
20
|
+
def do_GET(self):
|
|
21
|
+
if self.path.split("?")[0] == "/api/rows":
|
|
22
|
+
t0 = time.time()
|
|
23
|
+
data = load(self.projects_dir) if self.projects_dir else load()
|
|
24
|
+
data["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
25
|
+
data["scan_ms"] = int((time.time() - t0) * 1000)
|
|
26
|
+
body = json.dumps(data).encode()
|
|
27
|
+
self.send_response(200)
|
|
28
|
+
self.send_header("Content-Type", "application/json")
|
|
29
|
+
self.send_header("Content-Length", str(len(body)))
|
|
30
|
+
self.end_headers()
|
|
31
|
+
self.wfile.write(body)
|
|
32
|
+
return
|
|
33
|
+
if self.path == "/":
|
|
34
|
+
self.path = "/index.html"
|
|
35
|
+
return super().do_GET()
|
|
36
|
+
|
|
37
|
+
def end_headers(self):
|
|
38
|
+
self.send_header("Cache-Control", "no-store")
|
|
39
|
+
super().end_headers()
|
|
40
|
+
|
|
41
|
+
def log_message(self, fmt, *args):
|
|
42
|
+
if "/api/" in (args[0] if args else ""):
|
|
43
|
+
super().log_message(fmt, *args)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def serve(port=8765, open_browser=True, projects_dir=None):
|
|
47
|
+
handler = partial(Handler, projects_dir=projects_dir)
|
|
48
|
+
srv = ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
49
|
+
url = f"http://localhost:{port}"
|
|
50
|
+
print(f"Claude Code spend portal: {url} (Ctrl+C to stop)")
|
|
51
|
+
if open_browser:
|
|
52
|
+
threading.Timer(0.5, webbrowser.open, args=(url,)).start()
|
|
53
|
+
try:
|
|
54
|
+
srv.serve_forever()
|
|
55
|
+
except KeyboardInterrupt:
|
|
56
|
+
pass
|
|
57
|
+
finally:
|
|
58
|
+
srv.server_close()
|