Skip to content

Commit eb683ae

Browse files
refactor for DRY, setup a quora_store class to be shared across tools
1 parent 9d99046 commit eb683ae

6 files changed

Lines changed: 241 additions & 125 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ Override any of these in your `~/.claude/settings.json`:
5555
"TOKEN_QUOTA_DIR": "~/.claude-token-quota",
5656
"TOKEN_QUOTA_RETAIN_DAYS": "31",
5757
"TOKEN_QUOTA_WARN_CRITICAL": "95",
58-
"TOKEN_QUOTA_WARN": "85"
58+
"TOKEN_QUOTA_WARN": "85",
59+
"TOKEN_QUOTA_SNOOZE_TOKENS": "1000000"
5960
}
6061
}
6162
```
@@ -70,6 +71,7 @@ Override any of these in your `~/.claude/settings.json`:
7071
| `TOKEN_QUOTA_WARN_CRITICAL` | `95` | % of daily limit at which a visible warning is shown |
7172
| `TOKEN_QUOTA_WARN` | `85` | % of daily limit at which a stderr warning is shown |
7273
| `TOKEN_QUOTA_COST_PER_M` | _(unset)_ | Blended cost per 1M tokens — enables `~$X.XX` estimates in status output |
74+
| `TOKEN_QUOTA_SNOOZE_TOKENS` | `1000000` | Extra tokens added to all limits when `/tokenbudget:snooze` is run |
7375

7476
Weekly and monthly limits are opt-in — omit them to enforce only the daily limit. When multiple limits are set, any one being exceeded blocks new prompts.
7577

hooks/enforce_quota.py

Lines changed: 27 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
TOKEN_QUOTA_DIR=~/.claude-token-quota (default ledger location)
1111
TOKEN_QUOTA_WARN_CRITICAL=95 (default: warn loudly at 95% of daily limit)
1212
TOKEN_QUOTA_WARN=85 (default: warn quietly at 85% of daily limit)
13+
TOKEN_QUOTA_COST_PER_M=5.40 (optional; blended cost per 1M tokens for dollar estimates)
14+
TOKEN_QUOTA_SNOOZE_TOKENS=1000000 (tokens added when snoozed; default 1,000,000)
1315
"""
1416

1517
import json
@@ -18,101 +20,76 @@
1820
from datetime import date, timedelta
1921
from pathlib import Path
2022

21-
LEDGER_DIR = Path(os.environ.get("TOKEN_QUOTA_DIR", Path.home() / ".claude-token-quota"))
22-
DAILY_LIMIT = int(os.environ.get("TOKEN_QUOTA_DAILY", 1_000_000))
23+
sys.path.insert(0, str(Path(__file__).parent))
24+
from quota_store import QuotaStore
25+
2326
WARN_CRITICAL = int(os.environ.get("TOKEN_QUOTA_WARN_CRITICAL", 95))
2427
WARN = int(os.environ.get("TOKEN_QUOTA_WARN", 85))
2528

26-
_weekly_raw = os.environ.get("TOKEN_QUOTA_WEEKLY")
27-
WEEKLY_LIMIT = int(_weekly_raw) if _weekly_raw else None
28-
29-
_monthly_raw = os.environ.get("TOKEN_QUOTA_MONTHLY")
30-
MONTHLY_LIMIT = int(_monthly_raw) if _monthly_raw else None
31-
32-
33-
def get_today_total() -> int:
34-
p = LEDGER_DIR / f"{date.today().isoformat()}.json"
35-
if not p.exists():
36-
return 0
37-
try:
38-
data = json.loads(p.read_text())
39-
return data.get("total_tokens", 0)
40-
except Exception:
41-
return 0
42-
43-
44-
def get_period_total(start: date, end: date) -> int:
45-
total = 0
46-
current = start
47-
while current <= end:
48-
p = LEDGER_DIR / f"{current.isoformat()}.json"
49-
if p.exists():
50-
try:
51-
data = json.loads(p.read_text())
52-
total += data.get("total_tokens", 0)
53-
except Exception:
54-
pass
55-
current += timedelta(days=1)
56-
return total
57-
5829

5930
def main():
6031
try:
6132
sys.stdin.read()
6233
except Exception:
6334
pass
6435

36+
store = QuotaStore()
6537
today = date.today()
66-
used_daily = get_today_total()
67-
pct_daily = (used_daily / DAILY_LIMIT) * 100 if DAILY_LIMIT > 0 else 0
38+
snooze = store.get_snooze()
39+
40+
used_daily = store.get_today_total()
41+
effective_daily = store.daily_limit + snooze
42+
pct_daily = (used_daily / effective_daily) * 100 if effective_daily > 0 else 0
6843

69-
if used_daily >= DAILY_LIMIT:
44+
if used_daily >= effective_daily:
7045
result = {
7146
"decision": "block",
7247
"reason": (
7348
f"Daily token quota exceeded.\n"
74-
f"Used: {used_daily:,} / {DAILY_LIMIT:,} tokens ({pct_daily:.1f}%)\n"
49+
f"Used: {used_daily:,} / {effective_daily:,} tokens ({pct_daily:.1f}%)\n"
7550
f"Quota resets at midnight. Edit TOKEN_QUOTA_DAILY to change the limit."
7651
)
7752
}
7853
print(json.dumps(result))
7954
sys.exit(0)
8055

81-
if WEEKLY_LIMIT is not None:
56+
if store.weekly_limit is not None:
8257
week_start = today - timedelta(days=6)
83-
used_weekly = get_period_total(week_start, today)
84-
if used_weekly >= WEEKLY_LIMIT:
85-
pct = (used_weekly / WEEKLY_LIMIT) * 100
58+
used_weekly = store.get_period_total(week_start, today)
59+
effective_weekly = store.weekly_limit + snooze
60+
if used_weekly >= effective_weekly:
61+
pct = (used_weekly / effective_weekly) * 100
8662
result = {
8763
"decision": "block",
8864
"reason": (
8965
f"Weekly token quota exceeded (rolling 7-day window).\n"
90-
f"Used: {used_weekly:,} / {WEEKLY_LIMIT:,} tokens ({pct:.1f}%)\n"
66+
f"Used: {used_weekly:,} / {effective_weekly:,} tokens ({pct:.1f}%)\n"
9167
f"Edit TOKEN_QUOTA_WEEKLY to change the limit."
9268
)
9369
}
9470
print(json.dumps(result))
9571
sys.exit(0)
9672

97-
if MONTHLY_LIMIT is not None:
73+
if store.monthly_limit is not None:
9874
month_start = today.replace(day=1)
99-
used_monthly = get_period_total(month_start, today)
100-
if used_monthly >= MONTHLY_LIMIT:
101-
pct = (used_monthly / MONTHLY_LIMIT) * 100
75+
used_monthly = store.get_period_total(month_start, today)
76+
effective_monthly = store.monthly_limit + snooze
77+
if used_monthly >= effective_monthly:
78+
pct = (used_monthly / effective_monthly) * 100
10279
result = {
10380
"decision": "block",
10481
"reason": (
10582
f"Monthly token quota exceeded.\n"
106-
f"Used: {used_monthly:,} / {MONTHLY_LIMIT:,} tokens ({pct:.1f}%)\n"
83+
f"Used: {used_monthly:,} / {effective_monthly:,} tokens ({pct:.1f}%)\n"
10784
f"Quota resets on the 1st. Edit TOKEN_QUOTA_MONTHLY to change the limit."
10885
)
10986
}
11087
print(json.dumps(result))
11188
sys.exit(0)
11289

113-
remaining = DAILY_LIMIT - used_daily
90+
remaining = effective_daily - used_daily
11491
if pct_daily >= WARN_CRITICAL:
115-
warning = f"Token quota at {pct_daily:.1f}% ({used_daily:,} / {DAILY_LIMIT:,}). Nearly exhausted."
92+
warning = f"Token quota at {pct_daily:.1f}% ({used_daily:,} / {effective_daily:,}). Nearly exhausted."
11693
result = {"decision": "allow", "reason": warning}
11794
print(json.dumps(result))
11895
elif pct_daily >= WARN:

hooks/quota_status.py

Lines changed: 32 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -12,39 +12,16 @@
1212
import os
1313
from datetime import date, timedelta
1414
from pathlib import Path
15+
import sys
1516

16-
LEDGER_DIR = Path(os.environ.get("TOKEN_QUOTA_DIR", Path.home() / ".claude-token-quota"))
17-
DAILY_LIMIT = int(os.environ.get("TOKEN_QUOTA_DAILY", 1_000_000))
18-
19-
_weekly_raw = os.environ.get("TOKEN_QUOTA_WEEKLY")
20-
WEEKLY_LIMIT = int(_weekly_raw) if _weekly_raw else None
21-
22-
_monthly_raw = os.environ.get("TOKEN_QUOTA_MONTHLY")
23-
MONTHLY_LIMIT = int(_monthly_raw) if _monthly_raw else None
17+
sys.path.insert(0, str(Path(__file__).parent))
18+
from quota_store import QuotaStore
2419

2520
_cost_raw = os.environ.get("TOKEN_QUOTA_COST_PER_M")
2621
COST_PER_M = float(_cost_raw) if _cost_raw else None
2722

28-
RETAIN_DAYS = int(os.environ.get("TOKEN_QUOTA_RETAIN_DAYS", 31))
29-
30-
31-
def get_period_total(start: date, end: date) -> int:
32-
total = 0
33-
current = start
34-
while current <= end:
35-
p = LEDGER_DIR / f"{current.isoformat()}.json"
36-
if p.exists():
37-
try:
38-
data = json.loads(p.read_text())
39-
total += data.get("total_tokens", 0)
40-
except Exception:
41-
pass
42-
current += timedelta(days=1)
43-
return total
44-
4523

4624
def _cost_str(tokens: int) -> str:
47-
"""Return a formatted cost string, or empty string if cost tracking is off."""
4825
if COST_PER_M is None:
4926
return ""
5027
return f" (~${tokens / 1_000_000 * COST_PER_M:.2f})"
@@ -59,62 +36,74 @@ def _render_bar(used: int, limit: int) -> tuple[str, float, str]:
5936

6037

6138
def main():
39+
store = QuotaStore()
6240
today = date.today()
6341

64-
if MONTHLY_LIMIT is not None and RETAIN_DAYS < 31:
65-
print(f" ⚠️ WARNING: TOKEN_QUOTA_RETAIN_DAYS={RETAIN_DAYS} is below 31.")
42+
if store.monthly_limit is not None and store.retain_days < 31:
43+
print(f" ⚠️ WARNING: TOKEN_QUOTA_RETAIN_DAYS={store.retain_days} is below 31.")
6644
print(f" Monthly totals may be incomplete for 31-day months.")
6745
print(f" Set TOKEN_QUOTA_RETAIN_DAYS=31 or higher to fix this.")
6846

6947
print(f"\n{'─'*50}")
7048
print(f" Claude Code Token Quota — {today.isoformat()}")
7149
print(f"{'─'*50}")
7250

73-
ledger_file = LEDGER_DIR / f"{today.isoformat()}.json"
51+
snooze = store.get_snooze()
52+
53+
ledger_file = store.ledger_path()
7454
if not ledger_file.exists():
7555
print(f" No usage recorded today ({today.isoformat()}).")
76-
print(f" Daily limit: {DAILY_LIMIT:,} tokens")
56+
print(f" Daily limit: {store.daily_limit:,} tokens")
7757
sessions = []
7858
else:
7959
data = json.loads(ledger_file.read_text())
8060
used = data.get("total_tokens", 0)
81-
remaining = max(0, DAILY_LIMIT - used)
61+
effective_daily = store.daily_limit + snooze
62+
remaining = max(0, effective_daily - used)
8263
sessions = data.get("sessions", [])
83-
bar, pct, status = _render_bar(used, DAILY_LIMIT)
64+
bar, pct, status = _render_bar(used, effective_daily)
8465

8566
print(f" [{bar}] {pct:.1f}%")
8667
print(f" Used: {used:>12,} tokens{_cost_str(used)}")
8768
print(f" Remaining: {remaining:>12,} tokens{_cost_str(remaining)}")
88-
print(f" Limit: {DAILY_LIMIT:>12,} tokens{_cost_str(DAILY_LIMIT)}")
69+
print(f" Limit: {store.daily_limit:>12,} tokens{_cost_str(store.daily_limit)}")
70+
if snooze:
71+
print(f" Snooze: {snooze:>12,} tokens (expires midnight)")
8972
print(f" Status: {status}")
9073
print(f" Turns: {len(sessions)}")
9174
if sessions:
9275
print(f" Last turn: {sessions[-1]['timestamp']}")
9376

94-
if WEEKLY_LIMIT is not None:
77+
if store.weekly_limit is not None:
9578
week_start = today - timedelta(days=6)
96-
used_weekly = get_period_total(week_start, today)
97-
remaining_weekly = max(0, WEEKLY_LIMIT - used_weekly)
98-
bar, pct, status = _render_bar(used_weekly, WEEKLY_LIMIT)
79+
used_weekly = store.get_period_total(week_start, today)
80+
effective_weekly = store.weekly_limit + snooze
81+
remaining_weekly = max(0, effective_weekly - used_weekly)
82+
bar, pct, status = _render_bar(used_weekly, effective_weekly)
9983

10084
print(f"\n Weekly (rolling 7-day: {week_start.isoformat()}{today.isoformat()})")
10185
print(f" [{bar}] {pct:.1f}%")
10286
print(f" Used: {used_weekly:>12,} tokens{_cost_str(used_weekly)}")
10387
print(f" Remaining: {remaining_weekly:>12,} tokens{_cost_str(remaining_weekly)}")
104-
print(f" Limit: {WEEKLY_LIMIT:>12,} tokens{_cost_str(WEEKLY_LIMIT)}")
88+
print(f" Limit: {store.weekly_limit:>12,} tokens{_cost_str(store.weekly_limit)}")
89+
if snooze:
90+
print(f" Snooze: {snooze:>12,} tokens (expires midnight)")
10591
print(f" Status: {status}")
10692

107-
if MONTHLY_LIMIT is not None:
93+
if store.monthly_limit is not None:
10894
month_start = today.replace(day=1)
109-
used_monthly = get_period_total(month_start, today)
110-
remaining_monthly = max(0, MONTHLY_LIMIT - used_monthly)
111-
bar, pct, status = _render_bar(used_monthly, MONTHLY_LIMIT)
95+
used_monthly = store.get_period_total(month_start, today)
96+
effective_monthly = store.monthly_limit + snooze
97+
remaining_monthly = max(0, effective_monthly - used_monthly)
98+
bar, pct, status = _render_bar(used_monthly, effective_monthly)
11299

113100
print(f"\n Monthly ({today.strftime('%B %Y')})")
114101
print(f" [{bar}] {pct:.1f}%")
115102
print(f" Used: {used_monthly:>12,} tokens{_cost_str(used_monthly)}")
116103
print(f" Remaining: {remaining_monthly:>12,} tokens{_cost_str(remaining_monthly)}")
117-
print(f" Limit: {MONTHLY_LIMIT:>12,} tokens{_cost_str(MONTHLY_LIMIT)}")
104+
print(f" Limit: {store.monthly_limit:>12,} tokens{_cost_str(store.monthly_limit)}")
105+
if snooze:
106+
print(f" Snooze: {snooze:>12,} tokens (expires midnight)")
118107
print(f" Status: {status}")
119108

120109
print(f"\n{'─'*50}\n")

hooks/quota_store.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
quota_store.py — shared data access layer for all token-quota hooks.
3+
4+
Encapsulates config parsing, ledger reads/writes, snooze state, and cleanup.
5+
Import via:
6+
import sys
7+
from pathlib import Path
8+
sys.path.insert(0, str(Path(__file__).parent))
9+
from quota_store import QuotaStore
10+
"""
11+
12+
import json
13+
import os
14+
from datetime import date, timedelta
15+
from pathlib import Path
16+
17+
18+
class QuotaStore:
19+
def __init__(self):
20+
self.ledger_dir = Path(os.environ.get("TOKEN_QUOTA_DIR", Path.home() / ".claude-token-quota"))
21+
self.daily_limit = int(os.environ.get("TOKEN_QUOTA_DAILY", 1_000_000))
22+
self.retain_days = int(os.environ.get("TOKEN_QUOTA_RETAIN_DAYS", 31))
23+
self.snooze_tokens = int(os.environ.get("TOKEN_QUOTA_SNOOZE_TOKENS", 1_000_000))
24+
25+
_weekly = os.environ.get("TOKEN_QUOTA_WEEKLY")
26+
self.weekly_limit = int(_weekly) if _weekly else None
27+
28+
_monthly = os.environ.get("TOKEN_QUOTA_MONTHLY")
29+
self.monthly_limit = int(_monthly) if _monthly else None
30+
31+
# ── Ledger paths ──────────────────────────────────────────────────────────
32+
33+
def ledger_path(self, for_date: date | None = None) -> Path:
34+
return self.ledger_dir / f"{(for_date or date.today()).isoformat()}.json"
35+
36+
# ── Token reads ───────────────────────────────────────────────────────────
37+
38+
def get_today_total(self) -> int:
39+
p = self.ledger_path()
40+
if not p.exists():
41+
return 0
42+
try:
43+
return json.loads(p.read_text()).get("total_tokens", 0)
44+
except Exception:
45+
return 0
46+
47+
def get_period_total(self, start: date, end: date) -> int:
48+
total = 0
49+
current = start
50+
while current <= end:
51+
p = self.ledger_path(current)
52+
if p.exists():
53+
try:
54+
total += json.loads(p.read_text()).get("total_tokens", 0)
55+
except Exception:
56+
pass
57+
current += timedelta(days=1)
58+
return total
59+
60+
# ── Ledger read/write ─────────────────────────────────────────────────────
61+
62+
def load_ledger(self) -> dict:
63+
p = self.ledger_path()
64+
if p.exists():
65+
try:
66+
return json.loads(p.read_text())
67+
except Exception:
68+
pass
69+
return {"date": date.today().isoformat(), "total_tokens": 0, "sessions": []}
70+
71+
def save_ledger(self, ledger: dict):
72+
self.ledger_dir.mkdir(parents=True, exist_ok=True)
73+
self.ledger_path().write_text(json.dumps(ledger, indent=2))
74+
75+
def cleanup_old_ledgers(self):
76+
cutoff = date.today() - timedelta(days=self.retain_days)
77+
for f in self.ledger_dir.glob("????-??-??.json"):
78+
try:
79+
if date.fromisoformat(f.stem) < cutoff:
80+
f.unlink()
81+
except ValueError:
82+
pass
83+
84+
# ── Snooze ────────────────────────────────────────────────────────────────
85+
86+
def get_snooze(self) -> int:
87+
"""Return extra tokens from an active snooze, or 0 if none is active."""
88+
p = self.ledger_dir / "snooze.json"
89+
if not p.exists():
90+
return 0
91+
try:
92+
data = json.loads(p.read_text())
93+
if data.get("expires") == date.today().isoformat():
94+
return int(data.get("extra_tokens", 0))
95+
except Exception:
96+
pass
97+
return 0
98+
99+
def write_snooze(self, extra_tokens: int):
100+
self.ledger_dir.mkdir(parents=True, exist_ok=True)
101+
snooze_file = self.ledger_dir / "snooze.json"
102+
snooze_file.write_text(json.dumps({
103+
"extra_tokens": extra_tokens,
104+
"expires": date.today().isoformat(),
105+
}, indent=2))

0 commit comments

Comments
 (0)