devin-cli-usage 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.
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .ruff_cache/
5
+ dist/*
6
+ !dist/.gitignore
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: devin-cli-usage
3
+ Version: 0.1.0
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`, then calls the read-only
63
+ `GetUserStatus` service used by the installed client. It rereads credentials
64
+ for every live request. It never refreshes credentials or modifies
65
+ 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`. JSON results report whether they are
81
+ `live`, `cached`, `stale`, or `unavailable`.
82
+
83
+ Environment overrides:
84
+
85
+ - `DEVIN_USAGE_CREDENTIALS_FILE`: alternate credentials file
86
+ - `DEVIN_USAGE_FILE`: alternate cache path
87
+
88
+ The user-status service is undocumented and may change with Devin client
89
+ releases.
90
+
91
+ ## Options
92
+
93
+ ```text
94
+ usage: devin-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
95
+ {status,json,daemon,statusline,refresh,install}
96
+ ```
97
+
98
+ - `--max-age SECS`: maximum cache age used by `statusline`
99
+ - `--refresh`: bypass the cache for `statusline`
100
+ - `-i SECS`: daemon refresh interval
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ uv --no-config lock --check
106
+ uv --no-config run --locked python -m unittest discover -s tests
107
+ uv --no-config build --no-sources
108
+ ```
@@ -0,0 +1,99 @@
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`, then calls the read-only
54
+ `GetUserStatus` service used by the installed client. It rereads credentials
55
+ for every live request. It never refreshes credentials or modifies
56
+ 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`. JSON results report whether they are
72
+ `live`, `cached`, `stale`, or `unavailable`.
73
+
74
+ Environment overrides:
75
+
76
+ - `DEVIN_USAGE_CREDENTIALS_FILE`: alternate credentials file
77
+ - `DEVIN_USAGE_FILE`: alternate cache path
78
+
79
+ The user-status service is undocumented and may change with Devin client
80
+ releases.
81
+
82
+ ## Options
83
+
84
+ ```text
85
+ usage: devin-cli-usage [-h] [-i INTERVAL] [--max-age MAX_AGE] [--refresh]
86
+ {status,json,daemon,statusline,refresh,install}
87
+ ```
88
+
89
+ - `--max-age SECS`: maximum cache age used by `statusline`
90
+ - `--refresh`: bypass the cache for `statusline`
91
+ - `-i SECS`: daemon refresh interval
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ uv --no-config lock --check
97
+ uv --no-config run --locked python -m unittest discover -s tests
98
+ uv --no-config build --no-sources
99
+ ```
@@ -0,0 +1,2 @@
1
+ *
2
+ !.gitignore
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "devin-cli-usage"
3
+ version = "0.1.0"
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"
@@ -0,0 +1,414 @@
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 os
9
+ import re
10
+ import shutil
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import time
16
+ import tomllib
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+
23
+ DEFAULT_CREDENTIALS_FILE = (
24
+ Path.home() / ".local" / "share" / "devin" / "credentials.toml"
25
+ )
26
+ DEFAULT_USAGE_FILE = Path.home() / ".local" / "share" / "devin" / "usage-limits.json"
27
+ USER_STATUS_PATH = "/exa.seat_management_pb.SeatManagementService/GetUserStatus"
28
+ CACHE_MAX_AGE = 300
29
+ DAEMON_INTERVAL = 300
30
+
31
+
32
+ def _iso_now() -> str:
33
+ return datetime.now(UTC).isoformat()
34
+
35
+
36
+ def _parse_iso(value: object) -> datetime | None:
37
+ if not isinstance(value, str) or not value:
38
+ return None
39
+ try:
40
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
41
+ except ValueError:
42
+ return None
43
+ return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed
44
+
45
+
46
+ def _number(value: object) -> int | float | None:
47
+ if isinstance(value, bool):
48
+ return None
49
+ if isinstance(value, int | float):
50
+ return value
51
+ if isinstance(value, str):
52
+ try:
53
+ parsed = float(value)
54
+ except ValueError:
55
+ return None
56
+ return int(parsed) if parsed.is_integer() else parsed
57
+ return None
58
+
59
+
60
+ def _format_reset(value: object) -> str:
61
+ seconds = _number(value)
62
+ if seconds is None:
63
+ return ""
64
+ try:
65
+ reset = datetime.fromtimestamp(float(seconds), UTC)
66
+ except (ValueError, OSError):
67
+ return ""
68
+ minutes = max(0, int((reset - datetime.now(UTC)).total_seconds()) // 60)
69
+ if minutes >= 1440:
70
+ return f"{minutes // 1440}d{(minutes % 1440) // 60}h"
71
+ if minutes >= 60:
72
+ return f"{minutes // 60}h{minutes % 60}m"
73
+ return f"{minutes}m"
74
+
75
+
76
+ def get_credentials_file() -> Path:
77
+ override = os.environ.get("DEVIN_USAGE_CREDENTIALS_FILE")
78
+ return Path(override).expanduser() if override else DEFAULT_CREDENTIALS_FILE
79
+
80
+
81
+ def get_usage_file() -> Path:
82
+ override = os.environ.get("DEVIN_USAGE_FILE")
83
+ return Path(override).expanduser() if override else DEFAULT_USAGE_FILE
84
+
85
+
86
+ def get_credentials() -> tuple[str, str]:
87
+ """Read the current API key and server without changing Devin credentials."""
88
+ path = get_credentials_file()
89
+ try:
90
+ data = tomllib.loads(path.read_text())
91
+ except FileNotFoundError as exc:
92
+ raise RuntimeError(f"Devin credentials not found at {path}") from exc
93
+ except (OSError, tomllib.TOMLDecodeError) as exc:
94
+ raise RuntimeError(f"Could not read Devin credentials from {path}") from exc
95
+ api_key = data.get("windsurf_api_key")
96
+ server = data.get("api_server_url")
97
+ if not isinstance(api_key, str) or not api_key:
98
+ raise RuntimeError("Devin credentials do not contain a local API key")
99
+ if not isinstance(server, str) or not server:
100
+ raise RuntimeError("Devin credentials do not contain an API server")
101
+ parsed = urllib.parse.urlparse(server)
102
+ if parsed.scheme != "https" or not parsed.hostname:
103
+ raise RuntimeError("Devin credential API server is not a valid HTTPS URL")
104
+ return api_key, server.rstrip("/")
105
+
106
+
107
+ def _devin_version() -> str:
108
+ executable = shutil.which("devin")
109
+ if not executable:
110
+ return "unknown"
111
+ try:
112
+ result = subprocess.run(
113
+ [executable, "version"],
114
+ check=False,
115
+ capture_output=True,
116
+ text=True,
117
+ timeout=5,
118
+ )
119
+ except (OSError, subprocess.SubprocessError):
120
+ return "unknown"
121
+ match = re.search(r"\b(\d+(?:\.\d+)+)\b", result.stdout)
122
+ return match.group(1) if match else "unknown"
123
+
124
+
125
+ def fetch_usage() -> dict:
126
+ api_key, server = get_credentials()
127
+ version = _devin_version()
128
+ payload = {
129
+ "metadata": {
130
+ "apiKey": api_key,
131
+ "ideName": "devin",
132
+ "ideVersion": version,
133
+ "extensionVersion": version,
134
+ "locale": "en",
135
+ }
136
+ }
137
+ request = urllib.request.Request(
138
+ server + USER_STATUS_PATH,
139
+ data=json.dumps(payload, separators=(",", ":")).encode(),
140
+ method="POST",
141
+ headers={
142
+ "Content-Type": "application/json",
143
+ "Connect-Protocol-Version": "1",
144
+ "User-Agent": "devin-cli-usage/0.1",
145
+ },
146
+ )
147
+ try:
148
+ with urllib.request.urlopen(request, timeout=15) as response:
149
+ result = json.loads(response.read())
150
+ except urllib.error.HTTPError as exc:
151
+ if exc.code in (401, 403):
152
+ raise RuntimeError(
153
+ "Devin local API key is no longer accepted; sign in with Devin again"
154
+ ) from exc
155
+ raise RuntimeError(f"Devin user-status API returned HTTP {exc.code}") from exc
156
+ except (urllib.error.URLError, TimeoutError) as exc:
157
+ raise RuntimeError("Devin user-status API is unavailable") from exc
158
+ except json.JSONDecodeError as exc:
159
+ raise RuntimeError("Devin user-status API returned invalid JSON") from exc
160
+ if not isinstance(result, dict):
161
+ raise RuntimeError("Devin user-status API returned an unexpected response")
162
+ return result
163
+
164
+
165
+ def _normalized_plan_info(payload: dict, plan_status: dict) -> dict:
166
+ info = payload.get("planInfo")
167
+ if not isinstance(info, dict):
168
+ info = plan_status.get("planInfo")
169
+ if not isinstance(info, dict):
170
+ info = {}
171
+ return {
172
+ key: value
173
+ for key, source in (
174
+ ("name", "planName"),
175
+ ("billing_strategy", "billingStrategy"),
176
+ ("monthly_prompt_credits", "monthlyPromptCredits"),
177
+ ("monthly_flow_credits", "monthlyFlowCredits"),
178
+ ("monthly_flex_credits", "monthlyFlexCreditPurchaseAmount"),
179
+ )
180
+ if (value := info.get(source)) is not None
181
+ }
182
+
183
+
184
+ def _normalize(payload: dict) -> dict:
185
+ user_status = payload.get("userStatus")
186
+ if not isinstance(user_status, dict):
187
+ raise RuntimeError("Devin user-status response did not contain userStatus")
188
+ plan_status = user_status.get("planStatus")
189
+ if not isinstance(plan_status, dict):
190
+ plan_status = {}
191
+ quotas = {
192
+ "daily": {
193
+ "remaining_pct": _number(plan_status.get("dailyQuotaRemainingPercent")),
194
+ "reset_at": _number(plan_status.get("dailyQuotaResetAtUnix")),
195
+ },
196
+ "weekly": {
197
+ "remaining_pct": _number(plan_status.get("weeklyQuotaRemainingPercent")),
198
+ "reset_at": _number(plan_status.get("weeklyQuotaResetAtUnix")),
199
+ },
200
+ }
201
+ credits = {
202
+ key: value
203
+ for key, source in (
204
+ ("available_prompt", "availablePromptCredits"),
205
+ ("used_prompt", "usedPromptCredits"),
206
+ ("available_flow", "availableFlowCredits"),
207
+ ("used_flow", "usedFlowCredits"),
208
+ ("available_flex", "availableFlexCredits"),
209
+ ("used_flex", "usedFlexCredits"),
210
+ ("overage_balance_micros", "overageBalanceMicros"),
211
+ ("acu_consumed", "acuConsumed"),
212
+ ("acu_limit", "acuLimit"),
213
+ )
214
+ if (value := _number(plan_status.get(source))) is not None
215
+ }
216
+ return {
217
+ "plan": _normalized_plan_info(payload, plan_status),
218
+ "plan_period": {
219
+ "start": plan_status.get("planStart"),
220
+ "end": plan_status.get("planEnd"),
221
+ },
222
+ "quotas": quotas,
223
+ "credits": credits,
224
+ }
225
+
226
+
227
+ def build_usage_json() -> dict:
228
+ updated_at = _iso_now()
229
+ try:
230
+ usage = _normalize(fetch_usage())
231
+ except Exception as exc:
232
+ return {
233
+ "provider": "devin",
234
+ "status": "unavailable",
235
+ "source": "devin_user_status_api",
236
+ "retrieved_at": updated_at,
237
+ "error": str(exc),
238
+ }
239
+ return {
240
+ "provider": "devin",
241
+ "status": "live",
242
+ "source": "devin_user_status_api",
243
+ "retrieved_at": updated_at,
244
+ **usage,
245
+ }
246
+
247
+
248
+ def write_usage_file(data: dict) -> None:
249
+ path = get_usage_file()
250
+ path.parent.mkdir(parents=True, exist_ok=True)
251
+ fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
252
+ temporary = Path(temporary_name)
253
+ try:
254
+ with os.fdopen(fd, "w") as handle:
255
+ json.dump(data, handle, indent=2)
256
+ handle.write("\n")
257
+ os.replace(temporary, path)
258
+ except Exception:
259
+ temporary.unlink(missing_ok=True)
260
+ raise
261
+
262
+
263
+ def _read_cache() -> dict | None:
264
+ try:
265
+ data = json.loads(get_usage_file().read_text())
266
+ except (OSError, json.JSONDecodeError):
267
+ return None
268
+ return data if isinstance(data, dict) else None
269
+
270
+
271
+ def get_cached_usage(max_age: int = CACHE_MAX_AGE, force_refresh: bool = False) -> dict:
272
+ cached = _read_cache()
273
+ if not force_refresh and cached:
274
+ retrieved = _parse_iso(cached.get("retrieved_at"))
275
+ if retrieved and (datetime.now(UTC) - retrieved).total_seconds() < max_age:
276
+ return {**cached, "status": "cached"}
277
+ fresh = build_usage_json()
278
+ if fresh["status"] == "live":
279
+ write_usage_file(fresh)
280
+ return fresh
281
+ if cached:
282
+ return {**cached, "status": "stale", "refresh_error": fresh.get("error")}
283
+ return fresh
284
+
285
+
286
+ def _quota_value(data: dict) -> tuple[str, dict] | None:
287
+ quotas = data.get("quotas")
288
+ if not isinstance(quotas, dict):
289
+ return None
290
+ candidates = [
291
+ (name, quota)
292
+ for name, quota in quotas.items()
293
+ if isinstance(quota, dict) and quota.get("remaining_pct") is not None
294
+ ]
295
+ return (
296
+ min(candidates, key=lambda item: float(item[1]["remaining_pct"]))
297
+ if candidates
298
+ else None
299
+ )
300
+
301
+
302
+ def _statusline_text(data: dict) -> str:
303
+ selected = _quota_value(data)
304
+ if not selected:
305
+ return "devin:q:unavailable"
306
+ name, quota = selected
307
+ remaining = float(quota["remaining_pct"])
308
+ parts = [f"devin:{name}:{remaining:g}%left"]
309
+ reset = _format_reset(quota.get("reset_at"))
310
+ if reset:
311
+ parts.append(f"reset:{reset}")
312
+ if data.get("status") in {"cached", "stale"}:
313
+ parts.append(str(data["status"]))
314
+ return " ".join(parts)
315
+
316
+
317
+ def _print_status(data: dict) -> None:
318
+ print("Devin usage")
319
+ print(f"Status: {data['status']}")
320
+ plan = data.get("plan")
321
+ if isinstance(plan, dict) and plan.get("name"):
322
+ print(f"Plan: {plan['name']}")
323
+ quotas = data.get("quotas")
324
+ if isinstance(quotas, dict):
325
+ for name in ("daily", "weekly"):
326
+ quota = quotas.get(name)
327
+ if isinstance(quota, dict) and quota.get("remaining_pct") is not None:
328
+ suffix = _format_reset(quota.get("reset_at"))
329
+ reset = f", resets in {suffix}" if suffix else ""
330
+ print(
331
+ f"{name.title()}: {float(quota['remaining_pct']):g}% remaining{reset}"
332
+ )
333
+ credits = data.get("credits")
334
+ if isinstance(credits, dict):
335
+ if credits.get("available_prompt") is not None:
336
+ print(f"Prompt credits available: {credits['available_prompt']}")
337
+ if credits.get("available_flow") is not None:
338
+ print(f"Flow credits available: {credits['available_flow']}")
339
+ if credits.get("available_flex") is not None:
340
+ print(f"Flex credits available: {credits['available_flex']}")
341
+ if data.get("error"):
342
+ print(f"Error: {data['error']}")
343
+ if data.get("refresh_error"):
344
+ print(f"Refresh error: {data['refresh_error']}")
345
+
346
+
347
+ def cmd_status(_args: argparse.Namespace) -> None:
348
+ _print_status(build_usage_json())
349
+
350
+
351
+ def cmd_json(_args: argparse.Namespace) -> None:
352
+ print(json.dumps(build_usage_json(), indent=2))
353
+
354
+
355
+ def cmd_statusline(args: argparse.Namespace) -> None:
356
+ print(_statusline_text(get_cached_usage(args.max_age, args.refresh)))
357
+
358
+
359
+ def cmd_refresh(_args: argparse.Namespace) -> None:
360
+ data = build_usage_json()
361
+ if data["status"] == "live":
362
+ write_usage_file(data)
363
+ _print_status(data)
364
+
365
+
366
+ def cmd_daemon(args: argparse.Namespace) -> None:
367
+ signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
368
+ if hasattr(signal, "SIGTERM"):
369
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
370
+ print(f"devin-cli-usage daemon started (refreshing every {args.interval}s)")
371
+ print(f"Writing to {get_usage_file()}")
372
+ while True:
373
+ data = build_usage_json()
374
+ if data["status"] == "live":
375
+ write_usage_file(data)
376
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {_statusline_text(data)}")
377
+ time.sleep(args.interval)
378
+
379
+
380
+ def cmd_install(_args: argparse.Namespace) -> None:
381
+ print(
382
+ "Install with:\n uv tool install devin-cli-usage\n\nThen run:\n devin-cli-usage"
383
+ )
384
+
385
+
386
+ def _build_parser() -> argparse.ArgumentParser:
387
+ parser = argparse.ArgumentParser(description="Devin account usage monitor")
388
+ parser.add_argument(
389
+ "command",
390
+ nargs="?",
391
+ default="status",
392
+ choices=["status", "json", "daemon", "statusline", "refresh", "install"],
393
+ )
394
+ parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL)
395
+ parser.add_argument("--max-age", type=int, default=CACHE_MAX_AGE)
396
+ parser.add_argument("--refresh", action="store_true")
397
+ return parser
398
+
399
+
400
+ def main() -> None:
401
+ args = _build_parser().parse_args()
402
+ commands = {
403
+ "status": cmd_status,
404
+ "json": cmd_json,
405
+ "daemon": cmd_daemon,
406
+ "statusline": cmd_statusline,
407
+ "refresh": cmd_refresh,
408
+ "install": cmd_install,
409
+ }
410
+ commands[args.command](args)
411
+
412
+
413
+ if __name__ == "__main__":
414
+ main()
@@ -0,0 +1,3 @@
1
+ from devin_cli_usage import main
2
+
3
+ main()
@@ -0,0 +1,143 @@
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_credentials_are_reread_without_writes(self):
29
+ with tempfile.TemporaryDirectory() as temporary:
30
+ credentials = Path(temporary) / "credentials.toml"
31
+ credentials.write_text(
32
+ 'windsurf_api_key = "first"\napi_server_url = "https://server.codeium.com"\n'
33
+ )
34
+ with mock.patch.object(
35
+ devin_usage, "DEFAULT_CREDENTIALS_FILE", credentials
36
+ ):
37
+ self.assertEqual(devin_usage.get_credentials()[0], "first")
38
+ credentials.write_text(
39
+ 'windsurf_api_key = "second"\napi_server_url = "https://server.codeium.com"\n'
40
+ )
41
+ self.assertEqual(devin_usage.get_credentials()[0], "second")
42
+
43
+ def test_fetch_puts_api_key_in_request_metadata(self):
44
+ payload = {"userStatus": {"planStatus": {}}}
45
+
46
+ def fake_urlopen(request, timeout):
47
+ body = json.loads(request.data)
48
+ metadata = body["metadata"]
49
+ self.assertEqual(metadata["apiKey"], "local-key")
50
+ self.assertEqual(metadata["ideName"], "devin")
51
+ self.assertEqual(metadata["ideVersion"], "3000.3.27")
52
+ self.assertNotIn("Authorization", request.headers)
53
+ self.assertEqual(timeout, 15)
54
+ return FakeResponse(payload)
55
+
56
+ with (
57
+ mock.patch.object(
58
+ devin_usage,
59
+ "get_credentials",
60
+ return_value=("local-key", "https://server.codeium.com"),
61
+ ),
62
+ mock.patch.object(devin_usage, "_devin_version", return_value="3000.3.27"),
63
+ mock.patch.object(
64
+ devin_usage.urllib.request, "urlopen", side_effect=fake_urlopen
65
+ ),
66
+ ):
67
+ self.assertEqual(devin_usage.fetch_usage(), payload)
68
+
69
+ def test_build_normalizes_quota_and_credit_fields(self):
70
+ payload = {
71
+ "planInfo": {"planName": "Pro", "monthlyPromptCredits": 1000},
72
+ "userStatus": {
73
+ "planStatus": {
74
+ "dailyQuotaRemainingPercent": 75,
75
+ "dailyQuotaResetAtUnix": 2_000_000_000,
76
+ "weeklyQuotaRemainingPercent": "50",
77
+ "availablePromptCredits": "900",
78
+ "usedPromptCredits": 100,
79
+ "overageBalanceMicros": "0",
80
+ }
81
+ },
82
+ }
83
+ with mock.patch.object(devin_usage, "fetch_usage", return_value=payload):
84
+ data = devin_usage.build_usage_json()
85
+ self.assertEqual(data["status"], "live")
86
+ self.assertEqual(data["plan"]["name"], "Pro")
87
+ self.assertEqual(data["quotas"]["daily"]["remaining_pct"], 75)
88
+ self.assertEqual(data["quotas"]["weekly"]["remaining_pct"], 50)
89
+ self.assertEqual(data["credits"]["available_prompt"], 900)
90
+
91
+ def test_non_https_server_is_rejected(self):
92
+ with tempfile.TemporaryDirectory() as temporary:
93
+ credentials = Path(temporary) / "credentials.toml"
94
+ credentials.write_text(
95
+ 'windsurf_api_key = "key"\napi_server_url = "http://server.codeium.com"\n'
96
+ )
97
+ with mock.patch.object(
98
+ devin_usage, "DEFAULT_CREDENTIALS_FILE", credentials
99
+ ):
100
+ with self.assertRaises(RuntimeError):
101
+ devin_usage.get_credentials()
102
+
103
+ def test_unauthorized_never_attempts_refresh(self):
104
+ error = urllib.error.HTTPError("https://example.test", 401, "", {}, None)
105
+ with (
106
+ mock.patch.object(
107
+ devin_usage,
108
+ "get_credentials",
109
+ return_value=("expired", "https://server.codeium.com"),
110
+ ),
111
+ mock.patch.object(devin_usage, "_devin_version", return_value="1.0.0"),
112
+ mock.patch.object(devin_usage.urllib.request, "urlopen", side_effect=error),
113
+ ):
114
+ data = devin_usage.build_usage_json()
115
+ self.assertEqual(data["status"], "unavailable")
116
+ self.assertIn("sign in", data["error"])
117
+
118
+ def test_stale_cache_is_returned_when_refresh_fails(self):
119
+ cached = {
120
+ "provider": "devin",
121
+ "status": "live",
122
+ "source": "devin_user_status_api",
123
+ "retrieved_at": "2020-01-01T00:00:00+00:00",
124
+ "quotas": {"daily": {"remaining_pct": 50}},
125
+ }
126
+ with tempfile.TemporaryDirectory() as temporary:
127
+ cache = Path(temporary) / "usage.json"
128
+ cache.write_text(json.dumps(cached))
129
+ with (
130
+ mock.patch.object(devin_usage, "DEFAULT_USAGE_FILE", cache),
131
+ mock.patch.object(
132
+ devin_usage,
133
+ "build_usage_json",
134
+ return_value={"status": "unavailable", "error": "offline"},
135
+ ),
136
+ ):
137
+ result = devin_usage.get_cached_usage()
138
+ self.assertEqual(result["status"], "stale")
139
+ self.assertEqual(result["refresh_error"], "offline")
140
+
141
+
142
+ if __name__ == "__main__":
143
+ unittest.main()
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "devin-cli-usage"
7
+ version = "0.1.0"
8
+ source = { editable = "." }