pocketshell 0.3.29__tar.gz → 0.3.31__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 (32) hide show
  1. {pocketshell-0.3.29 → pocketshell-0.3.31}/PKG-INFO +32 -1
  2. {pocketshell-0.3.29 → pocketshell-0.3.31}/README.md +31 -0
  3. {pocketshell-0.3.29 → pocketshell-0.3.31}/pyproject.toml +1 -1
  4. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/cli.py +4 -2
  5. pocketshell-0.3.31/src/pocketshell/github.py +174 -0
  6. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/usage.py +126 -17
  7. pocketshell-0.3.31/tests/test_github.py +270 -0
  8. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_usage.py +148 -4
  9. {pocketshell-0.3.29 → pocketshell-0.3.31}/uv.lock +2 -2
  10. {pocketshell-0.3.29 → pocketshell-0.3.31}/.gitignore +0 -0
  11. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/__init__.py +0 -0
  12. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/__main__.py +0 -0
  13. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/agent_log.py +0 -0
  14. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/daemon.py +0 -0
  15. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/env.py +0 -0
  16. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/hooks.py +0 -0
  17. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/jobs.py +0 -0
  18. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/logs.py +0 -0
  19. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/qr_share.py +0 -0
  20. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/repos.py +0 -0
  21. {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/sessions.py +0 -0
  22. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/__init__.py +0 -0
  23. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_agent_log.py +0 -0
  24. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_cli.py +0 -0
  25. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_daemon.py +0 -0
  26. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_env.py +0 -0
  27. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_hooks.py +0 -0
  28. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_jobs.py +0 -0
  29. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_logs.py +0 -0
  30. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_qr_share.py +0 -0
  31. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_repos.py +0 -0
  32. {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_sessions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.3.29
3
+ Version: 0.3.31
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
@@ -81,6 +81,7 @@ pocketshell sessions list [--by activity] # tmux session summaries
81
81
  pocketshell jobs ... # tmux recurring jobs
82
82
  pocketshell agent-log ... # agent conversation logs
83
83
  pocketshell repos list ... # local / GitHub repositories
84
+ pocketshell github status [--json] # gh install / auth state
84
85
  pocketshell env ... # .env / .envrc management
85
86
  pocketshell hooks ... # Claude/Codex/OpenCode hooks
86
87
  pocketshell logs ... # server-side trace sink
@@ -164,6 +165,36 @@ Daemon mode caches `repos.list_local` for 10 s and `repos.list_remote`
164
165
  for 5 min. `--no-daemon` forces the in-process path; `--no-cache`
165
166
  forces the daemon to re-run upstream on the next call.
166
167
 
168
+ ### `pocketshell github status`
169
+
170
+ Reports whether the GitHub CLI (`gh`) is installed and authenticated, as
171
+ structured JSON the app consumes to gate GitHub features and prompt the
172
+ user to configure `gh` when it is missing (epic #644, slice #645).
173
+
174
+ ```bash
175
+ pocketshell github status # human-readable summary
176
+ pocketshell github status --json # machine-readable JSON (consumed by the app)
177
+ ```
178
+
179
+ Schema:
180
+
181
+ ```json
182
+ {
183
+ "installed": true, // shutil.which("gh") found the binary
184
+ "authenticated": true, // `gh auth status` exited 0
185
+ "account": "alexeygrigorev", // logged-in username, or null
186
+ "hint": null // actionable hint when something is missing
187
+ }
188
+ ```
189
+
190
+ The command always exits 0 — "gh missing" and "not authenticated" are
191
+ normal, reportable states (not probe failures), so the app can poll the
192
+ status without treating it as an error. When `gh` is absent the `hint` tells
193
+ the user to install it and run `gh auth login`; when present but
194
+ unauthenticated the `hint` tells them to run `gh auth login`. The only
195
+ network access is whatever `gh auth status` itself performs (a token-validity
196
+ check); the command does NOT call the GitHub API.
197
+
167
198
  ### `pocketshell qr-share`
168
199
 
169
200
  Builds a `pocketshell.ssh-import.v1` payload from an `~/.ssh/config`
@@ -53,6 +53,7 @@ pocketshell sessions list [--by activity] # tmux session summaries
53
53
  pocketshell jobs ... # tmux recurring jobs
54
54
  pocketshell agent-log ... # agent conversation logs
55
55
  pocketshell repos list ... # local / GitHub repositories
56
+ pocketshell github status [--json] # gh install / auth state
56
57
  pocketshell env ... # .env / .envrc management
57
58
  pocketshell hooks ... # Claude/Codex/OpenCode hooks
58
59
  pocketshell logs ... # server-side trace sink
@@ -136,6 +137,36 @@ Daemon mode caches `repos.list_local` for 10 s and `repos.list_remote`
136
137
  for 5 min. `--no-daemon` forces the in-process path; `--no-cache`
137
138
  forces the daemon to re-run upstream on the next call.
138
139
 
140
+ ### `pocketshell github status`
141
+
142
+ Reports whether the GitHub CLI (`gh`) is installed and authenticated, as
143
+ structured JSON the app consumes to gate GitHub features and prompt the
144
+ user to configure `gh` when it is missing (epic #644, slice #645).
145
+
146
+ ```bash
147
+ pocketshell github status # human-readable summary
148
+ pocketshell github status --json # machine-readable JSON (consumed by the app)
149
+ ```
150
+
151
+ Schema:
152
+
153
+ ```json
154
+ {
155
+ "installed": true, // shutil.which("gh") found the binary
156
+ "authenticated": true, // `gh auth status` exited 0
157
+ "account": "alexeygrigorev", // logged-in username, or null
158
+ "hint": null // actionable hint when something is missing
159
+ }
160
+ ```
161
+
162
+ The command always exits 0 — "gh missing" and "not authenticated" are
163
+ normal, reportable states (not probe failures), so the app can poll the
164
+ status without treating it as an error. When `gh` is absent the `hint` tells
165
+ the user to install it and run `gh auth login`; when present but
166
+ unauthenticated the `hint` tells them to run `gh auth login`. The only
167
+ network access is whatever `gh auth status` itself performs (a token-validity
168
+ check); the command does NOT call the GitHub API.
169
+
139
170
  ### `pocketshell qr-share`
140
171
 
141
172
  Builds a `pocketshell.ssh-import.v1` payload from an `~/.ssh/config`
@@ -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.29"
11
+ version = "0.3.31"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -24,6 +24,7 @@ import click
24
24
  from pocketshell import __version__
25
25
  from pocketshell.agent_log import agent_log_command
26
26
  from pocketshell.env import env_group
27
+ from pocketshell.github import github_group
27
28
  from pocketshell.hooks import hooks_group
28
29
  from pocketshell.jobs import jobs_group
29
30
  from pocketshell.logs import logs_group
@@ -39,8 +40,8 @@ from pocketshell.usage import usage_command
39
40
  "Unified server-side helper for the PocketShell Android client.\n\n"
40
41
  "Subcommands replace the separately-installed `quse`, `tmuxctl`, "
41
42
  "and `qr-share` CLIs. Today `usage`, `jobs`, `sessions`, "
42
- "`agent-log`, `repos`, `daemon`, and `qr-share` are wired up; "
43
- "more subcommands will land in follow-up rounds."
43
+ "`agent-log`, `repos`, `github`, `daemon`, and `qr-share` are wired "
44
+ "up; more subcommands will land in follow-up rounds."
44
45
  ),
45
46
  )
46
47
  @click.version_option(__version__, "-V", "--version", prog_name="pocketshell")
@@ -53,6 +54,7 @@ cli.add_command(jobs_group, name="jobs")
53
54
  cli.add_command(sessions_group, name="sessions")
54
55
  cli.add_command(agent_log_command, name="agent-log")
55
56
  cli.add_command(repos_group, name="repos")
57
+ cli.add_command(github_group, name="github")
56
58
  cli.add_command(env_group, name="env")
57
59
  cli.add_command(hooks_group, name="hooks")
58
60
  cli.add_command(logs_group, name="logs")
@@ -0,0 +1,174 @@
1
+ """`pocketshell github` subcommand group.
2
+
3
+ First slice of the Git + GitHub integration epic
4
+ ([#644](https://github.com/alexeygrigorev/pocketshell/issues/644), slice 1 is
5
+ [#645](https://github.com/alexeygrigorev/pocketshell/issues/645)).
6
+
7
+ Server-side foundation: report whether the GitHub CLI (`gh`) is **installed**
8
+ and **authenticated** as structured JSON the Android app can consume to gate
9
+ GitHub features and show a "configure gh" hint when the tooling is missing.
10
+
11
+ Unlike `pocketshell sessions`/`usage`, which proxy another binary's output
12
+ byte-for-byte, this command generates its own JSON envelope. The shape is:
13
+
14
+ ```json
15
+ {
16
+ "installed": true,
17
+ "authenticated": true,
18
+ "account": "alexeygrigorev",
19
+ "hint": null
20
+ }
21
+ ```
22
+
23
+ - `installed` — `shutil.which("gh")` found the binary on PATH.
24
+ - `authenticated` — `gh auth status` exited 0 (a token is present and valid).
25
+ - `account` — the logged-in GitHub username parsed from `gh auth status`,
26
+ or `null` when it could not be determined / not authenticated.
27
+ - `hint` — a short, actionable string when something is missing (install `gh`,
28
+ or run `gh auth login`), or `null` when everything is ready.
29
+
30
+ Why subprocess (`gh auth status`) instead of reading `~/.config/gh/hosts.yml`
31
+ directly: `gh auth status` is the canonical liveness/validity check the user
32
+ themselves would run, it validates the token rather than just checking that a
33
+ config file exists, and it keeps this command decoupled from gh's on-disk
34
+ layout. No network call beyond what `gh auth status` itself performs (a single
35
+ token-validity check); the command does NOT hit the GitHub API.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import re
42
+ import shutil
43
+ import subprocess
44
+ from typing import Optional
45
+
46
+ import click
47
+
48
+ # Hints surfaced to the user (and the app) when tooling is missing. Kept as
49
+ # module constants so the test suite can assert on the exact wording.
50
+ INSTALL_HINT = (
51
+ "install gh (https://cli.github.com) and run `gh auth login`"
52
+ )
53
+ AUTH_HINT = "run `gh auth login` to authenticate the GitHub CLI"
54
+
55
+ # `gh auth status` prints e.g.
56
+ # ✓ Logged in to github.com account alexeygrigorev (keyring)
57
+ # across gh versions. Capture the username after "account ".
58
+ _ACCOUNT_RE = re.compile(r"account\s+(\S+)")
59
+
60
+
61
+ def _resolve_gh_binary() -> Optional[str]:
62
+ """Locate the `gh` CLI on PATH, or return ``None`` if absent.
63
+
64
+ Pulled out as a function so the unit suite can monkeypatch it.
65
+ `shutil.which` returns the same path the user would see from
66
+ `command -v gh`, which is the probe the app's bootstrap already runs.
67
+ """
68
+ return shutil.which("gh")
69
+
70
+
71
+ def _run_gh_auth_status(gh_path: str) -> subprocess.CompletedProcess:
72
+ """Invoke ``gh auth status`` and capture its output.
73
+
74
+ Modern `gh` writes the status report to stdout on success and to
75
+ stderr when unauthenticated; we capture both and parse whichever
76
+ carries the text. The exit code is the authoritative auth signal.
77
+ """
78
+ return subprocess.run(
79
+ [gh_path, "auth", "status"],
80
+ check=False,
81
+ capture_output=True,
82
+ text=True,
83
+ )
84
+
85
+
86
+ def _parse_account(text: str) -> Optional[str]:
87
+ """Best-effort extract of the logged-in GitHub username from gh output."""
88
+ match = _ACCOUNT_RE.search(text)
89
+ if match:
90
+ return match.group(1)
91
+ return None
92
+
93
+
94
+ def gh_status() -> dict[str, object]:
95
+ """Build the structured gh install/auth status envelope.
96
+
97
+ Returns a plain dict with the stable shape documented at module level so
98
+ both the CLI (`--json`) and any in-process caller can reuse it.
99
+ """
100
+ gh_path = _resolve_gh_binary()
101
+ if gh_path is None:
102
+ return {
103
+ "installed": False,
104
+ "authenticated": False,
105
+ "account": None,
106
+ "hint": INSTALL_HINT,
107
+ }
108
+
109
+ completed = _run_gh_auth_status(gh_path)
110
+ authenticated = completed.returncode == 0
111
+ # `gh auth status` puts the report on stdout (authed) or stderr
112
+ # (unauthed) depending on version; scan both so account parsing is
113
+ # robust across gh releases.
114
+ combined = (completed.stdout or "") + "\n" + (completed.stderr or "")
115
+ account = _parse_account(combined) if authenticated else None
116
+
117
+ return {
118
+ "installed": True,
119
+ "authenticated": authenticated,
120
+ "account": account,
121
+ "hint": None if authenticated else AUTH_HINT,
122
+ }
123
+
124
+
125
+ @click.group(
126
+ name="github",
127
+ context_settings={"help_option_names": ["-h", "--help"]},
128
+ help=(
129
+ "GitHub CLI (`gh`) integration helpers.\n\n"
130
+ "`status` reports whether `gh` is installed and authenticated as "
131
+ "structured JSON the PocketShell app uses to gate GitHub features "
132
+ "and prompt the user to configure `gh` when it is missing (issue "
133
+ "#645)."
134
+ ),
135
+ )
136
+ def github_group() -> None:
137
+ """Top-level group registered onto the root `pocketshell` CLI."""
138
+
139
+
140
+ @github_group.command(
141
+ "status",
142
+ context_settings={"help_option_names": ["-h", "--help"]},
143
+ )
144
+ @click.option(
145
+ "--json",
146
+ "as_json",
147
+ is_flag=True,
148
+ help="Emit the status as a single-line JSON object (consumed by the app).",
149
+ )
150
+ @click.pass_context
151
+ def github_status(ctx: click.Context, as_json: bool) -> None:
152
+ """Report whether `gh` is installed and authenticated.
153
+
154
+ With ``--json`` emits a one-line JSON object the app parses to gate
155
+ GitHub features; without it prints a short human-readable summary. The
156
+ command always exits 0 — "gh missing" / "not authenticated" are normal,
157
+ reportable states, not errors, so the app can poll the status without
158
+ treating it as a failed probe.
159
+ """
160
+ status = gh_status()
161
+ if as_json:
162
+ click.echo(json.dumps(status, sort_keys=True))
163
+ return
164
+
165
+ if not status["installed"]:
166
+ click.echo("gh: not installed")
167
+ click.echo(f"hint: {status['hint']}")
168
+ return
169
+ if not status["authenticated"]:
170
+ click.echo("gh: installed, not authenticated")
171
+ click.echo(f"hint: {status['hint']}")
172
+ return
173
+ account = status["account"] or "(unknown account)"
174
+ click.echo(f"gh: installed, authenticated as {account}")
@@ -61,6 +61,17 @@ import click
61
61
 
62
62
  _CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
63
63
  _CODEX_AUTH_PATH = Path.home() / ".codex" / "auth.json"
64
+ _CODEX_COMPATIBLE_PROVIDERS = {
65
+ "codex",
66
+ "openai",
67
+ "openai-codex",
68
+ "openai_codex",
69
+ "chatgpt",
70
+ }
71
+ _CLAUDE_USAGE_AUTH_SETUP_MESSAGE = (
72
+ "Claude usage authentication needs setup on this host. "
73
+ "Open Claude Code on the host and complete sign-in, then refresh usage."
74
+ )
64
75
 
65
76
 
66
77
  def _resolve_quse_binary() -> Optional[str]:
@@ -151,16 +162,78 @@ def _percent_remaining_from_used(value: Any) -> Optional[float]:
151
162
  return round(max(0.0, min(100.0, 100.0 - used)), 2)
152
163
 
153
164
 
154
- def _window_from_detail(detail: Any) -> Optional[dict[str, Any]]:
165
+ def _reset_after_seconds_to_iso(
166
+ value: Any,
167
+ *,
168
+ now: Optional[datetime] = None,
169
+ ) -> Optional[str]:
170
+ if value is None:
171
+ return None
172
+ try:
173
+ seconds = float(value)
174
+ except (TypeError, ValueError):
175
+ return None
176
+ if seconds < 0:
177
+ return None
178
+ base = now or datetime.now(timezone.utc)
179
+ if base.tzinfo is None:
180
+ base = base.replace(tzinfo=timezone.utc)
181
+ reset_at = base.astimezone(timezone.utc).timestamp() + seconds
182
+ try:
183
+ parsed = datetime.fromtimestamp(reset_at, tz=timezone.utc)
184
+ except (OverflowError, OSError, ValueError):
185
+ return None
186
+ return parsed.strftime("%Y-%m-%dT%H:%M:%SZ")
187
+
188
+
189
+ def _window_label_from_seconds(value: Any) -> Optional[str]:
190
+ try:
191
+ seconds = int(float(value))
192
+ except (TypeError, ValueError, OverflowError):
193
+ return None
194
+ if seconds <= 0:
195
+ return None
196
+ units = (
197
+ (24 * 60 * 60, "d"),
198
+ (60 * 60, "h"),
199
+ (60, "m"),
200
+ )
201
+ for unit_seconds, suffix in units:
202
+ if seconds >= unit_seconds and seconds % unit_seconds == 0:
203
+ return f"{seconds // unit_seconds}{suffix}"
204
+ return f"{seconds}s"
205
+
206
+
207
+ def _window_label_from_detail(detail: dict[str, Any]) -> Optional[str]:
208
+ label = _window_label_from_seconds(detail.get("limit_window_seconds"))
209
+ if label is not None:
210
+ return label
211
+ try:
212
+ minutes = float(detail.get("window_minutes"))
213
+ except (TypeError, ValueError):
214
+ return None
215
+ return _window_label_from_seconds(minutes * 60)
216
+
217
+
218
+ def _window_from_detail(
219
+ detail: Any,
220
+ *,
221
+ now: Optional[datetime] = None,
222
+ ) -> Optional[dict[str, Any]]:
155
223
  if not isinstance(detail, dict):
156
224
  return None
157
225
  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:
226
+ reset_at = _normalize_reset_at(detail.get("reset_at")) or _reset_after_seconds_to_iso(
227
+ detail.get("reset_after_seconds"),
228
+ now=now,
229
+ )
230
+ window = _window_label_from_detail(detail)
231
+ if percent_remaining is None and reset_at is None and window is None:
160
232
  return None
161
233
  return {
162
234
  "percent_remaining": percent_remaining,
163
235
  "reset_at": reset_at,
236
+ "window": window,
164
237
  }
165
238
 
166
239
 
@@ -169,6 +242,7 @@ def _merge_window(
169
242
  detail: Any,
170
243
  *,
171
244
  prefer_detail_percent: bool = False,
245
+ now: Optional[datetime] = None,
172
246
  ) -> Any:
173
247
  if not isinstance(current, dict):
174
248
  if not isinstance(detail, dict):
@@ -177,14 +251,21 @@ def _merge_window(
177
251
  else:
178
252
  current = dict(current)
179
253
 
180
- detail_window = _window_from_detail(detail)
254
+ detail_window = _window_from_detail(detail, now=now)
181
255
  if detail_window is not None:
182
256
  if prefer_detail_percent or current.get("percent_remaining") is None:
183
257
  current["percent_remaining"] = detail_window.get("percent_remaining")
184
258
  if current.get("reset_at") is None:
185
259
  current["reset_at"] = detail_window.get("reset_at")
260
+ if current.get("window") is None and detail_window.get("window") is not None:
261
+ current["window"] = detail_window.get("window")
186
262
 
187
- current["reset_at"] = _normalize_reset_at(current.get("reset_at"))
263
+ reset_after_seconds = current.get("reset_after_seconds")
264
+ current["reset_at"] = _normalize_reset_at(current.get("reset_at")) or _reset_after_seconds_to_iso(
265
+ reset_after_seconds,
266
+ now=now,
267
+ )
268
+ current.pop("reset_after_seconds", None)
188
269
  return current
189
270
 
190
271
 
@@ -195,15 +276,19 @@ def _actionable_error(provider: str, error: Any) -> Optional[str]:
195
276
  if not text:
196
277
  return None
197
278
  lower = text.lower()
279
+ if provider == "claude" and (
280
+ "claude " + "/login" in lower
281
+ or "run `claude" in lower
282
+ or "run claude" in lower
283
+ or "authentication " + "failed" in lower
284
+ ):
285
+ return _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
198
286
  if provider == "claude" and (
199
287
  "http error 401" in lower
200
288
  or "unauthorized" in lower
201
289
  or lower in {"no-credentials", "no credentials"}
202
290
  ):
203
- return (
204
- "Claude Code authentication failed on this host. "
205
- "Run `claude /login` in the host shell, then refresh usage."
206
- )
291
+ return _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
207
292
  if provider == "codex" and lower in {"no auth token", "no-auth-token", "no credentials"}:
208
293
  return (
209
294
  "Codex authentication is missing on this host. "
@@ -261,14 +346,30 @@ def _codex_needs_source_patch(record: dict[str, Any], detail_windows: dict[str,
261
346
  ):
262
347
  top_level = record.get(top_level_key)
263
348
  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
349
+ top_level_reset = None
350
+ if isinstance(top_level, dict):
351
+ top_level_reset = top_level.get("reset_at")
352
+ if top_level_reset is None:
353
+ top_level_reset = top_level.get("reset_after_seconds")
354
+ detail_reset = None
355
+ if isinstance(detail, dict):
356
+ detail_reset = detail.get("reset_at")
357
+ if detail_reset is None:
358
+ detail_reset = detail.get("reset_after_seconds")
266
359
  if top_level_reset is None and detail_reset is None:
267
360
  return True
268
361
  return False
269
362
 
270
363
 
271
- def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
364
+ def _is_codex_compatible_provider(provider: str) -> bool:
365
+ return provider.replace(" ", "_").lower() in _CODEX_COMPATIBLE_PROVIDERS
366
+
367
+
368
+ def normalize_usage_record(
369
+ record: dict[str, Any],
370
+ *,
371
+ now: Optional[datetime] = None,
372
+ ) -> dict[str, Any]:
272
373
  """Normalize a provider record emitted by ``quse --json``.
273
374
 
274
375
  PocketShell owns the app-facing schema even when it delegates provider
@@ -282,7 +383,7 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
282
383
  if not isinstance(detail_windows, dict):
283
384
  detail_windows = {}
284
385
 
285
- if provider == "codex":
386
+ if _is_codex_compatible_provider(provider):
286
387
  # Codex's ChatGPT usage response exposes the real primary/secondary
287
388
  # windows under details. Older quse versions hard-code short_term to
288
389
  # 100% and lose epoch reset timestamps, which regressed issue #501.
@@ -297,6 +398,7 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
297
398
  normalized.get("short_term"),
298
399
  detail_windows.get("primary_window"),
299
400
  prefer_detail_percent=True,
401
+ now=now,
300
402
  )
301
403
  if short_term is not None:
302
404
  normalized["short_term"] = short_term
@@ -304,6 +406,7 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
304
406
  normalized.get("long_term"),
305
407
  detail_windows.get("secondary_window"),
306
408
  prefer_detail_percent=True,
409
+ now=now,
307
410
  )
