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.
Files changed (31) hide show
  1. {pocketshell-0.3.27 → pocketshell-0.3.28}/PKG-INFO +1 -1
  2. {pocketshell-0.3.27 → pocketshell-0.3.28}/pyproject.toml +1 -1
  3. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/daemon.py +7 -8
  4. pocketshell-0.3.28/src/pocketshell/usage.py +502 -0
  5. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_usage.py +109 -3
  6. pocketshell-0.3.27/src/pocketshell/usage.py +0 -222
  7. {pocketshell-0.3.27 → pocketshell-0.3.28}/.gitignore +0 -0
  8. {pocketshell-0.3.27 → pocketshell-0.3.28}/README.md +0 -0
  9. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/__init__.py +0 -0
  10. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/__main__.py +0 -0
  11. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/agent_log.py +0 -0
  12. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/cli.py +0 -0
  13. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/env.py +0 -0
  14. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/hooks.py +0 -0
  15. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/jobs.py +0 -0
  16. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/logs.py +0 -0
  17. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/qr_share.py +0 -0
  18. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/repos.py +0 -0
  19. {pocketshell-0.3.27 → pocketshell-0.3.28}/src/pocketshell/sessions.py +0 -0
  20. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/__init__.py +0 -0
  21. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_agent_log.py +0 -0
  22. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_cli.py +0 -0
  23. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_daemon.py +0 -0
  24. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_env.py +0 -0
  25. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_hooks.py +0 -0
  26. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_jobs.py +0 -0
  27. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_logs.py +0 -0
  28. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_qr_share.py +0 -0
  29. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_repos.py +0 -0
  30. {pocketshell-0.3.27 → pocketshell-0.3.28}/tests/test_sessions.py +0 -0
  31. {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.27
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.27"
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 raw stdout (rather than parsed JSON) preserves byte-for-
354
- byte parity with ``quse --json``; the existing Kotlin
355
- ``QuseUsageJsonParser`` keeps working without modification when the
356
- CLI re-emits the daemon's response on stdout. ``quse --json``
357
- actually emits **NDJSON** (one provider per line, not a single
358
- document), so we cannot parse-then-re-serialise without changing
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 check works".
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
- # The stdout we got back must be the exact bytes `quse --json` emitted.
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