trap-cli 0.0.1__py3-none-any.whl

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 (54) hide show
  1. trap/__init__.py +6 -0
  2. trap/_version.py +24 -0
  3. trap/auth/__init__.py +15 -0
  4. trap/auth/client.py +56 -0
  5. trap/auth/login.py +39 -0
  6. trap/auth/oauth.py +82 -0
  7. trap/auth/store.py +39 -0
  8. trap/cli/__init__.py +274 -0
  9. trap/cli/_auth.py +103 -0
  10. trap/cost/__init__.py +3 -0
  11. trap/cost/calculator.py +41 -0
  12. trap/cost/providers.py +122 -0
  13. trap/cost/proxy.py +186 -0
  14. trap/display/__init__.py +4 -0
  15. trap/display/progress.py +61 -0
  16. trap/display/submit.py +20 -0
  17. trap/environment/__init__.py +3 -0
  18. trap/environment/detector.py +81 -0
  19. trap/git_ops/__init__.py +13 -0
  20. trap/git_ops/base.py +10 -0
  21. trap/git_ops/local.py +60 -0
  22. trap/git_ops/remote.py +63 -0
  23. trap/git_ops/rev.py +119 -0
  24. trap/git_ops/url.py +98 -0
  25. trap/loader/__init__.py +5 -0
  26. trap/loader/errors.py +7 -0
  27. trap/loader/trap_yaml.py +99 -0
  28. trap/loader/traptask_yaml.py +85 -0
  29. trap/models/__init__.py +36 -0
  30. trap/models/cost.py +36 -0
  31. trap/models/environment.py +24 -0
  32. trap/models/provenance.py +20 -0
  33. trap/models/report.py +61 -0
  34. trap/models/results.py +17 -0
  35. trap/models/trap_yaml.py +64 -0
  36. trap/models/traptask_yaml.py +58 -0
  37. trap/report/__init__.py +19 -0
  38. trap/report/base.py +8 -0
  39. trap/report/handle.py +54 -0
  40. trap/report/json.py +11 -0
  41. trap/report/rich.py +116 -0
  42. trap/runner/__init__.py +5 -0
  43. trap/runner/capture.py +34 -0
  44. trap/runner/grader.py +36 -0
  45. trap/runner/judge.py +50 -0
  46. trap/runner/layout.py +36 -0
  47. trap/runner/proc.py +122 -0
  48. trap/runner/solution.py +80 -0
  49. trap/runner/task.py +102 -0
  50. trap_cli-0.0.1.dist-info/METADATA +88 -0
  51. trap_cli-0.0.1.dist-info/RECORD +54 -0
  52. trap_cli-0.0.1.dist-info/WHEEL +4 -0
  53. trap_cli-0.0.1.dist-info/entry_points.txt +2 -0
  54. trap_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