308
411
  if long_term is not None:
309
412
  normalized["long_term"] = long_term
@@ -311,20 +414,22 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
311
414
  short_term = _merge_window(
312
415
  normalized.get("short_term"),
313
416
  detail_windows.get("five_hour"),
417
+ now=now,
314
418
  )
315
419
  if short_term is not None:
316
420
  normalized["short_term"] = short_term
317
421
  long_term = _merge_window(
318
422
  normalized.get("long_term"),
319
423
  detail_windows.get("seven_day"),
424
+ now=now,
320
425
  )
321
426
  if long_term is not None:
322
427
  normalized["long_term"] = long_term
323
428
  else:
324
429
  if isinstance(normalized.get("short_term"), dict):
325
- normalized["short_term"] = _merge_window(normalized.get("short_term"), None)
430
+ normalized["short_term"] = _merge_window(normalized.get("short_term"), None, now=now)
326
431
  if isinstance(normalized.get("long_term"), dict):
327
- normalized["long_term"] = _merge_window(normalized.get("long_term"), None)
432
+ normalized["long_term"] = _merge_window(normalized.get("long_term"), None, now=now)
328
433
 
329
434
  actionable = _actionable_error(provider, normalized.get("error"))
330
435
  if actionable != normalized.get("error"):
@@ -332,7 +437,11 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
332
437
  return normalized
