orbiads-cli 1.0.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.
Files changed (30) hide show
  1. orbiads_cli-1.0.0/.gitignore +44 -0
  2. orbiads_cli-1.0.0/LICENSE +21 -0
  3. orbiads_cli-1.0.0/PKG-INFO +51 -0
  4. orbiads_cli-1.0.0/README.md +21 -0
  5. orbiads_cli-1.0.0/pyproject.toml +45 -0
  6. orbiads_cli-1.0.0/src/orbiads_cli/__init__.py +3 -0
  7. orbiads_cli-1.0.0/src/orbiads_cli/client.py +279 -0
  8. orbiads_cli-1.0.0/src/orbiads_cli/commands/__init__.py +0 -0
  9. orbiads_cli-1.0.0/src/orbiads_cli/commands/advertisers.py +47 -0
  10. orbiads_cli-1.0.0/src/orbiads_cli/commands/auth.py +173 -0
  11. orbiads_cli-1.0.0/src/orbiads_cli/commands/billing.py +54 -0
  12. orbiads_cli-1.0.0/src/orbiads_cli/commands/campaigns.py +111 -0
  13. orbiads_cli-1.0.0/src/orbiads_cli/commands/config_cmd.py +81 -0
  14. orbiads_cli-1.0.0/src/orbiads_cli/commands/creatives.py +105 -0
  15. orbiads_cli-1.0.0/src/orbiads_cli/commands/inventory.py +111 -0
  16. orbiads_cli-1.0.0/src/orbiads_cli/commands/network.py +53 -0
  17. orbiads_cli-1.0.0/src/orbiads_cli/commands/orders.py +89 -0
  18. orbiads_cli-1.0.0/src/orbiads_cli/commands/reporting.py +92 -0
  19. orbiads_cli-1.0.0/src/orbiads_cli/config.py +69 -0
  20. orbiads_cli-1.0.0/src/orbiads_cli/errors.py +84 -0
  21. orbiads_cli-1.0.0/src/orbiads_cli/main.py +68 -0
  22. orbiads_cli-1.0.0/src/orbiads_cli/output.py +115 -0
  23. orbiads_cli-1.0.0/tests/__init__.py +0 -0
  24. orbiads_cli-1.0.0/tests/conftest.py +61 -0
  25. orbiads_cli-1.0.0/tests/test_auth.py +247 -0
  26. orbiads_cli-1.0.0/tests/test_client.py +323 -0
  27. orbiads_cli-1.0.0/tests/test_commands.py +262 -0
  28. orbiads_cli-1.0.0/tests/test_exit_codes.py +153 -0
  29. orbiads_cli-1.0.0/tests/test_network.py +194 -0
  30. orbiads_cli-1.0.0/tests/test_output.py +238 -0
