clanker-usage 0.4.2__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/PKG-INFO +8 -2
  2. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/README.md +7 -1
  3. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/build_wrappers.py +5 -0
  4. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/pyproject.toml +1 -1
  5. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/cli.py +10 -0
  6. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/paths.py +16 -0
  7. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/__init__.py +2 -2
  8. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/agy.py +2 -9
  9. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/codex.py +2 -3
  10. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/devin.py +113 -7
  11. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/gemini.py +2 -3
  12. clanker_usage-0.4.4/src/clanker_usage/providers/muse.py +93 -0
  13. clanker_usage-0.4.4/src/clanker_usage/shapes/devin/devin-2026-10-24-rate-limits.json +29 -0
  14. clanker_usage-0.4.4/src/clanker_usage/shapes/muse-code/muse-2026-10-25.json +17 -0
  15. clanker_usage-0.4.4/src/clanker_usage/shapes/opencode/opencode-2026-10-24-idle-5h.json +30 -0
  16. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/tests/test_zcode.py +1 -1
  17. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/.gitignore +0 -0
  18. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/LICENSE +0 -0
  19. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/__init__.py +0 -0
  20. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/__main__.py +0 -0
  21. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/cache.py +0 -0
  22. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/errors.py +0 -0
  23. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/http.py +0 -0
  24. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/oauth.py +0 -0
  25. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/claude.py +0 -0
  26. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/cursor.py +0 -0
  27. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/deepseek.py +0 -0
  28. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/google.py +0 -0
  29. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/opencode.py +0 -0
  30. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/providers/zcode.py +0 -0
  31. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/agy/agy-2026-10-19.json +0 -0
  32. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/claude/claude-2026-10-19.json +0 -0
  33. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/codex/api-2026-10-19.json +0 -0
  34. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/codex/app-server-2026-10-19.json +0 -0
  35. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/cursor/cursor-2026-10-19.json +0 -0
  36. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/deepseek/deepseek-2026-10-19.json +0 -0
  37. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/devin/devin-2026-10-19.json +0 -0
  38. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/gemini/from-gemini-cli-0.56-source.json +0 -0
  39. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/opencode/opencode-2026-10-19.json +0 -0
  40. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/zcode/no-plan-2026-10-18.json +0 -0
  41. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes/zcode/quota-2026-09-05.json +0 -0
  42. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/src/clanker_usage/shapes.py +0 -0
  43. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/tests/test_core.py +0 -0
  44. {clanker_usage-0.4.2 → clanker_usage-0.4.4}/tests/test_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: clanker-usage
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Plan and quota of coding-agent accounts, with a statusline, a cache daemon and a check for provider changes
5
5
  Project-URL: Homepage, https://github.com/wakamex/clanker-usage
6
6
  Project-URL: Source, https://github.com/wakamex/clanker-usage
@@ -16,7 +16,7 @@ Description-Content-Type: text/markdown
16
16
 
17
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
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.
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`), Muse Code (`muse-code-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
20
 
21
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
22
 