333
438
 
334
439
 
335
- def normalize_usage_stdout(stdout: str) -> str:
440
+ def normalize_usage_stdout(
441
+ stdout: str,
442
+ *,
443
+ now: Optional[datetime] = None,
444
+ ) -> str:
336
445
  """Normalize NDJSON stdout from ``quse --json`` for app consumption."""
337
446
  if not stdout.strip():
338
447
  return stdout
@@ -348,7 +457,7 @@ def normalize_usage_stdout(stdout: str) -> str:
348
457
  return stdout
349
458
  if not isinstance(parsed, dict):
350
459
  return stdout
351
- normalized = normalize_usage_record(parsed)
460
+ normalized = normalize_usage_record(parsed, now=now)
352
461
  changed = changed or normalized != parsed
353
462
  lines.append(json.dumps(normalized, sort_keys=True))
354
463
  suffix = "\n" if stdout.endswith("\n") else ""
@@ -0,0 +1,270 @@
1
+ """Unit tests for `pocketshell github status`.
2
+
3
+ First slice of the Git + GitHub integration epic (#644 / #645). Exercises:
4
+
5
+ - `pocketshell --help` lists the `github` subcommand.
6
+ - `pocketshell github --help` lists the `status` subcommand.
7
+ - gh missing (`shutil.which` -> None): installed=False, authenticated=False,
8
+ install hint present, account null.
9
+ - gh installed but unauthenticated (`gh auth status` exit != 0):
10
+ installed=True, authenticated=False, auth hint present, account null.
11
+ - gh installed and authenticated (`gh auth status` exit 0): installed=True,
12
+ authenticated=True, account parsed, hint null.
13
+ - The `--json` envelope shape is stable and machine-parseable.
14
+ - The command exits 0 in every state (missing/unauthed are reportable
15
+ states, not probe failures).
16
+ - Human (non-JSON) output for each state.
17
+
18
+ The tests stub `pocketshell.github._resolve_gh_binary` and
19
+ `subprocess.run` so they never invoke a real `gh` binary or hit the
20
+ network; the contract under test is "pocketshell reports gh state
21
+ correctly", not "the GitHub CLI works".
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import subprocess
28
+ from typing import Sequence
29
+ from unittest.mock import patch
30
+
31
+ from click.testing import CliRunner
32
+
33
+ from pocketshell.cli import cli
34
+ from pocketshell.github import (
35
+ AUTH_HINT,
36
+ INSTALL_HINT,
37
+ gh_status,
38
+ github_group,
39
+ )
40
+
41
+
42
+ def _fake_completed(
43
+ stdout: str = "",
44
+ stderr: str = "",
45
+ returncode: int = 0,
46
+ ) -> subprocess.CompletedProcess:
47
+ return subprocess.CompletedProcess(
48
+ args=[],
49
+ returncode=returncode,
50
+ stdout=stdout,
51
+ stderr=stderr,
52
+ )
53
+
54
+
55
+ # Realistic `gh auth status` output for an authenticated host (gh 2.x puts
56
+ # this on stdout with exit 0).
57
+ _AUTHED_STDOUT = (
58
+ "github.com\n"
59
+ " ✓ Logged in to github.com account alexeygrigorev "
60
+ "(/home/alexey/.config/gh/hosts.yml)\n"
61
+ " - Active account: true\n"
62
+ " - Git operations protocol: ssh\n"
63
+ " - Token scopes: 'repo', 'read:org'\n"
64
+ )
65
+
66
+ # `gh auth status` when no token is configured: non-zero exit, text on stderr.
67
+ _UNAUTHED_STDERR = (
68
+ "You are not logged into any GitHub hosts. "
69
+ "To log in, run: gh auth login\n"
70
+ )
71
+
72
+
73
+ # ----- help wiring ---------------------------------------------------
74
+
75
+
76
+ def test_top_level_help_lists_github_subcommand() -> None:
77
+ runner = CliRunner()
78
+ result = runner.invoke(cli, ["--help"])
79
+ assert result.exit_code == 0, result.output
80
+ assert "github" in result.output
81
+
82
+
83
+ def test_github_help_lists_status_subcommand() -> None:
84
+ runner = CliRunner()
85
+ with patch("pocketshell.github.subprocess.run") as run:
86
+ result = runner.invoke(github_group, ["--help"])
87
+ assert result.exit_code == 0, result.output
88
+ assert "status" in result.output
89
+ run.assert_not_called()
90
+
91
+
92
+ # ----- gh missing ----------------------------------------------------
93
+
94
+
95
+ def test_status_json_when_gh_missing() -> None:
96
+ runner = CliRunner()
97
+ with patch(
98
+ "pocketshell.github._resolve_gh_binary", return_value=None
99
+ ), patch("pocketshell.github.subprocess.run") as run:
100
+ result = runner.invoke(github_group, ["status", "--json"])
101
+ assert result.exit_code == 0, result.output
102
+ payload = json.loads(result.output)
103
+ assert payload == {
104
+ "installed": False,
105
+ "authenticated": False,
106
+ "account": None,
107
+ "hint": INSTALL_HINT,
108
+ }
109
+ # gh missing -> we must NOT have tried to run `gh auth status`.
110
+ run.assert_not_called()
111
+
112
+
113
+ def test_status_human_when_gh_missing() -> None:
114
+ runner = CliRunner()
115
+ with patch("pocketshell.github._resolve_gh_binary", return_value=None):
116
+ result = runner.invoke(github_group, ["status"])
117
+ assert result.exit_code == 0, result.output
118
+ assert "not installed" in result.output
119
+ assert INSTALL_HINT in result.output
120
+
121
+
122
+ # ----- gh installed but unauthenticated ------------------------------
123
+
124
+
125
+ def test_status_json_when_installed_unauthed() -> None:
126
+ runner = CliRunner()
127
+ with patch(
128
+ "pocketshell.github._resolve_gh_binary", return_value="/fake/gh"
129
+ ), patch(
130
+ "pocketshell.github.subprocess.run",
131
+ return_value=_fake_completed(stderr=_UNAUTHED_STDERR, returncode=1),
132
+ ) as run:
133
+ result = runner.invoke(github_group, ["status", "--json"])
134
+ assert result.exit_code == 0, result.output
135
+ payload = json.loads(result.output)
136
+ assert payload == {
137
+ "installed": True,
138
+ "authenticated": False,
139
+ "account": None,
140
+ "hint": AUTH_HINT,
141
+ }
142
+ invoked: Sequence[str] = run.call_args.args[0]
143
+ assert invoked == ["/fake/gh", "auth", "status"]
144
+
145
+
146
+ def test_status_human_when_installed_unauthed() -> None:
147
+ runner = CliRunner()
148
+ with patch(
149
+ "pocketshell.github._resolve_gh_binary", return_value="/fake/gh"
150
+ ), patch(
151
+ "pocketshell.github.subprocess.run",
152
+ return_value=_fake_completed(stderr=_UNAUTHED_STDERR, returncode=1),
153
+ ):
154
+ result = runner.invoke(github_group, ["status"])
155
+ assert result.exit_code == 0, result.output
156
+ assert "not authenticated" in result.output
157
+ assert AUTH_HINT in result.output
158
+
159
+
160
+ # ----- gh installed and authenticated --------------------------------
161
+
162
+
163
+ def test_status_json_when_installed_authed() -> None:
164
+ runner = CliRunner()
165
+ with patch(
166
+ "pocketshell.github._resolve_gh_binary", return_value="/fake/gh"
167
+ ), patch(
168
+ "pocketshell.github.subprocess.run",
169
+ return_value=_fake_completed(stdout=_AUTHED_STDOUT, returncode=0),
170
+ ) as run:
171
+ result = runner.invoke(github_group, ["status", "--json"])
172
+ assert result.exit_code == 0, result.output
173
+ payload = json.loads(result.output)
174
+ assert payload == {
175
+ "installed": True,
176
+ "authenticated": True,
177
+ "account": "alexeygrigorev",
178
+ "hint": None,
179
+ }
180
+ invoked: Sequence[str] = run.call_args.args[0]
181
+ assert invoked == ["/fake/gh", "auth", "status"]
182
+
183
+
184
+ def test_status_human_when_installed_authed() -> None:
185
+ runner = CliRunner()
186
+ with patch(
187
+ "pocketshell.github._resolve_gh_binary", return_value="/fake/gh"
188
+ ), patch(
189
+ "pocketshell.github.subprocess.run",
190
+ return_value=_fake_completed(stdout=_AUTHED_STDOUT, returncode=0),
191
+ ):
192
+ result = runner.invoke(github_group, ["status"])
193
+ assert result.exit_code == 0, result.output
194
+ assert "authenticated as alexeygrigorev" in result.output
195
+
196
+
197
+ def test_status_authed_account_parsed_from_stderr_variant() -> None:
198
+ """Older gh writes the report to stderr even when exit code is 0.
199
+
200
+ Account parsing must scan both streams, so a stderr-only authed report
201
+ still yields the username.
202
+ """
203
+ runner = CliRunner()
204
+ with patch(
205
+ "pocketshell.github._resolve_gh_binary", return_value="/fake/gh"
206
+ ), patch(
207
+ "pocketshell.github.subprocess.run",
208
+ return_value=_fake_completed(stderr=_AUTHED_STDOUT, returncode=0),
209
+ ):
210
+ result = runner.invoke(github_group, ["status", "--json"])
211
+ assert result.exit_code == 0, result.output
212
+ payload = json.loads(result.output)
213
+ assert payload["authenticated"] is True
214
+ assert payload["account"] == "alexeygrigorev"
215
+
216
+
217
+ def test_status_authed_without_parseable_account_yields_null() -> None:
218
+ """Authenticated but no parseable 'account <name>' -> account null, still authed."""
219
+ runner = CliRunner()
220
+ with patch(
221
+ "pocketshell.github._resolve_gh_binary", return_value="/fake/gh"
222
+ ), patch(
223
+ "pocketshell.github.subprocess.run",
224
+ return_value=_fake_completed(stdout="Logged in to github.com\n", returncode=0),
225
+ ):
226
+ result = runner.invoke(github_group, ["status", "--json"])
227
+ assert result.exit_code == 0, result.output
228
+ payload = json.loads(result.output)
229
+ assert payload["authenticated"] is True
230
+ assert payload["account"] is None
231
+ assert payload["hint"] is None
232
+
233
+
234
+ # ----- JSON shape contract -------------------------------------------
235
+
236
+
237
+ def test_json_envelope_shape_has_exact_keys() -> None:
238
+ """The app depends on a fixed key set; pin it so a rename is caught."""
239
+ runner = CliRunner()
240
+ with patch(
241
+ "pocketshell.github._resolve_gh_binary", return_value="/fake/gh"
242
+ ), patch(
243
+ "pocketshell.github.subprocess.run",
244
+ return_value=_fake_completed(stdout=_AUTHED_STDOUT, returncode=0),
245
+ ):
246
+ result = runner.invoke(github_group, ["status", "--json"])
247
+ payload = json.loads(result.output)
248
+ assert set(payload.keys()) == {
249
+ "installed",
250
+ "authenticated",
251
+ "account",
252
+ "hint",
253
+ }
254
+ # Single-line output so the app can read one line off stdout.
255
+ assert result.output.strip().count("\n") == 0
256
+
257
+
258
+ # ----- in-process helper ---------------------------------------------
259
+
260
+
261
+ def test_gh_status_helper_returns_dict_without_gh() -> None:
262
+ """The reusable `gh_status()` helper works independent of the CLI layer."""
263
+ with patch("pocketshell.github._resolve_gh_binary", return_value=None):
264
+ status = gh_status()
265
+ assert status == {
266
+ "installed": False,
267
+ "authenticated": False,
268
+ "account": None,
269
+ "hint": INSTALL_HINT,
270
+ }
@@ -20,13 +20,19 @@ from __future__ import annotations
20
20
 
21
21
  import json
22
22
  import subprocess
23
+ from datetime import datetime, timezone
23
24
  from typing import Sequence
24
25
  from unittest.mock import patch
25
26
 
26
27
  from click.testing import CliRunner
27
28
 
28
29
  from pocketshell.cli import cli, main
29
- from pocketshell.usage import usage_command
30
+ from pocketshell.usage import (
31
+ _CLAUDE_USAGE_AUTH_SETUP_MESSAGE,
32
+ _actionable_error,
33
+ normalize_usage_record,
34
+ usage_command,
35
+ )
30
36
 
31
37
 
32
38
  def _fake_completed(
@@ -112,10 +118,12 @@ def test_usage_json_normalizes_codex_detail_windows_and_epoch_resets() -> None:
112
118
  "windows": {
113
119
  "primary_window": {
114
120
  "used_percent": 12,
121
+ "limit_window_seconds": 18000,
115
122
  "reset_at": 1780828285,
116
123
  },
117
124
  "secondary_window": {
118
125
  "used_percent": 31,
126
+ "limit_window_seconds": 604800,
119
127
  "reset_at": 1781137638,
120
128
  },
121
129
  },
@@ -148,15 +156,29 @@ def test_usage_json_normalizes_codex_detail_windows_and_epoch_resets() -> None:
148
156
  assert codex["short_term"] == {
149
157
  "percent_remaining": 88.0,
150
158
  "reset_at": "2026-06-07T10:31:25Z",
159
+ "window": "5h",
151
160
  }
152
161
  assert codex["long_term"] == {
153
162
  "percent_remaining": 69.0,
154
163
  "reset_at": "2026-06-11T00:27:18Z",
164
+ "window": "7d",
155
165
  }
156
- assert "claude /login" in lines[1]["error"]
166
+ assert lines[1]["error"] == _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
167
+ assert "claude " + "/login" not in lines[1]["error"]
168
+ assert "authentication " + "failed" not in lines[1]["error"].lower()
157
169
  assert "HTTP Error 401" not in lines[1]["error"]
158
170
 
159
171
 
172
+ def test_claude_stale_auth_telemetry_error_is_usage_unavailable() -> None:
173
+ stale_error = (
174
+ "Claude Code authentication "
175
+ + "failed on this host. Run `claude "
176
+ + "/login` in the host shell."
177
+ )
178
+
179
+ assert _actionable_error("claude", stale_error) == _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
180
+
181
+
160
182
  def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() -> None:
161
183
  raw = json.dumps(
162
184
  {
@@ -182,8 +204,16 @@ def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() ->
182
204
  ), patch(
183
205
  "pocketshell.usage._fetch_codex_detail_windows",
184
206
  return_value={
185
- "primary_window": {"used_percent": 13, "reset_at": 1780828285},
186
- "secondary_window": {"used_percent": 31, "reset_at": 1781137638},
207
+ "primary_window": {
208
+ "used_percent": 13,
209
+ "limit_window_seconds": 18000,
210
+ "reset_at": 1780828285,
211
+ },
212
+ "secondary_window": {
213
+ "used_percent": 31,
214
+ "limit_window_seconds": 604800,
215
+ "reset_at": 1781137638,
216
+ },
187
217
  },
188
218
  ):
189
219
  result = runner.invoke(usage_command, ["--json"])
@@ -193,10 +223,124 @@ def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() ->
193
223
  assert codex["short_term"] == {
194
224
  "percent_remaining": 87.0,
195
225
  "reset_at": "2026-06-07T10:31:25Z",
226
+ "window": "5h",
196
227
  }
197
228
  assert codex["long_term"] == {
198
229
  "percent_remaining": 69.0,
199
230
  "reset_at": "2026-06-11T00:27:18Z",
231
+ "window": "7d",
232
+ }
233
+
234
+
235
+ def test_usage_json_normalizes_openai_compatible_detail_windows() -> None:
236
+ raw = json.dumps(
237
+ {
238
+ "provider": "openai",
239
+ "status": "ok",
240
+ "short_term": {"percent_remaining": 100.0, "reset_at": None},
241
+ "long_term": {"percent_remaining": 35.0, "reset_at": None},
242
+ "block_reason": None,
243
+ "error": None,
244
+ "details": {
245
+ "windows": {
246
+ "primary_window": {
247
+ "used_percent": 22,
248
+ "limit_window_seconds": 18000,
249
+ "reset_at": "2026-06-08T02:19:59Z",
250
+ },
251
+ "secondary_window": {
252
+ "used_percent": 65,
253
+ "limit_window_seconds": 604800,
254
+ "reset_at": "2026-06-11T00:27:17Z",
255
+ },
256
+ },
257
+ },
258
+ },
259
+ )
260
+ runner = CliRunner()
261
+ with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
262
+ "pocketshell.usage.subprocess.run",
263
+ return_value=_fake_completed(stdout=raw + "\n"),
264
+ ):
265
+ result = runner.invoke(usage_command, ["--json"])
266
+
267
+ assert result.exit_code == 0, result.output
268
+ record = json.loads(result.output)
269
+ assert record["short_term"] == {
270
+ "percent_remaining": 78.0,
271
+ "reset_at": "2026-06-08T02:19:59Z",
272
+ "window": "5h",
273
+ }
274
+ assert record["long_term"] == {
275
+ "percent_remaining": 35.0,
276
+ "reset_at": "2026-06-11T00:27:17Z",
277
+ "window": "7d",
278
+ }
279
+
280
+
281
+ def test_normalize_usage_record_preserves_codex_reset_after_seconds() -> None:
282
+ record = normalize_usage_record(
283
+ {
284
+ "provider": "codex",
285
+ "status": "ok",
286
+ "short_term": {"percent_remaining": 35.0, "reset_at": None},
287
+ "long_term": {"percent_remaining": 69.0, "reset_at": None},
288
+ "block_reason": None,
289
+ "error": None,
290
+ "details": {
291
+ "windows": {
292
+ "primary_window": {
293
+ "used_percent": 65,
294
+ "window_minutes": 300,
295
+ "reset_after_seconds": 3600,
296
+ },
297
+ "secondary_window": {
298
+ "used_percent": 31,
299
+ "limit_window_seconds": 604800,
300
+ "reset_after_seconds": "604800",
301
+ },
302
+ },
303
+ },
304
+ },
305
+ now=datetime(2026, 6, 8, 10, 0, tzinfo=timezone.utc),
306
+ )
307
+
308
+ assert record["short_term"] == {
309
+ "percent_remaining": 35.0,
310
+ "reset_at": "2026-06-08T11:00:00Z",
311
+ "window": "5h",
312
+ }
313
+ assert record["long_term"] == {
314
+ "percent_remaining": 69.0,
315
+ "reset_at": "2026-06-15T10:00:00Z",
316
+ "window": "7d",
317
+ }
318
+
319
+
320
+ def test_normalize_usage_record_converts_top_level_reset_after_seconds() -> None:
321
+ with patch("pocketshell.usage._fetch_codex_detail_windows", return_value=None):
322
+ record = normalize_usage_record(
323
+ {
324
+ "provider": "codex",
325
+ "status": "ok",
326
+ "short_term": {
327
+ "percent_remaining": 35.0,
328
+ "reset_at": None,
329
+ "reset_after_seconds": 3600,
330
+ "window": "5h",
331
+ },
332
+ "long_term": None,
333
+ "block_reason": None,
334
+ "error": None,
335
+ "details": {},
336
+ },
337
+ now=datetime(2026, 6, 8, 10, 0, tzinfo=timezone.utc),
338
+ )
339
+
340
+ assert record["short_term"] == {
341
+ "percent_remaining": 35.0,
342
+ "reset_at": "2026-06-08T11:00:00Z",
343
+ "window": "5h",
200
344
  }
201
345
 
202
346
 
@@ -3,7 +3,7 @@ revision = 3
3
3
  requires-python = ">=3.11"
4
4
 
5
5
  [options]
6
- exclude-newer = "2026-05-30T11:30:48.503303377Z"
6
+ exclude-newer = "2026-06-03T14:05:02.806775926Z"
7
7
  exclude-newer-span = "P7D"
8
8
 
9
9
  [[package]]
@@ -143,7 +143,7 @@ wheels = [
143
143
 
144
144
  [[package]]
145
145
  name = "pocketshell"
146
- version = "0.3.29"
146
+ version = "0.3.30"
147
147
  source = { editable = "." }
148
148
  dependencies = [
149
149
  { name = "click" },
File without changes