pocketshell 0.1.0__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.
@@ -0,0 +1,11 @@
1
+ # Python build / cache artifacts produced by `uv pip install -e .`,
2
+ # `pytest`, and `ruff`. These never want to be checked in.
3
+ .venv/
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ dist/
11
+ build/
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: pocketshell
3
+ Version: 0.1.0
4
+ Summary: Unified server-side Python utility for the PocketShell Android client.
5
+ Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
6
+ Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
7
+ Author: Alexey Grigorev
8
+ License: MIT
9
+ Keywords: agents,pocketshell,ssh,tmux,usage
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development
19
+ Classifier: Topic :: System :: Monitoring
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: click>=8.2.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.4.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.15.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pocketshell-cli
28
+
29
+ Unified server-side Python utility for the [PocketShell](https://github.com/alexeygrigorev/pocketshell)
30
+ Android client. Replaces the separately-installed `quse` and `tmuxctl`
31
+ utilities the app currently probes for on every remote host.
32
+
33
+ This first release ships the **skeleton plus the `pocketshell usage`
34
+ subcommand only**. Follow-up rounds will add `jobs`, `agent-log`,
35
+ `sessions`, `repos`, and an optional daemon mode. See
36
+ [issue #170](https://github.com/alexeygrigorev/pocketshell/issues/170) for
37
+ the design spike and phased roll-out plan.
38
+
39
+ ## Install
40
+
41
+ The recommended path is `uv tool install`, which lands the binary on PATH
42
+ under `~/.local/bin/`:
43
+
44
+ ```bash
45
+ uv tool install pocketshell-cli
46
+ ```
47
+
48
+ For local development from a clone:
49
+
50
+ ```bash
51
+ cd tools/pocketshell-cli
52
+ uv venv
53
+ uv pip install -e .
54
+ pocketshell --help
55
+ ```
56
+
57
+ `pipx install pocketshell-cli` works the same way for users who prefer
58
+ pipx. Both install paths produce a `pocketshell` binary that the
59
+ PocketShell app's bootstrap probe detects.
60
+
61
+ ## Usage
62
+
63
+ ```text
64
+ pocketshell usage # human-readable lines, one per provider
65
+ pocketshell usage --json # machine-readable JSON (consumed by the app)
66
+ pocketshell usage codex # filter to a single provider
67
+ ```
68
+
69
+ The output shape is byte-identical to `quse [provider] [--json]` so any
70
+ consumer that already parses `quse` output keeps working when the app
71
+ routes through `pocketshell usage` instead. Under the hood the first
72
+ release delegates to the `quse` CLI via subprocess; later rounds will
73
+ fold the provider-detection logic in directly and drop the subprocess
74
+ hop.
75
+
76
+ If `quse` is not installed, `pocketshell usage` exits with code 127 and
77
+ prints an install hint to stderr.
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ cd tools/pocketshell-cli
83
+ uv venv
84
+ uv pip install -e ".[dev]"
85
+ uv run pytest
86
+ ```
87
+
88
+ Or via the dependency-group:
89
+
90
+ ```bash
91
+ uv sync --group dev
92
+ uv run pytest
93
+ ```
94
+
95
+ The tests stub `quse.usage.collect_usage` so they run in seconds without
96
+ hitting any provider API.
97
+
98
+ ## Why a unified CLI?
99
+
100
+ The PocketShell app previously probed for two binaries (`quse`,
101
+ `tmuxctl`) on every host. That meant two installs to keep up to date,
102
+ two probes to surface failures from, and two PATH-discovery edge cases
103
+ (see [issue #41](https://github.com/alexeygrigorev/pocketshell/issues/41)).
104
+ A single `pocketshell` binary collapses those into one install, one
105
+ probe, one bootstrap row. The app keeps detecting `quse` and `tmuxctl`
106
+ as a parallel path while `pocketshell` ramps up to feature parity; once
107
+ parity is reached, the legacy probes are removed in a hard-cut follow-up
108
+ (no compat shim — see decision D22 in `docs/decisions.md`).
@@ -0,0 +1,82 @@
1
+ # pocketshell-cli
2
+
3
+ Unified server-side Python utility for the [PocketShell](https://github.com/alexeygrigorev/pocketshell)
4
+ Android client. Replaces the separately-installed `quse` and `tmuxctl`
5
+ utilities the app currently probes for on every remote host.
6
+
7
+ This first release ships the **skeleton plus the `pocketshell usage`
8
+ subcommand only**. Follow-up rounds will add `jobs`, `agent-log`,
9
+ `sessions`, `repos`, and an optional daemon mode. See
10
+ [issue #170](https://github.com/alexeygrigorev/pocketshell/issues/170) for
11
+ the design spike and phased roll-out plan.
12
+
13
+ ## Install
14
+
15
+ The recommended path is `uv tool install`, which lands the binary on PATH
16
+ under `~/.local/bin/`:
17
+
18
+ ```bash
19
+ uv tool install pocketshell-cli
20
+ ```
21
+
22
+ For local development from a clone:
23
+
24
+ ```bash
25
+ cd tools/pocketshell-cli
26
+ uv venv
27
+ uv pip install -e .
28
+ pocketshell --help
29
+ ```
30
+
31
+ `pipx install pocketshell-cli` works the same way for users who prefer
32
+ pipx. Both install paths produce a `pocketshell` binary that the
33
+ PocketShell app's bootstrap probe detects.
34
+
35
+ ## Usage
36
+
37
+ ```text
38
+ pocketshell usage # human-readable lines, one per provider
39
+ pocketshell usage --json # machine-readable JSON (consumed by the app)
40
+ pocketshell usage codex # filter to a single provider
41
+ ```
42
+
43
+ The output shape is byte-identical to `quse [provider] [--json]` so any
44
+ consumer that already parses `quse` output keeps working when the app
45
+ routes through `pocketshell usage` instead. Under the hood the first
46
+ release delegates to the `quse` CLI via subprocess; later rounds will
47
+ fold the provider-detection logic in directly and drop the subprocess
48
+ hop.
49
+
50
+ If `quse` is not installed, `pocketshell usage` exits with code 127 and
51
+ prints an install hint to stderr.
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ cd tools/pocketshell-cli
57
+ uv venv
58
+ uv pip install -e ".[dev]"
59
+ uv run pytest
60
+ ```
61
+
62
+ Or via the dependency-group:
63
+
64
+ ```bash
65
+ uv sync --group dev
66
+ uv run pytest
67
+ ```
68
+
69
+ The tests stub `quse.usage.collect_usage` so they run in seconds without
70
+ hitting any provider API.
71
+
72
+ ## Why a unified CLI?
73
+
74
+ The PocketShell app previously probed for two binaries (`quse`,
75
+ `tmuxctl`) on every host. That meant two installs to keep up to date,
76
+ two probes to surface failures from, and two PATH-discovery edge cases
77
+ (see [issue #41](https://github.com/alexeygrigorev/pocketshell/issues/41)).
78
+ A single `pocketshell` binary collapses those into one install, one
79
+ probe, one bootstrap row. The app keeps detecting `quse` and `tmuxctl`
80
+ as a parallel path while `pocketshell` ramps up to feature parity; once
81
+ parity is reached, the legacy probes are removed in a hard-cut follow-up
82
+ (no compat shim — see decision D22 in `docs/decisions.md`).
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pocketshell"
7
+ version = "0.1.0"
8
+ description = "Unified server-side Python utility for the PocketShell Android client."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Alexey Grigorev" }]
12
+ license = { text = "MIT" }
13
+ keywords = ["pocketshell", "ssh", "tmux", "agents", "usage"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development",
24
+ "Topic :: System :: Monitoring",
25
+ ]
26
+ # First PR delegates `pocketshell usage` to the existing `quse` CLI via
27
+ # subprocess (see `pocketshell/usage.py`) so the new utility does not need
28
+ # `quse` published on PyPI. Later PRs will fold the provider-detection
29
+ # implementation in directly. Pin lower-bound only.
30
+ dependencies = [
31
+ "click>=8.2.0",
32
+ ]
33
+
34
+ [project.scripts]
35
+ pocketshell = "pocketshell.cli:main"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/alexeygrigorev/pocketshell"
39
+ Issues = "https://github.com/alexeygrigorev/pocketshell/issues"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=8.4.0",
44
+ "ruff>=0.15.0",
45
+ ]
46
+
47
+ [project.optional-dependencies]
48
+ # Mirror of the dev `dependency-groups` so `pip install -e ".[dev]"` and
49
+ # `uv pip install -e ".[dev]"` both work for CI and ad-hoc local setups.
50
+ # `uv sync --group dev` reads `dependency-groups`; PEP-621 tooling reads
51
+ # `optional-dependencies`. Keeping both in lock-step is cheap.
52
+ dev = [
53
+ "pytest>=8.4.0",
54
+ "ruff>=0.15.0",
55
+ ]
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ packages = ["src/pocketshell"]
59
+
60
+ [tool.hatch.build.targets.sdist]
61
+ exclude = [
62
+ "/.github",
63
+ "/.pytest_cache",
64
+ "/.ruff_cache",
65
+ "/.venv",
66
+ "/dist",
67
+ ]
68
+
69
+ [tool.pytest.ini_options]
70
+ testpaths = ["tests"]
71
+
72
+ [tool.ruff]
73
+ line-length = 100
74
+ target-version = "py311"
@@ -0,0 +1,14 @@
1
+ """Unified server-side Python utility for the PocketShell Android client.
2
+
3
+ This package is intended to replace the separately-installed `quse` and
4
+ `tmuxctl` utilities the PocketShell app currently probes for. The first PR
5
+ ships the skeleton plus the `pocketshell usage` subcommand only; later PRs
6
+ will add `jobs`, `agent-log`, `sessions`, `repos`, and daemon mode.
7
+
8
+ See https://github.com/alexeygrigorev/pocketshell/issues/170.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __all__ = ["__version__"]
14
+ __version__ = "0.1.0"
@@ -0,0 +1,14 @@
1
+ """Allow `python -m pocketshell` as an alternative to the installed entry point.
2
+
3
+ Used in CI and developer environments where the console-script shim from
4
+ `pip install -e .` / `uv tool install pocketshell-cli` is not on PATH.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+
11
+ from pocketshell.cli import main
12
+
13
+ if __name__ == "__main__":
14
+ sys.exit(main())
@@ -0,0 +1,67 @@
1
+ """Top-level Click dispatcher for the unified `pocketshell` CLI.
2
+
3
+ This is the skeleton landed in the first PR of issue
4
+ [#170](https://github.com/alexeygrigorev/pocketshell/issues/170). Only the
5
+ `usage` subcommand is wired up today; later PRs will add `jobs`,
6
+ `agent-log`, `sessions`, `repos`, and `daemon`.
7
+
8
+ Per the D22 locked principle (no backwards compatibility, hard cuts only)
9
+ the eventual goal is for the PocketShell Android app to probe for this
10
+ single binary instead of `quse` / `tmuxctl`. The first PR keeps the
11
+ existing probes in place — parallel detection, not legacy detection — so
12
+ the app keeps working while we ramp up `pocketshell`'s feature parity.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from typing import Optional, Sequence
19
+
20
+ import click
21
+
22
+ from pocketshell import __version__
23
+ from pocketshell.usage import usage_command
24
+
25
+
26
+ @click.group(
27
+ context_settings={"help_option_names": ["-h", "--help"]},
28
+ help=(
29
+ "Unified server-side helper for the PocketShell Android client.\n\n"
30
+ "Subcommands replace the separately-installed `quse` and `tmuxctl` "
31
+ "CLIs. The first PR ships `usage` only; more subcommands will land "
32
+ "in follow-up rounds."
33
+ ),
34
+ )
35
+ @click.version_option(__version__, "-V", "--version", prog_name="pocketshell")
36
+ def cli() -> None:
37
+ """Top-level group. Each subcommand is registered below."""
38
+
39
+
40
+ cli.add_command(usage_command, name="usage")
41
+
42
+
43
+ def main(argv: Optional[Sequence[str]] = None) -> int:
44
+ """Entrypoint for both the console-script and `python -m pocketshell`.
45
+
46
+ Returns an integer exit code rather than letting Click call
47
+ `sys.exit` so the function is testable from the unit suite.
48
+ """
49
+ try:
50
+ result = cli.main(args=list(argv) if argv is not None else None,
51
+ prog_name="pocketshell",
52
+ standalone_mode=False)
53
+ except click.exceptions.Exit as exc:
54
+ return int(exc.exit_code)
55
+ except click.ClickException as exc:
56
+ exc.show()
57
+ return int(exc.exit_code)
58
+ if result is None:
59
+ return 0
60
+ try:
61
+ return int(result)
62
+ except (TypeError, ValueError):
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ sys.exit(main())
@@ -0,0 +1,115 @@
1
+ """`pocketshell usage` subcommand.
2
+
3
+ First-PR implementation: delegate to the existing `quse` CLI via
4
+ `subprocess.run`. The arguments and stdout are proxied through verbatim
5
+ so the JSON payload (and human-readable lines) are byte-identical to
6
+ `quse [provider] [--json]`. The existing Kotlin `QuseUsageJsonParser`
7
+ keeps working when the Android app eventually switches its probe over.
8
+
9
+ Why subprocess instead of `import quse`:
10
+
11
+ - `quse` is currently not published to PyPI; declaring it as a normal
12
+ `pyproject.toml` dependency would break `uv tool install
13
+ pocketshell-cli` for any user (including the maintainer's dev box).
14
+ - Subprocess delegation keeps `pocketshell-cli` decoupled from `quse`'s
15
+ internal module layout, so updates to `quse` don't break the wrapper.
16
+ - The PATH-discovery story for `quse` is already solved on the app side
17
+ (see issue #41 + the `pathOverride` column on `HostEntity`). Wrapping
18
+ `quse` here means the app's existing PATH override mechanism keeps
19
+ working without re-implementation.
20
+
21
+ Later PRs will fold the provider-detection logic in directly so
22
+ `pocketshell-cli` is the canonical implementation and the subprocess
23
+ hop disappears, but that is explicit non-scope here per the brief on
24
+ issue [#170](https://github.com/alexeygrigorev/pocketshell/issues/170).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import shutil
30
+ import subprocess
31
+ import sys
32
+ from typing import Optional, Sequence
33
+
34
+ import click
35
+
36
+
37
+ def _resolve_quse_binary() -> Optional[str]:
38
+ """Locate the `quse` CLI on PATH, or return ``None`` if absent.
39
+
40
+ Pulled out as a function so the unit suite can monkeypatch it.
41
+ `shutil.which` returns the same path the user would see from
42
+ `command -v quse`, which is the probe the Android app already runs.
43
+ """
44
+ return shutil.which("quse")
45
+
46
+
47
+ def _run_quse(args: Sequence[str]) -> int:
48
+ """Invoke `quse` with [args]; proxy stdout/stderr and exit code.
49
+
50
+ Using `subprocess.run(..., check=False)` and forwarding the captured
51
+ output rather than `os.execvp` keeps the call testable (the test
52
+ suite can monkeypatch `subprocess.run`) and lets us decorate the
53
+ failure mode with a friendly hint when `quse` is missing.
54
+ """
55
+ quse_path = _resolve_quse_binary()
56
+ if quse_path is None:
57
+ # Same wording the bootstrap sheet uses so the user sees a
58
+ # consistent message whether they hit the bin via `pocketshell
59
+ # usage` or the app's poll loop.
60
+ click.echo(
61
+ "pocketshell: `quse` is not installed on this host. "
62
+ "Install it via `uv tool install quse` or `pipx install quse` "
63
+ "and re-run.",
64
+ err=True,
65
+ )
66
+ return 127
67
+
68
+ completed = subprocess.run(
69
+ [quse_path, *args],
70
+ check=False,
71
+ capture_output=True,
72
+ text=True,
73
+ )
74
+ # Echo verbatim so the JSON output is byte-identical to `quse --json`.
75
+ if completed.stdout:
76
+ sys.stdout.write(completed.stdout)
77
+ if completed.stderr:
78
+ sys.stderr.write(completed.stderr)
79
+ return completed.returncode
80
+
81
+
82
+ @click.command(
83
+ context_settings={"help_option_names": ["-h", "--help"], "ignore_unknown_options": True},
84
+ )
85
+ @click.argument("provider", required=False)
86
+ @click.option(
87
+ "--json",
88
+ "json_output",
89
+ is_flag=True,
90
+ help="Emit machine-readable JSON output identical to `quse --json`.",
91
+ )
92
+ @click.pass_context
93
+ def usage_command(
94
+ ctx: click.Context,
95
+ provider: Optional[str] = None,
96
+ json_output: bool = False,
97
+ ) -> None:
98
+ """Report quota / usage for coding-agent providers on this host.
99
+
100
+ Delegates to the `quse` CLI via subprocess. Output shape (both human
101
+ and JSON) is byte-identical to `quse [provider] [--json]` so any
102
+ consumer that already parses `quse` output keeps working when the
103
+ PocketShell app routes through `pocketshell usage` instead.
104
+ """
105
+ args: list[str] = []
106
+ if provider:
107
+ args.append(provider)
108
+ if json_output:
109
+ args.append("--json")
110
+ exit_code = _run_quse(args)
111
+ # Click ignores the return value of a callback by default; we need to
112
+ # explicitly propagate non-zero exit codes through `ctx.exit` so the
113
+ # outer `main()` (and the OS) sees the same exit code `quse` reported.
114
+ if exit_code != 0:
115
+ ctx.exit(exit_code)
File without changes
@@ -0,0 +1,168 @@
1
+ """Unit tests for `pocketshell usage`.
2
+
3
+ The first PR exercises:
4
+ - `pocketshell --help` lists the `usage` subcommand.
5
+ - `pocketshell usage --help` shows the click usage line.
6
+ - `pocketshell usage --json` forwards through to `quse --json`.
7
+ - `pocketshell usage <provider>` forwards the positional arg.
8
+ - Missing `quse` produces a friendly stderr message + exit 127.
9
+ - stdout/stderr/exit-code from the subprocess are proxied verbatim.
10
+
11
+ The tests stub `pocketshell.usage._resolve_quse_binary` and
12
+ `subprocess.run` so they never invoke a real `quse` binary; the contract
13
+ under test is "pocketshell delegates correctly to whatever quse exists
14
+ on the host", not "the provider check works".
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import subprocess
21
+ from typing import Sequence
22
+ from unittest.mock import patch
23
+
24
+ from click.testing import CliRunner
25
+
26
+ from pocketshell.cli import cli, main
27
+ from pocketshell.usage import usage_command
28
+
29
+
30
+ def _fake_completed(
31
+ stdout: str = "",
32
+ stderr: str = "",
33
+ returncode: int = 0,
34
+ ) -> subprocess.CompletedProcess:
35
+ return subprocess.CompletedProcess(
36
+ args=[],
37
+ returncode=returncode,
38
+ stdout=stdout,
39
+ stderr=stderr,
40
+ )
41
+
42
+
43
+ def _fake_json_payload() -> str:
44
+ return json.dumps(
45
+ {
46
+ "claude": {
47
+ "plan": "Pro",
48
+ "session_used_percent": 12.5,
49
+ "session_window_resets_at": "2026-05-27T15:00:00Z",
50
+ },
51
+ "codex": {
52
+ "plan": "Plus",
53
+ "session_used_percent": 4.0,
54
+ "session_window_resets_at": "2026-05-27T16:00:00Z",
55
+ },
56
+ },
57
+ indent=2,
58
+ sort_keys=True,
59
+ )
60
+
61
+
62
+ def test_top_level_help_lists_usage_subcommand() -> None:
63
+ runner = CliRunner()
64
+ result = runner.invoke(cli, ["--help"])
65
+ assert result.exit_code == 0, result.output
66
+ assert "usage" in result.output
67
+
68
+
69
+ def test_usage_help_does_not_call_quse() -> None:
70
+ runner = CliRunner()
71
+ with patch("pocketshell.usage.subprocess.run") as run, patch(
72
+ "pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"
73
+ ):
74
+ result = runner.invoke(usage_command, ["--help"])
75
+ assert result.exit_code == 0, result.output
76
+ assert "provider" in result.output.lower()
77
+ run.assert_not_called()
78
+
79
+
80
+ def test_usage_json_forwards_to_quse_and_proxies_stdout() -> None:
81
+ payload = _fake_json_payload()
82
+ runner = CliRunner()
83
+ with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
84
+ "pocketshell.usage.subprocess.run",
85
+ return_value=_fake_completed(stdout=payload),
86
+ ) as run:
87
+ result = runner.invoke(usage_command, ["--json"])
88
+ assert result.exit_code == 0, result.output
89
+ # The stdout we got back must be the exact bytes `quse --json` emitted.
90
+ assert result.output == payload
91
+ # Args forwarded to the quse subprocess must include `--json`.
92
+ call_args = run.call_args
93
+ invoked: Sequence[str] = call_args.args[0]
94
+ assert invoked == ["/fake/quse", "--json"]
95
+
96
+
97
+ def test_usage_forwards_provider_argument() -> None:
98
+ runner = CliRunner()
99
+ with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
100
+ "pocketshell.usage.subprocess.run",
101
+ return_value=_fake_completed(stdout="claude — 12.5% used\n"),
102
+ ) as run:
103
+ result = runner.invoke(usage_command, ["claude"])
104
+ assert result.exit_code == 0, result.output
105
+ invoked: Sequence[str] = run.call_args.args[0]
106
+ assert invoked == ["/fake/quse", "claude"]
107
+ assert "claude" in result.output
108
+
109
+
110
+ def test_usage_forwards_provider_and_json_flag_together() -> None:
111
+ runner = CliRunner()
112
+ with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
113
+ "pocketshell.usage.subprocess.run",
114
+ return_value=_fake_completed(stdout="{\n \"claude\": {}\n}\n"),
115
+ ) as run:
116
+ result = runner.invoke(usage_command, ["claude", "--json"])
117
+ assert result.exit_code == 0, result.output
118
+ invoked: Sequence[str] = run.call_args.args[0]
119
+ assert invoked == ["/fake/quse", "claude", "--json"]
120
+
121
+
122
+ def test_usage_returns_127_when_quse_missing() -> None:
123
+ runner = CliRunner()
124
+ with patch("pocketshell.usage._resolve_quse_binary", return_value=None), patch(
125
+ "pocketshell.usage.subprocess.run"
126
+ ) as run:
127
+ result = runner.invoke(usage_command, ["--json"], catch_exceptions=False)
128
+ # 127 is the POSIX exit code for "command not found" and matches the
129
+ # signal `UsageRemoteSource.fetchUsage` already special-cases on the
130
+ # Kotlin side.
131
+ assert result.exit_code == 127, result.output
132
+ # The friendly message lands on stderr; CliRunner mixes stdout+stderr
133
+ # by default but `mix_stderr=False` is removed in click>=8.2, so just
134
+ # check that the install hint was emitted somewhere.
135
+ assert "quse" in result.output.lower()
136
+ run.assert_not_called()
137
+
138
+
139
+ def test_usage_proxies_nonzero_exit_from_quse() -> None:
140
+ runner = CliRunner()
141
+ with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
142
+ "pocketshell.usage.subprocess.run",
143
+ return_value=_fake_completed(stderr="error: unknown provider\n", returncode=2),
144
+ ):
145
+ result = runner.invoke(usage_command, ["wat"])
146
+ assert result.exit_code == 2
147
+ # stderr from the subprocess must reach the user (otherwise debugging
148
+ # a failing provider would be opaque).
149
+ assert "unknown provider" in result.output
150
+
151
+
152
+ def test_main_returns_int_on_success() -> None:
153
+ """`main` is the canonical entrypoint for the console-script and
154
+ `python -m pocketshell`. It must translate Click's exit-code object
155
+ into a plain int and never raise SystemExit through to the caller.
156
+ """
157
+ with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
158
+ "pocketshell.usage.subprocess.run",
159
+ return_value=_fake_completed(stdout=_fake_json_payload()),
160
+ ):
161
+ exit_code = main(["usage", "--json"])
162
+ assert isinstance(exit_code, int)
163
+ assert exit_code == 0
164
+
165
+
166
+ def test_main_returns_nonzero_on_unknown_subcommand() -> None:
167
+ exit_code = main(["bogus-subcommand-that-does-not-exist"])
168
+ assert exit_code != 0
@@ -0,0 +1,139 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [options]
6
+ exclude-newer = "2026-05-20T13:56:21.125647543Z"
7
+ exclude-newer-span = "P7D"
8
+
9
+ [[package]]
10
+ name = "click"
11
+ version = "8.4.0"
12
+ source = { registry = "https://pypi.org/simple" }
13
+ dependencies = [
14
+ { name = "colorama", marker = "sys_platform == 'win32'" },
15
+ ]
16
+ sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" }
17
+ wheels = [
18
+ { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" },
19
+ ]
20
+
21
+ [[package]]
22
+ name = "colorama"
23
+ version = "0.4.6"
24
+ source = { registry = "https://pypi.org/simple" }
25
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
26
+ wheels = [
27
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
28
+ ]
29
+
30
+ [[package]]
31
+ name = "iniconfig"
32
+ version = "2.3.0"
33
+ source = { registry = "https://pypi.org/simple" }
34
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
35
+ wheels = [
36
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
37
+ ]
38
+
39
+ [[package]]
40
+ name = "packaging"
41
+ version = "26.2"
42
+ source = { registry = "https://pypi.org/simple" }
43
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
44
+ wheels = [
45
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
46
+ ]
47
+
48
+ [[package]]
49
+ name = "pluggy"
50
+ version = "1.6.0"
51
+ source = { registry = "https://pypi.org/simple" }
52
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
53
+ wheels = [
54
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
55
+ ]
56
+
57
+ [[package]]
58
+ name = "pocketshell-cli"
59
+ version = "0.1.0"
60
+ source = { editable = "." }
61
+ dependencies = [
62
+ { name = "click" },
63
+ ]
64
+
65
+ [package.optional-dependencies]
66
+ dev = [
67
+ { name = "pytest" },
68
+ { name = "ruff" },
69
+ ]
70
+
71
+ [package.dev-dependencies]
72
+ dev = [
73
+ { name = "pytest" },
74
+ { name = "ruff" },
75
+ ]
76
+
77
+ [package.metadata]
78
+ requires-dist = [
79
+ { name = "click", specifier = ">=8.2.0" },
80
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.0" },
81
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" },
82
+ ]
83
+ provides-extras = ["dev"]
84
+
85
+ [package.metadata.requires-dev]
86
+ dev = [
87
+ { name = "pytest", specifier = ">=8.4.0" },
88
+ { name = "ruff", specifier = ">=0.15.0" },
89
+ ]
90
+
91
+ [[package]]
92
+ name = "pygments"
93
+ version = "2.20.0"
94
+ source = { registry = "https://pypi.org/simple" }
95
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
96
+ wheels = [
97
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
98
+ ]
99
+
100
+ [[package]]
101
+ name = "pytest"
102
+ version = "9.0.3"
103
+ source = { registry = "https://pypi.org/simple" }
104
+ dependencies = [
105
+ { name = "colorama", marker = "sys_platform == 'win32'" },
106
+ { name = "iniconfig" },
107
+ { name = "packaging" },
108
+ { name = "pluggy" },
109
+ { name = "pygments" },
110
+ ]
111
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
112
+ wheels = [
113
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
114
+ ]
115
+
116
+ [[package]]
117
+ name = "ruff"
118
+ version = "0.15.13"
119
+ source = { registry = "https://pypi.org/simple" }
120
+ sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
121
+ wheels = [
122
+ { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
123
+ { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
124
+ { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
125
+ { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
126
+ { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
127
+ { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
128
+ { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
129
+ { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
130
+ { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
131
+ { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
132
+ { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
133
+ { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
134
+ { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
135
+ { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
136
+ { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
137
+ { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
138
+ { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
139
+ ]