@@ -158,12 +158,18 @@ Sign in with the Devin CLI. The tool reads its local API key and server from `~/
158
158
 
159
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
160
 
161
+ Devin's free models also have a rate limit that this API does not report. When a request hits it, Devin writes the error, with the time the limit resets, to its CLI log, so the tool reads the logs from the last seven days in `~/.local/share/devin/cli/logs`. `details.free_model_limits` lists, per model, the last hit, its reset and whether the limit still applied when the document was made, and `details.free_model_limit_hits` keeps the last 50 hits. `status` and `statusline` show a limit that still applies. The model is known only when the log names it, as Devin's interactive mode does; logs from its ACP server do not, so those hits have a null `model`. To read the logs of other Devin homes, such as ones another tool creates for each run, add a glob once: `devin-cli-usage configure --log-glob '~/work/*/devin/cli/logs'`.
162
+
161
163
  ## Gemini CLI
162
164
 
163
165
  Quota exists only for a Google login, and since 2026-06-18 Google allows that only for [Gemini Code Assist](https://developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals) Standard and Enterprise subscriptions. Individual, Google AI Pro and Ultra accounts can no longer sign Gemini CLI in with Google; their quota is Antigravity's, which `agy-usage` reports. A Gemini API key has no quota to read. 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
166
 
165
167
  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
168
 
169
+ ## Muse Code
170
+
171
+ Sign in with Muse Code (`/login`). The tool reads Muse's Meta login from `~/.config/muse/auth.json` (under `$XDG_CONFIG_HOME` when set) and never changes it. Muse reports its usage only when it signs in and at the end of each model call, so the tool makes the sign-in's key request, `POST https://api.meta.ai/muse-code/key`, which uses no quota and, as checked with Muse 1.4.12, returns the key Muse already stored. The windows are the 5-hour window and the weekly limit, and the plan is the subscription tier, such as Muse Code Power Usage. The account id is a fingerprint of the account's email.
172
+
167
173
  ## OpenCode Go
168
174
 
169
175
  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.
@@ -2,7 +2,7 @@
2
2
 
3
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
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.
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`), Muse Code (`muse-code-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
6
 
7
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
8
 
@@ -144,12 +144,18 @@ Sign in with the Devin CLI. The tool reads its local API key and server from `~/
144
144
 
145
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
146
 
147
+ Devin's free models also have a rate limit that this API does not report. When a request hits it, Devin writes the error, with the time the limit resets, to its CLI log, so the tool reads the logs from the last seven days in `~/.local/share/devin/cli/logs`. `details.free_model_limits` lists, per model, the last hit, its reset and whether the limit still applied when the document was made, and `details.free_model_limit_hits` keeps the last 50 hits. `status` and `statusline` show a limit that still applies. The model is known only when the log names it, as Devin's interactive mode does; logs from its ACP server do not, so those hits have a null `model`. To read the logs of other Devin homes, such as ones another tool creates for each run, add a glob once: `devin-cli-usage configure --log-glob '~/work/*/devin/cli/logs'`.
148
+
147
149
  ## Gemini CLI
148
150
 
149
151
  Quota exists only for a Google login, and since 2026-06-18 Google allows that only for [Gemini Code Assist](https://developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals) Standard and Enterprise subscriptions. Individual, Google AI Pro and Ultra accounts can no longer sign Gemini CLI in with Google; their quota is Antigravity's, which `agy-usage` reports. A Gemini API key has no quota to read. 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
152
 
151
153
  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
154
 
155
+ ## Muse Code
156
+
157
+ Sign in with Muse Code (`/login`). The tool reads Muse's Meta login from `~/.config/muse/auth.json` (under `$XDG_CONFIG_HOME` when set) and never changes it. Muse reports its usage only when it signs in and at the end of each model call, so the tool makes the sign-in's key request, `POST https://api.meta.ai/muse-code/key`, which uses no quota and, as checked with Muse 1.4.12, returns the key Muse already stored. The windows are the 5-hour window and the weekly limit, and the plan is the subscription tier, such as Muse Code Power Usage. The account id is a fingerprint of the account's email.
158
+
153
159
  ## OpenCode Go
154
160
 
155
161
  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.
@@ -52,6 +52,11 @@ WRAPPERS = {
52
52
  "description": "Gemini CLI model quota monitor",
53
53
  "keywords": ["gemini", "gemini-cli", "google", "usage", "quota", "statusline", "cli"],
54
54
  },
