devin-cli-usage 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,6 @@
1
+ devin_cli_usage/__init__.py,sha256=mkQy-Z30BhJD0dcUsBElVFGl3m0VWqTFM2P0YB9OtAE,13742
2
+ devin_cli_usage/__main__.py,sha256=7TeSQI-QmKsfFfmV4NMb_bfaZUD7Z-lQg5ezRlTE0Ek,41
3
+ devin_cli_usage-0.1.0.dist-info/METADATA,sha256=0yOM9xCZMXjihwlV4ZkkbMwnXp4qiGdY9etGInu01Pk,3107
4
+ devin_cli_usage-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ devin_cli_usage-0.1.0.dist-info/entry_points.txt,sha256=gnWUbRjslNwx1s8Uo9S2N9M-8G25jCvxoVonMGTARnE,57
6
+ devin_cli_usage-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devin-cli-usage = devin_cli_usage:main