ai-limits 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.
Files changed (33) hide show
  1. ai_limits-0.1.0/LICENSE +21 -0
  2. ai_limits-0.1.0/MANIFEST.in +4 -0
  3. ai_limits-0.1.0/PKG-INFO +189 -0
  4. ai_limits-0.1.0/README.md +160 -0
  5. ai_limits-0.1.0/ai_limits/__init__.py +1 -0
  6. ai_limits-0.1.0/ai_limits/__main__.py +5 -0
  7. ai_limits-0.1.0/ai_limits/cli.py +129 -0
  8. ai_limits-0.1.0/ai_limits/config.py +124 -0
  9. ai_limits-0.1.0/ai_limits/core.py +72 -0
  10. ai_limits-0.1.0/ai_limits/model.py +110 -0
  11. ai_limits-0.1.0/ai_limits/netguard.py +147 -0
  12. ai_limits-0.1.0/ai_limits/providers/__init__.py +45 -0
  13. ai_limits-0.1.0/ai_limits/providers/claude.py +209 -0
  14. ai_limits-0.1.0/ai_limits/providers/codex.py +122 -0
  15. ai_limits-0.1.0/ai_limits.egg-info/PKG-INFO +189 -0
  16. ai_limits-0.1.0/ai_limits.egg-info/SOURCES.txt +31 -0
  17. ai_limits-0.1.0/ai_limits.egg-info/dependency_links.txt +1 -0
  18. ai_limits-0.1.0/ai_limits.egg-info/entry_points.txt +2 -0
  19. ai_limits-0.1.0/ai_limits.egg-info/top_level.txt +1 -0
  20. ai_limits-0.1.0/docs/state-schema.md +59 -0
  21. ai_limits-0.1.0/gnome-extension/README.md +84 -0
  22. ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/extension.js +491 -0
  23. ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/metadata.json +8 -0
  24. ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/prefs.js +85 -0
  25. ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/schemas/org.gnome.shell.extensions.ai-limits.gschema.xml +25 -0
  26. ai_limits-0.1.0/gnome-extension/ai-limits@jointsome0-lgtm.github.io/stylesheet.css +27 -0
  27. ai_limits-0.1.0/gnome-extension/install.sh +74 -0
  28. ai_limits-0.1.0/pyproject.toml +43 -0
  29. ai_limits-0.1.0/setup.cfg +4 -0
  30. ai_limits-0.1.0/tests/fixtures/state-sample.json +24 -0
  31. ai_limits-0.1.0/tests/test_core.py +403 -0
  32. ai_limits-0.1.0/tests/test_install.py +70 -0
  33. ai_limits-0.1.0/tests/test_portability.py +173 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agniv
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,4 @@
1
+ include LICENSE README.md
2
+ recursive-include tests *.py *.json
3
+ recursive-include docs *.md
4
+ recursive-include gnome-extension *.md *.sh *.js *.json *.css *.xml
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-limits
3
+ Version: 0.1.0
4
+ Summary: AI subscription usage limits (Claude, Codex) for your desktop panel. Offline by default.
5
+ Author: jointsome0-lgtm
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jointsome0-lgtm/ai-limits
8
+ Project-URL: Repository, https://github.com/jointsome0-lgtm/ai-limits
9
+ Project-URL: Issues, https://github.com/jointsome0-lgtm/ai-limits/issues
10
+ Project-URL: Documentation, https://github.com/jointsome0-lgtm/ai-limits#readme
11
+ Keywords: claude,codex,usage,limits,gnome,offline,cli
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Dynamic: license-file
29
+
30
+ # ai-limits
31
+
32
+ See how much of your AI subscriptions is left — Claude and Codex, any number of accounts — right
33
+ in the GNOME top bar.
34
+
35
+ ```
36
+ ✳ 42% ◎ 19% Oct 27 15:49
37
+ ```
38
+
39
+ ```
40
+ Claude Max
41
+ Session (5h) ████░░░░░░ 42% ↻ 18:30 (in 2h10m)
42
+ Week ██░░░░░░░░ 18% ↻ Sun 19:00 (in 5d3h)
43
+ Codex · personal ★ most headroom
44
+ Week ██░░░░░░░░ 19% ↻ Thu 12:00 (in 2d21h)
45
+ Codex · work updated 8h ago
46
+ Week █████████░ 91% ↻ 18:00 (in 2h)
47
+ ```
48
+
49
+ **Offline by default.** ai-limits reads only what Claude Code and Codex already saved on your
50
+ machine. It sends no requests and does not read your tokens unless you turn on network mode, and
51
+ network mode refuses to send anything unless the route goes through the VPN interface you name.
52
+
53
+ > Unofficial. Not affiliated with Anthropic or OpenAI. The local formats and the usage endpoint it
54
+ > reads are undocumented and may change.
55
+
56
+ ## Install
57
+
58
+ ```sh
59
+ uv tool install ai-limits # or: pipx install ai-limits
60
+ ai-limits # check it sees your accounts
61
+ ```
62
+
63
+ For the optional GNOME Shell 45–49 extension, install from a checkout (it is not in the wheel):
64
+
65
+ ```sh
66
+ git clone https://github.com/jointsome0-lgtm/ai-limits.git
67
+ cd ai-limits
68
+ ./gnome-extension/install.sh
69
+ ```
70
+
71
+ ### Platforms
72
+
73
+ Python **3.11–3.14**, with no runtime dependencies. On distributions with an older system Python
74
+ (e.g. Ubuntu 22.04 ships 3.10), use `uv tool install ai-limits`: uv fetches a suitable Python itself.
75
+ **Linux** supports the offline CLI,
76
+ Claude tap, and VPN-guarded network mode (`ip` from iproute2 is required for network mode).
77
+ **macOS and Windows** support the offline CLI and Claude tap; network mode refuses to run
78
+ there, before reading credentials or looking up the host. The GNOME extension is Linux-only.
79
+
80
+ Config and cache use `~/.config/ai-limits` and `~/.cache/ai-limits` on all platforms, unless
81
+ `XDG_CONFIG_HOME` / `XDG_CACHE_HOME` override them. On Windows, `~` is the user profile directory
82
+ and cache privacy relies on Windows ACLs; on POSIX, directories/files use 0700/0600.
83
+ Files and CLI streams use UTF-8. Reset times display in the local timezone; timestamps without
84
+ an explicit timezone are interpreted as UTC.
85
+
86
+ ## Where the numbers come from
87
+
88
+ | Provider | Offline source | Network source (opt-in) |
89
+ |---|---|---|
90
+ | Codex | rate-limit snapshots in `~/.codex/sessions/**/rollout-*.jsonl`, updated whenever you use Codex | — |
91
+ | Claude | `rate_limits` Claude Code passes to its status line; saved by `ai-limits tap claude` | `api.anthropic.com/api/oauth/usage` (adds per-model weekly limits) |
92
+
93
+ When a window's reset time has passed, the limit is shown as `~0%` until fresh data arrives.
94
+
95
+ ### Claude: add the tap to your status line
96
+
97
+ In `~/.claude/settings.json`:
98
+
99
+ ```jsonc
100
+ "statusLine": { "type": "command", "command": "ai-limits tap claude --print" }
101
+ // already have a status line? pass stdin through to it:
102
+ "statusLine": { "type": "command", "command": "ai-limits tap claude | bash ~/.claude/statusline.sh" }
103
+ ```
104
+
105
+ ## Several accounts
106
+
107
+ Both CLIs keep each login in its own directory (`CLAUDE_CONFIG_DIR=…`, `CODEX_HOME=…`). List them in
108
+ `~/.config/ai-limits/config.toml`:
109
+
110
+ ```toml
111
+ [[account]]
112
+ provider = "claude"
113
+ label = "Claude Max"
114
+ home = "~/.claude"
115
+
116
+ [[account]]
117
+ provider = "codex"
118
+ label = "Codex · personal"
119
+ home = "~/.codex"
120
+
121
+ [[account]]
122
+ provider = "codex"
123
+ label = "Codex · work"
124
+ home = "~/.codex-work"
125
+ ```
126
+
127
+ Without a config file, `~/.claude` and `~/.codex` are picked up automatically. With two or more
128
+ accounts of one provider, the one with the most headroom gets a ★ and is the one shown in the panel.
129
+
130
+ ## Network mode (Linux only, behind a VPN)
131
+
132
+ ```toml
133
+ [network]
134
+ enabled = true
135
+ require_interface = "tun0" # mandatory: no interface, no network
136
+ min_interval_seconds = 300
137
+ ```
138
+
139
+ Before every request ai-limits:
140
+
141
+ 1. checks the interface exists — if not, it stops before even a DNS lookup;
142
+ 2. resolves the host and asks the kernel which interface the packet would leave by
143
+ (`ip route get`, which honours policy routing as used by sing-box / xray / WireGuard);
144
+ 3. sends an anonymous probe (no token, no cookies) to the same host and IP, e.g.
145
+ `api.anthropic.com/v1/models`. Being the same domain, it takes the same routing rule in your VPN
146
+ client, so a `direct` rule or a dead tunnel shows up here. Only the exact answer an allowed
147
+ region gets for a missing token (`401 authentication_error`) passes; a region block (403), a
148
+ timeout or anything unexpected stops here;
149
+ 4. only then sends the real request, to exactly that IP, ignoring proxy environment variables.
150
+
151
+ Every socket is also bound to the interface (`SO_BINDTODEVICE`), so if the VPN drops between the
152
+ checks and the request, the kernel refuses to send it another way. Any failure means no request
153
+ with your token, and the panel shows `⏸` next to the last known numbers. Tokens are only read,
154
+ never refreshed, never put on a command line, and never written anywhere.
155
+
156
+ **What this cannot see:** what your VPN client does *inside* the tunnel. If its rules send
157
+ `anthropic.com` to a `direct` outbound (or fall back to direct when the proxy is down), traffic
158
+ enters `tun0` and still leaves from your real IP. The probe catches a region block, but the only
159
+ real guarantee is in your VPN config: route these domains to the proxy with no direct fallback,
160
+ or block them when the proxy is down (a kill switch).
161
+
162
+ ## CLI
163
+
164
+ ```
165
+ ai-limits summary
166
+ ai-limits --version installed version
167
+ ai-limits json state for UIs (docs/state-schema.md), also written to ~/.cache/ai-limits/state.json
168
+ ai-limits accounts configured / detected accounts
169
+ ai-limits tap claude status line helper (see above)
170
+ ```
171
+
172
+ ## Development
173
+
174
+ ```sh
175
+ python3 -m unittest discover tests
176
+ python3 -m ai_limits json
177
+ ```
178
+
179
+ Releases go to PyPI through Trusted Publishing: run the **Build or publish to PyPI** workflow
180
+ (`publish.yml`) on `main` with mode `publish`; `build-only` just builds and checks the packages.
181
+ The PyPI publisher is owner `jointsome0-lgtm`, repository `ai-limits`, workflow `publish.yml`,
182
+ environment `pypi`. No API token is needed.
183
+
184
+ The GNOME extension only runs `ai-limits json` and draws the result; adding a provider means one
185
+ module in `ai_limits/providers/`.
186
+
187
+ ## License
188
+
189
+ MIT
@@ -0,0 +1,160 @@
1
+ # ai-limits
2
+
3
+ See how much of your AI subscriptions is left — Claude and Codex, any number of accounts — right
4
+ in the GNOME top bar.
5
+
6
+ ```
7
+ ✳ 42% ◎ 19% Oct 27 15:49
8
+ ```
9
+
10
+ ```
11
+ Claude Max
12
+ Session (5h) ████░░░░░░ 42% ↻ 18:30 (in 2h10m)
13
+ Week ██░░░░░░░░ 18% ↻ Sun 19:00 (in 5d3h)
14
+ Codex · personal ★ most headroom
15
+ Week ██░░░░░░░░ 19% ↻ Thu 12:00 (in 2d21h)
16
+ Codex · work updated 8h ago
17
+ Week █████████░ 91% ↻ 18:00 (in 2h)
18
+ ```
19
+
20
+ **Offline by default.** ai-limits reads only what Claude Code and Codex already saved on your
21
+ machine. It sends no requests and does not read your tokens unless you turn on network mode, and
22
+ network mode refuses to send anything unless the route goes through the VPN interface you name.
23
+
24
+ > Unofficial. Not affiliated with Anthropic or OpenAI. The local formats and the usage endpoint it
25
+ > reads are undocumented and may change.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ uv tool install ai-limits # or: pipx install ai-limits
31
+ ai-limits # check it sees your accounts
32
+ ```
33
+
34
+ For the optional GNOME Shell 45–49 extension, install from a checkout (it is not in the wheel):
35
+
36
+ ```sh
37
+ git clone https://github.com/jointsome0-lgtm/ai-limits.git
38
+ cd ai-limits
39
+ ./gnome-extension/install.sh
40
+ ```
41
+
42
+ ### Platforms
43
+
44
+ Python **3.11–3.14**, with no runtime dependencies. On distributions with an older system Python
45
+ (e.g. Ubuntu 22.04 ships 3.10), use `uv tool install ai-limits`: uv fetches a suitable Python itself.
46
+ **Linux** supports the offline CLI,
47
+ Claude tap, and VPN-guarded network mode (`ip` from iproute2 is required for network mode).
48
+ **macOS and Windows** support the offline CLI and Claude tap; network mode refuses to run
49
+ there, before reading credentials or looking up the host. The GNOME extension is Linux-only.
50
+
51
+ Config and cache use `~/.config/ai-limits` and `~/.cache/ai-limits` on all platforms, unless
52
+ `XDG_CONFIG_HOME` / `XDG_CACHE_HOME` override them. On Windows, `~` is the user profile directory
53
+ and cache privacy relies on Windows ACLs; on POSIX, directories/files use 0700/0600.
54
+ Files and CLI streams use UTF-8. Reset times display in the local timezone; timestamps without
55
+ an explicit timezone are interpreted as UTC.
56
+
57
+ ## Where the numbers come from
58
+
59
+ | Provider | Offline source | Network source (opt-in) |
60
+ |---|---|---|
61
+ | Codex | rate-limit snapshots in `~/.codex/sessions/**/rollout-*.jsonl`, updated whenever you use Codex | — |
62
+ | Claude | `rate_limits` Claude Code passes to its status line; saved by `ai-limits tap claude` | `api.anthropic.com/api/oauth/usage` (adds per-model weekly limits) |
63
+
64
+ When a window's reset time has passed, the limit is shown as `~0%` until fresh data arrives.
65
+
66
+ ### Claude: add the tap to your status line
67
+
68
+ In `~/.claude/settings.json`:
69
+
70
+ ```jsonc
71
+ "statusLine": { "type": "command", "command": "ai-limits tap claude --print" }
72
+ // already have a status line? pass stdin through to it:
73
+ "statusLine": { "type": "command", "command": "ai-limits tap claude | bash ~/.claude/statusline.sh" }
74
+ ```
75
+
76
+ ## Several accounts
77
+
78
+ Both CLIs keep each login in its own directory (`CLAUDE_CONFIG_DIR=…`, `CODEX_HOME=…`). List them in
79
+ `~/.config/ai-limits/config.toml`:
80
+
81
+ ```toml
82
+ [[account]]
83
+ provider = "claude"
84
+ label = "Claude Max"
85
+ home = "~/.claude"
86
+
87
+ [[account]]
88
+ provider = "codex"
89
+ label = "Codex · personal"
90
+ home = "~/.codex"
91
+
92
+ [[account]]
93
+ provider = "codex"
94
+ label = "Codex · work"
95
+ home = "~/.codex-work"
96
+ ```
97
+
98
+ Without a config file, `~/.claude` and `~/.codex` are picked up automatically. With two or more
99
+ accounts of one provider, the one with the most headroom gets a ★ and is the one shown in the panel.
100
+
101
+ ## Network mode (Linux only, behind a VPN)
102
+
103
+ ```toml
104
+ [network]
105
+ enabled = true
106
+ require_interface = "tun0" # mandatory: no interface, no network
107
+ min_interval_seconds = 300
108
+ ```
109
+
110
+ Before every request ai-limits:
111
+
112
+ 1. checks the interface exists — if not, it stops before even a DNS lookup;
113
+ 2. resolves the host and asks the kernel which interface the packet would leave by
114
+ (`ip route get`, which honours policy routing as used by sing-box / xray / WireGuard);
115
+ 3. sends an anonymous probe (no token, no cookies) to the same host and IP, e.g.
116
+ `api.anthropic.com/v1/models`. Being the same domain, it takes the same routing rule in your VPN
117
+ client, so a `direct` rule or a dead tunnel shows up here. Only the exact answer an allowed
118
+ region gets for a missing token (`401 authentication_error`) passes; a region block (403), a
119
+ timeout or anything unexpected stops here;
120
+ 4. only then sends the real request, to exactly that IP, ignoring proxy environment variables.
121
+
122
+ Every socket is also bound to the interface (`SO_BINDTODEVICE`), so if the VPN drops between the
123
+ checks and the request, the kernel refuses to send it another way. Any failure means no request
124
+ with your token, and the panel shows `⏸` next to the last known numbers. Tokens are only read,
125
+ never refreshed, never put on a command line, and never written anywhere.
126
+
127
+ **What this cannot see:** what your VPN client does *inside* the tunnel. If its rules send
128
+ `anthropic.com` to a `direct` outbound (or fall back to direct when the proxy is down), traffic
129
+ enters `tun0` and still leaves from your real IP. The probe catches a region block, but the only
130
+ real guarantee is in your VPN config: route these domains to the proxy with no direct fallback,
131
+ or block them when the proxy is down (a kill switch).
132
+
133
+ ## CLI
134
+
135
+ ```
136
+ ai-limits summary
137
+ ai-limits --version installed version
138
+ ai-limits json state for UIs (docs/state-schema.md), also written to ~/.cache/ai-limits/state.json
139
+ ai-limits accounts configured / detected accounts
140
+ ai-limits tap claude status line helper (see above)
141
+ ```
142
+
143
+ ## Development
144
+
145
+ ```sh
146
+ python3 -m unittest discover tests
147
+ python3 -m ai_limits json
148
+ ```
149
+
150
+ Releases go to PyPI through Trusted Publishing: run the **Build or publish to PyPI** workflow
151
+ (`publish.yml`) on `main` with mode `publish`; `build-only` just builds and checks the packages.
152
+ The PyPI publisher is owner `jointsome0-lgtm`, repository `ai-limits`, workflow `publish.yml`,
153
+ environment `pypi`. No API token is needed.
154
+
155
+ The GNOME extension only runs `ai-limits json` and draws the result; adding a provider means one
156
+ module in `ai_limits/providers/`.
157
+
158
+ ## License
159
+
160
+ MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,129 @@
1
+ """ai-limits: AI subscription usage limits, offline by default.
2
+
3
+ ai-limits human-readable summary
4
+ ai-limits --version installed version
5
+ ai-limits json state JSON (docs/state-schema.md), also saved to ~/.cache/ai-limits/state.json
6
+ ai-limits accounts configured / detected accounts
7
+ ai-limits tap claude for Claude Code's statusLine: saves rate_limits from stdin, passes stdin through
8
+ ai-limits tap claude --print same, but prints a short status line instead of passing stdin through
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import io
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from datetime import datetime
19
+
20
+ from . import __version__, config
21
+ from .core import build_state, format_duration, write_state
22
+ from .model import normalise_home
23
+ from .providers import claude
24
+
25
+ DIM, RESET = "\033[2m", "\033[0m"
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> int:
29
+ # JSON and Claude's status-line protocol are UTF-8, including redirected Windows streams.
30
+ for stream in (sys.stdout, sys.stderr):
31
+ if isinstance(stream, io.TextIOWrapper):
32
+ stream.reconfigure(encoding="utf-8")
33
+ args = sys.argv[1:] if argv is None else argv
34
+ cmd = args[0] if args else "show"
35
+
36
+ if args == ["--version"]:
37
+ print(f"ai-limits {__version__}")
38
+ return 0
39
+ if cmd in ("-h", "--help", "help"):
40
+ print(__doc__.strip())
41
+ return 0
42
+ if cmd == "tap":
43
+ return _tap(args[1:])
44
+ if cmd not in ("show", "json", "accounts") or len(args) > 1:
45
+ # before loading config or providers: a typo must never trigger collection (or network)
46
+ print(f"ai-limits: unknown command {' '.join(args)!r} (see --help)", file=sys.stderr)
47
+ return 2
48
+
49
+ try:
50
+ cfg = config.load()
51
+ except config.ConfigError as e:
52
+ print(f"ai-limits: {e}", file=sys.stderr)
53
+ return 2
54
+
55
+ if cmd == "accounts":
56
+ print(f"config: {cfg.path or '(none, auto-detected)'}")
57
+ net = cfg.network
58
+ print(f"network: {'on via ' + net.require_interface if net.active else 'off'}")
59
+ for acc in cfg.accounts:
60
+ print(f" {acc.id:22} {acc.provider:7} {acc.label!r:24} {acc.home}")
61
+ return 0
62
+
63
+ state = build_state(cfg)
64
+ write_state(state)
65
+ if cmd == "json":
66
+ json.dump(state, sys.stdout, ensure_ascii=False, indent=1, allow_nan=False)
67
+ print()
68
+ else:
69
+ _show(state, color=sys.stdout.isatty())
70
+ return 0
71
+
72
+
73
+ def _tap(args: list[str]) -> int:
74
+ if not args or args[0] != "claude":
75
+ print("usage: ai-limits tap claude [--print]", file=sys.stderr)
76
+ return 2
77
+ if isinstance(sys.stdin, io.TextIOWrapper):
78
+ sys.stdin.reconfigure(encoding="utf-8")
79
+ raw = sys.stdin.read()
80
+ claude.tap_from_stdin(normalise_home(os.environ.get("CLAUDE_CONFIG_DIR") or "~/.claude"), raw)
81
+ if "--print" in args:
82
+ try:
83
+ rl = json.loads(raw).get("rate_limits") or {}
84
+ except (ValueError, AttributeError):
85
+ rl = {}
86
+ parts = [f"{label} {rl[key]['used_percentage']:.0f}%" for key, label in (("five_hour", "5h"), ("seven_day", "7d"))
87
+ if isinstance(rl.get(key), dict) and rl[key].get("used_percentage") is not None]
88
+ print(" · ".join(parts) or "no limit data yet")
89
+ else:
90
+ sys.stdout.write(raw)
91
+ return 0
92
+
93
+
94
+ def _show(state: dict, color: bool) -> None:
95
+ dim, reset = (DIM, RESET) if color else ("", "")
96
+ now = int(time.time())
97
+ if not state["accounts"]:
98
+ print("No accounts found (no ~/.claude or ~/.codex). See ~/.config/ai-limits/config.toml.")
99
+ for acc in state["accounts"]:
100
+ head = acc["label"] + (f" {dim}{acc['plan']}{reset}" if acc["plan"] else "")
101
+ if acc["recommended"]:
102
+ head += " ★ most headroom"
103
+ if acc["status"] == "stale" and acc["updated_at"]:
104
+ head += f" {dim}[updated {format_duration(now - acc['updated_at'])} ago]{reset}"
105
+ elif acc["status"] != "ok":
106
+ head += f" {dim}[{acc['status']}{': ' + acc['message'] if acc['message'] else ''}]{reset}"
107
+ print(head)
108
+ for lim in acc["limits"]:
109
+ pct = f"{'~' if lim['estimated'] else ''}{lim['used_percent']:.0f}%"
110
+ bar = _bar(lim["used_percent"])
111
+ print(f" {lim['label']:16} {bar} {pct:>5} {dim}{_reset_text(lim['resets_at'], now)}{reset}")
112
+ net = state["network"]
113
+ print(f"{dim}network: {'on via ' + net['interface'] if net['enabled'] else 'off (offline mode)'}{reset}")
114
+
115
+
116
+ def _bar(pct: float, width: int = 12) -> str:
117
+ filled = round(min(max(pct, 0), 100) / 100 * width)
118
+ return "█" * filled + "░" * (width - filled)
119
+
120
+
121
+ def _reset_text(resets_at: int | None, now: int) -> str:
122
+ if not resets_at:
123
+ return ""
124
+ try:
125
+ when = datetime.fromtimestamp(resets_at)
126
+ except (ValueError, OverflowError, OSError):
127
+ return "reset time out of range"
128
+ same_day = when.date() == datetime.fromtimestamp(now).date()
129
+ return f"↻ {when.strftime('%H:%M' if same_day else '%a %H:%M')} (in {format_duration(resets_at - now)})"
@@ -0,0 +1,124 @@
1
+ """~/.config/ai-limits/config.toml, with auto-detection when no accounts are listed.
2
+
3
+ stale_after_hours = 6
4
+
5
+ [network] # off unless enabled AND require_interface is set
6
+ enabled = false
7
+ require_interface = "tun0"
8
+ min_interval_seconds = 300
9
+
10
+ [[account]]
11
+ provider = "codex"
12
+ label = "Codex · work"
13
+ home = "~/.codex-work"
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ import os
20
+ import tomllib
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from .model import AccountConfig, normalise_home
25
+
26
+ PROVIDERS = ("claude", "codex")
27
+ DEFAULT_HOMES = {
28
+ "claude": ("CLAUDE_CONFIG_DIR", "~/.claude", "Claude"),
29
+ "codex": ("CODEX_HOME", "~/.codex", "Codex"),
30
+ }
31
+
32
+
33
+ @dataclass
34
+ class NetworkConfig:
35
+ enabled: bool = False
36
+ require_interface: str | None = None
37
+ min_interval_seconds: int = 300
38
+
39
+ @property
40
+ def active(self) -> bool:
41
+ # without a required interface there is nothing to check against -> stay offline
42
+ return self.enabled and bool(self.require_interface)
43
+
44
+
45
+ @dataclass
46
+ class Config:
47
+ accounts: list[AccountConfig] = field(default_factory=list)
48
+ network: NetworkConfig = field(default_factory=NetworkConfig)
49
+ stale_after_hours: float = 6.0
50
+ path: Path | None = None
51
+
52
+
53
+ def config_dir() -> Path:
54
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "ai-limits"
55
+
56
+
57
+ def cache_dir() -> Path:
58
+ return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "ai-limits"
59
+
60
+
61
+ class ConfigError(Exception):
62
+ pass
63
+
64
+
65
+ def load(path: Path | None = None) -> Config:
66
+ path = path or config_dir() / "config.toml"
67
+ raw: dict = {}
68
+ if path.exists():
69
+ try:
70
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
71
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError) as e:
72
+ raise ConfigError(f"{path}: {e}") from e
73
+
74
+ net = raw.get("network", {})
75
+ if not isinstance(net, dict):
76
+ raise ConfigError(f"{path}: [network] must be a table")
77
+ cfg = Config(
78
+ network=NetworkConfig(
79
+ # strict types: `enabled = "false"` must not switch the network on
80
+ enabled=_typed(path, "network.enabled", net.get("enabled", False), bool),
81
+ require_interface=_typed(path, "network.require_interface", net.get("require_interface"), str, None) or None,
82
+ min_interval_seconds=_typed(path, "network.min_interval_seconds", net.get("min_interval_seconds", 300), int),
83
+ ),
84
+ stale_after_hours=float(_typed(path, "stale_after_hours", raw.get("stale_after_hours", 6), (int, float))),
85
+ path=path if path.exists() else None,
86
+ )
87
+ if cfg.network.min_interval_seconds < 60:
88
+ raise ConfigError(f"{path}: network.min_interval_seconds must be at least 60")
89
+ if not (math.isfinite(cfg.stale_after_hours) and cfg.stale_after_hours > 0):
90
+ raise ConfigError(f"{path}: stale_after_hours must be a positive number")
91
+
92
+ accounts = raw.get("account", [])
93
+ if not isinstance(accounts, list) or not all(isinstance(e, dict) for e in accounts):
94
+ raise ConfigError(f"{path}: accounts must be [[account]] tables")
95
+ for i, entry in enumerate(accounts):
96
+ provider = entry.get("provider")
97
+ if provider not in PROVIDERS:
98
+ raise ConfigError(f"{path}: account #{i + 1}: provider must be one of {', '.join(PROVIDERS)}")
99
+ env, default_home, default_label = DEFAULT_HOMES[provider]
100
+ home = entry.get("home") or os.environ.get(env) or default_home
101
+ acc = AccountConfig(provider, _typed(path, "account.label", entry.get("label"), str, None) or default_label,
102
+ normalise_home(_typed(path, "account.home", home, str)))
103
+ if any(a.id == acc.id for a in cfg.accounts):
104
+ raise ConfigError(f"{path}: account #{i + 1}: {provider} {acc.home} is listed twice")
105
+ cfg.accounts.append(acc)
106
+
107
+ if not cfg.accounts:
108
+ cfg.accounts = detect_accounts()
109
+ return cfg
110
+
111
+
112
+ def _typed(path, key, value, types, *allowed):
113
+ if value in allowed or (isinstance(value, types) and not (isinstance(value, bool) and types is not bool)):
114
+ return value
115
+ raise ConfigError(f"{path}: {key} has the wrong type ({type(value).__name__})")
116
+
117
+
118
+ def detect_accounts() -> list[AccountConfig]:
119
+ found = []
120
+ for provider, (env, default_home, label) in DEFAULT_HOMES.items():
121
+ home = normalise_home(os.environ.get(env) or default_home)
122
+ if os.path.isdir(home):
123
+ found.append(AccountConfig(provider, label, home))
124
+ return found