devin-cli-usage 0.1.1__tar.gz → 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.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ .venv/
3
+ dist/
4
+ dist-wrappers/
@@ -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,25 @@
1
+ Metadata-Version: 2.5
2
+ Name: devin-cli-usage
3
+ Version: 0.4.0
4
+ Summary: Devin quota and credits monitor
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: cli,devin,quota,statusline,usage,windsurf
10
+ Requires-Python: >=3.12
11
+ Requires-Dist: clanker-usage==0.4.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # devin-cli-usage
15
+
16
+ Monitor your Devin quota from the terminal: current usage, a one-line statusline, and a daemon that keeps a cache fresh.
17
+
18
+ ```sh
19
+ uv tool install devin-cli-usage
20
+ devin-cli-usage # current quotas
21
+ devin-cli-usage statusline # one line, from a cache less than five minutes old
22
+ devin-cli-usage json # the usage-v1 document
23
+ ```
24
+
25
+ `devin-cli-usage` is the Devin command of [clanker-usage](https://github.com/wakamex/clanker-usage), so it installs clanker-usage at the same version. The documentation lives there.
@@ -0,0 +1,12 @@
1
+ # devin-cli-usage
2
+
3
+ Monitor your Devin quota from the terminal: current usage, a one-line statusline, and a daemon that keeps a cache fresh.
4
+
5
+ ```sh
6
+ uv tool install devin-cli-usage
7
+ devin-cli-usage # current quotas
8
+ devin-cli-usage statusline # one line, from a cache less than five minutes old
9
+ devin-cli-usage json # the usage-v1 document
10
+ ```
11
+
12
+ `devin-cli-usage` is the Devin command of [clanker-usage](https://github.com/wakamex/clanker-usage), so it installs clanker-usage at the same version. The documentation lives there.
@@ -0,0 +1,7 @@
1
+ """devin-cli-usage: the Devin command of clanker-usage."""
2
+
3
+ from clanker_usage import run
4
+
5
+
6
+ def main():
7
+ return run("devin")
@@ -0,0 +1,3 @@
1
+ from . import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.32.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "devin-cli-usage"
7
+ version = "0.4.0"
8
+ description = "Devin quota and credits monitor"
9
+ readme = "README.md"
10
+ keywords = ["devin", "windsurf", "usage", "quota", "statusline", "cli"]
11
+ requires-python = ">=3.12"
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ dependencies = ["clanker-usage==0.4.0"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/wakamex/clanker-usage"
18
+ Source = "https://github.com/wakamex/clanker-usage"
19
+
20
+ [project.scripts]
21
+ devin-cli-usage = "devin_cli_usage:main"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["devin_cli_usage"]
@@ -1,29 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- push:
5
- tags:
6
- - "v*"
7
-
8
- jobs:
9
- publish:
10
- runs-on: ubuntu-latest
11
- environment: pypi
12
- permissions:
13
- contents: read
14
- id-token: write
15
- steps:
16
- - name: Checkout
17
- uses: actions/checkout@v7.0.1
18
- - name: Install uv
19
- uses: astral-sh/setup-uv@v9.0.0
20
- with:
21
- enable-cache: false
22
- - name: Verify version tag
23
- run: test "$GITHUB_REF_NAME" = "v$(uv --no-config version --short)"
24
- - name: Test
25
- run: uv --no-config run --locked python -m unittest discover -s tests
26
- - name: Build
27
- run: uv --no-config build --no-sources
28
- - name: Publish
29
- run: uv publish --trusted-publishing always
@@ -1,6 +0,0 @@
1
- .venv/
2
- __pycache__/
3
- *.py[cod]
4
- .ruff_cache/
5
- dist/*
6
- !dist/.gitignore
@@ -1,109 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: devin-cli-usage
3
- Version: 0.1.1
4
- Summary: Devin CLI account quota monitor using local Devin authentication
5
- Project-URL: Homepage, https://github.com/wakamex/devin-cli-usage
6
- Project-URL: Source, https://github.com/wakamex/devin-cli-usage
7
- Requires-Python: >=3.12
8
- Description-Content-Type: text/markdown
9
-
10
- # devin-cli-usage
11
-
12
- Devin account usage and quota monitor. It follows the same dependency-free
13
- interface as the other `/code/*usage` tools.
14
-
15
- The tool targets the local Devin CLI harness, but reports account-wide Devin
16
- quota. It does not estimate usage from local CLI sessions.
17
-
18
- ## Example output
19
-
20
- ```text
21
- Devin usage
22
- Status: live
23
- Plan: Pro
24
- Daily: 100% remaining, resets in 11h
25
- Weekly: 100% remaining, resets in 6d11h
26
- Prompt credits available: -1
27
- ```
28
-
29
- Statusline:
30
-
31
- ```text
32
- devin:daily:100%left reset:11h
33
- ```
34
-
35
- ## Install
36
-
37
- ```bash
38
- uv tool install devin-cli-usage
39
- ```
40
-
41
- For local development:
42
-
43
- ```bash
44
- uv tool install .
45
- ```
46
-
47
- ## Commands
48
-
49
- | Command | Description |
50
- | --- | --- |
51
- | `devin-cli-usage` | Show current account usage |
52
- | `devin-cli-usage status` | Same as above |
53
- | `devin-cli-usage json` | Print normalized JSON |
54
- | `devin-cli-usage statusline` | Print compact cached output |
55
- | `devin-cli-usage refresh` | Refresh the cache and print status |
56
- | `devin-cli-usage daemon [-i SECS]` | Keep the cache fresh |
57
- | `devin-cli-usage install` | Print installation instructions |
58
-
59
- ## Authentication and data
60
-
61
- The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
62
- `~/.local/share/devin/credentials.toml`, or the corresponding path below
63
- `XDG_DATA_HOME` when it is set, then calls the read-only `GetUserStatus`
64
- service used by the installed client. It rereads credentials for every live
65
- request. It never refreshes credentials or modifies Devin-owned files.
66
-
67
- The normalized JSON includes:
68
-
69
- - `provider`, `source`, `retrieved_at`, and `status`
70
- - plan name, billing strategy, and plan period
71
- - daily and weekly quota percentages and reset times
72
- - available and consumed credits, ACU limits, and overage balance when supplied
73
-
74
- `status` is `live`, `cached`, `stale`, or `unavailable`. Errors and caches do
75
- not include the API key or Devin account identity. A credit value of `-1` is a
76
- vendor sentinel for an unlimited or non-credit-based allowance and is
77
- preserved verbatim.
78
-
79
- The tool writes only its own non-secret cache at
80
- `~/.local/share/devin/usage-limits.json`, also following `XDG_DATA_HOME` when
81
- set. JSON results report whether they are `live`, `cached`, `stale`, or
82
- `unavailable`.
83
-
84
- Environment overrides:
85
-
86
- - `DEVIN_USAGE_CREDENTIALS_FILE`: alternate credentials file
87
- - `DEVIN_USAGE_FILE`: alternate cache path
88
-
89
- The user-status service is undocumented and may change with Devin client
90
- releases.
91
-
92
- ## Options
93
-
94
- ```text
95
- usage: devin-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
96
- {status,json,daemon,statusline,refresh,install}
97
- ```
98
-
99
- - `--max-age SECS`: maximum cache age used by `statusline`
100
- - `--refresh`: bypass the cache for `statusline`
101
- - `-i SECS`: daemon refresh interval
102
-
103
- ## Development
104
-
105
- ```bash
106
- uv --no-config lock --check
107
- uv --no-config run --locked python -m unittest discover -s tests
108
- uv --no-config build --no-sources
109
- ```
@@ -1,100 +0,0 @@
1
- # devin-cli-usage
2
-
3
- Devin account usage and quota monitor. It follows the same dependency-free
4
- interface as the other `/code/*usage` tools.
5
-
6
- The tool targets the local Devin CLI harness, but reports account-wide Devin
7
- quota. It does not estimate usage from local CLI sessions.
8
-
9
- ## Example output
10
-
11
- ```text
12
- Devin usage
13
- Status: live
14
- Plan: Pro
15
- Daily: 100% remaining, resets in 11h
16
- Weekly: 100% remaining, resets in 6d11h
17
- Prompt credits available: -1
18
- ```
19
-
20
- Statusline:
21
-
22
- ```text
23
- devin:daily:100%left reset:11h
24
- ```
25
-
26
- ## Install
27
-
28
- ```bash
29
- uv tool install devin-cli-usage
30
- ```
31
-
32
- For local development:
33
-
34
- ```bash
35
- uv tool install .
36
- ```
37
-
38
- ## Commands
39
-
40
- | Command | Description |
41
- | --- | --- |
42
- | `devin-cli-usage` | Show current account usage |
43
- | `devin-cli-usage status` | Same as above |
44
- | `devin-cli-usage json` | Print normalized JSON |
45
- | `devin-cli-usage statusline` | Print compact cached output |
46
- | `devin-cli-usage refresh` | Refresh the cache and print status |
47
- | `devin-cli-usage daemon [-i SECS]` | Keep the cache fresh |
48
- | `devin-cli-usage install` | Print installation instructions |
49
-
50
- ## Authentication and data
51
-
52
- The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
53
- `~/.local/share/devin/credentials.toml`, or the corresponding path below
54
- `XDG_DATA_HOME` when it is set, then calls the read-only `GetUserStatus`
55
- service used by the installed client. It rereads credentials for every live
56
- request. It never refreshes credentials or modifies Devin-owned files.
57
-
58
- The normalized JSON includes:
59
-
60
- - `provider`, `source`, `retrieved_at`, and `status`
61
- - plan name, billing strategy, and plan period
62
- - daily and weekly quota percentages and reset times
63
- - available and consumed credits, ACU limits, and overage balance when supplied
64
-
65
- `status` is `live`, `cached`, `stale`, or `unavailable`. Errors and caches do
66
- not include the API key or Devin account identity. A credit value of `-1` is a
67
- vendor sentinel for an unlimited or non-credit-based allowance and is
68
- preserved verbatim.
69
-
70
- The tool writes only its own non-secret cache at
71
- `~/.local/share/devin/usage-limits.json`, also following `XDG_DATA_HOME` when
72
- set. JSON results report whether they are `live`, `cached`, `stale`, or
73
- `unavailable`.
74
-
75
- Environment overrides:
76
-
77
- - `DEVIN_USAGE_CREDENTIALS_FILE`: alternate credentials file
78
- - `DEVIN_USAGE_FILE`: alternate cache path
79
-
80
- The user-status service is undocumented and may change with Devin client
81
- releases.
82
-
83
- ## Options
84
-
85
- ```text
86
- usage: devin-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
87
- {status,json,daemon,statusline,refresh,install}
88
- ```
89
-
90
- - `--max-age SECS`: maximum cache age used by `statusline`
91
- - `--refresh`: bypass the cache for `statusline`
92
- - `-i SECS`: daemon refresh interval
93
-
94
- ## Development
95
-
96
- ```bash
97
- uv --no-config lock --check
98
- uv --no-config run --locked python -m unittest discover -s tests
99
- uv --no-config build --no-sources
100
- ```
@@ -1,2 +0,0 @@
1
- *
2
- !.gitignore
@@ -1,24 +0,0 @@
1
- [project]
2
- name = "devin-cli-usage"
3
- version = "0.1.1"
4
- description = "Devin CLI account quota monitor using local Devin authentication"
5
- readme = "README.md"
6
- requires-python = ">=3.12"
7
- dependencies = []
8
-
9
- [tool.uv]
10
- package = true
11
-
12
- [build-system]
13
- requires = ["hatchling"]
14
- build-backend = "hatchling.build"
15
-
16
- [tool.hatch.build.targets.wheel]
17
- packages = ["src/devin_cli_usage"]
18
-
19
- [project.urls]
20
- Homepage = "https://github.com/wakamex/devin-cli-usage"
21
- Source = "https://github.com/wakamex/devin-cli-usage"
22
-
23
- [project.scripts]
24
- devin-cli-usage = "devin_cli_usage:main"
@@ -1,456 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Devin account usage monitor using Devin-owned local authentication."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import json
8
- import math
9
- import os
10
- import re
11
- import shutil
12
- import signal
13
- import subprocess
14
- import sys
15
- import tempfile
16
- import time
17
- import tomllib
18
- import urllib.error
19
- import urllib.parse
20
- import urllib.request
21
- from datetime import UTC, datetime
22
- from pathlib import Path
23
-
24
- DEFAULT_CREDENTIALS_FILE = (
25
- Path.home() / ".local" / "share" / "devin" / "credentials.toml"
26
- )
27
- DEFAULT_USAGE_FILE = Path.home() / ".local" / "share" / "devin" / "usage-limits.json"
28
- USER_STATUS_PATH = "/exa.seat_management_pb.SeatManagementService/GetUserStatus"
29
- CACHE_MAX_AGE = 300
30
- DAEMON_INTERVAL = 300
31
-
32
-
33
- def _iso_now() -> str:
34
- return datetime.now(UTC).isoformat()
35
-
36
-
37
- def _parse_iso(value: object) -> datetime | None:
38
- if not isinstance(value, str) or not value:
39
- return None
40
- try:
41
- parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
42
- except ValueError:
43
- return None
44
- return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
45
-
46
-
47
- def _number(value: object) -> int | float | None:
48
- if isinstance(value, bool):
49
- return None
50
- if isinstance(value, int):
51
- return value
52
- if isinstance(value, float):
53
- return value if math.isfinite(value) else None
54
- if isinstance(value, str):
55
- try:
56
- parsed = float(value)
57
- except ValueError:
58
- return None
59
- if not math.isfinite(parsed):
60
- return None
61
- return int(parsed) if parsed.is_integer() else parsed
62
- return None
63
-
64
-
65
- def _optional_number(data: dict, key: str) -> int | float | None:
66
- raw = data.get(key)
67
- if raw is None:
68
- return None
69
- value = _number(raw)
70
- if value is None:
71
- raise RuntimeError(f"Devin user-status response contained invalid {key}")
72
- return value
73
-
74
-
75
- def _format_reset(value: object) -> str:
76
- seconds = _number(value)
77
- if seconds is None:
78
- return ""
79
- try:
80
- reset = datetime.fromtimestamp(float(seconds), UTC)
81
- except (ValueError, OSError):
82
- return ""
83
- minutes = max(0, int((reset - datetime.now(UTC)).total_seconds()) // 60)
84
- if minutes >= 1440:
85
- return f"{minutes // 1440}d{(minutes % 1440) // 60}h"
86
- if minutes >= 60:
87
- return f"{minutes // 60}h{minutes % 60}m"
88
- return f"{minutes}m"
89
-
90
-
91
- def get_credentials_file() -> Path:
92
- override = os.environ.get("DEVIN_USAGE_CREDENTIALS_FILE")
93
- if override:
94
- return Path(override).expanduser()
95
- data_home = os.environ.get("XDG_DATA_HOME")
96
- return (
97
- Path(data_home).expanduser() / "devin" / "credentials.toml"
98
- if data_home
99
- else DEFAULT_CREDENTIALS_FILE
100
- )
101
-
102
-
103
- def get_usage_file() -> Path:
104
- override = os.environ.get("DEVIN_USAGE_FILE")
105
- if override:
106
- return Path(override).expanduser()
107
- data_home = os.environ.get("XDG_DATA_HOME")
108
- return (
109
- Path(data_home).expanduser() / "devin" / "usage-limits.json"
110
- if data_home
111
- else DEFAULT_USAGE_FILE
112
- )
113
-
114
-
115
- def get_credentials() -> tuple[str, str]:
116
- """Read the current API key and server without changing Devin credentials."""
117
- path = get_credentials_file()
118
- try:
119
- data = tomllib.loads(path.read_text())
120
- except FileNotFoundError as exc:
121
- raise RuntimeError(f"Devin credentials not found at {path}") from exc
122
- except (OSError, tomllib.TOMLDecodeError) as exc:
123
- raise RuntimeError(f"Could not read Devin credentials from {path}") from exc
124
- api_key = data.get("windsurf_api_key")
125
- server = data.get("api_server_url")
126
- if not isinstance(api_key, str) or not api_key:
127
- raise RuntimeError("Devin credentials do not contain a local API key")
128
- if not isinstance(server, str) or not server:
129
- raise RuntimeError("Devin credentials do not contain an API server")
130
- parsed = urllib.parse.urlparse(server)
131
- if parsed.scheme != "https" or not parsed.hostname:
132
- raise RuntimeError("Devin credential API server is not a valid HTTPS URL")
133
- return api_key, server.rstrip("/")
134
-
135
-
136
- def _devin_version() -> str:
137
- executable = shutil.which("devin")
138
- if not executable:
139
- return "unknown"
140
- try:
141
- result = subprocess.run(
142
- [executable, "version"],
143
- check=False,
144
- capture_output=True,
145
- text=True,
146
- timeout=5,
147
- )
148
- except (OSError, subprocess.SubprocessError):
149
- return "unknown"
150
- match = re.search(r"\b(\d+(?:\.\d+)+)\b", result.stdout)
151
- return match.group(1) if match else "unknown"
152
-
153
-
154
- def fetch_usage() -> dict:
155
- api_key, server = get_credentials()
156
- version = _devin_version()
157
- payload = {
158
- "metadata": {
159
- "apiKey": api_key,
160
- "ideName": "devin",
161
- "ideVersion": version,
162
- "extensionVersion": version,
163
- "locale": "en",
164
- }
165
- }
166
- request = urllib.request.Request(
167
- server + USER_STATUS_PATH,
168
- data=json.dumps(payload, separators=(",", ":")).encode(),
169
- method="POST",
170
- headers={
171
- "Content-Type": "application/json",
172
- "Connect-Protocol-Version": "1",
173
- "User-Agent": "devin-cli-usage/0.1",
174
- },
175
- )
176
- try:
177
- with urllib.request.urlopen(request, timeout=15) as response:
178
- result = json.loads(response.read())
179
- except urllib.error.HTTPError as exc:
180
- if exc.code in (401, 403):
181
- raise RuntimeError(
182
- "Devin local API key is no longer accepted; sign in with Devin again"
183
- ) from exc
184
- raise RuntimeError(f"Devin user-status API returned HTTP {exc.code}") from exc
185
- except (urllib.error.URLError, TimeoutError) as exc:
186
- raise RuntimeError("Devin user-status API is unavailable") from exc
187
- except json.JSONDecodeError as exc:
188
- raise RuntimeError("Devin user-status API returned invalid JSON") from exc
189
- if not isinstance(result, dict):
190
- raise RuntimeError("Devin user-status API returned an unexpected response")
191
- return result
192
-
193
-
194
- def _normalized_plan_info(payload: dict, plan_status: dict) -> dict:
195
- info = payload.get("planInfo")
196
- if not isinstance(info, dict):
197
- info = plan_status.get("planInfo")
198
- if not isinstance(info, dict):
199
- info = {}
200
- normalized = {
201
- key: value
202
- for key, source in (
203
- ("name", "planName"),
204
- ("billing_strategy", "billingStrategy"),
205
- )
206
- if (value := info.get(source)) is not None
207
- }
208
- for key, source in (
209
- ("monthly_prompt_credits", "monthlyPromptCredits"),
210
- ("monthly_flow_credits", "monthlyFlowCredits"),
211
- ("monthly_flex_credits", "monthlyFlexCreditPurchaseAmount"),
212
- ):
213
- if (value := _optional_number(info, source)) is not None:
214
- normalized[key] = value
215
- return normalized
216
-
217
-
218
- def _normalize(payload: dict) -> dict:
219
- user_status = payload.get("userStatus")
220
- if not isinstance(user_status, dict):
221
- raise RuntimeError("Devin user-status response did not contain userStatus")
222
- plan_status = user_status.get("planStatus")
223
- if not isinstance(plan_status, dict):
224
- raise RuntimeError("Devin user-status response did not contain planStatus")
225
- quotas = {
226
- "daily": {
227
- "remaining_pct": _optional_number(
228
- plan_status, "dailyQuotaRemainingPercent"
229
- ),
230
- "reset_at": _optional_number(plan_status, "dailyQuotaResetAtUnix"),
231
- },
232
- "weekly": {
233
- "remaining_pct": _optional_number(
234
- plan_status, "weeklyQuotaRemainingPercent"
235
- ),
236
- "reset_at": _optional_number(plan_status, "weeklyQuotaResetAtUnix"),
237
- },
238
- }
239
- credits = {
240
- key: value
241
- for key, source in (
242
- ("available_prompt", "availablePromptCredits"),
243
- ("used_prompt", "usedPromptCredits"),
244
- ("available_flow", "availableFlowCredits"),
245
- ("used_flow", "usedFlowCredits"),
246
- ("available_flex", "availableFlexCredits"),
247
- ("used_flex", "usedFlexCredits"),
248
- ("overage_balance_micros", "overageBalanceMicros"),
249
- ("acu_consumed", "acuConsumed"),
250
- ("acu_limit", "acuLimit"),
251
- )
252
- if (value := _optional_number(plan_status, source)) is not None
253
- }
254
- if not credits and not any(
255
- quota["remaining_pct"] is not None for quota in quotas.values()
256
- ):
257
- raise RuntimeError("Devin user-status response did not contain usage data")
258
- return {
259
- "plan": _normalized_plan_info(payload, plan_status),
260
- "plan_period": {
261
- "start": plan_status.get("planStart"),
262
- "end": plan_status.get("planEnd"),
263
- },
264
- "quotas": quotas,
265
- "credits": credits,
266
- }
267
-
268
-
269
- def build_usage_json() -> dict:
270
- updated_at = _iso_now()
271
- try:
272
- usage = _normalize(fetch_usage())
273
- except Exception as exc:
274
- return {
275
- "provider": "devin",
276
- "status": "unavailable",
277
- "source": "devin_user_status_api",
278
- "retrieved_at": updated_at,
279
- "error": str(exc),
280
- }
281
- return {
282
- "provider": "devin",
283
- "status": "live",
284
- "source": "devin_user_status_api",
285
- "retrieved_at": updated_at,
286
- **usage,
287
- }
288
-
289
-
290
- def write_usage_file(data: dict) -> None:
291
- path = get_usage_file()
292
- path.parent.mkdir(parents=True, exist_ok=True)
293
- fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
294
- temporary = Path(temporary_name)
295
- try:
296
- with os.fdopen(fd, "w") as handle:
297
- json.dump(data, handle, indent=2)
298
- handle.write("\n")
299
- os.replace(temporary, path)
300
- except Exception:
301
- temporary.unlink(missing_ok=True)
302
- raise
303
-
304
-
305
- def _read_cache() -> dict | None:
306
- try:
307
- data = json.loads(get_usage_file().read_text())
308
- except (OSError, json.JSONDecodeError):
309
- return None
310
- return data if isinstance(data, dict) else None
311
-
312
-
313
- def get_cached_usage(max_age: int = CACHE_MAX_AGE, force_refresh: bool = False) -> dict:
314
- cached = _read_cache()
315
- if not force_refresh and cached:
316
- retrieved = _parse_iso(cached.get("retrieved_at"))
317
- if retrieved and (datetime.now(UTC) - retrieved).total_seconds() < max_age:
318
- return {**cached, "status": "cached"}
319
- fresh = build_usage_json()
320
- if fresh["status"] == "live":
321
- write_usage_file(fresh)
322
- return fresh
323
- if cached:
324
- return {**cached, "status": "stale", "refresh_error": fresh.get("error")}
325
- return fresh
326
-
327
-
328
- def _quota_value(data: dict) -> tuple[str, dict] | None:
329
- quotas = data.get("quotas")
330
- if not isinstance(quotas, dict):
331
- return None
332
- candidates = [
333
- (name, quota)
334
- for name, quota in quotas.items()
335
- if isinstance(quota, dict) and quota.get("remaining_pct") is not None
336
- ]
337
- return (
338
- min(candidates, key=lambda item: float(item[1]["remaining_pct"]))
339
- if candidates
340
- else None
341
- )
342
-
343
-
344
- def _statusline_text(data: dict) -> str:
345
- selected = _quota_value(data)
346
- if not selected:
347
- return "devin:q:unavailable"
348
- name, quota = selected
349
- remaining = float(quota["remaining_pct"])
350
- parts = [f"devin:{name}:{remaining:g}%left"]
351
- reset = _format_reset(quota.get("reset_at"))
352
- if reset:
353
- parts.append(f"reset:{reset}")
354
- if data.get("status") in {"cached", "stale"}:
355
- parts.append(str(data["status"]))
356
- return " ".join(parts)
357
-
358
-
359
- def _print_status(data: dict) -> None:
360
- print("Devin usage")
361
- print(f"Status: {data['status']}")
362
- plan = data.get("plan")
363
- if isinstance(plan, dict) and plan.get("name"):
364
- print(f"Plan: {plan['name']}")
365
- quotas = data.get("quotas")
366
- if isinstance(quotas, dict):
367
- for name in ("daily", "weekly"):
368
- quota = quotas.get(name)
369
- if isinstance(quota, dict) and quota.get("remaining_pct") is not None:
370
- suffix = _format_reset(quota.get("reset_at"))
371
- reset = f", resets in {suffix}" if suffix else ""
372
- print(
373
- f"{name.title()}: {float(quota['remaining_pct']):g}% remaining{reset}"
374
- )
375
- credits = data.get("credits")
376
- if isinstance(credits, dict):
377
- if credits.get("available_prompt") is not None:
378
- print(f"Prompt credits available: {credits['available_prompt']}")
379
- if credits.get("available_flow") is not None:
380
- print(f"Flow credits available: {credits['available_flow']}")
381
- if credits.get("available_flex") is not None:
382
- print(f"Flex credits available: {credits['available_flex']}")
383
- if data.get("error"):
384
- print(f"Error: {data['error']}")
385
- if data.get("refresh_error"):
386
- print(f"Refresh error: {data['refresh_error']}")
387
-
388
-
389
- def cmd_status(_args: argparse.Namespace) -> None:
390
- _print_status(build_usage_json())
391
-
392
-
393
- def cmd_json(_args: argparse.Namespace) -> None:
394
- print(json.dumps(build_usage_json(), indent=2))
395
-
396
-
397
- def cmd_statusline(args: argparse.Namespace) -> None:
398
- print(_statusline_text(get_cached_usage(args.max_age, args.refresh)))
399
-
400
-
401
- def cmd_refresh(_args: argparse.Namespace) -> None:
402
- data = build_usage_json()
403
- if data["status"] == "live":
404
- write_usage_file(data)
405
- _print_status(data)
406
-
407
-
408
- def cmd_daemon(args: argparse.Namespace) -> None:
409
- signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
410
- if hasattr(signal, "SIGTERM"):
411
- signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
412
- print(f"devin-cli-usage daemon started (refreshing every {args.interval}s)")
413
- print(f"Writing to {get_usage_file()}")
414
- while True:
415
- data = build_usage_json()
416
- if data["status"] == "live":
417
- write_usage_file(data)
418
- print(f"[{datetime.now().strftime('%H:%M:%S')}] {_statusline_text(data)}")
419
- time.sleep(args.interval)
420
-
421
-
422
- def cmd_install(_args: argparse.Namespace) -> None:
423
- print(
424
- "Install with:\n uv tool install devin-cli-usage\n\nThen run:\n devin-cli-usage"
425
- )
426
-
427
-
428
- def _build_parser() -> argparse.ArgumentParser:
429
- parser = argparse.ArgumentParser(description="Devin account usage monitor")
430
- parser.add_argument(
431
- "command",
432
- nargs="?",
433
- default="status",
434
- choices=["status", "json", "daemon", "statusline", "refresh", "install"],
435
- )
436
- parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL)
437
- parser.add_argument("--max-age", type=int, default=CACHE_MAX_AGE)
438
- parser.add_argument("--refresh", action="store_true")
439
- return parser
440
-
441
-
442
- def main() -> None:
443
- args = _build_parser().parse_args()
444
- commands = {
445
- "status": cmd_status,
446
- "json": cmd_json,
447
- "daemon": cmd_daemon,
448
- "statusline": cmd_statusline,
449
- "refresh": cmd_refresh,
450
- "install": cmd_install,
451
- }
452
- commands[args.command](args)
453
-
454
-
455
- if __name__ == "__main__":
456
- main()
@@ -1,3 +0,0 @@
1
- from devin_cli_usage import main
2
-
3
- main()
@@ -1,193 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import tempfile
5
- import unittest
6
- import urllib.error
7
- from pathlib import Path
8
- from unittest import mock
9
-
10
- import devin_cli_usage as devin_usage
11
-
12
-
13
- class FakeResponse:
14
- def __init__(self, payload: dict):
15
- self.payload = payload
16
-
17
- def __enter__(self):
18
- return self
19
-
20
- def __exit__(self, *_args):
21
- return False
22
-
23
- def read(self):
24
- return json.dumps(self.payload).encode()
25
-
26
-
27
- class DevinUsageTests(unittest.TestCase):
28
- def test_default_files_follow_xdg_data_home(self):
29
- with (
30
- tempfile.TemporaryDirectory() as temporary,
31
- mock.patch.dict(
32
- devin_usage.os.environ,
33
- {"XDG_DATA_HOME": temporary},
34
- clear=True,
35
- ),
36
- ):
37
- data_dir = Path(temporary) / "devin"
38
- self.assertEqual(
39
- devin_usage.get_credentials_file(), data_dir / "credentials.toml"
40
- )
41
- self.assertEqual(
42
- devin_usage.get_usage_file(), data_dir / "usage-limits.json"
43
- )
44
-
45
- def test_credentials_are_reread_without_writes(self):
46
- with tempfile.TemporaryDirectory() as temporary:
47
- credentials = Path(temporary) / "credentials.toml"
48
- credentials.write_text(
49
- 'windsurf_api_key = "first"\napi_server_url = "https://server.codeium.com"\n'
50
- )
51
- with mock.patch.object(
52
- devin_usage, "DEFAULT_CREDENTIALS_FILE", credentials
53
- ):
54
- self.assertEqual(devin_usage.get_credentials()[0], "first")
55
- credentials.write_text(
56
- 'windsurf_api_key = "second"\napi_server_url = "https://server.codeium.com"\n'
57
- )
58
- self.assertEqual(devin_usage.get_credentials()[0], "second")
59
-
60
- def test_fetch_puts_api_key_in_request_metadata(self):
61
- payload = {"userStatus": {"planStatus": {}}}
62
-
63
- def fake_urlopen(request, timeout):
64
- body = json.loads(request.data)
65
- metadata = body["metadata"]
66
- self.assertEqual(metadata["apiKey"], "local-key")
67
- self.assertEqual(metadata["ideName"], "devin")
68
- self.assertEqual(metadata["ideVersion"], "3000.3.27")
69
- self.assertNotIn("Authorization", request.headers)
70
- self.assertEqual(timeout, 15)
71
- return FakeResponse(payload)
72
-
73
- with (
74
- mock.patch.object(
75
- devin_usage,
76
- "get_credentials",
77
- return_value=("local-key", "https://server.codeium.com"),
78
- ),
79
- mock.patch.object(devin_usage, "_devin_version", return_value="3000.3.27"),
80
- mock.patch.object(
81
- devin_usage.urllib.request, "urlopen", side_effect=fake_urlopen
82
- ),
83
- ):
84
- self.assertEqual(devin_usage.fetch_usage(), payload)
85
-
86
- def test_build_normalizes_quota_and_credit_fields(self):
87
- payload = {
88
- "planInfo": {"planName": "Pro", "monthlyPromptCredits": 1000},
89
- "userStatus": {
90
- "planStatus": {
91
- "dailyQuotaRemainingPercent": 75,
92
- "dailyQuotaResetAtUnix": 2_000_000_000,
93
- "weeklyQuotaRemainingPercent": "50",
94
- "availablePromptCredits": "900",
95
- "usedPromptCredits": 100,
96
- "overageBalanceMicros": "0",
97
- }
98
- },
99
- }
100
- with mock.patch.object(devin_usage, "fetch_usage", return_value=payload):
101
- data = devin_usage.build_usage_json()
102
- self.assertEqual(data["status"], "live")
103
- self.assertEqual(data["plan"]["name"], "Pro")
104
- self.assertEqual(data["quotas"]["daily"]["remaining_pct"], 75)
105
- self.assertEqual(data["quotas"]["weekly"]["remaining_pct"], 50)
106
- self.assertEqual(data["credits"]["available_prompt"], 900)
107
-
108
- def test_incomplete_plan_status_is_unavailable(self):
109
- for plan_status in (None, [], {}):
110
- with self.subTest(plan_status=plan_status):
111
- payload = {"userStatus": {"planStatus": plan_status}}
112
- with mock.patch.object(
113
- devin_usage, "fetch_usage", return_value=payload
114
- ):
115
- data = devin_usage.build_usage_json()
116
- self.assertEqual(data["status"], "unavailable")
117
-
118
- def test_nonfinite_usage_is_unavailable(self):
119
- payloads = (
120
- {
121
- "userStatus": {
122
- "planStatus": {"dailyQuotaRemainingPercent": float("inf")}
123
- }
124
- },
125
- {
126
- "planInfo": {"monthlyPromptCredits": "NaN"},
127
- "userStatus": {
128
- "planStatus": {"dailyQuotaRemainingPercent": 75}
129
- },
130
- },
131
- )
132
- for payload in payloads:
133
- with self.subTest(payload=payload):
134
- with mock.patch.object(
135
- devin_usage, "fetch_usage", return_value=payload
136
- ):
137
- data = devin_usage.build_usage_json()
138
- self.assertEqual(data["status"], "unavailable")
139
- json.dumps(data, allow_nan=False)
140
-
141
- def test_non_https_server_is_rejected(self):
142
- with tempfile.TemporaryDirectory() as temporary:
143
- credentials = Path(temporary) / "credentials.toml"
144
- credentials.write_text(
145
- 'windsurf_api_key = "key"\napi_server_url = "http://server.codeium.com"\n'
146
- )
147
- with mock.patch.object(
148
- devin_usage, "DEFAULT_CREDENTIALS_FILE", credentials
149
- ):
150
- with self.assertRaises(RuntimeError):
151
- devin_usage.get_credentials()
152
-
153
- def test_unauthorized_never_attempts_refresh(self):
154
- error = urllib.error.HTTPError("https://example.test", 401, "", {}, None)
155
- with (
156
- mock.patch.object(
157
- devin_usage,
158
- "get_credentials",
159
- return_value=("expired", "https://server.codeium.com"),
160
- ),
161
- mock.patch.object(devin_usage, "_devin_version", return_value="1.0.0"),
162
- mock.patch.object(devin_usage.urllib.request, "urlopen", side_effect=error),
163
- ):
164
- data = devin_usage.build_usage_json()
165
- self.assertEqual(data["status"], "unavailable")
166
- self.assertIn("sign in", data["error"])
167
-
168
- def test_stale_cache_is_returned_when_refresh_fails(self):
169
- cached = {
170
- "provider": "devin",
171
- "status": "live",
172
- "source": "devin_user_status_api",
173
- "retrieved_at": "2020-01-01T00:00:00+00:00",
174
- "quotas": {"daily": {"remaining_pct": 50}},
175
- }
176
- with tempfile.TemporaryDirectory() as temporary:
177
- cache = Path(temporary) / "usage.json"
178
- cache.write_text(json.dumps(cached))
179
- with (
180
- mock.patch.object(devin_usage, "DEFAULT_USAGE_FILE", cache),
181
- mock.patch.object(
182
- devin_usage,
183
- "build_usage_json",
184
- return_value={"status": "unavailable", "error": "offline"},
185
- ),
186
- ):
187
- result = devin_usage.get_cached_usage()
188
- self.assertEqual(result["status"], "stale")
189
- self.assertEqual(result["refresh_error"], "offline")
190
-
191
-
192
- if __name__ == "__main__":
193
- unittest.main()
@@ -1,8 +0,0 @@
1
- version = 1
2
- revision = 3
3
- requires-python = ">=3.12"
4
-
5
- [[package]]
6
- name = "devin-cli-usage"
7
- version = "0.1.1"
8
- source = { editable = "." }