55
+ "muse-code-usage": {
56
+ "provider": "muse-code", "label": "Muse Code",
57
+ "description": "Muse Code subscription limit monitor",
58
+ "keywords": ["muse", "muse-code", "meta", "usage", "quota", "statusline", "cli"],
59
+ },
55
60
  "opencode-cli-usage": {
56
61
  "provider": "opencode", "label": "OpenCode Go",
57
62
  "description": "OpenCode Go subscription limit monitor",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "clanker-usage"
7
- version = "0.4.2"
7
+ version = "0.4.4"
8
8
  description = "Plan and quota of coding-agent accounts, with a statusline, a cache daemon and a check for provider changes"
9
9
  readme = "README.md"
10
10
  keywords = ["usage", "quota", "statusline", "z.ai", "ai-agents", "cli"]
@@ -146,10 +146,19 @@ def render(provider, record):
146
146
  reset = datetime.fromisoformat(window["resets_at"]).astimezone()
147
147
  line += f", resets {reset:%Y-%m-%d %H:%M %Z}"
148
148
  lines.append(line)
149
+ lines += [f" {note}" for note in provider_notes(provider, record)]
149
150
  note = status_note(record)
150
151
  return "\n".join(lines + ([f" ({note})"] if note else []))
151
152
 
152
153
 
154
+ def provider_notes(provider, record):
155
+ """What a provider adds about the document beyond its windows, such as Devin's free-model rate limits."""
156
+ try:
157
+ return provider.notes(record) if hasattr(provider, "notes") else []
158
+ except (KeyError, TypeError, ValueError):
159
+ return []
160
+
161
+
153
162
  def harness_prefix(status):
154
163
  """The directory, model and cost from the status JSON a harness pipes to its statusline, as Claude Code does."""
155
164
  if not isinstance(status, dict):
@@ -191,6 +200,7 @@ def statusline(provider, record):
191
200
  if until:
192
201
  part += f" ({until})"
193
202
  parts.append(part)
203
+ parts += provider_notes(provider, record)
194
204
  return " · ".join(parts) + (" (stale)" if record["status"] == "stale" else "")
195
205
 
196
206
 
@@ -1,6 +1,7 @@
1
1
  """Where clanker-usage keeps its cache, check state and configuration."""
2
2
 
3
3
  import os
4
+ import shutil
4
5
  from pathlib import Path
5
6
 
6
7
  from . import cache
@@ -36,3 +37,18 @@ def set_config(provider, section):
36
37
  data = cache.read_json(config_file()) or {}
37
38
  data[provider] = section
38
39
  cache.write_json(config_file(), data)
40
+
41
+
42
+ def executable(name):
43
+ """A harness's command: on PATH, else where user installers put it.
44
+
45
+ A systemd user service runs with a PATH that leaves out ~/.local/bin, where Claude Code, Devin,
46
+ agy and Gemini CLI install themselves, so PATH alone misses them under a daemon.
47
+ """
48
+ if found := shutil.which(name):
49
+ return Path(found)
50
+ for folder in (Path.home() / ".local/bin", Path.home() / "bin", Path("/usr/local/bin"), Path("/usr/bin")):
51
+ candidate = folder / name
52
+ if candidate.is_file() and os.access(candidate, os.X_OK):
53
+ return candidate
54
+ return None
@@ -8,6 +8,6 @@ add_commands(sub) and run_command(args) for its own setup command, and selectors
8
8
  when one person can have several accounts on one machine, such as Claude's config directories.
9
9
  """
10
10
 
11
- from . import agy, claude, codex, cursor, deepseek, devin, gemini, opencode, zcode
11
+ from . import agy, claude, codex, cursor, deepseek, devin, gemini, muse, opencode, zcode
12
12
 
13
- PROVIDERS = {module.NAME: module for module in (agy, claude, codex, cursor, deepseek, devin, gemini, opencode, zcode)}
13
+ PROVIDERS = {module.NAME: module for module in (agy, claude, codex, cursor, deepseek, devin, gemini, muse, opencode, zcode)}
@@ -2,13 +2,12 @@
2
2
 
3
3
  import os
4
4
  import re
5
- import shutil
6
5
  import subprocess
7
6
  from datetime import datetime, timedelta, timezone
8
7
  from functools import cache
9
8
  from pathlib import Path
10
9
 
11
- from .. import cache as files, http, oauth
10
+ from .. import cache as files, http, oauth, paths
12
11
  from . import google
13
12
 
14
13
  NAME = "agy"
@@ -29,13 +28,7 @@ def token_file():
29
28
 
30
29
 
31
30
  def agy_binary():
32
- found = shutil.which("agy")
33
- if found:
34
- return Path(found)
35
- for candidate in (Path.home() / ".local/bin/agy", Path.home() / "bin/agy", Path("/usr/local/bin/agy"), Path("/usr/bin/agy")):
36
- if candidate.is_file() and os.access(candidate, os.X_OK):
37
- return candidate
38
- return None
31
+ return paths.executable("agy")
39
32
 
40
33
 
41
34
  @cache
@@ -6,7 +6,6 @@ import json
6
6
  import math
7
7
  import os
8
8
  import queue
9
- import shutil
10
9
  import subprocess
11
10
  import threading
12
11
  import time
@@ -14,7 +13,7 @@ from datetime import datetime, timezone
14
13
  from importlib.metadata import PackageNotFoundError, version
15
14
  from pathlib import Path
16
15
 
17
- from .. import cache as files, http
16
+ from .. import cache as files, http, paths
18
17
 
19
18
  NAME = "codex"
20
19
  LABEL = "Codex"
@@ -93,7 +92,7 @@ def _response(messages, request_id, deadline):
93
92
 
94
93
  def via_app_server():
95
94
  """account/rateLimits/read from `codex app-server`, or None when Codex is missing or does not answer."""
96
- codex = shutil.which("codex")
95
+ codex = paths.executable("codex")
97
96
  if not codex:
98
97
  return None
99
98
  deadline = time.monotonic() + APP_SERVER_TIMEOUT
@@ -1,17 +1,18 @@
1
1
  """Devin quota and credits, read with the local API key the Devin CLI already holds."""
2
2
 
3
+ import glob
3
4
  import hashlib
4
5
  import math
5
6
  import os
6
7
  import re
7
- import shutil
8
+ import time
8
9
  import subprocess
9
10
  import tomllib
10
11
  import urllib.parse
11
- from datetime import datetime, timezone
12
+ from datetime import datetime, timedelta, timezone
12
13
  from pathlib import Path
13
14
 
14
- from .. import http
15
+ from .. import http, paths
15
16
 
16
17
  NAME = "devin"
17
18
  LABEL = "Devin"
@@ -26,6 +27,73 @@ CREDIT_KEYS = ("availablePromptCredits", "usedPromptCredits", "availableFlowCred
26
27
  "availableFlexCredits", "usedFlexCredits", "overageBalanceMicros", "acuConsumed", "acuLimit")
27
28
 
28
29
 
30
+ # Devin's free models have a rate limit that its status API does not report; the only record is the
31
+ # error Devin writes to its CLI log, such as "Reached free model rate limit. ... Your limit will reset
32
+ # in 1 minute (at 09:51 UTC)." It names no model, so a hit's model comes from the log's model line.
33
+ RATE_LIMIT = re.compile(r"^(\S+)\s.*Reached free model rate limit\..*?reset in [^(]*\(at (\d{1,2}):(\d{2}) UTC\)")
34
+ MODEL_LINE = re.compile(r"resolved_model_uid=([A-Za-z0-9_.-]+)|model_uid=([A-Za-z0-9_.-]+)")
35
+ # Logs older than this are not read, so status stays fast on hosts with many Devin homes.
36
+ LOG_MAX_AGE = 7 * 86400
37
+ HISTORY = 50
38
+
39
+
40
+ def data_dir():
41
+ return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "devin"
42
+
43
+
44
+ def log_dirs():
45
+ """Devin's own log directory and every directory matching a glob added with configure --log-glob."""
46
+ found = [data_dir() / "cli" / "logs"]
47
+ for pattern in paths.config(NAME).get("log_globs", []):
48
+ found += [Path(match) for match in sorted(glob.glob(os.path.expanduser(pattern)))]
49
+ return list(dict.fromkeys(folder for folder in found if folder.is_dir()))
50
+
51
+
52
+ def rate_limit_hits(now=None):
53
+ """Each free-model rate-limit hit in recent Devin logs: when, the reset it named, and the model when the log gives it.
54
+
55
+ Devin retries a failed request and logs the same error several times, so lines in one log that name
56
+ the same reset count as one hit.
57
+ """
58
+ cutoff = (now or time.time()) - LOG_MAX_AGE
59
+ hits = {}
60
+ for folder in log_dirs():
61
+ for log in folder.glob("*.log"):
62
+ try:
63
+ if log.stat().st_mtime < cutoff:
64
+ continue
65
+ lines = log.read_text(errors="replace").splitlines()
66
+ except OSError:
67
+ continue
68
+ model = None
69
+ for line in lines:
70
+ if (found := MODEL_LINE.search(line)) and "Reached free model rate limit" not in line:
71
+ model = found.group(1) or found.group(2)
72
+ if "Reached free model rate limit" not in line or not (match := RATE_LIMIT.match(line)):
73
+ continue
74
+ try:
75
+ at = datetime.fromisoformat(match.group(1).replace("Z", "+00:00")).astimezone(timezone.utc)
76
+ except ValueError:
77
+ continue
78
+ reset = at.replace(hour=int(match.group(2)), minute=int(match.group(3)), second=0, microsecond=0)
79
+ if reset < at - timedelta(minutes=1):
80
+ reset += timedelta(days=1) # The reset is after midnight UTC.
81
+ key = (str(log), reset)
82
+ if key not in hits:
83
+ hits[key] = {"at": at.isoformat(timespec="seconds"), "resets_at": reset.isoformat(), "model": model}
84
+ return sorted(hits.values(), key=lambda hit: hit["at"])
85
+
86
+
87
+ def free_model_limits(hits, now):
88
+ """Per model, the last hit, its reset and whether the limit still applies at now; a model of None is unnamed."""
89
+ latest = {}
90
+ for hit in hits:
91
+ latest[hit["model"]] = hit
92
+ return [{"model": model, "last_hit_at": hit["at"], "resets_at": hit["resets_at"],
93
+ "limited": datetime.fromisoformat(hit["resets_at"]) > now}
94
+ for model, hit in sorted(latest.items(), key=lambda item: item[1]["at"], reverse=True)]
95
+
96
+
29
97
  def credentials_file():
30
98
  return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "devin" / "credentials.toml"
31
99
 
@@ -53,16 +121,21 @@ def account():
53
121
  return hashlib.sha256(credentials()[0].encode()).hexdigest()[:16]
54
122
 
55
123
 
124
+ # Devin's server answers HTTP 500 when the client version is not a version, so a host without the
125
+ # devin command sends this recent one.
126
+ FALLBACK_VERSION = "3000.11.205"
127
+
128
+
56
129
  def devin_version():
57
- executable = shutil.which("devin")
130
+ executable = paths.executable("devin")
58
131
  if not executable:
59
- return "unknown"
132
+ return FALLBACK_VERSION
60
133
  try:
61
134
  result = subprocess.run([executable, "version"], capture_output=True, text=True, timeout=5, check=False)
62
135
  except (OSError, subprocess.SubprocessError):
63
- return "unknown"
136
+ return FALLBACK_VERSION
64
137
  match = re.search(r"\b(\d+(?:\.\d+)+)\b", result.stdout)
65
- return match.group(1) if match else "unknown"
138
+ return match.group(1) if match else FALLBACK_VERSION
66
139
 
67
140
 
68
141
  def fetch():
@@ -79,6 +152,7 @@ def fetch():
79
152
  return {
80
153
  "planInfo": {key: info[key] for key in PLAN_KEYS if key in info} if isinstance(info, dict) else {},
81
154
  "planStatus": {key: value for key, value in plan_status.items() if key != "planInfo"},
155
+ "rateLimitHits": rate_limit_hits()[-HISTORY:],
82
156
  }
83
157
 
84
158
 
@@ -116,4 +190,36 @@ def normalize(payload):
116
190
  plan = info.get("planName")
117
191
  details = {"credits": credits, "plan_period": {"start": status.get("planStart"), "end": status.get("planEnd")}}
118
192
  details.update({key: info[key] for key in PLAN_KEYS[1:] if key in info})
193
+ hits = payload.get("rateLimitHits") or []
194
+ if not isinstance(hits, list) or not all(isinstance(hit, dict) and isinstance(hit.get("resets_at"), str) for hit in hits):
195
+ raise ValueError("Devin rate-limit hits are malformed")
196
+ details["free_model_limits"] = free_model_limits(hits, datetime.now(timezone.utc))
197
+ details["free_model_limit_hits"] = hits
119
198
  return {"plan": plan if isinstance(plan, str) and plan else None, "windows": windows, "details": details}
199
+
200
+
201
+ def notes(record):
202
+ """Free models whose rate limit applies now, for status and statusline."""
203
+ now = datetime.now(timezone.utc)
204
+ out = []
205
+ for limit in (record.get("details") or {}).get("free_model_limits") or []:
206
+ reset = datetime.fromisoformat(limit["resets_at"])
207
+ if reset > now:
208
+ out.append(f"{limit['model'] or 'free model'} rate-limited until {reset.astimezone():%H:%M}")
209
+ return out
210
+
211
+
212
+ def add_commands(sub):
213
+ configure = sub.add_parser("configure", help="Also read Devin logs from directories matching a glob")
214
+ configure.add_argument("--log-glob", required=True,
215
+ help="such as the logs of Devin homes another tool creates, ~/work/*/devin/cli/logs")
216
+
217
+
218
+ def run_command(args):
219
+ if args.command != "configure":
220
+ return False
221
+ section = paths.config(NAME)
222
+ section["log_globs"] = list(dict.fromkeys([*section.get("log_globs", []), args.log_glob]))
223
+ paths.set_config(NAME, section)
224
+ print(f"Configured {paths.config_file()}; {len(log_dirs())} Devin log directories match")
225
+ return True
@@ -8,12 +8,11 @@ key has no quota to read.
8
8
  import json
9
9
  import os
10
10
  import re
11
- import shutil
12
11
  import sys
13
12
  import time
14
13
  from pathlib import Path
15
14
 
16
- from .. import cache as files, http, oauth
15
+ from .. import cache as files, http, oauth, paths
17
16
  from ..errors import ExpectedUnavailable
18
17
  from . import google
19
18
 
@@ -84,7 +83,7 @@ def _oauth2_sources():
84
83
  relative = [Path("node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js"),
85
84
  Path("node_modules/@google/gemini-cli/node_modules/@google/gemini-cli-core/dist/src/code_assist/oauth2.js")]
86
85
  roots = []
87
- if binary := shutil.which("gemini"):
86
+ if binary := paths.executable("gemini"):
88
87
  resolved = Path(binary).resolve()
89
88
  if resolved.parent.name == "bundle":
90
89
  yield from sorted(resolved.parent.glob("*.js"))
@@ -0,0 +1,93 @@
1
+ """Muse Code subscription limits, read with the login Muse Code already holds.
2
+
3
+ Muse reports its usage in only two places: the key call it makes at /login, and an event at the end
4
+ of every model call. This provider makes the key call, which uses no quota. Checked on 2026-10-25
5
+ with Muse 1.4.12: the call returns the API key already stored, and leaves Muse's login working.
6
+ """
7
+
8
+ import hashlib
9
+ import math
10
+ import os
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from .. import cache as files, http
15
+
16
+ NAME = "muse-code"
17
+ LABEL = "Muse Code"
18
+ PACKAGE = "muse-code-usage"
19
+ DESCRIPTION = "Read Muse Code subscription limits with the login Muse Code already holds."
20
+ KEY_URL = "https://api.meta.ai/muse-code/key"
21
+ INTERVAL = 300
22
+ KEEP = frozenset({"is_subs_active", "is_subs_upgrade_available", "subs_tier_name", "window_duration_mins"})
23
+
24
+
25
+ def auth_file():
26
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "muse" / "auth.json"
27
+
28
+
29
+ def login():
30
+ """Muse's Meta login, read without changing Muse's file."""
31
+ meta = ((files.read_json(auth_file()) or {}).get("providers") or {}).get("meta")
32
+ if not isinstance(meta, dict) or not meta.get("access_token") or not meta.get("api_key"):
33
+ raise RuntimeError(f"Muse Code is not signed in: no Meta login in {auth_file()}; run muse and /login")
34
+ return meta
35
+
36
+
37
+ def account():
38
+ """A one-way fingerprint of the account's email, else of its API key."""
39
+ meta = login()
40
+ email = meta.get("user_email")
41
+ source = "email:" + email.strip().lower() if isinstance(email, str) and email.strip() else meta["api_key"]
42
+ return hashlib.sha256(source.encode()).hexdigest()[:16]
43
+
44
+
45
+ def fetch():
46
+ """The subscription part of the key response; the key itself, names and payment details are left out."""
47
+ response = http.request_json(KEY_URL, {"Authorization": "Bearer " + login()["access_token"], "x-api-version": "1.0.0"},
48
+ {"onboard": False})
49
+ if not isinstance(response, dict):
50
+ raise ValueError("Muse Code key response is not an object")
51
+ return {key: response[key] for key in ("is_subs_active", "subs_tier_name", "is_subs_upgrade_available", "subs_usage")
52
+ if key in response}
53
+
54
+
55
+ def percent(value):
56
+ if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value) or not 0 <= value <= 100:
57
+ raise ValueError("Invalid Muse Code usage: used_percent must be between 0 and 100")
58
+ return value
59
+
60
+
61
+ def reset(value):
62
+ if value is None:
63
+ return None
64
+ if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value) or value < 0:
65
+ raise ValueError("Invalid Muse Code usage: resets_at must be a Unix time")
66
+ return datetime.fromtimestamp(value, timezone.utc).isoformat()
67
+
68
+
69
+ def normalize(payload):
70
+ """The short window and the weekly limit; an account without a subscription has no plan."""
71
+ if not payload.get("is_subs_active"):
72
+ return {"plan": None, "windows": [], "details": {}}
73
+ usage = payload.get("subs_usage")
74
+ if not isinstance(usage, dict):
75
+ raise ValueError("Muse Code key response has no subscription usage")
76
+ windows = []
77
+ window = usage.get("window")
78
+ if isinstance(window, dict):
79
+ minutes = window.get("window_duration_mins")
80
+ if isinstance(minutes, bool) or not isinstance(minutes, int) or minutes <= 0:
81
+ raise ValueError("Invalid Muse Code usage: window_duration_mins must be a positive whole number")
82
+ name = f"{minutes // 60}-hour" if minutes % 60 == 0 else f"{minutes}-minute"
83
+ windows.append({"name": name, "seconds": minutes * 60, "scope": None,
84
+ "used_pct": percent(window.get("used_percent")), "resets_at": reset(window.get("resets_at"))})
85
+ weekly = usage.get("weekly")
86
+ if isinstance(weekly, dict):
87
+ windows.append({"name": "weekly", "seconds": 7 * 86400, "scope": None,
88
+ "used_pct": percent(weekly.get("used_percent")), "resets_at": reset(weekly.get("resets_at"))})
89
+ if not windows:
90
+ raise ValueError("Muse Code key response has no usage windows")
91
+ plan = payload.get("subs_tier_name")
92
+ return {"plan": plan if isinstance(plan, str) and plan else None, "windows": windows,
93
+ "details": {"upgrade_available": bool(payload.get("is_subs_upgrade_available"))}}
@@ -0,0 +1,29 @@
1
+ {
2
+ "planInfo": {
3
+ "planName": "synthetic",
4
+ "billingStrategy": "BILLING_STRATEGY_QUOTA",
5
+ "monthlyPromptCredits": 1
6
+ },
7
+ "planStatus": {
8
+ "planStart": "2001-01-01T00:00:00Z",
9
+ "planEnd": "2001-01-01T00:00:00Z",
10
+ "availablePromptCredits": 1,
11
+ "dailyQuotaRemainingPercent": 1,
12
+ "weeklyQuotaRemainingPercent": 1,
13
+ "overageBalanceMicros": "-100000",
14
+ "dailyQuotaResetAtUnix": "1000000000",
15
+ "weeklyQuotaResetAtUnix": "1000000000"
16
+ },
17
+ "rateLimitHits": [
18
+ {
19
+ "at": "2001-01-01T00:00:00+00:00",
20
+ "resets_at": "2001-01-01T00:00:00+00:00",
21
+ "model": null
22
+ },
23
+ {
24
+ "at": "2001-01-01T00:00:00+00:00",
25
+ "resets_at": "2001-01-01T00:00:00+00:00",
26
+ "model": "synthetic"
27
+ }
28
+ ]
29
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "is_subs_active": true,
3
+ "subs_tier_name": "Muse Code Power Usage",
4
+ "is_subs_upgrade_available": true,
5
+ "subs_usage": {
6
+ "window": {
7
+ "used_percent": 1,
8
+ "window_duration_mins": 300,
9
+ "resets_at": 1
10
+ },
11
+ "weekly": {
12
+ "used_percent": 1,
13
+ "resets_at": 1
14
+ },
15
+ "tier": "10000000000000000"
16
+ }
17
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "product": "go",
3
+ "renewalProduct": "go",
4
+ "resumability": "renewing",
5
+ "cancelAtPeriodEnd": false,
6
+ "access": {
7
+ "startsAt": "2001-01-01T00:00:00Z",
8
+ "endsAt": "2001-01-01T00:00:00Z",
9
+ "cancelAtPeriodEnd": false,
10
+ "meters": {
11
+ "fiveHour": {
12
+ "startsAt": null,
13
+ "resetsAt": null,
14
+ "limitMicroCents": "1000000000",
15
+ "usedMicroCents": "1"
16
+ },
17
+ "week": {
18
+ "startsAt": "2001-01-01T00:00:00Z",
19
+ "resetsAt": "2001-01-01T00:00:00Z",
20
+ "limitMicroCents": "1000000000",
21
+ "usedMicroCents": "100000000"
22
+ },
23
+ "month": {
24
+ "resetsAt": "2001-01-01T00:00:00Z",
25
+ "limitMicroCents": "1000000000",
26
+ "usedMicroCents": "100000000"
27
+ }
28
+ }
29
+ }
30
+ }
@@ -170,7 +170,7 @@ class StandardCommandTests(Home):
170
170
  self.assertEqual(cli.main(["--json"]), 0)
171
171
  statuses = {record["provider"]: record["status"] for record in json.loads(out.getvalue())}
172
172
  self.assertEqual(statuses, {"agy": "unavailable", "claude": "unavailable", "codex": "unavailable", "cursor": "unavailable", "deepseek": "unavailable",
173
- "devin": "unavailable", "gemini": "unavailable", "opencode": "unavailable",
173
+ "devin": "unavailable", "gemini": "unavailable", "muse-code": "unavailable", "opencode": "unavailable",
174
174
  "zcode": "cached"})
175
175
 
176
176
  def test_refresh_fails_without_a_key(self):
File without changes
File without changes