pocketshell 0.3.30__tar.gz → 0.3.32__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.30 → pocketshell-0.3.32}/PKG-INFO +32 -1
  2. {pocketshell-0.3.30 → pocketshell-0.3.32}/README.md +31 -0
  3. {pocketshell-0.3.30 → pocketshell-0.3.32}/pyproject.toml +1 -1
  4. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/cli.py +4 -2
  5. pocketshell-0.3.32/src/pocketshell/github.py +174 -0
  6. pocketshell-0.3.32/tests/test_github.py +270 -0
  7. {pocketshell-0.3.30 → pocketshell-0.3.32}/uv.lock +2 -2
  8. {pocketshell-0.3.30 → pocketshell-0.3.32}/.gitignore +0 -0
  9. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/__init__.py +0 -0
  10. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/__main__.py +0 -0
  11. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/agent_log.py +0 -0
  12. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/daemon.py +0 -0
  13. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/env.py +0 -0
  14. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/hooks.py +0 -0
  15. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/jobs.py +0 -0
  16. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/logs.py +0 -0
  17. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/qr_share.py +0 -0
  18. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/repos.py +0 -0
  19. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/sessions.py +0 -0
  20. {pocketshell-0.3.30 → pocketshell-0.3.32}/src/pocketshell/usage.py +0 -0
  21. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/__init__.py +0 -0
  22. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_agent_log.py +0 -0
  23. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_cli.py +0 -0
  24. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_daemon.py +0 -0
  25. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_env.py +0 -0
  26. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_hooks.py +0 -0
  27. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_jobs.py +0 -0
  28. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_logs.py +0 -0
  29. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_qr_share.py +0 -0
  30. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_repos.py +0 -0
  31. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_sessions.py +0 -0
  32. {pocketshell-0.3.30 → pocketshell-0.3.32}/tests/test_usage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.3.30
3
+ Version: 0.3.32
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.30"
11
+ version = "0.3.32"
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}")
@@ -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-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