tokenwatch 0.2.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.
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: tokenwatch
3
+ Version: 0.2.0
4
+ Summary: Real-time Claude Code cost dashboard — know exactly what you're spending
5
+ License: MIT
6
+ Project-URL: Homepage, https://tokenwatch.dev
7
+ Project-URL: Issues, https://github.com/yourusername/tokenwatch/issues
8
+ Keywords: claude,anthropic,llm,cost,tokens,dashboard
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: fastapi>=0.111
12
+ Requires-Dist: uvicorn[standard]>=0.29
13
+ Requires-Dist: watchdog>=4.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == "dev"
16
+ Requires-Dist: build; extra == "dev"
17
+ Requires-Dist: twine; extra == "dev"
18
+
19
+ # TokenWatch
20
+
21
+ > Real-time Claude Code cost dashboard. Know exactly what you're spending — per session, per project, per day.
22
+
23
+ No proxy. No SDK wrapping. No API key required.
24
+ Reads Claude Code's local session logs directly.
25
+
26
+ ---
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install tokenwatch
32
+ ```
33
+
34
+ ## Run
35
+
36
+ ```bash
37
+ tokenwatch
38
+ ```
39
+
40
+ Opens `http://localhost:7842` automatically. That's it.
41
+
42
+ ## Commands
43
+
44
+ ```bash
45
+ tokenwatch # start dashboard + open browser
46
+ tokenwatch start # same as above
47
+ tokenwatch status # print today's cost in terminal
48
+ tokenwatch export # export 30 days to CSV
49
+ tokenwatch export --format json --days 90
50
+ tokenwatch seed # generate demo data (no real logs needed)
51
+ tokenwatch seed --clean # remove demo data
52
+ tokenwatch config # show current settings
53
+ ```
54
+
55
+ ## Options
56
+
57
+ ```bash
58
+ tokenwatch --budget 20 # set $20/day budget
59
+ tokenwatch --port 8080 # different port
60
+ tokenwatch --no-browser # don't open browser
61
+ ```
62
+
63
+ ## Features
64
+
65
+ - **Live cost meter** — updates in real time as Claude Code responds
66
+ - **Daily budget bar** — turns orange at 80%, red at 100%
67
+ - **Desktop notifications** — alerts at configurable thresholds (macOS, Linux)
68
+ - **Session drill-down** — click any session to see per-message cost breakdown
69
+ - **Project breakdown** — doughnut chart showing cost by project
70
+ - **CSV/JSON export** — one-click download
71
+ - **Live pricing** — fetched from Anthropic on startup, cached locally
72
+ - **Weekly email digest** — optional (requires `RESEND_API_KEY`)
73
+ - **Auto-start on login** — run `bash install_autostart.sh` once (macOS)
74
+
75
+ ## Configuration
76
+
77
+ Settings live in `~/.claude/tokenwatch_config.json`. Edit directly or use the ⚙ button in the dashboard.
78
+
79
+ | Key | Default | Description |
80
+ |---|---|---|
81
+ | `daily_budget` | 10.00 | Daily spend limit (USD) |
82
+ | `alert_at_pct` | [80, 100] | Notification thresholds |
83
+ | `history_days` | 30 | Days of history to show |
84
+ | `port` | 7842 | Server port |
85
+ | `open_browser` | true | Auto-open on start |
86
+ | `digest_email` | "" | Weekly email (needs RESEND_API_KEY) |
87
+
88
+ ## Weekly digest email
89
+
90
+ ```bash
91
+ export RESEND_API_KEY=re_...
92
+ tokenwatch # set digest_email in Settings
93
+ ```
94
+
95
+ ## Auto-start on macOS login
96
+
97
+ ```bash
98
+ bash install_autostart.sh
99
+ ```
100
+
101
+ ## How it works
102
+
103
+ Claude Code writes JSONL session logs to `~/.claude/projects/<project>/<session>.jsonl`.
104
+ TokenWatch watches that directory with OS-native file events (FSEvents on macOS, inotify on Linux),
105
+ reads new entries within 300ms, and streams cost updates to the dashboard via Server-Sent Events.
106
+
107
+ Costs = token counts × live Anthropic prices. Cache tokens are priced correctly (10× cheaper than input).
108
+
109
+ ## Publishing to PyPI
110
+
111
+ ```bash
112
+ # Tag a release
113
+ git tag v0.2.0 && git push --tags
114
+ # GitHub Actions auto-publishes via trusted publishing
115
+ ```
116
+
117
+ Or manually:
118
+ ```bash
119
+ make publish
120
+ ```
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,106 @@
1
+ # TokenWatch
2
+
3
+ > Real-time Claude Code cost dashboard. Know exactly what you're spending — per session, per project, per day.
4
+
5
+ No proxy. No SDK wrapping. No API key required.
6
+ Reads Claude Code's local session logs directly.
7
+
8
+ ---
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install tokenwatch
14
+ ```
15
+
16
+ ## Run
17
+
18
+ ```bash
19
+ tokenwatch
20
+ ```
21
+
22
+ Opens `http://localhost:7842` automatically. That's it.
23
+
24
+ ## Commands
25
+
26
+ ```bash
27
+ tokenwatch # start dashboard + open browser
28
+ tokenwatch start # same as above
29
+ tokenwatch status # print today's cost in terminal
30
+ tokenwatch export # export 30 days to CSV
31
+ tokenwatch export --format json --days 90
32
+ tokenwatch seed # generate demo data (no real logs needed)
33
+ tokenwatch seed --clean # remove demo data
34
+ tokenwatch config # show current settings
35
+ ```
36
+
37
+ ## Options
38
+
39
+ ```bash
40
+ tokenwatch --budget 20 # set $20/day budget
41
+ tokenwatch --port 8080 # different port
42
+ tokenwatch --no-browser # don't open browser
43
+ ```
44
+
45
+ ## Features
46
+
47
+ - **Live cost meter** — updates in real time as Claude Code responds
48
+ - **Daily budget bar** — turns orange at 80%, red at 100%
49
+ - **Desktop notifications** — alerts at configurable thresholds (macOS, Linux)
50
+ - **Session drill-down** — click any session to see per-message cost breakdown
51
+ - **Project breakdown** — doughnut chart showing cost by project
52
+ - **CSV/JSON export** — one-click download
53
+ - **Live pricing** — fetched from Anthropic on startup, cached locally
54
+ - **Weekly email digest** — optional (requires `RESEND_API_KEY`)
55
+ - **Auto-start on login** — run `bash install_autostart.sh` once (macOS)
56
+
57
+ ## Configuration
58
+
59
+ Settings live in `~/.claude/tokenwatch_config.json`. Edit directly or use the ⚙ button in the dashboard.
60
+
61
+ | Key | Default | Description |
62
+ |---|---|---|
63
+ | `daily_budget` | 10.00 | Daily spend limit (USD) |
64
+ | `alert_at_pct` | [80, 100] | Notification thresholds |
65
+ | `history_days` | 30 | Days of history to show |
66
+ | `port` | 7842 | Server port |
67
+ | `open_browser` | true | Auto-open on start |
68
+ | `digest_email` | "" | Weekly email (needs RESEND_API_KEY) |
69
+
70
+ ## Weekly digest email
71
+
72
+ ```bash
73
+ export RESEND_API_KEY=re_...
74
+ tokenwatch # set digest_email in Settings
75
+ ```
76
+
77
+ ## Auto-start on macOS login
78
+
79
+ ```bash
80
+ bash install_autostart.sh
81
+ ```
82
+
83
+ ## How it works
84
+
85
+ Claude Code writes JSONL session logs to `~/.claude/projects/<project>/<session>.jsonl`.
86
+ TokenWatch watches that directory with OS-native file events (FSEvents on macOS, inotify on Linux),
87
+ reads new entries within 300ms, and streams cost updates to the dashboard via Server-Sent Events.
88
+
89
+ Costs = token counts × live Anthropic prices. Cache tokens are priced correctly (10× cheaper than input).
90
+
91
+ ## Publishing to PyPI
92
+
93
+ ```bash
94
+ # Tag a release
95
+ git tag v0.2.0 && git push --tags
96
+ # GitHub Actions auto-publishes via trusted publishing
97
+ ```
98
+
99
+ Or manually:
100
+ ```bash
101
+ make publish
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tokenwatch"
7
+ version = "0.2.0"
8
+ description = "Real-time Claude Code cost dashboard — know exactly what you're spending"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ keywords = ["claude", "anthropic", "llm", "cost", "tokens", "dashboard"]
13
+ dependencies = [
14
+ "fastapi>=0.111",
15
+ "uvicorn[standard]>=0.29",
16
+ "watchdog>=4.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=8", "build", "twine"]
21
+
22
+ [project.scripts]
23
+ tokenwatch = "tokenwatch.cli:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://tokenwatch.dev"
27
+ Issues = "https://github.com/yourusername/tokenwatch/issues"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["."]
31
+ include = ["tokenwatch*"]
32
+
33
+ [tool.setuptools.package-data]
34
+ "tokenwatch" = ["static/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """TokenWatch — Real-time Claude Code cost dashboard."""
2
+ __version__ = "0.2.0"
@@ -0,0 +1,142 @@
1
+ """
2
+ alerts.py — Desktop notifications + weekly email digest.
3
+
4
+ Notifications work on:
5
+ macOS — osascript
6
+ Linux — notify-send (libnotify)
7
+ Windows — win10toast or plyer fallback
8
+
9
+ Email digest uses Resend (free tier: 3000 emails/mo).
10
+ Set RESEND_API_KEY env var + digest_email in config to enable.
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ import platform
16
+ import subprocess
17
+ from datetime import date, timedelta
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # ── Desktop notifications ─────────────────────────────────────────────────────
23
+
24
+ def notify(title: str, message: str) -> None:
25
+ """Send a desktop notification. Silently fails if not supported."""
26
+ system = platform.system()
27
+ try:
28
+ if system == "Darwin":
29
+ script = f'display notification "{message}" with title "{title}" sound name "Tink"'
30
+ subprocess.run(["osascript", "-e", script], capture_output=True, timeout=3)
31
+
32
+ elif system == "Linux":
33
+ subprocess.run(
34
+ ["notify-send", title, message, "--app-name=TokenWatch", "--icon=dialog-information"],
35
+ capture_output=True, timeout=3,
36
+ )
37
+
38
+ elif system == "Windows":
39
+ try:
40
+ from win10toast import ToastNotifier
41
+ ToastNotifier().show_toast(title, message, duration=5, threaded=True)
42
+ except ImportError:
43
+ try:
44
+ import plyer
45
+ plyer.notification.notify(title=title, message=message, app_name="TokenWatch")
46
+ except ImportError:
47
+ logger.debug("No Windows notification library available (win10toast / plyer).")
48
+
49
+ except Exception as e:
50
+ logger.debug(f"Notification failed: {e}")
51
+
52
+
53
+ # ── Budget alert state ────────────────────────────────────────────────────────
54
+
55
+ _alert_sent: set[str] = set()
56
+
57
+
58
+ def check_budget(cost: float, budget: float, thresholds: list[int]) -> None:
59
+ """Fire a notification when cost crosses a budget threshold."""
60
+ today = str(date.today())
61
+ for pct in thresholds:
62
+ key = f"{today}-{pct}"
63
+ if cost >= budget * (pct / 100) and key not in _alert_sent:
64
+ _alert_sent.add(key)
65
+ msg = f"${cost:.2f} spent today — {pct}% of your ${budget:.0f} budget"
66
+ notify("TokenWatch Budget Alert", msg)
67
+ logger.info(f"Budget alert sent: {pct}%")
68
+
69
+
70
+ def reset_alerts() -> None:
71
+ """Call at midnight to allow today's alerts to fire again tomorrow."""
72
+ _alert_sent.clear()
73
+
74
+
75
+ # ── Weekly email digest ───────────────────────────────────────────────────────
76
+
77
+ def send_weekly_digest(to_email: str, sessions_data: list[dict]) -> bool:
78
+ """
79
+ Send a weekly cost summary email via Resend.
80
+ Requires RESEND_API_KEY environment variable.
81
+ Returns True on success.
82
+ """
83
+ api_key = os.environ.get("RESEND_API_KEY", "")
84
+ if not api_key or not to_email:
85
+ logger.debug("Digest skipped: no RESEND_API_KEY or no email configured.")
86
+ return False
87
+
88
+ try:
89
+ import urllib.request, json as _json
90
+
91
+ total = sum(s.get("total_cost", 0) for s in sessions_data)
92
+ week_ago = str(date.today() - timedelta(days=7))
93
+ top_projects: dict[str, float] = {}
94
+ for s in sessions_data:
95
+ p = s.get("project", "unknown")
96
+ top_projects[p] = round(top_projects.get(p, 0) + s.get("total_cost", 0), 4)
97
+
98
+ top = sorted(top_projects.items(), key=lambda x: x[1], reverse=True)[:5]
99
+ rows = "".join(
100
+ f"<tr><td style='padding:6px 12px;border-bottom:1px solid #eee'>{p}</td>"
101
+ f"<td style='padding:6px 12px;border-bottom:1px solid #eee;text-align:right'>${c:.4f}</td></tr>"
102
+ for p, c in top
103
+ )
104
+
105
+ html = f"""
106
+ <div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;color:#1a1a18">
107
+ <h2 style="font-size:18px;font-weight:500;margin-bottom:4px">Your Claude Code spend last week</h2>
108
+ <p style="color:#6b6a64;margin-bottom:24px;font-size:13px">{week_ago} → {date.today()}</p>
109
+ <div style="background:#f8f7f4;border-radius:12px;padding:20px;margin-bottom:20px;text-align:center">
110
+ <p style="font-size:36px;font-weight:500;margin:0">${total:.2f}</p>
111
+ <p style="color:#6b6a64;font-size:13px;margin:4px 0 0">{len(sessions_data)} sessions</p>
112
+ </div>
113
+ <p style="font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;color:#6b6a64;margin-bottom:8px">By project</p>
114
+ <table style="width:100%;border-collapse:collapse;font-size:13px">{rows}</table>
115
+ <p style="margin-top:24px;font-size:12px;color:#6b6a64">
116
+ TokenWatch · <a href="http://localhost:7842" style="color:#178BCA">Open dashboard</a>
117
+ </p>
118
+ </div>
119
+ """
120
+
121
+ payload = _json.dumps({
122
+ "from": "TokenWatch <digest@tokenwatch.dev>",
123
+ "to": [to_email],
124
+ "subject": f"Your Claude Code bill last week: ${total:.2f}",
125
+ "html": html,
126
+ }).encode()
127
+
128
+ req = urllib.request.Request(
129
+ "https://api.resend.com/emails",
130
+ data=payload,
131
+ headers={
132
+ "Authorization": f"Bearer {api_key}",
133
+ "Content-Type": "application/json",
134
+ },
135
+ )
136
+ with urllib.request.urlopen(req, timeout=10) as resp:
137
+ logger.info(f"Digest sent to {to_email} (status {resp.status})")
138
+ return True
139
+
140
+ except Exception as e:
141
+ logger.warning(f"Digest send failed: {e}")
142
+ return False
@@ -0,0 +1,170 @@
1
+ """
2
+ cli.py — `tokenwatch` command.
3
+
4
+ tokenwatch start dashboard
5
+ tokenwatch start start dashboard
6
+ tokenwatch status print today's cost
7
+ tokenwatch export export to CSV/JSON
8
+ tokenwatch seed generate demo data
9
+ tokenwatch seed --clean remove demo data
10
+ tokenwatch config show config
11
+ tokenwatch license activate Pro license
12
+ """
13
+
14
+ import argparse
15
+ import sys
16
+ import threading
17
+ import webbrowser
18
+
19
+
20
+ def cmd_start(args) -> None:
21
+ from tokenwatch import config as cfg_module
22
+ import uvicorn
23
+
24
+ cfg = cfg_module.load()
25
+ if args.budget is not None:
26
+ cfg["daily_budget"] = args.budget
27
+ cfg_module.save(cfg)
28
+ if args.port:
29
+ cfg["port"] = args.port
30
+ cfg_module.save(cfg)
31
+
32
+ port = cfg["port"]
33
+ open_browser = cfg["open_browser"] and not args.no_browser
34
+ url = f"http://localhost:{port}"
35
+
36
+ from tokenwatch import __version__
37
+ print(f"\n ● TokenWatch v{__version__}")
38
+ print(f" Dashboard → {url}")
39
+ print(f" Budget → ${cfg['daily_budget']:.2f}/day")
40
+ print(f" Press Ctrl+C to stop\n")
41
+
42
+ if open_browser:
43
+ threading.Timer(1.4, lambda: webbrowser.open(url)).start()
44
+
45
+ uvicorn.run("tokenwatch.main:app", host="127.0.0.1", port=port, log_level="warning")
46
+
47
+
48
+ def cmd_status(args) -> None:
49
+ from tokenwatch.parser import get_daily_summary, get_today_cost
50
+ from tokenwatch import config as cfg_module
51
+
52
+ cfg = cfg_module.load()
53
+ budget = cfg.get("daily_budget", 10.0)
54
+ cost = get_today_cost()
55
+ pct = cost / budget * 100 if budget else 0
56
+ summary = get_daily_summary(days=7)
57
+ max_cost = max((d["cost"] for d in summary), default=1) or 1
58
+
59
+ print(f"\n TokenWatch — today")
60
+ print(f" Spent : ${cost:.4f} ({pct:.1f}% of ${budget:.0f} budget)")
61
+ if summary:
62
+ print(f"\n Last 7 days:")
63
+ for d in summary[-7:]:
64
+ bar = "█" * int(d["cost"] / max_cost * 20)
65
+ print(f" {d['date']} {bar:<20} ${d['cost']:.4f}")
66
+ print()
67
+
68
+
69
+ def cmd_export(args) -> None:
70
+ from tokenwatch.parser import export_csv, get_all_sessions
71
+ from pathlib import Path
72
+ import json
73
+
74
+ if args.format == "csv":
75
+ out = export_csv(days=args.days)
76
+ fname = "tokenwatch-export.csv"
77
+ Path(fname).write_text(out)
78
+ print(f"Exported to {fname}")
79
+ else:
80
+ data = [s.to_dict() for s in get_all_sessions(days=args.days)]
81
+ fname = "tokenwatch-export.json"
82
+ Path(fname).write_text(json.dumps(data, indent=2))
83
+ print(f"Exported {len(data)} sessions to {fname}")
84
+
85
+
86
+ def cmd_seed(args) -> None:
87
+ from tokenwatch import seed_demo
88
+ if args.clean:
89
+ seed_demo.clean()
90
+ else:
91
+ seed_demo.generate(days=args.days)
92
+
93
+
94
+ def cmd_config(args) -> None:
95
+ from tokenwatch import config as cfg_module
96
+ cfg = cfg_module.load()
97
+ print(f"\n Config → {cfg_module.CONFIG_FILE}\n")
98
+ for k, v in cfg.items():
99
+ if k == "license_key" and v:
100
+ v = v[:4] + "…" + v[-4:] if len(v) >= 8 else "set"
101
+ print(f" {k:<22} {v}")
102
+ print()
103
+
104
+
105
+ def cmd_license(args) -> None:
106
+ from tokenwatch.license_check import validate
107
+ from tokenwatch import config as cfg_module
108
+
109
+ key = args.key or input("Enter your license key: ").strip()
110
+ if not key:
111
+ print("No key entered.")
112
+ return
113
+
114
+ print("Validating…")
115
+ result = validate(key)
116
+
117
+ if result["valid"]:
118
+ cfg = cfg_module.load()
119
+ cfg["license_key"] = key
120
+ cfg_module.save(cfg)
121
+ print(f"✓ License activated ({result['plan']} plan)")
122
+ else:
123
+ print(f"✗ Invalid license: {result.get('message', '')}")
124
+ print(" Get a license at https://tokenwatch.dev")
125
+
126
+
127
+ def main() -> None:
128
+ parser = argparse.ArgumentParser(prog="tokenwatch", description="Real-time Claude Code cost dashboard")
129
+ sub = parser.add_subparsers(dest="command")
130
+
131
+ p_start = sub.add_parser("start")
132
+ p_start.add_argument("--port", type=int, default=None)
133
+ p_start.add_argument("--no-browser", action="store_true")
134
+ p_start.add_argument("--budget", type=float, default=None)
135
+
136
+ sub.add_parser("status")
137
+
138
+ p_exp = sub.add_parser("export")
139
+ p_exp.add_argument("--format", choices=["csv", "json"], default="csv")
140
+ p_exp.add_argument("--days", type=int, default=30)
141
+
142
+ p_seed = sub.add_parser("seed")
143
+ p_seed.add_argument("--days", type=int, default=30)
144
+ p_seed.add_argument("--clean", action="store_true")
145
+
146
+ sub.add_parser("config")
147
+
148
+ p_lic = sub.add_parser("license")
149
+ p_lic.add_argument("--key", type=str, default=None)
150
+
151
+ args = parser.parse_args()
152
+
153
+ if args.command is None:
154
+ args.command = "start"
155
+ args.port = None
156
+ args.no_browser = False
157
+ args.budget = None
158
+
159
+ {
160
+ "start": cmd_start,
161
+ "status": cmd_status,
162
+ "export": cmd_export,
163
+ "seed": cmd_seed,
164
+ "config": cmd_config,
165
+ "license": cmd_license,
166
+ }[args.command](args)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()
@@ -0,0 +1,50 @@
1
+ """
2
+ config.py — User configuration for TokenWatch.
3
+
4
+ Settings are stored in ~/.claude/tokenwatch_config.json
5
+ Edit the file directly, or use the /api/config endpoint.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from pathlib import Path
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ CONFIG_FILE = Path.home() / ".claude" / "tokenwatch_config.json"
15
+
16
+ DEFAULTS: dict = {
17
+ "daily_budget": 10.00, # USD — alert thresholds are % of this
18
+ "alert_at_pct": [80, 100], # fire desktop notification at these %
19
+ "history_days": 30, # how many days of sessions to keep in memory
20
+ "port": 7842,
21
+ "open_browser": True, # open browser automatically on `tokenwatch` start
22
+ "digest_email": "", # leave blank to disable weekly email digest
23
+ }
24
+
25
+
26
+ def load() -> dict:
27
+ try:
28
+ if CONFIG_FILE.exists():
29
+ stored = json.loads(CONFIG_FILE.read_text())
30
+ # Merge with defaults so new keys are always present
31
+ return {**DEFAULTS, **stored}
32
+ except Exception as e:
33
+ logger.warning(f"Could not read config: {e}")
34
+ return dict(DEFAULTS)
35
+
36
+
37
+ def save(cfg: dict) -> None:
38
+ try:
39
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
40
+ CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
41
+ logger.info(f"Config saved to {CONFIG_FILE}")
42
+ except Exception as e:
43
+ logger.warning(f"Could not save config: {e}")
44
+
45
+
46
+ def update(key: str, value) -> dict:
47
+ cfg = load()
48
+ cfg[key] = value
49
+ save(cfg)
50
+ return cfg