clanker-usage 0.4.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.
- clanker_usage-0.4.0/.gitignore +4 -0
- clanker_usage-0.4.0/LICENSE +21 -0
- clanker_usage-0.4.0/PKG-INFO +228 -0
- clanker_usage-0.4.0/README.md +214 -0
- clanker_usage-0.4.0/build_wrappers.py +159 -0
- clanker_usage-0.4.0/pyproject.toml +35 -0
- clanker_usage-0.4.0/src/clanker_usage/__init__.py +5 -0
- clanker_usage-0.4.0/src/clanker_usage/__main__.py +3 -0
- clanker_usage-0.4.0/src/clanker_usage/cache.py +58 -0
- clanker_usage-0.4.0/src/clanker_usage/cli.py +400 -0
- clanker_usage-0.4.0/src/clanker_usage/errors.py +10 -0
- clanker_usage-0.4.0/src/clanker_usage/http.py +68 -0
- clanker_usage-0.4.0/src/clanker_usage/oauth.py +29 -0
- clanker_usage-0.4.0/src/clanker_usage/paths.py +38 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/__init__.py +13 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/agy.py +189 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/claude.py +236 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/codex.py +243 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/cursor.py +122 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/deepseek.py +409 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/devin.py +119 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/gemini.py +192 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/google.py +63 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/opencode.py +96 -0
- clanker_usage-0.4.0/src/clanker_usage/providers/zcode.py +117 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/agy/agy-2026-10-19.json +59 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/claude/claude-2026-10-19.json +54 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/codex/api-2026-10-19.json +37 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/codex/app-server-2026-10-19.json +56 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/cursor/cursor-2026-10-19.json +35 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/deepseek/deepseek-2026-10-19.json +11 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/devin/devin-2026-10-19.json +17 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/gemini/from-gemini-cli-0.56-source.json +12 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/opencode/opencode-2026-10-19.json +30 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/zcode/no-plan-2026-10-18.json +5 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes/zcode/quota-2026-09-05.json +29 -0
- clanker_usage-0.4.0/src/clanker_usage/shapes.py +88 -0
- clanker_usage-0.4.0/tests/test_core.py +207 -0
- clanker_usage-0.4.0/tests/test_install.py +62 -0
- clanker_usage-0.4.0/tests/test_zcode.py +195 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mihai
|
|
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,228 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: clanker-usage
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Plan and quota of coding-agent accounts, with a statusline, a cache daemon and a check for provider changes
|
|
5
|
+
Project-URL: Homepage, https://github.com/wakamex/clanker-usage
|
|
6
|
+
Project-URL: Source, https://github.com/wakamex/clanker-usage
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai-agents,cli,quota,statusline,usage,z.ai
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Provides-Extra: deepseek
|
|
12
|
+
Requires-Dist: pyyaml>=6.0.3; extra == 'deepseek'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# clanker-usage
|
|
16
|
+
|
|
17
|
+
Monitor the plan and quota of your coding-agent accounts from the terminal: current usage, a one-line statusline, a daemon that keeps a cache fresh, and a check that fails loudly when a provider changes its API. Every provider has the same commands and the same JSON document.
|
|
18
|
+
|
|
19
|
+
Providers: Antigravity (`agy-usage`), Claude Code (`ccusage`), Codex (`codex-cli-usage`), Cursor (`cursor-cli-usage`), DeepSeek (`deepseek-harness-usage`), Devin (`devin-cli-usage`), Gemini CLI (`gemini-cli-usage`), OpenCode Go (`opencode-cli-usage`) and Z.ai's Coding Plan (`zcode-cli-usage`). Each package name and command is a former standalone tool, now one interface over one codebase.
|
|
20
|
+
|
|
21
|
+
clanker-usage reports what the provider says about the account: plan, quota windows and balance. Token use and cost from local session logs are the job of [clanker-analytics](https://github.com/wakamex/clanker-analytics).
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
$ zcode-cli-usage
|
|
25
|
+
Z.ai: lite
|
|
26
|
+
5-hour: 0% used
|
|
27
|
+
weekly: 1% used, resets 2026-09-10 23:38 EDT
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The numbers above are synthetic.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
Requires Python 3.12 or newer and [uv](https://docs.astral.sh/uv/). Install the command for your provider:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uv tool install zcode-cli-usage
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`zcode-cli-usage` installs `clanker-usage` at the same version. `zcode-cli-usage COMMAND` and `clanker-usage zcode COMMAND` are the same command.
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
Every provider command takes the same subcommands:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
zcode-cli-usage # status: the plan and each window
|
|
48
|
+
zcode-cli-usage json # the usage-v1 document
|
|
49
|
+
zcode-cli-usage statusline # one line for a status bar
|
|
50
|
+
zcode-cli-usage refresh # fetch and update the cache; fail if unavailable
|
|
51
|
+
zcode-cli-usage daemon # refresh the cache every five minutes
|
|
52
|
+
zcode-cli-usage check # compare the live response with the recorded response shapes
|
|
53
|
+
clanker-usage # every account in one table; --json for every document
|
|
54
|
+
clanker-usage check # check every provider
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `--max-age SECONDS` on `status`, `json`, `statusline` and `clanker-usage` itself reuses a cached document younger than that. It defaults to 0, always fetching, for `status` and `json`, and to 300 for `statusline` and the all-accounts table.
|
|
58
|
+
- `--interval SECONDS` on `daemon` sets the refresh interval. A daemon skips a refresh when another process updated the same account's cache within the interval, so several daemons or statuslines on one account do not multiply requests. After a failure it backs off, doubling up to an hour.
|
|
59
|
+
- When a fetch fails, the last cached document for the same account is shown as `stale`. With no cache to fall back on, the result is `unavailable`, which exits with status 1; `json` still prints the document.
|
|
60
|
+
- A provider can add a setup command, such as Z.ai's `configure`.
|
|
61
|
+
|
|
62
|
+
## The usage-v1 document
|
|
63
|
+
|
|
64
|
+
`json` prints one document per account, and the cache stores the same document:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"schema": "usage-v1",
|
|
69
|
+
"provider": "zcode",
|
|
70
|
+
"account": "3f2a9c1e5b7d4a60",
|
|
71
|
+
"plan": "lite",
|
|
72
|
+
"status": "live",
|
|
73
|
+
"error": null,
|
|
74
|
+
"retrieved_at": "2026-09-05T17:00:00+00:00",
|
|
75
|
+
"windows": [
|
|
76
|
+
{"name": "5-hour", "seconds": 18000, "scope": null, "used_pct": 0, "resets_at": null},
|
|
77
|
+
{"name": "weekly", "seconds": 604800, "scope": null, "used_pct": 1, "resets_at": "2026-09-11T03:38:39.995000+00:00"}
|
|
78
|
+
],
|
|
79
|
+
"balance": null,
|
|
80
|
+
"details": {"limits": []}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
| Field | Meaning |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| `provider` | the provider, such as `zcode` |
|
|
87
|
+
| `account` | a stable account id from the provider, or a one-way fingerprint of the credential when the provider gives none; null when no credential was found |
|
|
88
|
+
| `plan` | the plan name as the provider reports it, or null without a plan |
|
|
89
|
+
| `status` | `live` (fetched now), `cached` (reused under `--max-age`), `stale` (the fetch failed and this is the last cached document) or `unavailable` |
|
|
90
|
+
| `error` | why the fetch failed, for `stale` and `unavailable`; it never contains a credential or response body |
|
|
91
|
+
| `retrieved_at` | when the provider returned this data, in ISO 8601 UTC |
|
|
92
|
+
| `balance` | for an account billed from a balance, one entry per currency with its `currency` and `amount`, which goes below zero when usage outruns the balance; null for accounts with quota windows |
|
|
93
|
+
| `windows` | each quota window: `name`, length in `seconds` (null when the window is not a fixed length), `scope` (the models it covers, or null for the whole account), `used_pct` from 0 to 100, as exact as the provider allows, and `resets_at` in ISO 8601 UTC or null |
|
|
94
|
+
| `details` | what only this provider reports |
|
|
95
|
+
|
|
96
|
+
## Files
|
|
97
|
+
|
|
98
|
+
| File | Path |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| Cache | `~/.cache/clanker-usage/PROVIDER/ACCOUNT.json` |
|
|
101
|
+
| Check state | `~/.local/state/clanker-usage/check/PROVIDER.json` |
|
|
102
|
+
| Configuration | `~/.config/clanker-usage/config.json`, one section per provider |
|
|
103
|
+
|
|
104
|
+
The `XDG_CACHE_HOME`, `XDG_STATE_HOME` and `XDG_CONFIG_HOME` variables move them. Caches never contain a credential.
|
|
105
|
+
|
|
106
|
+
## Checking for provider changes
|
|
107
|
+
|
|
108
|
+
`check` fetches without the cache and reports each provider as one of:
|
|
109
|
+
|
|
110
|
+
- `ok`: the response matches a recorded shape and every window the previous check saw is present.
|
|
111
|
+
- `new-shape`: the response has a field or type that no recorded shape has. The new paths are listed, such as `$.data.limits[].rollover: number`.
|
|
112
|
+
- `missing`: a window that the previous check saw is absent.
|
|
113
|
+
- `error`: the request failed or the response did not parse.
|
|
114
|
+
|
|
115
|
+
A provider whose login has no quota, such as Gemini CLI on an API key, reports `expected-unavailable`. `check` exits with status 1 unless every provider is `ok` or `expected-unavailable`, so it can run from a timer or CI and notify on failure. Its state file keeps paths and window names but no values. `check --record DIR` also saves a synthetic copy of each live response in `DIR`.
|
|
116
|
+
|
|
117
|
+
## Antigravity
|
|
118
|
+
|
|
119
|
+
Sign in with the Antigravity CLI, `agy`. The tool reads its Google login from `~/.gemini/antigravity-cli/antigravity-oauth-token`. When agy has left the access token expired, the tool renews it with the OAuth client compiled into `agy` and saves it back, but only if agy has not renewed it meanwhile, so a renewal agy made is never undone. Requests carry the installed agy version, because the quota service leaves out the Gemini group for older clients. The account id is a fingerprint of the Google account in the login's id token.
|
|
120
|
+
|
|
121
|
+
Quota comes from Google's Code Assist `retrieveUserQuotaSummary`. Each quota group, such as Gemini or Claude and GPT, has a `5-hour` and a `weekly` window, with the group as the window's `scope`; the plan is the paid tier's name, such as Google AI Ultra.
|
|
122
|
+
|
|
123
|
+
## Claude Code
|
|
124
|
+
|
|
125
|
+
Sign in with Claude Code. The tool reads its login from `.credentials.json` in Claude's config directory, `~/.claude` or `$CLAUDE_CONFIG_DIR`, and the account from Claude Code's `.claude.json`. When Claude Code has left the access token expired, the tool renews it and writes the rotated refresh token back, because Anthropic rotates refresh tokens and Claude Code would otherwise be signed out; it replaces the file only if Claude Code has not renewed it meanwhile. A request during which `/login` switched accounts is discarded. The account id is a fingerprint of Claude's account UUID, so config directories signed into the same account share one cache and one daemon's refreshes.
|
|
126
|
+
|
|
127
|
+
The windows are the `5-hour` session and the `weekly` limit, plus a `weekly` window scoped to each model with its own weekly limit, such as Fable. An account without a subscription, or in an organization that turned off OAuth, is `unavailable`, which replaces its old windows in the cache and which `check` reports as expected.
|
|
128
|
+
|
|
129
|
+
As Claude Code's status line, `ccusage statusline` reads the status JSON Claude Code pipes to it and starts the line with the directory, model and session cost. In `~/.claude/settings.json`:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{"statusLine": {"type": "command", "command": "ccusage statusline"}}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
To see several accounts in `clanker-usage`, add each extra config directory once: `ccusage configure --config-dir ~/.claude-b`.
|
|
136
|
+
|
|
137
|
+
## Codex
|
|
138
|
+
|
|
139
|
+
Sign in with Codex. The tool asks Codex itself: it starts `codex app-server` and reads `account/rateLimits/read` over its JSON-RPC protocol, so Codex handles its own login and token renewal. When Codex is not installed or does not answer, the tool reads the token Codex saved in `$CODEX_HOME/auth.json` (`~/.codex` by default) and calls the usage API directly; it never renews that token, and rereads it once if Codex renewed it during the request. The account id is a fingerprint of Codex's ChatGPT account id.
|
|
140
|
+
|
|
141
|
+
Codex's own limits are the `5-hour` and `weekly` windows. Each additional limit, such as one for a single model, adds its windows with the limit's name as their `scope`, and the API's code review limit appears as a `code review` window. `details` keeps the credits balance when the account has credits.
|
|
142
|
+
|
|
143
|
+
## Cursor
|
|
144
|
+
|
|
145
|
+
Sign in with Cursor's CLI (`agent login`) or its IDE. The tool reads the CLI's access token from `~/.config/cursor/auth.json`, and otherwise the IDE's from its `state.vscdb` database, and never refreshes or changes either. The account id is a fingerprint of the token's subject, so it stays the same when Cursor renews the token.
|
|
146
|
+
|
|
147
|
+
Usage comes from Cursor's `DashboardService/GetCurrentPeriodUsage` and the plan name from `DashboardService/GetPlanInfo`. The one window is the `billing cycle`, with the share of the plan's included usage already used; `details` keeps the spend, the limit and the separate shares for automatic model selection and for API pricing.
|
|
148
|
+
|
|
149
|
+
## DeepSeek
|
|
150
|
+
|
|
151
|
+
The tool finds the API key the way DeepSeek Harness does: `DEEPSEEK_API_KEY` in the environment, then Harness's credential store `$DSH_HOME/.credentials.yaml` (`~/.dsh` by default), then a `.env` in the current directory, then `$DSH_HOME/.env`. It parses those files as Harness does, refuses a credential store readable by other users, and never changes them. When Harness routes DeepSeek to a custom endpoint or credential name, the tool will not send that key to DeepSeek; set `CLANKER_USAGE_DEEPSEEK_API_KEY` to the official account key instead. The account id is a fingerprint of the key.
|
|
152
|
+
|
|
153
|
+
The balance comes from DeepSeek's `GET /user/balance`. DeepSeek reports no quota windows or token history, so the document has a `balance` per currency and `details` keeps the granted and topped-up parts and whether the balance still allows requests. Install `deepseek-harness-usage`, or `clanker-usage[deepseek]`, since reading Harness's YAML store needs [PyYAML](https://pyyaml.org/).
|
|
154
|
+
|
|
155
|
+
## Devin
|
|
156
|
+
|
|
157
|
+
Sign in with the Devin CLI. The tool reads its local API key and server from `~/.local/share/devin/credentials.toml` (under `$XDG_DATA_HOME` when set), sends requests only to that server over HTTPS, and never changes the file. The account id is a fingerprint of the key.
|
|
158
|
+
|
|
159
|
+
Usage comes from the server's `SeatManagementService/GetUserStatus`. The `daily` and `weekly` windows come from the remaining quota it reports; `details.credits` keeps the prompt, flow and flex credits and the overage balance. The tool keeps only the plan and quota parts of the response, leaving out the account's name, email and model lists.
|
|
160
|
+
|
|
161
|
+
## Gemini CLI
|
|
162
|
+
|
|
163
|
+
Quota exists only for a Google login. The tool chooses the login as Gemini CLI does: the `GOOGLE_GENAI_USE_GCA`, `GOOGLE_GENAI_USE_VERTEXAI`, `GEMINI_API_KEY` and Cloud Shell variables, then `.gemini/settings.json` in the current directory, then `~/.gemini/settings.json` (under `$GEMINI_CLI_HOME` when set). With any other login the document is `unavailable` and `check` reports `expected-unavailable`, which does not fail it. With a Google login it reads `~/.gemini/oauth_creds.json` and renews an expired token the same way as for Antigravity, using the OAuth client in the credentials file or in the installed Gemini CLI.
|
|
164
|
+
|
|
165
|
+
Quota comes from Code Assist `retrieveUserQuota`, with one window per model, scoped to that model. `GOOGLE_CLOUD_PROJECT` selects the Code Assist project for accounts that need one, as in Gemini CLI.
|
|
166
|
+
|
|
167
|
+
## OpenCode Go
|
|
168
|
+
|
|
169
|
+
Connect OpenCode Go in OpenCode with `/connect`. The tool reads the Go key OpenCode stores in `~/.local/share/opencode/auth.json` (under `$XDG_DATA_HOME` when set) and never changes the file. The account id is a fingerprint of the key.
|
|
170
|
+
|
|
171
|
+
The limits come from the OpenCode Console's `/console/api/go/status`, the call the console's Go page makes. It is not part of the documented Console API, so `check` is what notices when its format changes. Go's limits are dollar amounts: the `5-hour` and `weekly` windows allow 20% and 50% of the `monthly` amount, and `details.spend` keeps each window's used and total dollars. The plan is the subscription product, such as `go`.
|
|
172
|
+
|
|
173
|
+
## Z.ai Coding Plan
|
|
174
|
+
|
|
175
|
+
Export `ZAI_API_KEY`, or point the tool at an existing `.env` file once:
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
zcode-cli-usage configure --env-file /path/to/.env
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Configuration stores only the file path. The tool reads `ZAI_API_KEY` from that file without executing shell code: plain or quoted assignments, optional `export`, and comments work, and shell variable expansion does not. An exported `ZAI_API_KEY` takes precedence. The account id is a fingerprint of the key, since the API names no account.
|
|
182
|
+
|
|
183
|
+
Quota data comes from `GET https://api.z.ai/api/monitor/usage/quota/limit` with `Authorization: Bearer <ZAI_API_KEY>`. Each quota becomes a window: unit `3` is hours and `6` is weeks, so the current quotas are `5-hour` and `weekly`. `used_pct` is computed from the used and total credits, since the API's own percentage is rounded; `details.limits` keeps each quota's `type`, `unit`, `number`, `used`, `limit` and `remaining` credits. An account without an active Coding Plan has a null `plan` and no windows.
|
|
184
|
+
|
|
185
|
+
## Background refresh on Linux
|
|
186
|
+
|
|
187
|
+
Save this as `~/.config/systemd/user/zcode-cli-usage.service`, adjusting the executable path if needed:
|
|
188
|
+
|
|
189
|
+
```ini
|
|
190
|
+
[Unit]
|
|
191
|
+
Description=Z.ai Coding Plan quota monitor
|
|
192
|
+
After=network-online.target
|
|
193
|
+
|
|
194
|
+
[Service]
|
|
195
|
+
ExecStart=%h/.local/bin/zcode-cli-usage daemon
|
|
196
|
+
Restart=on-failure
|
|
197
|
+
RestartSec=30
|
|
198
|
+
|
|
199
|
+
[Install]
|
|
200
|
+
WantedBy=default.target
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
systemctl --user daemon-reload
|
|
205
|
+
systemctl --user enable --now zcode-cli-usage.service
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Reliability and privacy
|
|
209
|
+
|
|
210
|
+
- Requests never follow redirects, so a credential is only sent to the provider's own URL. Responses over 1 MiB are rejected, and errors name only the HTTP status or failure kind.
|
|
211
|
+
- Caches, state and configuration are written through a private temporary file and atomically replaced, with mode `0600` in directories the tool creates with mode `0700`.
|
|
212
|
+
|
|
213
|
+
## Development
|
|
214
|
+
|
|
215
|
+
The provider packages, such as `zcode-cli-usage`, are generated by `build_wrappers.py`. Each depends on the same version of `clanker-usage` and contains only a module named after itself that runs its provider, so its command and `python -m` module work.
|
|
216
|
+
|
|
217
|
+
A provider module in `src/clanker_usage/providers/` supplies its account id, a fetch of the raw response, and a `normalize` that turns the response into the document's `plan`, `windows` and `details`; the core does everything else. Recorded response shapes live in `src/clanker_usage/shapes/<provider>/`. Every value in them is replaced by a fixed one of the same type, except under keys that describe the format, such as a window's unit, which each provider lists in `KEEP`. Every provider is tested against every recorded shape. When `check` reports a new shape with the same meaning, save it with `check --record DIR` and add the file to that folder; when the meaning changed, fix the provider.
|
|
218
|
+
|
|
219
|
+
```sh
|
|
220
|
+
uv run --locked python -m unittest discover -s tests
|
|
221
|
+
uv run --locked python build_wrappers.py # writes dist-wrappers/
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`tests/test_install.py` builds the core and every provider package, installs each provider package alone with uv, and runs its command and module.
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# clanker-usage
|
|
2
|
+
|
|
3
|
+
Monitor the plan and quota of your coding-agent accounts from the terminal: current usage, a one-line statusline, a daemon that keeps a cache fresh, and a check that fails loudly when a provider changes its API. Every provider has the same commands and the same JSON document.
|
|
4
|
+
|
|
5
|
+
Providers: Antigravity (`agy-usage`), Claude Code (`ccusage`), Codex (`codex-cli-usage`), Cursor (`cursor-cli-usage`), DeepSeek (`deepseek-harness-usage`), Devin (`devin-cli-usage`), Gemini CLI (`gemini-cli-usage`), OpenCode Go (`opencode-cli-usage`) and Z.ai's Coding Plan (`zcode-cli-usage`). Each package name and command is a former standalone tool, now one interface over one codebase.
|
|
6
|
+
|
|
7
|
+
clanker-usage reports what the provider says about the account: plan, quota windows and balance. Token use and cost from local session logs are the job of [clanker-analytics](https://github.com/wakamex/clanker-analytics).
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
$ zcode-cli-usage
|
|
11
|
+
Z.ai: lite
|
|
12
|
+
5-hour: 0% used
|
|
13
|
+
weekly: 1% used, resets 2026-09-10 23:38 EDT
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The numbers above are synthetic.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
Requires Python 3.12 or newer and [uv](https://docs.astral.sh/uv/). Install the command for your provider:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
uv tool install zcode-cli-usage
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`zcode-cli-usage` installs `clanker-usage` at the same version. `zcode-cli-usage COMMAND` and `clanker-usage zcode COMMAND` are the same command.
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
Every provider command takes the same subcommands:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
zcode-cli-usage # status: the plan and each window
|
|
34
|
+
zcode-cli-usage json # the usage-v1 document
|
|
35
|
+
zcode-cli-usage statusline # one line for a status bar
|
|
36
|
+
zcode-cli-usage refresh # fetch and update the cache; fail if unavailable
|
|
37
|
+
zcode-cli-usage daemon # refresh the cache every five minutes
|
|
38
|
+
zcode-cli-usage check # compare the live response with the recorded response shapes
|
|
39
|
+
clanker-usage # every account in one table; --json for every document
|
|
40
|
+
clanker-usage check # check every provider
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `--max-age SECONDS` on `status`, `json`, `statusline` and `clanker-usage` itself reuses a cached document younger than that. It defaults to 0, always fetching, for `status` and `json`, and to 300 for `statusline` and the all-accounts table.
|
|
44
|
+
- `--interval SECONDS` on `daemon` sets the refresh interval. A daemon skips a refresh when another process updated the same account's cache within the interval, so several daemons or statuslines on one account do not multiply requests. After a failure it backs off, doubling up to an hour.
|
|
45
|
+
- When a fetch fails, the last cached document for the same account is shown as `stale`. With no cache to fall back on, the result is `unavailable`, which exits with status 1; `json` still prints the document.
|
|
46
|
+
- A provider can add a setup command, such as Z.ai's `configure`.
|
|
47
|
+
|
|
48
|
+
## The usage-v1 document
|
|
49
|
+
|
|
50
|
+
`json` prints one document per account, and the cache stores the same document:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"schema": "usage-v1",
|
|
55
|
+
"provider": "zcode",
|
|
56
|
+
"account": "3f2a9c1e5b7d4a60",
|
|
57
|
+
"plan": "lite",
|
|
58
|
+
"status": "live",
|
|
59
|
+
"error": null,
|
|
60
|
+
"retrieved_at": "2026-09-05T17:00:00+00:00",
|
|
61
|
+
"windows": [
|
|
62
|
+
{"name": "5-hour", "seconds": 18000, "scope": null, "used_pct": 0, "resets_at": null},
|
|
63
|
+
{"name": "weekly", "seconds": 604800, "scope": null, "used_pct": 1, "resets_at": "2026-09-11T03:38:39.995000+00:00"}
|
|
64
|
+
],
|
|
65
|
+
"balance": null,
|
|
66
|
+
"details": {"limits": []}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Field | Meaning |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `provider` | the provider, such as `zcode` |
|
|
73
|
+
| `account` | a stable account id from the provider, or a one-way fingerprint of the credential when the provider gives none; null when no credential was found |
|
|
74
|
+
| `plan` | the plan name as the provider reports it, or null without a plan |
|
|
75
|
+
| `status` | `live` (fetched now), `cached` (reused under `--max-age`), `stale` (the fetch failed and this is the last cached document) or `unavailable` |
|
|
76
|
+
| `error` | why the fetch failed, for `stale` and `unavailable`; it never contains a credential or response body |
|
|
77
|
+
| `retrieved_at` | when the provider returned this data, in ISO 8601 UTC |
|
|
78
|
+
| `balance` | for an account billed from a balance, one entry per currency with its `currency` and `amount`, which goes below zero when usage outruns the balance; null for accounts with quota windows |
|
|
79
|
+
| `windows` | each quota window: `name`, length in `seconds` (null when the window is not a fixed length), `scope` (the models it covers, or null for the whole account), `used_pct` from 0 to 100, as exact as the provider allows, and `resets_at` in ISO 8601 UTC or null |
|
|
80
|
+
| `details` | what only this provider reports |
|
|
81
|
+
|
|
82
|
+
## Files
|
|
83
|
+
|
|
84
|
+
| File | Path |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| Cache | `~/.cache/clanker-usage/PROVIDER/ACCOUNT.json` |
|
|
87
|
+
| Check state | `~/.local/state/clanker-usage/check/PROVIDER.json` |
|
|
88
|
+
| Configuration | `~/.config/clanker-usage/config.json`, one section per provider |
|
|
89
|
+
|
|
90
|
+
The `XDG_CACHE_HOME`, `XDG_STATE_HOME` and `XDG_CONFIG_HOME` variables move them. Caches never contain a credential.
|
|
91
|
+
|
|
92
|
+
## Checking for provider changes
|
|
93
|
+
|
|
94
|
+
`check` fetches without the cache and reports each provider as one of:
|
|
95
|
+
|
|
96
|
+
- `ok`: the response matches a recorded shape and every window the previous check saw is present.
|
|
97
|
+
- `new-shape`: the response has a field or type that no recorded shape has. The new paths are listed, such as `$.data.limits[].rollover: number`.
|
|
98
|
+
- `missing`: a window that the previous check saw is absent.
|
|
99
|
+
- `error`: the request failed or the response did not parse.
|
|
100
|
+
|
|
101
|
+
A provider whose login has no quota, such as Gemini CLI on an API key, reports `expected-unavailable`. `check` exits with status 1 unless every provider is `ok` or `expected-unavailable`, so it can run from a timer or CI and notify on failure. Its state file keeps paths and window names but no values. `check --record DIR` also saves a synthetic copy of each live response in `DIR`.
|
|
102
|
+
|
|
103
|
+
## Antigravity
|
|
104
|
+
|
|
105
|
+
Sign in with the Antigravity CLI, `agy`. The tool reads its Google login from `~/.gemini/antigravity-cli/antigravity-oauth-token`. When agy has left the access token expired, the tool renews it with the OAuth client compiled into `agy` and saves it back, but only if agy has not renewed it meanwhile, so a renewal agy made is never undone. Requests carry the installed agy version, because the quota service leaves out the Gemini group for older clients. The account id is a fingerprint of the Google account in the login's id token.
|
|
106
|
+
|
|
107
|
+
Quota comes from Google's Code Assist `retrieveUserQuotaSummary`. Each quota group, such as Gemini or Claude and GPT, has a `5-hour` and a `weekly` window, with the group as the window's `scope`; the plan is the paid tier's name, such as Google AI Ultra.
|
|
108
|
+
|
|
109
|
+
## Claude Code
|
|
110
|
+
|
|
111
|
+
Sign in with Claude Code. The tool reads its login from `.credentials.json` in Claude's config directory, `~/.claude` or `$CLAUDE_CONFIG_DIR`, and the account from Claude Code's `.claude.json`. When Claude Code has left the access token expired, the tool renews it and writes the rotated refresh token back, because Anthropic rotates refresh tokens and Claude Code would otherwise be signed out; it replaces the file only if Claude Code has not renewed it meanwhile. A request during which `/login` switched accounts is discarded. The account id is a fingerprint of Claude's account UUID, so config directories signed into the same account share one cache and one daemon's refreshes.
|
|
112
|
+
|
|
113
|
+
The windows are the `5-hour` session and the `weekly` limit, plus a `weekly` window scoped to each model with its own weekly limit, such as Fable. An account without a subscription, or in an organization that turned off OAuth, is `unavailable`, which replaces its old windows in the cache and which `check` reports as expected.
|
|
114
|
+
|
|
115
|
+
As Claude Code's status line, `ccusage statusline` reads the status JSON Claude Code pipes to it and starts the line with the directory, model and session cost. In `~/.claude/settings.json`:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{"statusLine": {"type": "command", "command": "ccusage statusline"}}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
To see several accounts in `clanker-usage`, add each extra config directory once: `ccusage configure --config-dir ~/.claude-b`.
|
|
122
|
+
|
|
123
|
+
## Codex
|
|
124
|
+
|
|
125
|
+
Sign in with Codex. The tool asks Codex itself: it starts `codex app-server` and reads `account/rateLimits/read` over its JSON-RPC protocol, so Codex handles its own login and token renewal. When Codex is not installed or does not answer, the tool reads the token Codex saved in `$CODEX_HOME/auth.json` (`~/.codex` by default) and calls the usage API directly; it never renews that token, and rereads it once if Codex renewed it during the request. The account id is a fingerprint of Codex's ChatGPT account id.
|
|
126
|
+
|
|
127
|
+
Codex's own limits are the `5-hour` and `weekly` windows. Each additional limit, such as one for a single model, adds its windows with the limit's name as their `scope`, and the API's code review limit appears as a `code review` window. `details` keeps the credits balance when the account has credits.
|
|
128
|
+
|
|
129
|
+
## Cursor
|
|
130
|
+
|
|
131
|
+
Sign in with Cursor's CLI (`agent login`) or its IDE. The tool reads the CLI's access token from `~/.config/cursor/auth.json`, and otherwise the IDE's from its `state.vscdb` database, and never refreshes or changes either. The account id is a fingerprint of the token's subject, so it stays the same when Cursor renews the token.
|
|
132
|
+
|
|
133
|
+
Usage comes from Cursor's `DashboardService/GetCurrentPeriodUsage` and the plan name from `DashboardService/GetPlanInfo`. The one window is the `billing cycle`, with the share of the plan's included usage already used; `details` keeps the spend, the limit and the separate shares for automatic model selection and for API pricing.
|
|
134
|
+
|
|
135
|
+
## DeepSeek
|
|
136
|
+
|
|
137
|
+
The tool finds the API key the way DeepSeek Harness does: `DEEPSEEK_API_KEY` in the environment, then Harness's credential store `$DSH_HOME/.credentials.yaml` (`~/.dsh` by default), then a `.env` in the current directory, then `$DSH_HOME/.env`. It parses those files as Harness does, refuses a credential store readable by other users, and never changes them. When Harness routes DeepSeek to a custom endpoint or credential name, the tool will not send that key to DeepSeek; set `CLANKER_USAGE_DEEPSEEK_API_KEY` to the official account key instead. The account id is a fingerprint of the key.
|
|
138
|
+
|
|
139
|
+
The balance comes from DeepSeek's `GET /user/balance`. DeepSeek reports no quota windows or token history, so the document has a `balance` per currency and `details` keeps the granted and topped-up parts and whether the balance still allows requests. Install `deepseek-harness-usage`, or `clanker-usage[deepseek]`, since reading Harness's YAML store needs [PyYAML](https://pyyaml.org/).
|
|
140
|
+
|
|
141
|
+
## Devin
|
|
142
|
+
|
|
143
|
+
Sign in with the Devin CLI. The tool reads its local API key and server from `~/.local/share/devin/credentials.toml` (under `$XDG_DATA_HOME` when set), sends requests only to that server over HTTPS, and never changes the file. The account id is a fingerprint of the key.
|
|
144
|
+
|
|
145
|
+
Usage comes from the server's `SeatManagementService/GetUserStatus`. The `daily` and `weekly` windows come from the remaining quota it reports; `details.credits` keeps the prompt, flow and flex credits and the overage balance. The tool keeps only the plan and quota parts of the response, leaving out the account's name, email and model lists.
|
|
146
|
+
|
|
147
|
+
## Gemini CLI
|
|
148
|
+
|
|
149
|
+
Quota exists only for a Google login. The tool chooses the login as Gemini CLI does: the `GOOGLE_GENAI_USE_GCA`, `GOOGLE_GENAI_USE_VERTEXAI`, `GEMINI_API_KEY` and Cloud Shell variables, then `.gemini/settings.json` in the current directory, then `~/.gemini/settings.json` (under `$GEMINI_CLI_HOME` when set). With any other login the document is `unavailable` and `check` reports `expected-unavailable`, which does not fail it. With a Google login it reads `~/.gemini/oauth_creds.json` and renews an expired token the same way as for Antigravity, using the OAuth client in the credentials file or in the installed Gemini CLI.
|
|
150
|
+
|
|
151
|
+
Quota comes from Code Assist `retrieveUserQuota`, with one window per model, scoped to that model. `GOOGLE_CLOUD_PROJECT` selects the Code Assist project for accounts that need one, as in Gemini CLI.
|
|
152
|
+
|
|
153
|
+
## OpenCode Go
|
|
154
|
+
|
|
155
|
+
Connect OpenCode Go in OpenCode with `/connect`. The tool reads the Go key OpenCode stores in `~/.local/share/opencode/auth.json` (under `$XDG_DATA_HOME` when set) and never changes the file. The account id is a fingerprint of the key.
|
|
156
|
+
|
|
157
|
+
The limits come from the OpenCode Console's `/console/api/go/status`, the call the console's Go page makes. It is not part of the documented Console API, so `check` is what notices when its format changes. Go's limits are dollar amounts: the `5-hour` and `weekly` windows allow 20% and 50% of the `monthly` amount, and `details.spend` keeps each window's used and total dollars. The plan is the subscription product, such as `go`.
|
|
158
|
+
|
|
159
|
+
## Z.ai Coding Plan
|
|
160
|
+
|
|
161
|
+
Export `ZAI_API_KEY`, or point the tool at an existing `.env` file once:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
zcode-cli-usage configure --env-file /path/to/.env
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Configuration stores only the file path. The tool reads `ZAI_API_KEY` from that file without executing shell code: plain or quoted assignments, optional `export`, and comments work, and shell variable expansion does not. An exported `ZAI_API_KEY` takes precedence. The account id is a fingerprint of the key, since the API names no account.
|
|
168
|
+
|
|
169
|
+
Quota data comes from `GET https://api.z.ai/api/monitor/usage/quota/limit` with `Authorization: Bearer <ZAI_API_KEY>`. Each quota becomes a window: unit `3` is hours and `6` is weeks, so the current quotas are `5-hour` and `weekly`. `used_pct` is computed from the used and total credits, since the API's own percentage is rounded; `details.limits` keeps each quota's `type`, `unit`, `number`, `used`, `limit` and `remaining` credits. An account without an active Coding Plan has a null `plan` and no windows.
|
|
170
|
+
|
|
171
|
+
## Background refresh on Linux
|
|
172
|
+
|
|
173
|
+
Save this as `~/.config/systemd/user/zcode-cli-usage.service`, adjusting the executable path if needed:
|
|
174
|
+
|
|
175
|
+
```ini
|
|
176
|
+
[Unit]
|
|
177
|
+
Description=Z.ai Coding Plan quota monitor
|
|
178
|
+
After=network-online.target
|
|
179
|
+
|
|
180
|
+
[Service]
|
|
181
|
+
ExecStart=%h/.local/bin/zcode-cli-usage daemon
|
|
182
|
+
Restart=on-failure
|
|
183
|
+
RestartSec=30
|
|
184
|
+
|
|
185
|
+
[Install]
|
|
186
|
+
WantedBy=default.target
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
systemctl --user daemon-reload
|
|
191
|
+
systemctl --user enable --now zcode-cli-usage.service
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Reliability and privacy
|
|
195
|
+
|
|
196
|
+
- Requests never follow redirects, so a credential is only sent to the provider's own URL. Responses over 1 MiB are rejected, and errors name only the HTTP status or failure kind.
|
|
197
|
+
- Caches, state and configuration are written through a private temporary file and atomically replaced, with mode `0600` in directories the tool creates with mode `0700`.
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
The provider packages, such as `zcode-cli-usage`, are generated by `build_wrappers.py`. Each depends on the same version of `clanker-usage` and contains only a module named after itself that runs its provider, so its command and `python -m` module work.
|
|
202
|
+
|
|
203
|
+
A provider module in `src/clanker_usage/providers/` supplies its account id, a fetch of the raw response, and a `normalize` that turns the response into the document's `plan`, `windows` and `details`; the core does everything else. Recorded response shapes live in `src/clanker_usage/shapes/<provider>/`. Every value in them is replaced by a fixed one of the same type, except under keys that describe the format, such as a window's unit, which each provider lists in `KEEP`. Every provider is tested against every recorded shape. When `check` reports a new shape with the same meaning, save it with `check --record DIR` and add the file to that folder; when the meaning changed, fix the provider.
|
|
204
|
+
|
|
205
|
+
```sh
|
|
206
|
+
uv run --locked python -m unittest discover -s tests
|
|
207
|
+
uv run --locked python build_wrappers.py # writes dist-wrappers/
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`tests/test_install.py` builds the core and every provider package, installs each provider package alone with uv, and runs its command and module.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Generate the wrapper packages that keep each provider's original package name and command.
|
|
2
|
+
|
|
3
|
+
clanker-usage is the only codebase. A wrapper depends on the same version of clanker-usage and
|
|
4
|
+
contains one generated module named after itself, so `NAME` and `python -m MODULE` run that
|
|
5
|
+
provider. The module only calls clanker-usage's `run` for its provider.
|
|
6
|
+
|
|
7
|
+
Usage: python build_wrappers.py [OUT_DIR] [PACKAGE ...] (default OUT_DIR: dist-wrappers)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
ROOT = Path(__file__).resolve().parent
|
|
16
|
+
CORE = "clanker-usage"
|
|
17
|
+
REPOSITORY = "https://github.com/wakamex/clanker-usage"
|
|
18
|
+
|
|
19
|
+
WRAPPERS = {
|
|
20
|
+
"agy-usage": {
|
|
21
|
+
"provider": "agy", "label": "Antigravity",
|
|
22
|
+
"description": "Antigravity quota monitor",
|
|
23
|
+
"keywords": ["antigravity", "agy", "gemini", "usage", "quota", "statusline", "cli"],
|
|
24
|
+
},
|
|
25
|
+
"ccusage": {
|
|
26
|
+
"provider": "claude", "label": "Claude Code",
|
|
27
|
+
"description": "Claude Code usage limit monitor",
|
|
28
|
+
"keywords": ["claude", "claude-code", "anthropic", "usage", "rate-limits", "statusline", "cli"],
|
|
29
|
+
},
|
|
30
|
+
"codex-cli-usage": {
|
|
31
|
+
"provider": "codex", "label": "Codex",
|
|
32
|
+
"description": "Codex rate limit monitor",
|
|
33
|
+
"keywords": ["codex", "openai", "chatgpt", "usage", "rate-limits", "statusline", "cli"],
|
|
34
|
+
},
|
|
35
|
+
"cursor-cli-usage": {
|
|
36
|
+
"provider": "cursor", "label": "Cursor",
|
|
37
|
+
"description": "Cursor plan usage monitor",
|
|
38
|
+
"keywords": ["cursor", "cursor-agent", "usage", "quota", "statusline", "cli"],
|
|
39
|
+
},
|
|
40
|
+
"deepseek-harness-usage": {
|
|
41
|
+
"provider": "deepseek", "label": "DeepSeek", "extra": "deepseek",
|
|
42
|
+
"description": "DeepSeek account balance monitor for DeepSeek Harness",
|
|
43
|
+
"keywords": ["deepseek", "deepseek-harness", "balance", "usage", "statusline", "cli"],
|
|
44
|
+
},
|
|
45
|
+
"devin-cli-usage": {
|
|
46
|
+
"provider": "devin", "label": "Devin",
|
|
47
|
+
"description": "Devin quota and credits monitor",
|
|
48
|
+
"keywords": ["devin", "windsurf", "usage", "quota", "statusline", "cli"],
|
|
49
|
+
},
|
|
50
|
+
"gemini-cli-usage": {
|
|
51
|
+
"provider": "gemini", "label": "Gemini CLI",
|
|
52
|
+
"description": "Gemini CLI model quota monitor",
|
|
53
|
+
"keywords": ["gemini", "gemini-cli", "google", "usage", "quota", "statusline", "cli"],
|
|
54
|
+
},
|
|
55
|
+
"opencode-cli-usage": {
|
|
56
|
+
"provider": "opencode", "label": "OpenCode Go",
|
|
57
|
+
"description": "OpenCode Go subscription limit monitor",
|
|
58
|
+
"keywords": ["opencode", "opencode-go", "usage", "quota", "statusline", "cli"],
|
|
59
|
+
},
|
|
60
|
+
"zcode-cli-usage": {
|
|
61
|
+
"provider": "zcode", "label": "Z.ai Coding Plan",
|
|
62
|
+
"description": "Z.ai Coding Plan quota monitor",
|
|
63
|
+
"keywords": ["z.ai", "zai", "glm", "coding-plan", "usage", "quota", "statusline", "cli"],
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
PYPROJECT = '''[build-system]
|
|
68
|
+
requires = ["hatchling==1.32.4"]
|
|
69
|
+
build-backend = "hatchling.build"
|
|
70
|
+
|
|
71
|
+
[project]
|
|
72
|
+
name = "{name}"
|
|
73
|
+
version = "{version}"
|
|
74
|
+
description = "{description}"
|
|
75
|
+
readme = "README.md"
|
|
76
|
+
keywords = [{keywords}]
|
|
77
|
+
requires-python = ">=3.12"
|
|
78
|
+
license = "MIT"
|
|
79
|
+
license-files = ["LICENSE"]
|
|
80
|
+
dependencies = ["{core}{extras}=={version}"]
|
|
81
|
+
|
|
82
|
+
[project.urls]
|
|
83
|
+
Homepage = "{repository}"
|
|
84
|
+
Source = "{repository}"
|
|
85
|
+
|
|
86
|
+
[project.scripts]
|
|
87
|
+
{name} = "{module}:main"
|
|
88
|
+
|
|
89
|
+
[tool.hatch.build.targets.wheel]
|
|
90
|
+
packages = ["{module}"]
|
|
91
|
+
'''
|
|
92
|
+
|
|
93
|
+
INIT = '''"""{name}: the {label} command of {core}."""
|
|
94
|
+
|
|
95
|
+
from clanker_usage import run
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
return run("{provider}")
|
|
100
|
+
'''
|
|
101
|
+
|
|
102
|
+
MAIN = '''from . import main
|
|
103
|
+
|
|
104
|
+
raise SystemExit(main())
|
|
105
|
+
'''
|
|
106
|
+
|
|
107
|
+
README = '''# {name}
|
|
108
|
+
|
|
109
|
+
Monitor your {label} quota from the terminal: current usage, a one-line statusline, and a daemon that keeps a cache fresh.
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
uv tool install {name}
|
|
113
|
+
{name} # current quotas
|
|
114
|
+
{name} statusline # one line, from a cache less than five minutes old
|
|
115
|
+
{name} json # the usage-v1 document
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`{name}` is the {label} command of [{core}]({repository}), so it installs {core} at the same version. The documentation lives there.
|
|
119
|
+
'''
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def core_version():
|
|
123
|
+
match = re.search(r'(?m)^version = "([^"]+)"', (ROOT / "pyproject.toml").read_text())
|
|
124
|
+
if not match:
|
|
125
|
+
raise SystemExit("no version in pyproject.toml")
|
|
126
|
+
return match[1]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build(name, out_root):
|
|
130
|
+
if name not in WRAPPERS:
|
|
131
|
+
raise SystemExit(f"unknown wrapper package: {name}")
|
|
132
|
+
spec = WRAPPERS[name]
|
|
133
|
+
values = {
|
|
134
|
+
"name": name, "module": name.replace("-", "_"), "version": core_version(), "core": CORE,
|
|
135
|
+
"repository": REPOSITORY, **spec,
|
|
136
|
+
}
|
|
137
|
+
values["keywords"] = ", ".join(f'"{word}"' for word in spec["keywords"])
|
|
138
|
+
values["extras"] = f"[{spec['extra']}]" if spec.get("extra") else ""
|
|
139
|
+
out = out_root / name
|
|
140
|
+
if out.exists():
|
|
141
|
+
shutil.rmtree(out)
|
|
142
|
+
package = out / values["module"]
|
|
143
|
+
package.mkdir(parents=True)
|
|
144
|
+
(out / "pyproject.toml").write_text(PYPROJECT.format(**values))
|
|
145
|
+
(out / "README.md").write_text(README.format(**values))
|
|
146
|
+
(package / "__init__.py").write_text(INIT.format(**values))
|
|
147
|
+
(package / "__main__.py").write_text(MAIN)
|
|
148
|
+
shutil.copy(ROOT / "LICENSE", out / "LICENSE")
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main(argv):
|
|
153
|
+
out_root = Path(argv[0]) if argv else ROOT / "dist-wrappers"
|
|
154
|
+
for name in argv[1:] or WRAPPERS:
|
|
155
|
+
print(build(name, out_root))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main(sys.argv[1:])
|