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.
- {pocketshell-0.3.29 → pocketshell-0.3.31}/PKG-INFO +32 -1
- {pocketshell-0.3.29 → pocketshell-0.3.31}/README.md +31 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/pyproject.toml +1 -1
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/cli.py +4 -2
- pocketshell-0.3.31/src/pocketshell/github.py +174 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/usage.py +126 -17
- pocketshell-0.3.31/tests/test_github.py +270 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_usage.py +148 -4
- {pocketshell-0.3.29 → pocketshell-0.3.31}/uv.lock +2 -2
- {pocketshell-0.3.29 → pocketshell-0.3.31}/.gitignore +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/__init__.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_agent_log.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_cli.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_daemon.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_env.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_jobs.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_logs.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.29 → pocketshell-0.3.31}/tests/test_repos.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
265
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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": {
|
|
186
|
-
|
|
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-
|
|
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.
|
|
146
|
+
version = "0.3.30"
|
|
147
147
|
source = { editable = "." }
|
|
148
148
|
dependencies = [
|
|
149
149
|
{ name = "click" },
|
|
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
|