pocketshell 0.3.27__tar.gz → 0.3.28__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.
- {pocketshell-0.3.27 → pocketshell-0.3.28}/PKG-INFO +1 -1
- {pocketshell-0.3.27 → pocketshell-0.3.28}/pyproject.toml +1 -1
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/daemon.py +7 -8
- pocketshell-0.3.28/src/pocketshell/usage.py +502 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_usage.py +109 -3
- pocketshell-0.3.27/src/pocketshell/usage.py +0 -222
- {pocketshell-0.3.27 → pocketshell-0.3.28}/.gitignore +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/README.md +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/__init__.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_agent_log.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_cli.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_daemon.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_env.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_jobs.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_logs.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_repos.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_sessions.py +0 -0
- {pocketshell-0.3.27 → pocketshell-0.3.28}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.28
|
|
4
4
|
Summary: Unified server-side Python utility for the PocketShell Android client.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
|
|
6
6
|
Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
|
|
@@ -8,7 +8,7 @@ name = "pocketshell"
|
|
|
8
8
|
# scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
|
|
9
9
|
# runs that check before publishing to PyPI. See
|
|
10
10
|
# tools/pocketshell/README.md ("Release flow") for the bump procedure.
|
|
11
|
-
version = "0.3.
|
|
11
|
+
version = "0.3.28"
|
|
12
12
|
description = "Unified server-side Python utility for the PocketShell Android client."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.11"
|
|
@@ -350,13 +350,12 @@ def _usage_fetch_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
350
350
|
"provider": "codex" // or null
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
-
Returning
|
|
354
|
-
|
|
355
|
-
``
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
the wire format.
|
|
353
|
+
Returning stdout in the envelope preserves the CLI/daemon contract.
|
|
354
|
+
The stdout is normalized by :mod:`pocketshell.usage` before it is cached,
|
|
355
|
+
because ``quse --json`` can expose provider quirks that are not the
|
|
356
|
+
PocketShell app-facing schema. ``quse --json`` emits **NDJSON** (one
|
|
357
|
+
provider per line, not a single document), so normalization keeps that
|
|
358
|
+
line-oriented wire format.
|
|
360
359
|
"""
|
|
361
360
|
# Lazy import to avoid a circular module load at startup: the
|
|
362
361
|
# daemon module is imported from ``cli.py`` which also imports
|
|
@@ -397,7 +396,7 @@ def _usage_fetch_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
397
396
|
text=True,
|
|
398
397
|
)
|
|
399
398
|
return {
|
|
400
|
-
"stdout": completed.stdout,
|
|
399
|
+
"stdout": _usage.normalize_usage_stdout(completed.stdout),
|
|
401
400
|
"stderr": completed.stderr,
|
|
402
401
|
"returncode": completed.returncode,
|
|
403
402
|
"provider": provider,
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""`pocketshell usage` subcommand.
|
|
2
|
+
|
|
3
|
+
Implementation delegates to the existing `quse` CLI via `subprocess.run`
|
|
4
|
+
and normalizes JSON records into PocketShell's app-facing schema. Human
|
|
5
|
+
output is proxied verbatim.
|
|
6
|
+
|
|
7
|
+
Daemon mode (issue #219, first PR scope)
|
|
8
|
+
----------------------------------------
|
|
9
|
+
|
|
10
|
+
When the IPC daemon is running, ``pocketshell usage --json`` sends a
|
|
11
|
+
``usage.fetch`` JSON-RPC request to ``$XDG_RUNTIME_DIR/pocketshell/daemon.sock``
|
|
12
|
+
instead of forking ``quse`` itself. The daemon caches the result for
|
|
13
|
+
30 s, so a polled usage row in the Android app returns the second-and-
|
|
14
|
+
later calls in microseconds.
|
|
15
|
+
|
|
16
|
+
The fall-through is intentional and matches the spike's Q6 parity rule:
|
|
17
|
+
|
|
18
|
+
- ``--no-daemon`` forces the one-shot subprocess path even when a
|
|
19
|
+
daemon is up (debugging / belt-and-braces).
|
|
20
|
+
- ``--no-cache`` is honoured by the daemon (cache miss + populate)
|
|
21
|
+
but is intentionally a no-op on the subprocess path: there is no
|
|
22
|
+
cache to bypass when every call is a fresh subprocess. This keeps
|
|
23
|
+
the CLI surface uniform without paying for client-side caching that
|
|
24
|
+
duplicates the daemon's logic.
|
|
25
|
+
- The probe is best-effort: any error talking to the daemon
|
|
26
|
+
(``ECONNREFUSED``, stale socket, version-skew RPC error, slow
|
|
27
|
+
reply) falls through silently to the subprocess path. The daemon
|
|
28
|
+
never blocks correctness.
|
|
29
|
+
|
|
30
|
+
Why subprocess instead of `import quse`:
|
|
31
|
+
|
|
32
|
+
- `quse` is currently not published to PyPI; declaring it as a normal
|
|
33
|
+
`pyproject.toml` dependency would break `uv tool install
|
|
34
|
+
pocketshell` for any user (including the maintainer's dev box).
|
|
35
|
+
- Subprocess delegation keeps `pocketshell` decoupled from `quse`'s
|
|
36
|
+
internal module layout, so updates to `quse` don't break the wrapper.
|
|
37
|
+
- The PATH-discovery story for `quse` is solved by the Android bootstrap
|
|
38
|
+
wrapper, which derives PATH from the user's shell rc before probing
|
|
39
|
+
tools. Delegating to whatever `quse` is on PATH keeps this wrapper
|
|
40
|
+
decoupled from that bootstrap plumbing.
|
|
41
|
+
|
|
42
|
+
Later PRs will fold the provider-detection logic in directly so
|
|
43
|
+
`pocketshell` is the canonical implementation and the subprocess
|
|
44
|
+
hop disappears, but that is explicit non-scope here per the brief on
|
|
45
|
+
issue [#170](https://github.com/alexeygrigorev/pocketshell/issues/170).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import shutil
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
from datetime import datetime, timezone
|
|
54
|
+
import json
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
from typing import Any, Optional, Sequence
|
|
57
|
+
import urllib.error
|
|
58
|
+
import urllib.request
|
|
59
|
+
|
|
60
|
+
import click
|
|
61
|
+
|
|
62
|
+
_CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
|
|
63
|
+
_CODEX_AUTH_PATH = Path.home() / ".codex" / "auth.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_quse_binary() -> Optional[str]:
|
|
67
|
+
"""Locate the `quse` CLI on PATH, or return ``None`` if absent.
|
|
68
|
+
|
|
69
|
+
Pulled out as a function so the unit suite can monkeypatch it.
|
|
70
|
+
`shutil.which` returns the same path the user would see from
|
|
71
|
+
`command -v quse`, which is the probe the Android app already runs.
|
|
72
|
+
"""
|
|
73
|
+
return shutil.which("quse")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_quse(args: Sequence[str]) -> int:
|
|
77
|
+
"""Invoke `quse` with [args]; proxy stdout/stderr and exit code.
|
|
78
|
+
|
|
79
|
+
Using `subprocess.run(..., check=False)` and forwarding the captured
|
|
80
|
+
output rather than `os.execvp` keeps the call testable (the test
|
|
81
|
+
suite can monkeypatch `subprocess.run`) and lets us decorate the
|
|
82
|
+
failure mode with a friendly hint when `quse` is missing.
|
|
83
|
+
"""
|
|
84
|
+
quse_path = _resolve_quse_binary()
|
|
85
|
+
if quse_path is None:
|
|
86
|
+
# Same wording the bootstrap sheet uses so the user sees a
|
|
87
|
+
# consistent message whether they hit the bin via `pocketshell
|
|
88
|
+
# usage` or the app's poll loop.
|
|
89
|
+
click.echo(
|
|
90
|
+
"pocketshell: `quse` is not installed on this host. "
|
|
91
|
+
"Install it via `uv tool install quse` or `pipx install quse` "
|
|
92
|
+
"and re-run.",
|
|
93
|
+
err=True,
|
|
94
|
+
)
|
|
95
|
+
return 127
|
|
96
|
+
|
|
97
|
+
completed = subprocess.run(
|
|
98
|
+
[quse_path, *args],
|
|
99
|
+
check=False,
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
)
|
|
103
|
+
# Human-readable output stays byte-identical to `quse`.
|
|
104
|
+
if completed.stdout:
|
|
105
|
+
sys.stdout.write(completed.stdout)
|
|
106
|
+
if completed.stderr:
|
|
107
|
+
sys.stderr.write(completed.stderr)
|
|
108
|
+
return completed.returncode
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _normalize_reset_at(value: Any) -> Optional[str]:
|
|
112
|
+
"""Return an ISO-8601 UTC reset timestamp, accepting provider quirks.
|
|
113
|
+
|
|
114
|
+
OpenAI's Codex usage endpoint currently returns ``reset_at`` as Unix epoch
|
|
115
|
+
seconds. Older ``quse`` releases attempted ISO parsing only, which turned a
|
|
116
|
+
valid reset into ``null`` and left the app rendering ``resets —``.
|
|
117
|
+
"""
|
|
118
|
+
if value is None:
|
|
119
|
+
return None
|
|
120
|
+
parsed: datetime
|
|
121
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
122
|
+
try:
|
|
123
|
+
parsed = datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|
124
|
+
except (OverflowError, OSError, ValueError):
|
|
125
|
+
return None
|
|
126
|
+
else:
|
|
127
|
+
text = str(value).strip()
|
|
128
|
+
if not text:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
if text.isdigit():
|
|
132
|
+
parsed = datetime.fromtimestamp(float(text), tz=timezone.utc)
|
|
133
|
+
else:
|
|
134
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
135
|
+
except (OverflowError, OSError, ValueError):
|
|
136
|
+
try:
|
|
137
|
+
parsed = datetime.strptime(text, "%Y-%m-%d")
|
|
138
|
+
except ValueError:
|
|
139
|
+
return None
|
|
140
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
141
|
+
if parsed.tzinfo is None:
|
|
142
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
143
|
+
return parsed.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _percent_remaining_from_used(value: Any) -> Optional[float]:
|
|
147
|
+
try:
|
|
148
|
+
used = float(value)
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
return None
|
|
151
|
+
return round(max(0.0, min(100.0, 100.0 - used)), 2)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _window_from_detail(detail: Any) -> Optional[dict[str, Any]]:
|
|
155
|
+
if not isinstance(detail, dict):
|
|
156
|
+
return None
|
|
157
|
+
percent_remaining = _percent_remaining_from_used(detail.get("used_percent"))
|
|
158
|
+
reset_at = _normalize_reset_at(detail.get("reset_at"))
|
|
159
|
+
if percent_remaining is None and reset_at is None:
|
|
160
|
+
return None
|
|
161
|
+
return {
|
|
162
|
+
"percent_remaining": percent_remaining,
|
|
163
|
+
"reset_at": reset_at,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _merge_window(
|
|
168
|
+
current: Any,
|
|
169
|
+
detail: Any,
|
|
170
|
+
*,
|
|
171
|
+
prefer_detail_percent: bool = False,
|
|
172
|
+
) -> Any:
|
|
173
|
+
if not isinstance(current, dict):
|
|
174
|
+
if not isinstance(detail, dict):
|
|
175
|
+
return current
|
|
176
|
+
current = {"percent_remaining": None, "reset_at": None}
|
|
177
|
+
else:
|
|
178
|
+
current = dict(current)
|
|
179
|
+
|
|
180
|
+
detail_window = _window_from_detail(detail)
|
|
181
|
+
if detail_window is not None:
|
|
182
|
+
if prefer_detail_percent or current.get("percent_remaining") is None:
|
|
183
|
+
current["percent_remaining"] = detail_window.get("percent_remaining")
|
|
184
|
+
if current.get("reset_at") is None:
|
|
185
|
+
current["reset_at"] = detail_window.get("reset_at")
|
|
186
|
+
|
|
187
|
+
current["reset_at"] = _normalize_reset_at(current.get("reset_at"))
|
|
188
|
+
return current
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _actionable_error(provider: str, error: Any) -> Optional[str]:
|
|
192
|
+
if error is None:
|
|
193
|
+
return None
|
|
194
|
+
text = str(error).strip()
|
|
195
|
+
if not text:
|
|
196
|
+
return None
|
|
197
|
+
lower = text.lower()
|
|
198
|
+
if provider == "claude" and (
|
|
199
|
+
"http error 401" in lower
|
|
200
|
+
or "unauthorized" in lower
|
|
201
|
+
or lower in {"no-credentials", "no credentials"}
|
|
202
|
+
):
|
|
203
|
+
return (
|
|
204
|
+
"Claude Code authentication failed on this host. "
|
|
205
|
+
"Run `claude /login` in the host shell, then refresh usage."
|
|
206
|
+
)
|
|
207
|
+
if provider == "codex" and lower in {"no auth token", "no-auth-token", "no credentials"}:
|
|
208
|
+
return (
|
|
209
|
+
"Codex authentication is missing on this host. "
|
|
210
|
+
"Run `codex login` in the host shell, then refresh usage."
|
|
211
|
+
)
|
|
212
|
+
return text
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _read_codex_bearer_token(auth_path: Path = _CODEX_AUTH_PATH) -> Optional[str]:
|
|
216
|
+
try:
|
|
217
|
+
data = json.loads(auth_path.read_text(encoding="utf-8"))
|
|
218
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
219
|
+
return None
|
|
220
|
+
token = data.get("tokens", {}).get("access_token")
|
|
221
|
+
return token if isinstance(token, str) and token.strip() else None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _fetch_codex_detail_windows() -> Optional[dict[str, dict[str, Any]]]:
|
|
225
|
+
token = _read_codex_bearer_token()
|
|
226
|
+
if token is None:
|
|
227
|
+
return None
|
|
228
|
+
req = urllib.request.Request(
|
|
229
|
+
_CODEX_USAGE_URL,
|
|
230
|
+
headers={
|
|
231
|
+
"Authorization": f"Bearer {token}",
|
|
232
|
+
"Accept": "application/json",
|
|
233
|
+
},
|
|
234
|
+
method="GET",
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
with urllib.request.urlopen(req, timeout=10.0) as resp:
|
|
238
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
239
|
+
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError):
|
|
240
|
+
return None
|
|
241
|
+
rate_limit = payload.get("rate_limit")
|
|
242
|
+
if not isinstance(rate_limit, dict):
|
|
243
|
+
return None
|
|
244
|
+
windows: dict[str, dict[str, Any]] = {}
|
|
245
|
+
for name in ("primary_window", "secondary_window"):
|
|
246
|
+
value = rate_limit.get(name)
|
|
247
|
+
if isinstance(value, dict):
|
|
248
|
+
windows[name] = value
|
|
249
|
+
return windows or None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _codex_needs_source_patch(record: dict[str, Any], detail_windows: dict[str, Any]) -> bool:
|
|
253
|
+
has_window_shape = any(
|
|
254
|
+
isinstance(record.get(key), dict) for key in ("short_term", "long_term")
|
|
255
|
+
) or bool(detail_windows)
|
|
256
|
+
if not has_window_shape:
|
|
257
|
+
return False
|
|
258
|
+
for top_level_key, detail_key in (
|
|
259
|
+
("short_term", "primary_window"),
|
|
260
|
+
("long_term", "secondary_window"),
|
|
261
|
+
):
|
|
262
|
+
top_level = record.get(top_level_key)
|
|
263
|
+
detail = detail_windows.get(detail_key)
|
|
264
|
+
top_level_reset = top_level.get("reset_at") if isinstance(top_level, dict) else None
|
|
265
|
+
detail_reset = detail.get("reset_at") if isinstance(detail, dict) else None
|
|
266
|
+
if top_level_reset is None and detail_reset is None:
|
|
267
|
+
return True
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
|
|
272
|
+
"""Normalize a provider record emitted by ``quse --json``.
|
|
273
|
+
|
|
274
|
+
PocketShell owns the app-facing schema even when it delegates provider
|
|
275
|
+
probing to ``quse``. Keep this post-processing focused on schema repairs
|
|
276
|
+
that older ``quse`` releases cannot express correctly.
|
|
277
|
+
"""
|
|
278
|
+
normalized = dict(record)
|
|
279
|
+
provider = str(normalized.get("provider", "")).lower()
|
|
280
|
+
details = normalized.get("details")
|
|
281
|
+
detail_windows = details.get("windows") if isinstance(details, dict) else None
|
|
282
|
+
if not isinstance(detail_windows, dict):
|
|
283
|
+
detail_windows = {}
|
|
284
|
+
|
|
285
|
+
if provider == "codex":
|
|
286
|
+
# Codex's ChatGPT usage response exposes the real primary/secondary
|
|
287
|
+
# windows under details. Older quse versions hard-code short_term to
|
|
288
|
+
# 100% and lose epoch reset timestamps, which regressed issue #501.
|
|
289
|
+
if _codex_needs_source_patch(normalized, detail_windows):
|
|
290
|
+
fetched_windows = _fetch_codex_detail_windows()
|
|
291
|
+
if fetched_windows is not None:
|
|
292
|
+
detail_windows = {**detail_windows, **fetched_windows}
|
|
293
|
+
details_obj = dict(details) if isinstance(details, dict) else {}
|
|
294
|
+
details_obj["windows"] = detail_windows
|
|
295
|
+
normalized["details"] = details_obj
|
|
296
|
+
short_term = _merge_window(
|
|
297
|
+
normalized.get("short_term"),
|
|
298
|
+
detail_windows.get("primary_window"),
|
|
299
|
+
prefer_detail_percent=True,
|
|
300
|
+
)
|
|
301
|
+
if short_term is not None:
|
|
302
|
+
normalized["short_term"] = short_term
|
|
303
|
+
long_term = _merge_window(
|
|
304
|
+
normalized.get("long_term"),
|
|
305
|
+
detail_windows.get("secondary_window"),
|
|
306
|
+
prefer_detail_percent=True,
|
|
307
|
+
)
|
|
308
|
+
if long_term is not None:
|
|
309
|
+
normalized["long_term"] = long_term
|
|
310
|
+
elif provider == "claude":
|
|
311
|
+
short_term = _merge_window(
|
|
312
|
+
normalized.get("short_term"),
|
|
313
|
+
detail_windows.get("five_hour"),
|
|
314
|
+
)
|
|
315
|
+
if short_term is not None:
|
|
316
|
+
normalized["short_term"] = short_term
|
|
317
|
+
long_term = _merge_window(
|
|
318
|
+
normalized.get("long_term"),
|
|
319
|
+
detail_windows.get("seven_day"),
|
|
320
|
+
)
|
|
321
|
+
if long_term is not None:
|
|
322
|
+
normalized["long_term"] = long_term
|
|
323
|
+
else:
|
|
324
|
+
if isinstance(normalized.get("short_term"), dict):
|
|
325
|
+
normalized["short_term"] = _merge_window(normalized.get("short_term"), None)
|
|
326
|
+
if isinstance(normalized.get("long_term"), dict):
|
|
327
|
+
normalized["long_term"] = _merge_window(normalized.get("long_term"), None)
|
|
328
|
+
|
|
329
|
+
actionable = _actionable_error(provider, normalized.get("error"))
|
|
330
|
+
if actionable != normalized.get("error"):
|
|
331
|
+
normalized["error"] = actionable
|
|
332
|
+
return normalized
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def normalize_usage_stdout(stdout: str) -> str:
|
|
336
|
+
"""Normalize NDJSON stdout from ``quse --json`` for app consumption."""
|
|
337
|
+
if not stdout.strip():
|
|
338
|
+
return stdout
|
|
339
|
+
lines: list[str] = []
|
|
340
|
+
changed = False
|
|
341
|
+
for raw_line in stdout.splitlines():
|
|
342
|
+
if not raw_line.strip():
|
|
343
|
+
lines.append(raw_line)
|
|
344
|
+
continue
|
|
345
|
+
try:
|
|
346
|
+
parsed = json.loads(raw_line)
|
|
347
|
+
except json.JSONDecodeError:
|
|
348
|
+
return stdout
|
|
349
|
+
if not isinstance(parsed, dict):
|
|
350
|
+
return stdout
|
|
351
|
+
normalized = normalize_usage_record(parsed)
|
|
352
|
+
changed = changed or normalized != parsed
|
|
353
|
+
lines.append(json.dumps(normalized, sort_keys=True))
|
|
354
|
+
suffix = "\n" if stdout.endswith("\n") else ""
|
|
355
|
+
return "\n".join(lines) + suffix if changed else stdout
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _try_daemon_usage_fetch(
|
|
359
|
+
provider: Optional[str],
|
|
360
|
+
*,
|
|
361
|
+
no_cache: bool,
|
|
362
|
+
) -> Optional[dict[str, Any]]:
|
|
363
|
+
"""Probe the daemon and dispatch ``usage.fetch``; return None on miss.
|
|
364
|
+
|
|
365
|
+
Returns the JSON-RPC ``result`` envelope (a dict with
|
|
366
|
+
``stdout``/``stderr``/``returncode`` keys) on success, or ``None``
|
|
367
|
+
when the daemon is unreachable / errors out and the caller should
|
|
368
|
+
fall through to the one-shot subprocess path.
|
|
369
|
+
|
|
370
|
+
Lazy import keeps the cold-cache CLI start fast for users who
|
|
371
|
+
never hit the daemon path.
|
|
372
|
+
"""
|
|
373
|
+
from pocketshell import daemon as _daemon
|
|
374
|
+
|
|
375
|
+
socket_path = _daemon.resolve_socket_path()
|
|
376
|
+
if not socket_path.exists():
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
params: dict[str, Any] = {}
|
|
380
|
+
if provider is not None:
|
|
381
|
+
params["provider"] = provider
|
|
382
|
+
if no_cache:
|
|
383
|
+
params["no_cache"] = True
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
result = _daemon.call(
|
|
387
|
+
"usage.fetch",
|
|
388
|
+
params=params,
|
|
389
|
+
socket_path=socket_path,
|
|
390
|
+
timeout=5.0,
|
|
391
|
+
)
|
|
392
|
+
except (_daemon.DaemonClientError, RuntimeError, OSError):
|
|
393
|
+
# Daemon unreachable, returned an error, or socket dance went
|
|
394
|
+
# wrong. The contract is "daemon is an optimisation, never a
|
|
395
|
+
# dependency" — fall through silently.
|
|
396
|
+
return None
|
|
397
|
+
if not isinstance(result, dict):
|
|
398
|
+
return None
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@click.command(
|
|
403
|
+
context_settings={"help_option_names": ["-h", "--help"], "ignore_unknown_options": True},
|
|
404
|
+
)
|
|
405
|
+
@click.argument("provider", required=False)
|
|
406
|
+
@click.option(
|
|
407
|
+
"--json",
|
|
408
|
+
"json_output",
|
|
409
|
+
is_flag=True,
|
|
410
|
+
help="Emit machine-readable JSON output identical to `quse --json`.",
|
|
411
|
+
)
|
|
412
|
+
@click.option(
|
|
413
|
+
"--no-daemon",
|
|
414
|
+
"no_daemon",
|
|
415
|
+
is_flag=True,
|
|
416
|
+
help=(
|
|
417
|
+
"Skip the IPC daemon and run `quse` as a one-shot subprocess "
|
|
418
|
+
"even if a daemon is available. Useful for debugging."
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
@click.option(
|
|
422
|
+
"--no-cache",
|
|
423
|
+
"no_cache",
|
|
424
|
+
is_flag=True,
|
|
425
|
+
help=(
|
|
426
|
+
"Bypass the daemon's per-method cache (30 s for `usage.fetch`). "
|
|
427
|
+
"No effect on the one-shot subprocess path, which always runs fresh."
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
@click.pass_context
|
|
431
|
+
def usage_command(
|
|
432
|
+
ctx: click.Context,
|
|
433
|
+
provider: Optional[str] = None,
|
|
434
|
+
json_output: bool = False,
|
|
435
|
+
no_daemon: bool = False,
|
|
436
|
+
no_cache: bool = False,
|
|
437
|
+
) -> None:
|
|
438
|
+
"""Report quota / usage for coding-agent providers on this host.
|
|
439
|
+
|
|
440
|
+
By default the command probes the IPC daemon's Unix socket
|
|
441
|
+
(``$XDG_RUNTIME_DIR/pocketshell/daemon.sock``) and dispatches a
|
|
442
|
+
``usage.fetch`` JSON-RPC call when the daemon is live. Otherwise
|
|
443
|
+
it falls through to ``quse [provider] [--json]`` as a one-shot
|
|
444
|
+
subprocess. JSON output is normalized into PocketShell's schema so
|
|
445
|
+
the app is not pinned to provider-specific `quse` quirks.
|
|
446
|
+
"""
|
|
447
|
+
# JSON output is the format the daemon caches against (NDJSON
|
|
448
|
+
# straight from `quse --json`). Human-readable output is rare and
|
|
449
|
+
# not on the Android hot path, so it does not get the daemon
|
|
450
|
+
# speed-up — falling through to subprocess is simpler than
|
|
451
|
+
# teaching the daemon to render two formats.
|
|
452
|
+
if json_output and not no_daemon:
|
|
453
|
+
envelope = _try_daemon_usage_fetch(provider, no_cache=no_cache)
|
|
454
|
+
if envelope is not None:
|
|
455
|
+
if envelope.get("stdout"):
|
|
456
|
+
sys.stdout.write(normalize_usage_stdout(str(envelope["stdout"])))
|
|
457
|
+
if envelope.get("stderr"):
|
|
458
|
+
sys.stderr.write(envelope["stderr"])
|
|
459
|
+
exit_code = int(envelope.get("returncode", 0))
|
|
460
|
+
if exit_code != 0:
|
|
461
|
+
ctx.exit(exit_code)
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
args: list[str] = []
|
|
465
|
+
if provider:
|
|
466
|
+
args.append(provider)
|
|
467
|
+
if json_output:
|
|
468
|
+
args.append("--json")
|
|
469
|
+
if json_output:
|
|
470
|
+
exit_code = _run_quse_json(args)
|
|
471
|
+
else:
|
|
472
|
+
exit_code = _run_quse(args)
|
|
473
|
+
# Click ignores the return value of a callback by default; we need to
|
|
474
|
+
# explicitly propagate non-zero exit codes through `ctx.exit` so the
|
|
475
|
+
# outer `main()` (and the OS) sees the same exit code `quse` reported.
|
|
476
|
+
if exit_code != 0:
|
|
477
|
+
ctx.exit(exit_code)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _run_quse_json(args: Sequence[str]) -> int:
|
|
481
|
+
"""Invoke ``quse`` and normalize JSON stdout before proxying it."""
|
|
482
|
+
quse_path = _resolve_quse_binary()
|
|
483
|
+
if quse_path is None:
|
|
484
|
+
click.echo(
|
|
485
|
+
"pocketshell: `quse` is not installed on this host. "
|
|
486
|
+
"Install it via `uv tool install quse` or `pipx install quse` "
|
|
487
|
+
"and re-run.",
|
|
488
|
+
err=True,
|
|
489
|
+
)
|
|
490
|
+
return 127
|
|
491
|
+
|
|
492
|
+
completed = subprocess.run(
|
|
493
|
+
[quse_path, *args],
|
|
494
|
+
check=False,
|
|
495
|
+
capture_output=True,
|
|
496
|
+
text=True,
|
|
497
|
+
)
|
|
498
|
+
if completed.stdout:
|
|
499
|
+
sys.stdout.write(normalize_usage_stdout(completed.stdout))
|
|
500
|
+
if completed.stderr:
|
|
501
|
+
sys.stderr.write(completed.stderr)
|
|
502
|
+
return completed.returncode
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
The first PR exercises:
|
|
4
4
|
- `pocketshell --help` lists the `usage` subcommand.
|
|
5
5
|
- `pocketshell usage --help` shows the click usage line.
|
|
6
|
-
- `pocketshell usage --json` forwards through to `quse --json
|
|
6
|
+
- `pocketshell usage --json` forwards through to `quse --json` and
|
|
7
|
+
normalizes provider quirks into PocketShell's schema.
|
|
7
8
|
- `pocketshell usage <provider>` forwards the positional arg.
|
|
8
9
|
- Missing `quse` produces a friendly stderr message + exit 127.
|
|
9
10
|
- stdout/stderr/exit-code from the subprocess are proxied verbatim.
|
|
@@ -11,7 +12,8 @@ The first PR exercises:
|
|
|
11
12
|
The tests stub `pocketshell.usage._resolve_quse_binary` and
|
|
12
13
|
`subprocess.run` so they never invoke a real `quse` binary; the contract
|
|
13
14
|
under test is "pocketshell delegates correctly to whatever quse exists
|
|
14
|
-
on the host", not "the provider
|
|
15
|
+
on the host and repairs known schema mismatches", not "the provider
|
|
16
|
+
check works".
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
@@ -86,7 +88,7 @@ def test_usage_json_forwards_to_quse_and_proxies_stdout() -> None:
|
|
|
86
88
|
) as run:
|
|
87
89
|
result = runner.invoke(usage_command, ["--json"])
|
|
88
90
|
assert result.exit_code == 0, result.output
|
|
89
|
-
#
|
|
91
|
+
# Non-provider-shaped JSON is left untouched.
|
|
90
92
|
assert result.output == payload
|
|
91
93
|
# Args forwarded to the quse subprocess must include `--json`.
|
|
92
94
|
call_args = run.call_args
|
|
@@ -94,6 +96,110 @@ def test_usage_json_forwards_to_quse_and_proxies_stdout() -> None:
|
|
|
94
96
|
assert invoked == ["/fake/quse", "--json"]
|
|
95
97
|
|
|
96
98
|
|
|
99
|
+
def test_usage_json_normalizes_codex_detail_windows_and_epoch_resets() -> None:
|
|
100
|
+
raw = "\n".join(
|
|
101
|
+
[
|
|
102
|
+
json.dumps(
|
|
103
|
+
{
|
|
104
|
+
"provider": "codex",
|
|
105
|
+
"status": "ok",
|
|
106
|
+
"short_term": {"percent_remaining": 100.0, "reset_at": None},
|
|
107
|
+
"long_term": {"percent_remaining": 69.0, "reset_at": None},
|
|
108
|
+
"block_reason": None,
|
|
109
|
+
"error": None,
|
|
110
|
+
"details": {
|
|
111
|
+
"limit_reached": False,
|
|
112
|
+
"windows": {
|
|
113
|
+
"primary_window": {
|
|
114
|
+
"used_percent": 12,
|
|
115
|
+
"reset_at": 1780828285,
|
|
116
|
+
},
|
|
117
|
+
"secondary_window": {
|
|
118
|
+
"used_percent": 31,
|
|
119
|
+
"reset_at": 1781137638,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
),
|
|
125
|
+
json.dumps(
|
|
126
|
+
{
|
|
127
|
+
"provider": "claude",
|
|
128
|
+
"status": "error",
|
|
129
|
+
"short_term": {"percent_remaining": None, "reset_at": None},
|
|
130
|
+
"long_term": {"percent_remaining": None, "reset_at": None},
|
|
131
|
+
"block_reason": None,
|
|
132
|
+
"error": "HTTP Error 401: Unauthorized",
|
|
133
|
+
"details": {},
|
|
134
|
+
},
|
|
135
|
+
),
|
|
136
|
+
],
|
|
137
|
+
)
|
|
138
|
+
runner = CliRunner()
|
|
139
|
+
with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
|
|
140
|
+
"pocketshell.usage.subprocess.run",
|
|
141
|
+
return_value=_fake_completed(stdout=raw + "\n"),
|
|
142
|
+
):
|
|
143
|
+
result = runner.invoke(usage_command, ["--json"])
|
|
144
|
+
|
|
145
|
+
assert result.exit_code == 0, result.output
|
|
146
|
+
lines = [json.loads(line) for line in result.output.splitlines()]
|
|
147
|
+
codex = lines[0]
|
|
148
|
+
assert codex["short_term"] == {
|
|
149
|
+
"percent_remaining": 88.0,
|
|
150
|
+
"reset_at": "2026-06-07T10:31:25Z",
|
|
151
|
+
}
|
|
152
|
+
assert codex["long_term"] == {
|
|
153
|
+
"percent_remaining": 69.0,
|
|
154
|
+
"reset_at": "2026-06-11T00:27:18Z",
|
|
155
|
+
}
|
|
156
|
+
assert "claude /login" in lines[1]["error"]
|
|
157
|
+
assert "HTTP Error 401" not in lines[1]["error"]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() -> None:
|
|
161
|
+
raw = json.dumps(
|
|
162
|
+
{
|
|
163
|
+
"provider": "codex",
|
|
164
|
+
"status": "ok",
|
|
165
|
+
"short_term": {"percent_remaining": 100.0, "reset_at": None},
|
|
166
|
+
"long_term": {"percent_remaining": 69.0, "reset_at": None},
|
|
167
|
+
"block_reason": None,
|
|
168
|
+
"error": None,
|
|
169
|
+
"details": {
|
|
170
|
+
"limit_reached": False,
|
|
171
|
+
"windows": {
|
|
172
|
+
"primary_window": {"used_percent": 13, "reset_at": None},
|
|
173
|
+
"secondary_window": {"used_percent": 31, "reset_at": None},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
runner = CliRunner()
|
|
179
|
+
with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
|
|
180
|
+
"pocketshell.usage.subprocess.run",
|
|
181
|
+
return_value=_fake_completed(stdout=raw + "\n"),
|
|
182
|
+
), patch(
|
|
183
|
+
"pocketshell.usage._fetch_codex_detail_windows",
|
|
184
|
+
return_value={
|
|
185
|
+
"primary_window": {"used_percent": 13, "reset_at": 1780828285},
|
|
186
|
+
"secondary_window": {"used_percent": 31, "reset_at": 1781137638},
|
|
187
|
+
},
|
|
188
|
+
):
|
|
189
|
+
result = runner.invoke(usage_command, ["--json"])
|
|
190
|
+
|
|
191
|
+
assert result.exit_code == 0, result.output
|
|
192
|
+
codex = json.loads(result.output)
|
|
193
|
+
assert codex["short_term"] == {
|
|
194
|
+
"percent_remaining": 87.0,
|
|
195
|
+
"reset_at": "2026-06-07T10:31:25Z",
|
|
196
|
+
}
|
|
197
|
+
assert codex["long_term"] == {
|
|
198
|
+
"percent_remaining": 69.0,
|
|
199
|
+
"reset_at": "2026-06-11T00:27:18Z",
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
97
203
|
def test_usage_forwards_provider_argument() -> None:
|
|
98
204
|
runner = CliRunner()
|
|
99
205
|
with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
"""`pocketshell usage` subcommand.
|
|
2
|
-
|
|
3
|
-
First-PR implementation: delegate to the existing `quse` CLI via
|
|
4
|
-
`subprocess.run`. The arguments and stdout are proxied through verbatim
|
|
5
|
-
so the JSON payload (and human-readable lines) are byte-identical to
|
|
6
|
-
`quse [provider] [--json]`. The existing Kotlin `QuseUsageJsonParser`
|
|
7
|
-
keeps working when the Android app eventually switches its probe over.
|
|
8
|
-
|
|
9
|
-
Daemon mode (issue #219, first PR scope)
|
|
10
|
-
----------------------------------------
|
|
11
|
-
|
|
12
|
-
When the IPC daemon is running, ``pocketshell usage --json`` sends a
|
|
13
|
-
``usage.fetch`` JSON-RPC request to ``$XDG_RUNTIME_DIR/pocketshell/daemon.sock``
|
|
14
|
-
instead of forking ``quse`` itself. The daemon caches the result for
|
|
15
|
-
30 s, so a polled usage row in the Android app returns the second-and-
|
|
16
|
-
later calls in microseconds.
|
|
17
|
-
|
|
18
|
-
The fall-through is intentional and matches the spike's Q6 parity rule:
|
|
19
|
-
|
|
20
|
-
- ``--no-daemon`` forces the one-shot subprocess path even when a
|
|
21
|
-
daemon is up (debugging / belt-and-braces).
|
|
22
|
-
- ``--no-cache`` is honoured by the daemon (cache miss + populate)
|
|
23
|
-
but is intentionally a no-op on the subprocess path: there is no
|
|
24
|
-
cache to bypass when every call is a fresh subprocess. This keeps
|
|
25
|
-
the CLI surface uniform without paying for client-side caching that
|
|
26
|
-
duplicates the daemon's logic.
|
|
27
|
-
- The probe is best-effort: any error talking to the daemon
|
|
28
|
-
(``ECONNREFUSED``, stale socket, version-skew RPC error, slow
|
|
29
|
-
reply) falls through silently to the subprocess path. The daemon
|
|
30
|
-
never blocks correctness.
|
|
31
|
-
|
|
32
|
-
Why subprocess instead of `import quse`:
|
|
33
|
-
|
|
34
|
-
- `quse` is currently not published to PyPI; declaring it as a normal
|
|
35
|
-
`pyproject.toml` dependency would break `uv tool install
|
|
36
|
-
pocketshell` for any user (including the maintainer's dev box).
|
|
37
|
-
- Subprocess delegation keeps `pocketshell` decoupled from `quse`'s
|
|
38
|
-
internal module layout, so updates to `quse` don't break the wrapper.
|
|
39
|
-
- The PATH-discovery story for `quse` is solved by the Android bootstrap
|
|
40
|
-
wrapper, which derives PATH from the user's shell rc before probing
|
|
41
|
-
tools. Delegating to whatever `quse` is on PATH keeps this wrapper
|
|
42
|
-
decoupled from that bootstrap plumbing.
|
|
43
|
-
|
|
44
|
-
Later PRs will fold the provider-detection logic in directly so
|
|
45
|
-
`pocketshell` is the canonical implementation and the subprocess
|
|
46
|
-
hop disappears, but that is explicit non-scope here per the brief on
|
|
47
|
-
issue [#170](https://github.com/alexeygrigorev/pocketshell/issues/170).
|
|
48
|
-
"""
|
|
49
|
-
|
|
50
|
-
from __future__ import annotations
|
|
51
|
-
|
|
52
|
-
import shutil
|
|
53
|
-
import subprocess
|
|
54
|
-
import sys
|
|
55
|
-
from typing import Any, Optional, Sequence
|
|
56
|
-
|
|
57
|
-
import click
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _resolve_quse_binary() -> Optional[str]:
|
|
61
|
-
"""Locate the `quse` CLI on PATH, or return ``None`` if absent.
|
|
62
|
-
|
|
63
|
-
Pulled out as a function so the unit suite can monkeypatch it.
|
|
64
|
-
`shutil.which` returns the same path the user would see from
|
|
65
|
-
`command -v quse`, which is the probe the Android app already runs.
|
|
66
|
-
"""
|
|
67
|
-
return shutil.which("quse")
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _run_quse(args: Sequence[str]) -> int:
|
|
71
|
-
"""Invoke `quse` with [args]; proxy stdout/stderr and exit code.
|
|
72
|
-
|
|
73
|
-
Using `subprocess.run(..., check=False)` and forwarding the captured
|
|
74
|
-
output rather than `os.execvp` keeps the call testable (the test
|
|
75
|
-
suite can monkeypatch `subprocess.run`) and lets us decorate the
|
|
76
|
-
failure mode with a friendly hint when `quse` is missing.
|
|
77
|
-
"""
|
|
78
|
-
quse_path = _resolve_quse_binary()
|
|
79
|
-
if quse_path is None:
|
|
80
|
-
# Same wording the bootstrap sheet uses so the user sees a
|
|
81
|
-
# consistent message whether they hit the bin via `pocketshell
|
|
82
|
-
# usage` or the app's poll loop.
|
|
83
|
-
click.echo(
|
|
84
|
-
"pocketshell: `quse` is not installed on this host. "
|
|
85
|
-
"Install it via `uv tool install quse` or `pipx install quse` "
|
|
86
|
-
"and re-run.",
|
|
87
|
-
err=True,
|
|
88
|
-
)
|
|
89
|
-
return 127
|
|
90
|
-
|
|
91
|
-
completed = subprocess.run(
|
|
92
|
-
[quse_path, *args],
|
|
93
|
-
check=False,
|
|
94
|
-
capture_output=True,
|
|
95
|
-
text=True,
|
|
96
|
-
)
|
|
97
|
-
# Echo verbatim so the JSON output is byte-identical to `quse --json`.
|
|
98
|
-
if completed.stdout:
|
|
99
|
-
sys.stdout.write(completed.stdout)
|
|
100
|
-
if completed.stderr:
|
|
101
|
-
sys.stderr.write(completed.stderr)
|
|
102
|
-
return completed.returncode
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _try_daemon_usage_fetch(
|
|
106
|
-
provider: Optional[str],
|
|
107
|
-
*,
|
|
108
|
-
no_cache: bool,
|
|
109
|
-
) -> Optional[dict[str, Any]]:
|
|
110
|
-
"""Probe the daemon and dispatch ``usage.fetch``; return None on miss.
|
|
111
|
-
|
|
112
|
-
Returns the JSON-RPC ``result`` envelope (a dict with
|
|
113
|
-
``stdout``/``stderr``/``returncode`` keys) on success, or ``None``
|
|
114
|
-
when the daemon is unreachable / errors out and the caller should
|
|
115
|
-
fall through to the one-shot subprocess path.
|
|
116
|
-
|
|
117
|
-
Lazy import keeps the cold-cache CLI start fast for users who
|
|
118
|
-
never hit the daemon path.
|
|
119
|
-
"""
|
|
120
|
-
from pocketshell import daemon as _daemon
|
|
121
|
-
|
|
122
|
-
socket_path = _daemon.resolve_socket_path()
|
|
123
|
-
if not socket_path.exists():
|
|
124
|
-
return None
|
|
125
|
-
|
|
126
|
-
params: dict[str, Any] = {}
|
|
127
|
-
if provider is not None:
|
|
128
|
-
params["provider"] = provider
|
|
129
|
-
if no_cache:
|
|
130
|
-
params["no_cache"] = True
|
|
131
|
-
|
|
132
|
-
try:
|
|
133
|
-
result = _daemon.call(
|
|
134
|
-
"usage.fetch",
|
|
135
|
-
params=params,
|
|
136
|
-
socket_path=socket_path,
|
|
137
|
-
timeout=5.0,
|
|
138
|
-
)
|
|
139
|
-
except (_daemon.DaemonClientError, RuntimeError, OSError):
|
|
140
|
-
# Daemon unreachable, returned an error, or socket dance went
|
|
141
|
-
# wrong. The contract is "daemon is an optimisation, never a
|
|
142
|
-
# dependency" — fall through silently.
|
|
143
|
-
return None
|
|
144
|
-
if not isinstance(result, dict):
|
|
145
|
-
return None
|
|
146
|
-
return result
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
@click.command(
|
|
150
|
-
context_settings={"help_option_names": ["-h", "--help"], "ignore_unknown_options": True},
|
|
151
|
-
)
|
|
152
|
-
@click.argument("provider", required=False)
|
|
153
|
-
@click.option(
|
|
154
|
-
"--json",
|
|
155
|
-
"json_output",
|
|
156
|
-
is_flag=True,
|
|
157
|
-
help="Emit machine-readable JSON output identical to `quse --json`.",
|
|
158
|
-
)
|
|
159
|
-
@click.option(
|
|
160
|
-
"--no-daemon",
|
|
161
|
-
"no_daemon",
|
|
162
|
-
is_flag=True,
|
|
163
|
-
help=(
|
|
164
|
-
"Skip the IPC daemon and run `quse` as a one-shot subprocess "
|
|
165
|
-
"even if a daemon is available. Useful for debugging."
|
|
166
|
-
),
|
|
167
|
-
)
|
|
168
|
-
@click.option(
|
|
169
|
-
"--no-cache",
|
|
170
|
-
"no_cache",
|
|
171
|
-
is_flag=True,
|
|
172
|
-
help=(
|
|
173
|
-
"Bypass the daemon's per-method cache (30 s for `usage.fetch`). "
|
|
174
|
-
"No effect on the one-shot subprocess path, which always runs fresh."
|
|
175
|
-
),
|
|
176
|
-
)
|
|
177
|
-
@click.pass_context
|
|
178
|
-
def usage_command(
|
|
179
|
-
ctx: click.Context,
|
|
180
|
-
provider: Optional[str] = None,
|
|
181
|
-
json_output: bool = False,
|
|
182
|
-
no_daemon: bool = False,
|
|
183
|
-
no_cache: bool = False,
|
|
184
|
-
) -> None:
|
|
185
|
-
"""Report quota / usage for coding-agent providers on this host.
|
|
186
|
-
|
|
187
|
-
By default the command probes the IPC daemon's Unix socket
|
|
188
|
-
(``$XDG_RUNTIME_DIR/pocketshell/daemon.sock``) and dispatches a
|
|
189
|
-
``usage.fetch`` JSON-RPC call when the daemon is live. Otherwise
|
|
190
|
-
it falls through to ``quse [provider] [--json]`` as a one-shot
|
|
191
|
-
subprocess. Output shape (both human and JSON) is byte-identical
|
|
192
|
-
to `quse [provider] [--json]` so any consumer that already parses
|
|
193
|
-
`quse` output keeps working.
|
|
194
|
-
"""
|
|
195
|
-
# JSON output is the format the daemon caches against (NDJSON
|
|
196
|
-
# straight from `quse --json`). Human-readable output is rare and
|
|
197
|
-
# not on the Android hot path, so it does not get the daemon
|
|
198
|
-
# speed-up — falling through to subprocess is simpler than
|
|
199
|
-
# teaching the daemon to render two formats.
|
|
200
|
-
if json_output and not no_daemon:
|
|
201
|
-
envelope = _try_daemon_usage_fetch(provider, no_cache=no_cache)
|
|
202
|
-
if envelope is not None:
|
|
203
|
-
if envelope.get("stdout"):
|
|
204
|
-
sys.stdout.write(envelope["stdout"])
|
|
205
|
-
if envelope.get("stderr"):
|
|
206
|
-
sys.stderr.write(envelope["stderr"])
|
|
207
|
-
exit_code = int(envelope.get("returncode", 0))
|
|
208
|
-
if exit_code != 0:
|
|
209
|
-
ctx.exit(exit_code)
|
|
210
|
-
return
|
|
211
|
-
|
|
212
|
-
args: list[str] = []
|
|
213
|
-
if provider:
|
|
214
|
-
args.append(provider)
|
|
215
|
-
if json_output:
|
|
216
|
-
args.append("--json")
|
|
217
|
-
exit_code = _run_quse(args)
|
|
218
|
-
# Click ignores the return value of a callback by default; we need to
|
|
219
|
-
# explicitly propagate non-zero exit codes through `ctx.exit` so the
|
|
220
|
-
# outer `main()` (and the OS) sees the same exit code `quse` reported.
|
|
221
|
-
if exit_code != 0:
|
|
222
|
-
ctx.exit(exit_code)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|