token-tv 0.2.0__py3-none-any.whl

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 (52) hide show
  1. token_tv/__init__.py +2 -0
  2. token_tv/__main__.py +3 -0
  3. token_tv/app.py +75 -0
  4. token_tv/assets/claude-pixel.png +0 -0
  5. token_tv/assets/codex-pixel.png +0 -0
  6. token_tv/assets/grok-pixel.png +0 -0
  7. token_tv/assets/theme-catalog.json +75 -0
  8. token_tv/assets/theme-votes.json +36 -0
  9. token_tv/bridge.py +47 -0
  10. token_tv/catalog.py +138 -0
  11. token_tv/cli.py +366 -0
  12. token_tv/connect.py +72 -0
  13. token_tv/device.py +145 -0
  14. token_tv/display.py +375 -0
  15. token_tv/live.py +265 -0
  16. token_tv/providers/__init__.py +1 -0
  17. token_tv/providers/base.py +23 -0
  18. token_tv/providers/mock.py +11 -0
  19. token_tv/sample.py +25 -0
  20. token_tv/sources.py +450 -0
  21. token_tv/space.py +238 -0
  22. token_tv/state.py +53 -0
  23. token_tv/themes.py +531 -0
  24. token_tv/usage.py +90 -0
  25. token_tv/web/app.js +292 -0
  26. token_tv/web/chakra-petch-500.woff2 +0 -0
  27. token_tv/web/chakra-petch-700.woff2 +0 -0
  28. token_tv/web/chakra-petch-LICENSE.txt +93 -0
  29. token_tv/web/dseg-LICENSE.txt +95 -0
  30. token_tv/web/dseg7-classic-bold.woff2 +0 -0
  31. token_tv/web/index.html +79 -0
  32. token_tv/web/jersey-10-LICENSE.txt +93 -0
  33. token_tv/web/jersey-10.woff2 +0 -0
  34. token_tv/web/manrope-LICENSE.txt +93 -0
  35. token_tv/web/manrope.ttf +0 -0
  36. token_tv/web/orbitron-LICENSE.txt +93 -0
  37. token_tv/web/orbitron.ttf +0 -0
  38. token_tv/web/oxanium-LICENSE.txt +93 -0
  39. token_tv/web/oxanium.woff2 +0 -0
  40. token_tv/web/press-start-2p-LICENSE.txt +93 -0
  41. token_tv/web/press-start-2p.ttf +0 -0
  42. token_tv/web/style.css +717 -0
  43. token_tv/web/tokens.css +79 -0
  44. token_tv/web/vt323-LICENSE.txt +93 -0
  45. token_tv/web/vt323.woff2 +0 -0
  46. token_tv/web_assets.py +28 -0
  47. token_tv-0.2.0.dist-info/METADATA +31 -0
  48. token_tv-0.2.0.dist-info/RECORD +52 -0
  49. token_tv-0.2.0.dist-info/WHEEL +5 -0
  50. token_tv-0.2.0.dist-info/entry_points.txt +2 -0
  51. token_tv-0.2.0.dist-info/licenses/LICENSE +21 -0
  52. token_tv-0.2.0.dist-info/top_level.txt +1 -0