trap/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ try:
2
+ from trap._version import __version__
3
+ except ImportError: # pragma: no cover - only when built without hatch-vcs
4
+ __version__ = "0.0.0+unknown"
5
+
6
+ __all__ = ["__version__"]
trap/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = None
trap/auth/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from trap.auth.client import ApiClient, ApiError
2
+ from trap.auth.login import BrowserProvider, TokenProvider
3
+ from trap.auth.oauth import OAuthCallbackServer
4
+ from trap.auth.store import DEFAULT_SERVER, AuthData, AuthStore
5
+
6
+ __all__ = [
7
+ "DEFAULT_SERVER",
8
+ "ApiClient",
9
+ "ApiError",
10
+ "AuthData",
11
+ "AuthStore",
12
+ "BrowserProvider",
13
+ "OAuthCallbackServer",
14
+ "TokenProvider",
15
+ ]
trap/auth/client.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class ApiError(Exception):
11
+ """A trapstreet API call failed — bad status, unreachable server, or invalid token.
12
+ Carries a user-facing message; the CLI maps it to a clean error (no traceback)."""
13
+
14
+
15
+ class ApiClient:
16
+ """Authenticated HTTP client for the trapstreet API."""
17
+
18
+ def __init__(self, server: str, api_key: str, timeout: int = 30) -> None:
19
+ self._server = server.rstrip("/")
20
+ self._api_key = api_key
21
+ self._timeout = timeout
22
+
23
+ @cached_property
24
+ def _client(self) -> httpx.Client:
25
+ return httpx.Client(
26
+ base_url=self._server,
27
+ headers={
28
+ "authorization": f"Bearer {self._api_key}",
29
+ "content-type": "application/json",
30
+ },
31
+ timeout=self._timeout,
32
+ )
33
+
34
+ def get_me(self) -> dict[str, Any]:
35
+ try:
36
+ resp = self._client.get("/api/me", timeout=10)
37
+ resp.raise_for_status()
38
+ return resp.json()
39
+ except httpx.HTTPStatusError as e:
40
+ if e.response.status_code == 401:
41
+ raise ApiError("token is invalid") from None
42
+ raise ApiError(f"server error ({e.response.status_code})") from None
43
+ except httpx.RequestError:
44
+ raise ApiError("server unreachable") from None
45
+
46
+ def submit(self, report_path: Path) -> dict[str, Any]:
47
+ # Content-addressed ingest: the task identity travels inside the report
48
+ # (provenance.task.{repo,commit}), not the URL — so no task_id path segment.
49
+ try:
50
+ resp = self._client.post("/api/submit", content=report_path.read_bytes())
51
+ resp.raise_for_status()
52
+ return resp.json()
53
+ except httpx.HTTPStatusError as e:
54
+ raise ApiError(f"http {e.response.status_code}: {e.response.text}") from None
55
+ except httpx.RequestError as e:
56
+ raise ApiError(f"connection error: {e}") from None
trap/auth/login.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from trap.auth.oauth import OAuthCallbackServer
4
+ from trap.auth.store import DEFAULT_SERVER, AuthData
5
+
6
+
7
+ class TokenProvider:
8
+ def __init__(self, server: str, token: str) -> None:
9
+ self._server = server
10
+ self._token = token
11
+
12
+ @property
13
+ def pre_message(self) -> str:
14
+ return "authenticating with api_key"
15
+
16
+ def acquire(self) -> AuthData:
17
+ if not self._token:
18
+ raise ValueError("empty api_key")
19
+ return AuthData(server=self._server, api_key=self._token)
20
+
21
+
22
+ class BrowserProvider:
23
+ def __init__(self, server: str, timeout: int) -> None:
24
+ if server != DEFAULT_SERVER:
25
+ raise ValueError(
26
+ f"browser login is only supported on {DEFAULT_SERVER}. Use --with-token for custom servers."
27
+ )
28
+ self._cb = OAuthCallbackServer(server)
29
+ self._timeout = timeout
30
+
31
+ @property
32
+ def pre_message(self) -> str:
33
+ return f"opening [link={self._cb.auth_url}]{self._cb.auth_url}[/link]"
34
+
35
+ def acquire(self) -> AuthData:
36
+ if not self._cb.run(self._timeout):
37
+ raise TimeoutError(f"timed out after {self._timeout}s waiting for browser approval")
38
+ assert self._cb.auth_data is not None
39
+ return self._cb.auth_data
trap/auth/oauth.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import http.server
4
+ import socketserver
5
+ import threading
6
+ import urllib.parse
7
+ import webbrowser
8
+ from typing import Any
9
+
10
+ from trap.auth.store import AuthData
11
+
12
+
13
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
14
+ _SUCCESS_HTML = (
15
+ b"<!doctype html><meta charset=utf-8>"
16
+ b"<title>logged in</title>"
17
+ b"<style>body{font-family:ui-monospace,monospace;"
18
+ b"background:#0a0a0a;color:#ededed;padding:3em;}"
19
+ b"h1{color:#ff5f1f}</style>"
20
+ b"<h1>logged in</h1>"
21
+ b"<p>You can close this tab.</p>"
22
+ )
23
+
24
+ def log_message(self, format: str, *args: Any) -> None:
25
+ return
26
+
27
+ def _respond(self, status: int, content_type: str, body: bytes) -> None:
28
+ self.send_response(status)
29
+ self.send_header("content-type", content_type)
30
+ self.end_headers()
31
+ self.wfile.write(body)
32
+
33
+ def do_GET(self) -> None:
34
+ params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
35
+
36
+ api_key = (params.get("api_key") or [None])[0]
37
+ if not api_key:
38
+ self._respond(400, "text/plain", b"missing api_key in query string")
39
+ return
40
+
41
+ solution = (params.get("solution") or [None])[0]
42
+ srv: OAuthCallbackServer = self.server # type: ignore[assignment]
43
+ srv._receive(AuthData(server=srv._server_url, api_key=api_key, solution=solution))
44
+ self._respond(200, "text/html; charset=utf-8", self._SUCCESS_HTML)
45
+
46
+
47
+ class OAuthCallbackServer(socketserver.TCPServer):
48
+ """Local HTTP server that receives the OAuth callback with api_key."""
49
+
50
+ def __init__(self, server_url: str) -> None:
51
+ super().__init__(("127.0.0.1", 0), _CallbackHandler)
52
+ self._server_url = server_url.rstrip("/")
53
+ self._auth_data: AuthData | None = None
54
+ self._received = threading.Event()
55
+
56
+ def _receive(self, data: AuthData) -> None:
57
+ self._auth_data = data
58
+ self._received.set()
59
+
60
+ @property
61
+ def auth_data(self) -> AuthData | None:
62
+ return self._auth_data
63
+
64
+ @property
65
+ def port(self) -> int:
66
+ return self.server_address[1]
67
+
68
+ @property
69
+ def auth_url(self) -> str:
70
+ return f"{self._server_url}/cli/authorize?return=http://localhost:{self.port}/callback"
71
+
72
+ def run(self, timeout: int) -> bool:
73
+ """Start server, open browser, wait for callback, shut down. Returns True if api_key received."""
74
+ threading.Thread(target=self.serve_forever, daemon=True).start()
75
+ try:
76
+ webbrowser.open(self.auth_url)
77
+ except Exception:
78
+ pass
79
+ received = self._received.wait(timeout)
80
+ self.shutdown()
81
+ self.server_close()
82
+ return received
trap/auth/store.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import json as _json
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, ValidationError
7
+
8
+ DEFAULT_SERVER = "https://trapstreet.run"
9
+
10
+
11
+ class AuthData(BaseModel):
12
+ server: str
13
+ api_key: str
14
+ solution: str | None = None
15
+
16
+
17
+ class AuthStore:
18
+ PATH = Path.home() / ".config" / "trapstreet" / "auth.json"
19
+
20
+ def load(self) -> AuthData | None:
21
+ if not self.exists:
22
+ return None
23
+ try:
24
+ return AuthData.model_validate(_json.loads(self.PATH.read_text()))
25
+ except (OSError, _json.JSONDecodeError, ValidationError):
26
+ return None
27
+
28
+ def save(self, data: AuthData) -> Path:
29
+ self.PATH.parent.mkdir(parents=True, exist_ok=True)
30
+ self.PATH.write_text(data.model_dump_json(exclude_none=True, indent=2) + "\n")
31
+ self.PATH.chmod(0o600)
32
+ return self.PATH
33
+
34
+ @property
35
+ def exists(self) -> bool:
36
+ return self.PATH.exists()
37
+
38
+ def delete(self) -> None:
39
+ self.PATH.unlink()
trap/cli/__init__.py ADDED
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from trap.auth import DEFAULT_SERVER, ApiClient, ApiError, AuthStore
14
+ from trap.cli._auth import auth_app
15
+ from trap.display import CaseProgress, render_submit_result
16
+ from trap.environment import EnvironmentDetector
17
+ from trap.git_ops import GitOpsError, LocalRepo, ParsedGitUrl
18
+ from trap.loader import ConfigError, TrapLoader, TraptaskLoader
19
+ from trap.models import Provenance
20
+ from trap.report import OutputFormat, ReportHandle, renderer_factory
21
+ from trap.runner import TaskRunner
22
+
23
+ app = typer.Typer(help="AI prompt / agent / workflow / testing framework.")
24
+ app.add_typer(auth_app, name="auth")
25
+ console = Console()
26
+
27
+
28
+ def _die(msg: object) -> typer.Exit:
29
+ """Print an error and return an Exit(2) to raise."""
30
+ console.print(f"[red]error[/red]: {msg}")
31
+ return typer.Exit(code=2)
32
+
33
+
34
+ def _env_truthy(name: str) -> bool:
35
+ return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
36
+
37
+
38
+ def _confirm_remote(url: str, *, trust: bool) -> None:
39
+ """Gate trap's auto-download-and-run of a remote (git+ URL) source — the one place
40
+ trap fetches and executes code the user may not have seen. Returns to proceed;
41
+ raises to abort. Bypassed by --trust-remote / TRAP_TRUST_REMOTE (for CI / repeated
42
+ runs); refuses (rather than running silently) when there's no TTY to confirm at."""
43
+ if trust:
44
+ return
45
+ console.print(
46
+ "[yellow]⚠ about to download and RUN code from a remote source:[/yellow]\n"
47
+ f" [bold]{url}[/bold]\n"
48
+ " trap will execute its setup command, the solution, and any judge/grader\n"
49
+ " scripts — arbitrary code from this repo runs on your machine."
50
+ )
51
+ if not sys.stdin.isatty():
52
+ raise _die(
53
+ "remote source needs confirmation; pass --trust-remote "
54
+ "(or set TRAP_TRUST_REMOTE=1) to run it non-interactively"
55
+ )
56
+ if not typer.confirm("Continue?", default=False):
57
+ raise typer.Exit(code=1)
58
+
59
+
60
+ @app.command()
61
+ def run(
62
+ task: Annotated[str | None, typer.Argument()] = None,
63
+ solution: Annotated[
64
+ str | None,
65
+ typer.Option("--solution", help="Solution to run: a local path or a git+ URL (default: cwd)."),
66
+ ] = None,
67
+ clone_to: Annotated[
68
+ Path | None,
69
+ typer.Option("--clone-to", help="Where to clone a git+ URL --solution (default: ./<repo>)."),
70
+ ] = None,
71
+ trust_remote: Annotated[
72
+ bool,
73
+ typer.Option(
74
+ "--trust-remote",
75
+ help="Skip the confirmation before downloading and running a remote "
76
+ "(git+ URL) solution/task. Also settable via TRAP_TRUST_REMOTE=1.",
77
+ ),
78
+ ] = False,
79
+ tags: Annotated[list[str] | None, typer.Option("--tag", "-t")] = None,
80
+ output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
81
+ fail_fast: Annotated[bool, typer.Option("--fail-fast")] = False,
82
+ setup_solution: Annotated[
83
+ bool,
84
+ typer.Option(
85
+ "--setup-solution",
86
+ help="Force-run the solution's setup_cmd even when no remote pull brought new code.",
87
+ ),
88
+ ] = False,
89
+ setup_task: Annotated[
90
+ bool,
91
+ typer.Option(
92
+ "--setup-task",
93
+ help="Force-run the task's setup_cmd even when no remote pull brought new code.",
94
+ ),
95
+ ] = False,
96
+ workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
97
+ environment: Annotated[
98
+ bool,
99
+ typer.Option(
100
+ "--environment/--no-environment",
101
+ help="Collect host machine environment info (CPU/RAM/OS/Python) into the report.",
102
+ ),
103
+ ] = True,
104
+ cost: Annotated[
105
+ bool,
106
+ typer.Option(
107
+ "--cost/--no-cost",
108
+ help="Track LLM token usage and spend via the proxy (auto-detects providers from env).",
109
+ ),
110
+ ] = True,
111
+ ) -> None:
112
+ """Run a task against a solution.
113
+
114
+ --solution is the solution to run: a local path, or a git+ URL to clone
115
+ into ./<repo> (or --clone-to). Omit it to use the trap.yaml in the cwd.
116
+ """
117
+ # Gate trap's auto-download-and-run of any remote source before it happens — once
118
+ # for a remote --solution, once for a remote task source (resolved from trap.yaml).
119
+ trust = trust_remote or _env_truthy("TRAP_TRUST_REMOTE")
120
+ if solution is not None and ParsedGitUrl.looks_remote(solution):
121
+ _confirm_remote(solution, trust=trust)
122
+ try:
123
+ trap_yaml_loader = TrapLoader.from_solution(
124
+ solution,
125
+ clone_to,
126
+ allow_remote=True,
127
+ setup=setup_solution,
128
+ progress_func=(
129
+ (lambda m: console.print(f"[dim]{m}[/dim]")) if output == OutputFormat.rich else None
130
+ ),
131
+ )
132
+ task_binding = trap_yaml_loader.resolve_task(task)
133
+ if ParsedGitUrl.looks_remote(task_binding.source):
134
+ _confirm_remote(task_binding.source, trust=trust)
135
+ traptask_yaml_loader = TraptaskLoader.from_task(
136
+ task_binding, trap_yaml_loader.trap_dir, setup=setup_task
137
+ )
138
+ except (GitOpsError, ConfigError, subprocess.CalledProcessError) as e:
139
+ raise _die(e) from None
140
+
141
+ active_cases = traptask_yaml_loader.cases_with_tags(tags or [])
142
+
143
+ started_at_local = datetime.now()
144
+ ts = started_at_local.isoformat(timespec="seconds")
145
+ report_handle = ReportHandle(workspace.resolve(), task_binding.alias, ts)
146
+
147
+ runner = TaskRunner(
148
+ trap_config=trap_yaml_loader.config,
149
+ trap_dir=trap_yaml_loader.trap_dir,
150
+ traptask_config=traptask_yaml_loader.traptask,
151
+ traptask_dir=traptask_yaml_loader.traptask_dir,
152
+ run_dir=report_handle.run_dir,
153
+ cost_enabled=cost,
154
+ )
155
+ prog_console = console if output == OutputFormat.rich else None
156
+ with CaseProgress(active_cases, console=prog_console) as prog:
157
+ case_results, grader_metrics = runner.run(
158
+ active_cases,
159
+ fail_fast=fail_fast,
160
+ on_case_start=prog.on_case_start,
161
+ on_case_done=prog.on_case_done,
162
+ )
163
+ finished_at_utc = datetime.now(UTC)
164
+
165
+ # Record git provenance (repo + commit) of both checkouts — solution and task —
166
+ # so the run is reproducible. Each side is empty for a dirty/remote-less/local tree.
167
+ provenance = Provenance(
168
+ solution=LocalRepo.provenance_of(trap_yaml_loader.trap_dir),
169
+ task=LocalRepo.provenance_of(traptask_yaml_loader.traptask_dir),
170
+ )
171
+
172
+ # Capture the host machine environment (CPU/RAM/OS/Python) unless disabled.
173
+ # Detection is best-effort and must never abort a completed run.
174
+ environment_info = None
175
+ if environment:
176
+ try:
177
+ environment_info = EnvironmentDetector().detect()
178
+ except Exception:
179
+ environment_info = None
180
+
181
+ report_data = report_handle.save(
182
+ case_results=case_results,
183
+ trap_config=trap_yaml_loader.config,
184
+ started_at_utc=started_at_local.astimezone(UTC),
185
+ finished_at_utc=finished_at_utc,
186
+ grader_metrics=grader_metrics,
187
+ provenance=provenance,
188
+ environment=environment_info,
189
+ )
190
+ renderer_factory(output).render(report_data)
191
+ # trap reports facts, not a verdict: a completed run exits 0 regardless of per-case
192
+ # exit codes or scores (read the grader / report.json to gate CI). trap-level
193
+ # failures (bad config, git errors) still exit non-zero via _die.
194
+
195
+
196
+ @app.command()
197
+ def report(
198
+ task: Annotated[str | None, typer.Argument()] = None,
199
+ run: Annotated[str, typer.Argument()] = "latest",
200
+ solution: Annotated[
201
+ str | None,
202
+ typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
203
+ ] = None,
204
+ output: Annotated[OutputFormat, typer.Option("--output", "-o")] = OutputFormat.rich,
205
+ workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
206
+ ) -> None:
207
+ """Display a report for a task (defaults to latest run)."""
208
+ try:
209
+ task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
210
+ except (GitOpsError, ConfigError) as e:
211
+ raise _die(e) from None
212
+ handle = ReportHandle(workspace.resolve(), task_alias, run)
213
+ try:
214
+ report_data = handle.load()
215
+ except FileNotFoundError:
216
+ raise _die(f"no report at {handle.report_json_path}; run `tp run` first") from None
217
+ renderer_factory(output).render(report_data)
218
+
219
+
220
+ @app.command()
221
+ def submit(
222
+ task: Annotated[
223
+ str | None,
224
+ typer.Argument(
225
+ help="Task name (defaults to first task in trap.yaml). "
226
+ "Used as both the local run dir and the trapstreet task_id.",
227
+ ),
228
+ ] = None,
229
+ solution: Annotated[
230
+ str | None,
231
+ typer.Option("--solution", help="Local solution path holding trap.yaml (default: cwd)."),
232
+ ] = None,
233
+ workspace: Annotated[Path, typer.Option("--workspace", "-w")] = Path(".trap"),
234
+ run: Annotated[str, typer.Option("--run", "-r", help="Which run to upload.")] = "latest",
235
+ ) -> None:
236
+ """Upload a report.json to trapstreet.
237
+
238
+ Reads from the .trap/<task>/<run>/report.json workspace that `tp run`
239
+ populated.
240
+ """
241
+ stored = AuthStore().load()
242
+ # priority: TRAPSTREET_URL env > stored > default
243
+ resolved_server = (
244
+ os.environ.get("TRAPSTREET_URL") or (stored.server if stored else None) or DEFAULT_SERVER
245
+ )
246
+ # priority: TRAPSTREET_API_KEY env > stored
247
+ resolved_key = os.environ.get("TRAPSTREET_API_KEY") or (stored.api_key if stored else None)
248
+ if not resolved_key:
249
+ raise _die("not logged in. Run [bold]tp auth login[/bold] or set [bold]TRAPSTREET_API_KEY[/bold].")
250
+
251
+ try:
252
+ task_alias = TrapLoader.from_solution(solution).resolve_task(task).alias
253
+ except (GitOpsError, ConfigError) as e:
254
+ raise _die(e) from None
255
+ report_handle = ReportHandle(workspace.resolve(), task_alias, run)
256
+ try:
257
+ report_handle.assert_exists()
258
+ except FileNotFoundError:
259
+ raise _die(f"no report at {report_handle.report_json_path}. Run [bold]tp run[/bold] first.") from None
260
+ report_path = report_handle.report_json_path
261
+
262
+ client = ApiClient(resolved_server, resolved_key)
263
+ try:
264
+ resp_data = client.submit(report_path)
265
+ except ApiError as e:
266
+ raise _die(e) from None
267
+ render_submit_result(resp_data)
268
+
269
+
270
+ # Hidden until the scaffold is implemented — registered but not advertised in `--help`.
271
+ @app.command(hidden=True)
272
+ def init() -> None:
273
+ """Generate annotated trap.yaml + traptask.yaml scaffold."""
274
+ console.print("not yet")
trap/cli/_auth.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from trap.auth import DEFAULT_SERVER, ApiClient, ApiError, AuthStore, BrowserProvider, TokenProvider
10
+
11
+ auth_app = typer.Typer(help="Manage authentication.")
12
+ console = Console()
13
+
14
+
15
+ @auth_app.command("login")
16
+ def auth_login(
17
+ server: Annotated[
18
+ str | None,
19
+ typer.Option("--server", envvar="TRAPSTREET_URL", help="Trapstreet server URL."),
20
+ ] = None,
21
+ timeout: Annotated[int, typer.Option("--timeout", help="Seconds to wait for browser approval.")] = 300,
22
+ with_token: Annotated[
23
+ bool,
24
+ typer.Option("--with-token", help="Read api_key from stdin instead of opening a browser."),
25
+ ] = False,
26
+ ) -> None:
27
+ """Authenticate this machine with Trapstreet.
28
+
29
+ By default opens a browser to complete OAuth. Pass --with-token to supply
30
+ an api_key directly (useful for CI or headless environments).
31
+
32
+ The token is saved to ~/.config/trapstreet/auth.json (mode 600).
33
+ """
34
+ stored = AuthStore().load()
35
+ # priority: --server / TRAPSTREET_URL > stored > default
36
+ resolved_server = server or (stored.server if stored else None) or DEFAULT_SERVER
37
+
38
+ # BrowserProvider raises ValueError for a non-default server (the same check the
39
+ # CLI used to duplicate), so provider construction sits inside the try alongside
40
+ # acquire() — one error path for both.
41
+ try:
42
+ if with_token:
43
+ if sys.stdin.isatty(): # pragma: no cover - interactive prompt, no TTY under tests
44
+ token = typer.prompt("API key", hide_input=True)
45
+ else:
46
+ token = sys.stdin.read().strip()
47
+ provider = TokenProvider(resolved_server, token)
48
+ else:
49
+ provider = BrowserProvider(resolved_server, timeout)
50
+ console.print(provider.pre_message)
51
+ auth_data = provider.acquire()
52
+ except (ValueError, TimeoutError) as e:
53
+ console.print(f"[red]error[/red]: {e}")
54
+ raise typer.Exit(code=2) from None
55
+
56
+ path = AuthStore().save(auth_data)
57
+ console.print(
58
+ "[green]✓ logged in[/green]"
59
+ + (f" · solution [bold]{auth_data.solution}[/bold]" if auth_data.solution else "")
60
+ + f" · token saved to {path}"
61
+ )
62
+
63
+
64
+ @auth_app.command("logout")
65
+ def auth_logout() -> None:
66
+ """Delete the locally-stored api_key."""
67
+ auth_store = AuthStore()
68
+ if not auth_store.exists:
69
+ console.print(f"already logged out — no file at {auth_store.PATH}")
70
+ return
71
+ auth_store.delete()
72
+ console.print(f"[green]✓[/green] removed {auth_store.PATH}")
73
+
74
+
75
+ @auth_app.command("status")
76
+ def auth_status(
77
+ verify: Annotated[
78
+ bool,
79
+ typer.Option("--verify/--no-verify", help="Ping server to verify token validity."),
80
+ ] = True,
81
+ ) -> None:
82
+ """Show current authentication state."""
83
+ stored = AuthStore().load()
84
+ if not stored:
85
+ console.print("[red]not logged in[/red]. Run [bold]tp auth login[/bold].")
86
+ raise typer.Exit(code=1)
87
+
88
+ console.print(f" server {stored.server}")
89
+ if stored.solution:
90
+ console.print(f" solution [bold]{stored.solution}[/bold]")
91
+
92
+ if not verify:
93
+ return
94
+
95
+ client = ApiClient(stored.server, stored.api_key)
96
+ try:
97
+ me = client.get_me()
98
+ except ApiError as e:
99
+ console.print(f"[red]error[/red]: {e}")
100
+ raise typer.Exit(code=1) from None
101
+ user = me.get("user") or {}
102
+ identity = user.get("name") or user.get("email") or "(unknown)"
103
+ console.print(f" user {identity}\n[green]✓ token is valid[/green]")
trap/cost/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .proxy import CostProxy
2
+
3
+ __all__ = ["CostProxy"]