@@ -0,0 +1,44 @@
1
+ # Node / SvelteKit
2
+ node_modules/
3
+ dist/
4
+ .env
5
+ .env.*
6
+ !.env.example
7
+ frontend/.svelte-kit/
8
+ frontend/build/
9
+
10
+ # Python
11
+ backend/venv/
12
+ backend/__pycache__/
13
+ backend/**/__pycache__/
14
+ **/*.pyc
15
+ backend/.pytest_cache/
16
+
17
+ # Jules workflow — fichier temporaire de corps de PR (généré par Claude, supprimé par us-done.sh)
18
+ .github/.us-pr-body.md
19
+
20
+ # OS
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # IDE
25
+ .vscode/
26
+ .idea/
27
+
28
+ # Logs
29
+ *.log
30
+ backend/logs/
31
+
32
+ # Backup files
33
+ *.bak
34
+ backend/.venv/
35
+
36
+ # Secrets & credentials
37
+ client_secret*.json
38
+ *.credentials.json
39
+ service-account*.json
40
+
41
+ # Dev test scripts (non-pytest)
42
+ backend/test_gam*.py
43
+ backend/test_integrated*.py
44
+ backend/.venv/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OrbiAds
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: orbiads-cli
3
+ Version: 1.0.0
4
+ Summary: OrbiAds CLI — Google Ad Manager from the command line
5
+ Project-URL: Homepage, https://orbiads.com
6
+ Project-URL: Documentation, https://orbiads.com/docs/cli
7
+ Project-URL: Repository, https://github.com/OrbiAds/Orbiads-GAM-MCP
8
+ Author-email: OrbiAds <contact@orbiads.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: adtech,cli,gam,google-ad-manager
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: typer[all]>=0.12.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-cov; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: respx>=0.21; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # OrbiAds CLI
32
+
33
+ Google Ad Manager from the command line.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install orbiads-cli
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```bash
44
+ orbiads auth login
45
+ orbiads network info
46
+ orbiads campaigns list --json
47
+ ```
48
+
49
+ ## Documentation
50
+
51
+ See [https://orbiads.com/docs/cli](https://orbiads.com/docs/cli)
@@ -0,0 +1,21 @@
1
+ # OrbiAds CLI
2
+
3
+ Google Ad Manager from the command line.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install orbiads-cli
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ orbiads auth login
15
+ orbiads network info
16
+ orbiads campaigns list --json
17
+ ```
18
+
19
+ ## Documentation
20
+
21
+ See [https://orbiads.com/docs/cli](https://orbiads.com/docs/cli)
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "orbiads-cli"
7
+ version = "1.0.0"
8
+ description = "OrbiAds CLI — Google Ad Manager from the command line"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "OrbiAds", email = "contact@orbiads.com" },
14
+ ]
15
+ keywords = ["google-ad-manager", "gam", "cli", "adtech"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Internet :: WWW/HTTP",
26
+ ]
27
+ dependencies = [
28
+ "typer[all]>=0.12.0",
29
+ "httpx>=0.27.0",
30
+ "rich>=13.0.0",
31
+ ]
32
+
33
+ [project.scripts]
34
+ orbiads = "orbiads_cli.main:app"
35
+
36
+ [project.urls]
37
+ Homepage = "https://orbiads.com"
38
+ Documentation = "https://orbiads.com/docs/cli"
39
+ Repository = "https://github.com/OrbiAds/Orbiads-GAM-MCP"
40
+
41
+ [project.optional-dependencies]
42
+ dev = ["pytest>=8.0", "pytest-cov", "respx>=0.21"]
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/orbiads_cli"]
@@ -0,0 +1,3 @@
1
+ """OrbiAds CLI — Google Ad Manager from the command line."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,279 @@
1
+ """HTTP client with auto-refresh, retry, and JSend envelope parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from typing import Any
8
+
9
+ import httpx
10
+ import typer
11
+
12
+ from orbiads_cli import config
13
+
14
+ # Public Firebase client identifier (same as frontend).
15
+ # This is NOT a secret — it only identifies the Firebase project.
16
+ FIREBASE_API_KEY = "AIzaSyAr2J5-a6GjIStBSOoIKB48DzSr0K-wHiQ"
17
+ FIREBASE_REFRESH_URL = (
18
+ f"https://securetoken.googleapis.com/v1/token?key={FIREBASE_API_KEY}"
19
+ )
20
+
21
+ # Exponential backoff delays for 429 retries (seconds).
22
+ _BACKOFF_DELAYS = [1, 2, 4]
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Exit-code mapping
27
+ # ---------------------------------------------------------------------------
28
+
29
+ _STATUS_TO_EXIT: dict[int, int] = {
30
+ 404: 3,
31
+ 401: 4,
32
+ 403: 4,
33
+ 409: 5,
34
+ 412: 6,
35
+ }
36
+
37
+
38
+ def _exit_code(status: int) -> int:
39
+ """Map HTTP status to CLI exit code."""
40
+ return _STATUS_TO_EXIT.get(status, 1)
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Exceptions
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ class CliApiError(Exception):
49
+ """API error with semantic exit code for CLI commands."""
50
+
51
+ def __init__(
52
+ self,
53
+ exit_code: int,
54
+ message: str,
55
+ error_code: str = "",
56
+ details: dict[str, Any] | None = None,
57
+ ):
58
+ super().__init__(message)
59
+ self.exit_code = exit_code
60
+ self.message = message
61
+ self.error_code = error_code
62
+ self.details: dict[str, Any] = details or {}
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Helpers
67
+ # ---------------------------------------------------------------------------
68
+
69
+ _CREDITS_RE = re.compile(
70
+ r"[Bb]alance[:\s]+(\d+).*[Rr]equired[:\s]+(\d+)"
71
+ )
72
+
73
+
74
+ def _parse_credits_message(message: str, details: dict[str, Any]) -> None:
75
+ """Best-effort extraction of balance/required from an error message."""
76
+ m = _CREDITS_RE.search(message)
77
+ if m:
78
+ details["balance"] = int(m.group(1))
79
+ details["required"] = int(m.group(2))
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Client
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ class OrbiAdsClient:
88
+ """Thin wrapper around ``httpx.Client`` with auth and retry logic."""
89
+
90
+ def __init__(self, cfg: dict) -> None:
91
+ self._cfg = cfg
92
+ self._http = httpx.Client(
93
+ base_url=cfg["apiUrl"],
94
+ headers={"Authorization": f"Bearer {cfg['token']}"},
95
+ timeout=30.0,
96
+ )
97
+ self._refreshed = False # guard: at most one refresh per request
98
+
99
+ # -- public convenience methods ------------------------------------------
100
+
101
+ def get(self, path: str, **kwargs: Any) -> Any:
102
+ return self._request("GET", path, **kwargs)
103
+
104
+ def post(self, path: str, **kwargs: Any) -> Any:
105
+ return self._request("POST", path, **kwargs)
106
+
107
+ def patch(self, path: str, **kwargs: Any) -> Any:
108
+ return self._request("PATCH", path, **kwargs)
109
+
110
+ def delete(self, path: str, **kwargs: Any) -> Any:
111
+ return self._request("DELETE", path, **kwargs)
112
+
113
+ # -- core request loop ---------------------------------------------------
114
+
115
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
116
+ self._refreshed = False
117
+ resp = self._http.request(method, path, **kwargs)
118
+
119
+ # 401 → attempt one token refresh then retry
120
+ if resp.status_code == 401 and not self._refreshed:
121
+ if self._refresh_token():
122
+ self._refreshed = True
123
+ resp = self._http.request(method, path, **kwargs)
124
+ else:
125
+ raise CliApiError(
126
+ 4, "Session expired. Run `orbiads auth login`."
127
+ )
128
+
129
+ # 429 → exponential backoff (max 3 retries)
130
+ if resp.status_code == 429:
131
+ resp = self._retry_with_backoff(method, path, **kwargs)
132
+
133
+ # Parse JSend envelope
134
+ return self._parse_response(resp)
135
+
136
+ # -- token refresh -------------------------------------------------------
137
+
138
+ def _refresh_token(self) -> bool:
139
+ """Refresh Firebase ID token using the stored refresh token.
140
+
141
+ Returns ``True`` on success, ``False`` otherwise.
142
+ """
143
+ refresh_token = self._cfg.get("refreshToken")
144
+ if not refresh_token:
145
+ return False
146
+
147
+ try:
148
+ resp = httpx.post(
149
+ FIREBASE_REFRESH_URL,
150
+ data={
151
+ "grant_type": "refresh_token",
152
+ "refresh_token": refresh_token,
153
+ },
154
+ timeout=15.0,
155
+ )
156
+ except httpx.HTTPError:
157
+ return False
158
+
159
+ if resp.status_code != 200:
160
+ return False
161
+
162
+ data = resp.json()
163
+ new_token = data.get("id_token", "")
164
+ new_refresh = data.get("refresh_token", "")
165
+ if not new_token:
166
+ return False
167
+
168
+ # Persist new tokens
169
+ config.set_token(new_token, new_refresh)
170
+ self._cfg["token"] = new_token
171
+ self._cfg["refreshToken"] = new_refresh
172
+ self._http.headers["Authorization"] = f"Bearer {new_token}"
173
+ return True
174
+
175
+ # -- 429 backoff ---------------------------------------------------------
176
+
177
+ def _retry_with_backoff(
178
+ self, method: str, path: str, **kwargs: Any
179
+ ) -> httpx.Response:
180
+ resp: httpx.Response | None = None
181
+ for delay in _BACKOFF_DELAYS:
182
+ typer.echo(
183
+ f"Rate limited, retrying in {delay}s...", err=True
184
+ )
185
+ time.sleep(delay)
186
+ resp = self._http.request(method, path, **kwargs)
187
+ if resp.status_code != 429:
188
+ return resp
189
+ # Return the last 429 so the caller can raise the appropriate error.
190
+ assert resp is not None
191
+ return resp
192
+
193
+ # -- response parsing ----------------------------------------------------
194
+
195
+ @staticmethod
196
+ def _parse_response(resp: httpx.Response) -> Any:
197
+ """Parse a JSend-style envelope.
198
+
199
+ On success returns the ``data`` field.
200
+ On error raises :class:`CliApiError` with the correct exit code.
201
+ """
202
+ try:
203
+ body = resp.json()
204
+ except Exception:
205
+ if resp.status_code >= 400:
206
+ raise CliApiError(
207
+ _exit_code(resp.status_code),
208
+ f"HTTP {resp.status_code} (non-JSON response)",
209
+ )
210
+ return None
211
+
212
+ if resp.status_code >= 400:
213
+ err = body.get("error") or {}
214
+ code = err.get("code", "")
215
+ message = err.get("message", f"HTTP {resp.status_code}")
216
+ details: dict[str, Any] = {}
217
+
218
+ # Extract structured details from the error payload.
219
+ if isinstance(err.get("details"), dict):
220
+ details = err["details"]
221
+
222
+ # Map INSUFFICIENT_CREDITS (HTTP 412) to exit code 6 with
223
+ # balance/required details parsed from the message or payload.
224
+ exit = _exit_code(resp.status_code)
225
+ if code == "INSUFFICIENT_CREDITS":
226
+ exit = 6
227
+ # Backend may include balance/required in details or message.
228
+ if "balance" not in details:
229
+ _parse_credits_message(message, details)
230
+
231
+ # For 404, include the request path as the resource hint.
232
+ if resp.status_code == 404 and "resource" not in details:
233
+ try:
234
+ details["resource"] = resp.request.url.path
235
+ except RuntimeError:
236
+ pass # request not set (e.g. in tests)
237
+
238
+ raise CliApiError(exit, message, code, details)
239
+
240
+ return body.get("data")
241
+
242
+ # -- cleanup -------------------------------------------------------------
243
+
244
+ def close(self) -> None:
245
+ self._http.close()
246
+
247
+ def __enter__(self) -> "OrbiAdsClient":
248
+ return self
249
+
250
+ def __exit__(self, *exc: Any) -> None:
251
+ self.close()
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Factory
256
+ # ---------------------------------------------------------------------------
257
+
258
+ _singleton: OrbiAdsClient | None = None
259
+
260
+
261
+ def get_client() -> OrbiAdsClient:
262
+ """Return a configured :class:`OrbiAdsClient`.
263
+
264
+ Reuses a single instance within the process (singleton).
265
+ Exits with code 4 if the user is not authenticated.
266
+ """
267
+ global _singleton
268
+ if _singleton is not None:
269
+ return _singleton
270
+
271
+ cfg = config.load()
272
+ if not cfg or not cfg.get("token"):
273
+ typer.echo(
274
+ "Not authenticated. Run `orbiads auth login` first.", err=True
275
+ )
276
+ raise typer.Exit(code=4)
277
+
278
+ _singleton = OrbiAdsClient(cfg)
279
+ return _singleton
File without changes
@@ -0,0 +1,47 @@
1
+ """Manage GAM advertisers."""
2
+
3
+ import typer
4
+
5
+ from orbiads_cli.client import CliApiError, get_client
6
+ from orbiads_cli.errors import handle_error
7
+ from orbiads_cli.output import OutputContext, confirm, render, render_detail, success
8
+
9
+ app = typer.Typer(help="Manage GAM advertisers", no_args_is_help=True)
10
+
11
+
12
+ @app.command("list")
13
+ def list_advertisers(ctx: typer.Context):
14
+ """List advertisers."""
15
+ out: OutputContext = ctx.obj
16
+ try:
17
+ client = get_client()
18
+ data = client.get("/api/gam/advertisers")
19
+ # Response may be a model with "advertisers" key or a list
20
+ if isinstance(data, dict):
21
+ items = data.get("advertisers", data.get("companies", []))
22
+ else:
23
+ items = data if isinstance(data, list) else []
24
+ render(items, ["id", "name", "type"], out)
25
+ except CliApiError as e:
26
+ handle_error(e)
27
+
28
+
29
+ @app.command()
30
+ def create(
31
+ ctx: typer.Context,
32
+ name: str = typer.Option(..., "--name", help="Advertiser name"),
33
+ ):
34
+ """Create a new advertiser."""
35
+ out: OutputContext = ctx.obj
36
+ if not confirm(f'Create advertiser "{name}"?', out):
37
+ raise typer.Exit(code=0)
38
+ try:
39
+ client = get_client()
40
+ data = client.post("/api/gam/advertisers", json={"name": name})
41
+ if out.format == "json":
42
+ render_detail(data, out)
43
+ else:
44
+ adv_id = data.get("id", "?") if isinstance(data, dict) else "?"
45
+ success(f"Advertiser created: {adv_id}")
46
+ except CliApiError as e:
47
+ handle_error(e)
@@ -0,0 +1,173 @@
1
+ """Authentication commands: login, logout, status."""
2
+
3
+ import time
4
+ import webbrowser
5
+
6
+ import httpx
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from orbiads_cli import config
11
+ from orbiads_cli.config import DEFAULT_API_URL
12
+
13
+ app = typer.Typer(help="Authentication (login, logout, status)", no_args_is_help=True)
14
+
15
+ # stderr console for all auth output (stdout reserved for JSON data)
16
+ err_console = Console(stderr=True)
17
+
18
+ # Maximum polling duration in seconds (match server device code TTL)
19
+ _MAX_POLL_DURATION = 900
20
+
21
+ # HTTP timeout for individual requests
22
+ _HTTP_TIMEOUT = 30.0
23
+
24
+
25
+ def _get_api_url() -> str:
26
+ """Return the configured API URL or the default."""
27
+ cfg = config.load()
28
+ return cfg.get("apiUrl", DEFAULT_API_URL) if cfg else DEFAULT_API_URL
29
+
30
+
31
+ @app.command()
32
+ def login() -> None:
33
+ """Authenticate with OrbiAds via browser device flow."""
34
+ api_url = _get_api_url()
35
+
36
+ # Step 1: Request a device code
37
+ try:
38
+ resp = httpx.post(
39
+ f"{api_url}/api/auth/gam/device-code",
40
+ timeout=_HTTP_TIMEOUT,
41
+ )
42
+ resp.raise_for_status()
43
+ except httpx.HTTPError as exc:
44
+ err_console.print(f"[red]Failed to initiate login: {exc}[/red]")
45
+ raise typer.Exit(code=1) from None
46
+
47
+ body = resp.json()
48
+ if body.get("error"):
49
+ err_console.print(f"[red]Server error: {body['error'].get('message', 'Unknown error')}[/red]")
50
+ raise typer.Exit(code=1)
51
+
52
+ data = body["data"]
53
+ device_code = data["deviceCode"]
54
+ user_code = data["userCode"]
55
+ verification_url = data["verificationUrl"]
56
+ poll_interval = data.get("pollInterval", 5)
57
+
58
+ # Step 2: Display instructions and open browser
59
+ err_console.print()
60
+ err_console.print(f" To authorize, visit: [bold cyan]{verification_url}[/bold cyan]")
61
+ err_console.print(f" Your code: [bold yellow]{user_code}[/bold yellow]")
62
+ err_console.print()
63
+ err_console.print(" Waiting for authorization...", style="dim")
64
+
65
+ webbrowser.open(verification_url)
66
+
67
+ # Step 3: Poll for authorization
68
+ start_time = time.monotonic()
69
+ try:
70
+ with err_console.status("[bold green]Waiting for browser authorization...") as spinner:
71
+ while True:
72
+ elapsed = time.monotonic() - start_time
73
+ if elapsed >= _MAX_POLL_DURATION:
74
+ err_console.print(
75
+ "[red]Authorization timed out (max 15 min). Please try again.[/red]"
76
+ )
77
+ raise typer.Exit(code=4)
78
+
79
+ time.sleep(poll_interval)
80
+
81
+ try:
82
+ poll_resp = httpx.get(
83
+ f"{api_url}/api/auth/gam/device-token-status",
84
+ params={"deviceCode": device_code},
85
+ timeout=_HTTP_TIMEOUT,
86
+ )
87
+ poll_resp.raise_for_status()
88
+ except httpx.HTTPError as exc:
89
+ err_console.print(f"[red]Polling error: {exc}[/red]")
90
+ raise typer.Exit(code=1) from None
91
+
92
+ poll_body = poll_resp.json()
93
+ if poll_body.get("error"):
94
+ err_console.print(
95
+ f"[red]Server error: {poll_body['error'].get('message', 'Unknown error')}[/red]"
96
+ )
97
+ raise typer.Exit(code=1)
98
+
99
+ poll_data = poll_body["data"]
100
+ status_value = poll_data["status"]
101
+
102
+ if status_value == "authorized":
103
+ # Save tokens
104
+ config.set_token(
105
+ poll_data["accessToken"],
106
+ poll_data["refreshToken"],
107
+ )
108
+ spinner.stop()
109
+ err_console.print("[bold green]Authenticated successfully![/bold green]")
110
+ raise typer.Exit(code=0)
111
+
112
+ elif status_value == "expired":
113
+ spinner.stop()
114
+ err_console.print(
115
+ "[red]Authorization expired. Please try again.[/red]"
116
+ )
117
+ raise typer.Exit(code=4)
118
+
119
+ # status == "pending" — continue polling
120
+
121
+ except KeyboardInterrupt:
122
+ err_console.print("\n[yellow]Authorization cancelled.[/yellow]")
123
+ raise typer.Exit(code=1) from None
124
+
125
+
126
+ @app.command()
127
+ def logout() -> None:
128
+ """Clear local credentials."""
129
+ config.clear()
130
+ err_console.print("Logged out.")
131
+ raise typer.Exit(code=0)
132
+
133
+
134
+ @app.command()
135
+ def status() -> None:
136
+ """Show current authentication status."""
137
+ if not config.has_token():
138
+ err_console.print("Not authenticated.")
139
+ raise typer.Exit(code=4)
140
+
141
+ api_url = _get_api_url()
142
+ token = config.get_token()
143
+
144
+ try:
145
+ resp = httpx.get(
146
+ f"{api_url}/api/me",
147
+ headers={"Authorization": f"Bearer {token}"},
148
+ timeout=_HTTP_TIMEOUT,
149
+ )
150
+ except httpx.HTTPError as exc:
151
+ err_console.print(f"[red]Request failed: {exc}[/red]")
152
+ raise typer.Exit(code=1) from None
153
+
154
+ if resp.status_code == 401:
155
+ err_console.print("[red]Token invalid. Run `orbiads auth login` to re-authenticate.[/red]")
156
+ raise typer.Exit(code=4)
157
+
158
+ if resp.status_code != 200:
159
+ err_console.print(f"[red]Unexpected response (HTTP {resp.status_code}).[/red]")
160
+ raise typer.Exit(code=1)
161
+
162
+ body = resp.json()
163
+ if body.get("error"):
164
+ err_console.print(f"[red]Server error: {body['error'].get('message', 'Unknown')}[/red]")
165
+ raise typer.Exit(code=1)
166
+
167
+ data = body.get("data", {})
168
+ email = data.get("email", "unknown")
169
+ network = data.get("networkCode", "not set")
170
+
171
+ err_console.print(f" Authenticated as: [bold]{email}[/bold]")
172
+ err_console.print(f" GAM Network: [bold]{network}[/bold]")
173
+ raise typer.Exit(code=0)