token_tv/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """TokenTV: AI usage limits on a tiny desk clock."""
2
+ __version__ = "0.2.0"
token_tv/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from token_tv.cli import main
2
+
3
+ raise SystemExit(main())
token_tv/app.py ADDED
@@ -0,0 +1,75 @@
1
+ import argparse
2
+ import json
3
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
4
+
5
+ from token_tv.providers.base import Account, Provider, Usage
6
+ from token_tv.providers.mock import MockProvider
7
+
8
+
9
+ MOCK_ACCOUNTS = (
10
+ Account("claude_personal", "Claude personal", "claude"),
11
+ Account("claude_work", "Claude work", "claude"),
12
+ Account("codex_personal", "Codex personal", "codex"),
13
+ )
14
+
15
+
16
+ def build_snapshot(accounts: tuple[Account, ...], providers: dict[str, Provider]) -> dict:
17
+ result = {}
18
+ for account in accounts:
19
+ try:
20
+ usage = providers[account.provider].fetch(account)
21
+ except (KeyError, OSError, ValueError):
22
+ usage = Usage("error", None, None, None, None)
23
+ result[account.key] = {
24
+ "alias": account.alias,
25
+ "provider": account.provider,
26
+ "status": usage.status,
27
+ "session_percent": usage.session_percent,
28
+ "session_reset_minutes": usage.session_reset_minutes,
29
+ "weekly_percent": usage.weekly_percent,
30
+ "weekly_reset_minutes": usage.weekly_reset_minutes,
31
+ }
32
+ return {"schema": 1, "accounts": result}
33
+
34
+
35
+ def make_handler(accounts: tuple[Account, ...], providers: dict[str, Provider]):
36
+ class Handler(BaseHTTPRequestHandler):
37
+ def do_GET(self):
38
+ if self.path == "/snapshot":
39
+ data = build_snapshot(accounts, providers)
40
+ elif self.path.startswith("/snapshot/"):
41
+ key = self.path.removeprefix("/snapshot/")
42
+ account = next((item for item in accounts if item.key == key), None)
43
+ if account is None:
44
+ self.send_error(404)
45
+ return
46
+ data = build_snapshot((account,), providers)["accounts"][key]
47
+ else:
48
+ self.send_error(404)
49
+ return
50
+ body = json.dumps(data, separators=(",", ":")).encode()
51
+ self.send_response(200)
52
+ self.send_header("Content-Type", "application/json; charset=utf-8")
53
+ self.send_header("Content-Length", str(len(body)))
54
+ self.send_header("Cache-Control", "no-store")
55
+ self.end_headers()
56
+ self.wfile.write(body)
57
+
58
+ return Handler
59
+
60
+
61
+ def main():
62
+ parser = argparse.ArgumentParser(description="TokenTV mock usage daemon")
63
+ parser.add_argument("--host", default="127.0.0.1")
64
+ parser.add_argument("--port", type=int, default=8787)
65
+ args = parser.parse_args()
66
+ with ThreadingHTTPServer((args.host, args.port), make_handler(MOCK_ACCOUNTS, {
67
+ "claude": MockProvider(),
68
+ "codex": MockProvider(),
69
+ })) as server:
70
+ print(f"TokenTV mock listening at http://{args.host}:{server.server_port}/snapshot", flush=True)
71
+ server.serve_forever()
72
+
73
+
74
+ if __name__ == "__main__":
75
+ main()
Binary file
Binary file
Binary file
@@ -0,0 +1,75 @@
1
+ {
2
+ "schema_version": 1,
3
+ "themes": [
4
+ {
5
+ "id": "pixel",
6
+ "name": "Pixel",
7
+ "author": "TokenTV",
8
+ "added_at": "2026-10-03T00:00:00Z",
9
+ "min_version": "0.1.0",
10
+ "source_url": "https://github.com/click6067-ship-it/token-tv",
11
+ "license": "MIT",
12
+ "like_issue": 1
13
+ },
14
+ {
15
+ "id": "digital",
16
+ "name": "Digital",
17
+ "author": "TokenTV",
18
+ "added_at": "2026-10-03T00:00:00Z",
19
+ "min_version": "0.1.0",
20
+ "source_url": "https://github.com/click6067-ship-it/token-tv",
21
+ "license": "MIT",
22
+ "like_issue": 2
23
+ },
24
+ {
25
+ "id": "neon",
26
+ "name": "Neon",
27
+ "author": "TokenTV",
28
+ "added_at": "2026-10-03T00:00:00Z",
29
+ "min_version": "0.1.0",
30
+ "source_url": "https://github.com/click6067-ship-it/token-tv",
31
+ "license": "MIT",
32
+ "like_issue": 3
33
+ },
34
+ {
35
+ "id": "retro",
36
+ "name": "Pixel Retro",
37
+ "author": "TokenTV",
38
+ "added_at": "2026-10-03T00:00:00Z",
39
+ "min_version": "0.1.0",
40
+ "source_url": "https://github.com/click6067-ship-it/token-tv",
41
+ "license": "MIT",
42
+ "like_issue": 4
43
+ },
44
+ {
45
+ "id": "hud",
46
+ "name": "Sci-Fi HUD",
47
+ "author": "TokenTV",
48
+ "added_at": "2026-10-03T00:00:00Z",
49
+ "min_version": "0.1.0",
50
+ "source_url": "https://github.com/click6067-ship-it/token-tv",
51
+ "license": "MIT",
52
+ "like_issue": 5
53
+ },
54
+ {
55
+ "id": "space",
56
+ "name": "Space",
57
+ "author": "TokenTV",
58
+ "added_at": "2026-10-03T00:00:00Z",
59
+ "min_version": "0.1.0",
60
+ "source_url": "https://github.com/click6067-ship-it/token-tv",
61
+ "license": "MIT",
62
+ "like_issue": 6
63
+ },
64
+ {
65
+ "id": "gameboy",
66
+ "name": "Game Boy",
67
+ "author": "TokenTV",
68
+ "added_at": "2026-10-07T00:00:00Z",
69
+ "min_version": "0.1.0",
70
+ "source_url": "https://github.com/click6067-ship-it/token-tv",
71
+ "license": "MIT",
72
+ "like_issue": null
73
+ }
74
+ ]
75
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "schema_version": 1,
3
+ "last_attempt_at": "2026-10-03T14:32:40Z",
4
+ "counts": {
5
+ "1": {
6
+ "count": 0,
7
+ "status": "fresh",
8
+ "fetched_at": "2026-10-03T14:32:40Z"
9
+ },
10
+ "2": {
11
+ "count": 0,
12
+ "status": "fresh",
13
+ "fetched_at": "2026-10-03T14:32:40Z"
14
+ },
15
+ "3": {
16
+ "count": 0,
17
+ "status": "fresh",
18
+ "fetched_at": "2026-10-03T14:32:40Z"
19
+ },
20
+ "4": {
21
+ "count": 0,
22
+ "status": "fresh",
23
+ "fetched_at": "2026-10-03T14:32:40Z"
24
+ },
25
+ "5": {
26
+ "count": 0,
27
+ "status": "fresh",
28
+ "fetched_at": "2026-10-03T14:32:40Z"
29
+ },
30
+ "6": {
31
+ "count": 0,
32
+ "status": "fresh",
33
+ "fetched_at": "2026-10-03T14:32:40Z"
34
+ }
35
+ }
36
+ }
token_tv/bridge.py ADDED
@@ -0,0 +1,47 @@
1
+ """Transfer normalized usage only; credentials stay on the source computer."""
2
+ import argparse
3
+ import json
4
+ import shlex
5
+ import subprocess
6
+ import time
7
+
8
+ from token_tv.sources import load_config
9
+ from token_tv.state import UsageStore
10
+
11
+ REMOTE_WRITE = """import json,os,pathlib,sys
12
+ p=pathlib.Path(sys.argv[1]).expanduser()
13
+ data=json.load(sys.stdin)
14
+ p.parent.mkdir(parents=True,exist_ok=True)
15
+ t=p.with_suffix('.tmp')
16
+ t.write_text(json.dumps(data,ensure_ascii=False,separators=(',',':')))
17
+ os.chmod(t,0o600)
18
+ t.replace(p)
19
+ """
20
+
21
+
22
+ def main():
23
+ parser = argparse.ArgumentParser(description=__doc__)
24
+ parser.add_argument("--config", required=True)
25
+ parser.add_argument("--ssh-host", required=True)
26
+ parser.add_argument("--remote-path", required=True)
27
+ args = parser.parse_args()
28
+ if args.ssh_host.startswith("-"):
29
+ parser.error("A host name is required")
30
+ config = load_config(args.config)
31
+ store = UsageStore(config["accounts"])
32
+ while True:
33
+ try:
34
+ store.refresh()
35
+ data = json.dumps(store.snapshot(), ensure_ascii=False, separators=(",", ":"))
36
+ command = "python3 -c " + shlex.quote(REMOTE_WRITE) + " " + shlex.quote(args.remote_path)
37
+ subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
38
+ args.ssh_host, command], input=data, text=True, check=True,
39
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
40
+ print("TokenTV normalized account snapshot delivered.", flush=True)
41
+ except (OSError, subprocess.SubprocessError):
42
+ print("TokenTV bridge unavailable; display will mark stale.", flush=True)
43
+ time.sleep(max(60, int(config.get("poll_seconds", 300))))
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
token_tv/catalog.py ADDED
@@ -0,0 +1,138 @@
1
+ """Theme catalog: installed faces plus reviewed metadata and a manually refreshed likes snapshot.
2
+
3
+ Installed STYLES are the source of truth. The catalog only adds name/author/source/likes, so a face
4
+ you added in your own fork shows up as Local. Nothing here downloads or runs theme code.
5
+ """
6
+ import json
7
+ import re
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from token_tv import __version__
12
+ from token_tv.display import STYLES
13
+
14
+ ASSETS = Path(__file__).with_name('assets')
15
+ CATALOG_PATH = ASSETS / 'theme-catalog.json'
16
+ VOTES_PATH = ASSETS / 'theme-votes.json'
17
+ REPO = 'click6067-ship-it/token-tv'
18
+ BLANK_VOTES = {'schema_version': 1, 'last_attempt_at': None, 'counts': {}}
19
+ FIELDS = ('id', 'name', 'author', 'added_at', 'min_version', 'source_url', 'license', 'like_issue')
20
+
21
+
22
+ def version_tuple(text):
23
+ parts = [int(p) for p in str(text).split('.')]
24
+ while parts and parts[-1] == 0:
25
+ parts.pop()
26
+ return tuple(parts)
27
+
28
+
29
+ def version_at_least(have, need):
30
+ return version_tuple(have) >= version_tuple(need)
31
+
32
+
33
+ def validate_catalog(data):
34
+ if not isinstance(data, dict) or data.get('schema_version') != 1 or not isinstance(data.get('themes'), list):
35
+ raise ValueError('Unsupported theme catalog')
36
+ seen = set()
37
+ for theme in data['themes']:
38
+ if not isinstance(theme, dict) or set(FIELDS) - set(theme):
39
+ raise ValueError('Theme entries need ' + ', '.join(FIELDS))
40
+ if not re.fullmatch(r'[a-z0-9][a-z0-9_-]{0,31}', str(theme['id'])) or theme['id'] in seen:
41
+ raise ValueError('Theme ids must be unique lowercase names')
42
+ seen.add(theme['id'])
43
+ issue = theme['like_issue']
44
+ if issue is not None and (isinstance(issue, bool) or not isinstance(issue, int) or issue < 1):
45
+ raise ValueError('like_issue must be an issue number or null')
46
+ if not str(theme['source_url']).startswith('https://'):
47
+ raise ValueError('source_url must be an https link')
48
+ if theme.get('preview_url') is not None and not str(theme['preview_url']).startswith('https://'):
49
+ raise ValueError('preview_url must be an https link')
50
+ datetime.fromisoformat(str(theme['added_at']).replace('Z', '+00:00'))
51
+ version_tuple(theme['min_version'])
52
+ return data
53
+
54
+
55
+ def load_catalog(path=None):
56
+ return validate_catalog(json.loads(Path(path or CATALOG_PATH).read_text()))
57
+
58
+
59
+ def parse_time(text):
60
+ """A UTC timestamp string, or None when missing or unreadable."""
61
+ try:
62
+ return datetime.fromisoformat(str(text).replace('Z', '+00:00')).timestamp() if text else None
63
+ except ValueError:
64
+ return None
65
+
66
+
67
+ def load_votes(path=None):
68
+ """The saved snapshot, or a blank one. Each count keeps the time it was really counted.
69
+
70
+ A broken file, a bad count or an unreadable count time means unknown, never zero.
71
+ """
72
+ try:
73
+ data = json.loads(Path(path or VOTES_PATH).read_text())
74
+ counts = {}
75
+ for issue, value in data['counts'].items():
76
+ count, status, stamp = value.get('count'), value.get('status'), value.get('fetched_at')
77
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0 or status not in ('fresh', 'stale'):
78
+ continue
79
+ if stamp is not None and parse_time(stamp) is None:
80
+ continue # unreadable time: we cannot say when this number was true
81
+ if status == 'fresh' and stamp is None:
82
+ continue
83
+ counts[str(issue)] = {'count': count, 'status': status, 'fetched_at': stamp}
84
+ attempt = data.get('last_attempt_at')
85
+ return {'schema_version': 1, 'last_attempt_at': attempt if parse_time(attempt) else None, 'counts': counts}
86
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
87
+ return dict(BLANK_VOTES)
88
+
89
+
90
+ def theme_list(installed=STYLES, catalog=None, votes=None, version=__version__, repo=REPO):
91
+ catalog = load_catalog() if catalog is None else catalog
92
+ votes = load_votes() if votes is None else votes
93
+ known = {t['id']: t for t in catalog['themes']}
94
+ themes = []
95
+ for id in list(installed) + [i for i in known if i not in installed]:
96
+ meta = known.get(id)
97
+ if meta is None:
98
+ themes.append({'id': id, 'name': id.replace('-', ' ').title(), 'author': None, 'local': True,
99
+ 'installed': True, 'needs_update': False, 'min_version': None, 'added_at': None,
100
+ 'source_url': None, 'license': None, 'preview_url': None,
101
+ 'likes': None, 'likes_state': 'local', 'likes_counted_at': None, 'like_url': None})
102
+ continue
103
+ issue = meta['like_issue']
104
+ count = votes['counts'].get(str(issue)) if issue else None
105
+ state = 'not_open' if not issue else 'unavailable' if count is None else \
106
+ 'counted' if count['status'] == 'fresh' else 'stale'
107
+ themes.append({'id': id, 'name': meta['name'], 'author': meta['author'], 'local': False,
108
+ 'installed': id in installed,
109
+ 'needs_update': id not in installed or not version_at_least(version, meta['min_version']),
110
+ 'min_version': meta['min_version'], 'added_at': meta['added_at'],
111
+ 'source_url': meta['source_url'], 'license': meta['license'],
112
+ 'preview_url': meta.get('preview_url'),
113
+ 'likes': count['count'] if count else None, 'likes_state': state,
114
+ 'likes_counted_at': count['fetched_at'] if count else None,
115
+ 'like_url': f'https://github.com/{repo}/issues/{issue}' if issue else None})
116
+ return {'last_attempt_at': votes.get('last_attempt_at'), 'version': version, 'themes': sort_themes(themes, 'new')}
117
+
118
+
119
+ def _newest(theme):
120
+ stamp = theme['added_at']
121
+ return -datetime.fromisoformat(stamp.replace('Z', '+00:00')).timestamp() if stamp else float('inf')
122
+
123
+
124
+ def sort_themes(themes, mode):
125
+ """New: newest first, then id. Popular: known counts high to low, then the rest in New order."""
126
+ new = sorted(themes, key=lambda t: (_newest(t), t['id']))
127
+ if mode == 'new':
128
+ return new
129
+ counted = sorted((t for t in new if t['likes'] is not None), key=lambda t: -t['likes']) # stable: keeps New order
130
+ return counted + [t for t in new if t['likes'] is None]
131
+
132
+
133
+ def payload():
134
+ """theme_list() for the dashboard; a broken catalog still lists every installed face as Local."""
135
+ try:
136
+ return theme_list()
137
+ except (OSError, ValueError, KeyError, TypeError):
138
+ return dict(theme_list(catalog={'schema_version': 1, 'themes': []}), catalog_error=True)