pocketshell 0.3.30__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.30 → pocketshell-0.3.31}/PKG-INFO +32 -1
- {pocketshell-0.3.30 → pocketshell-0.3.31}/README.md +31 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/pyproject.toml +1 -1
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/cli.py +4 -2
- pocketshell-0.3.31/src/pocketshell/github.py +174 -0
- pocketshell-0.3.31/tests/test_github.py +270 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/uv.lock +2 -2
- {pocketshell-0.3.30 → pocketshell-0.3.31}/.gitignore +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/__init__.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_agent_log.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_cli.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_daemon.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_env.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_jobs.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_logs.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_repos.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_sessions.py +0 -0
- {pocketshell-0.3.30 → pocketshell-0.3.31}/tests/test_usage.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}")
|
|
@@ -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
|
+
}
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|