orbiads-cli 1.0.0__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.
@@ -0,0 +1,3 @@
1
+ """OrbiAds CLI — Google Ad Manager from the command line."""
2
+
3
+ __version__ = "1.0.0"
orbiads_cli/client.py ADDED
@@ -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)
@@ -0,0 +1,54 @@
1
+ """Check credits and billing."""
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 render, render_detail
8
+
9
+ app = typer.Typer(help="Check credits and billing", no_args_is_help=True)
10
+
11
+
12
+ @app.command()
13
+ def balance(ctx: typer.Context):
14
+ """Show current credit balance and plan info."""
15
+ try:
16
+ client = get_client()
17
+ data = client.get("/api/billing")
18
+
19
+ # Display key billing fields
20
+ detail = {
21
+ "credits": data.get("balance", 0),
22
+ "plan": data.get("plan", "unknown"),
23
+ "nextRenewal": data.get("cycleStart", "N/A"),
24
+ "overdue": data.get("overdue", False),
25
+ }
26
+ render_detail(detail, ctx.obj)
27
+ except CliApiError as e:
28
+ handle_error(e)
29
+
30
+
31
+ @app.command()
32
+ def transactions(
33
+ ctx: typer.Context,
34
+ limit: int = typer.Option(20, "--limit", help="Max results"),
35
+ ):
36
+ """List recent credit transactions."""
37
+ try:
38
+ client = get_client()
39
+ data = client.get("/api/billing/transactions", params={"limit": limit})
40
+
41
+ rows = data if isinstance(data, list) else []
42
+ columns = ["timestamp", "type", "amount", "reason"]
43
+ # Normalize column names: backend may use camelCase
44
+ normalized = []
45
+ for row in rows:
46
+ normalized.append({
47
+ "timestamp": row.get("timestamp", row.get("date", "")),
48
+ "type": row.get("type", ""),
49
+ "amount": row.get("amount", ""),
50
+ "reason": row.get("reason", ""),
51
+ })
52
+ render(normalized, columns, ctx.obj)
53
+ except CliApiError as e:
54
+ handle_error(e)
@@ -0,0 +1,111 @@
1
+ """Manage GAM campaigns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from orbiads_cli.client import CliApiError, get_client
8
+ from orbiads_cli.errors import handle_error
9
+ from orbiads_cli.output import OutputContext, confirm, info, render, render_detail, success
10
+
11
+ app = typer.Typer(help="Manage GAM campaigns", no_args_is_help=True)
12
+
13
+ _LIST_COLUMNS = ["id", "name", "status", "createdAt"]
14
+
15
+
16
+ @app.command("list")
17
+ def list_campaigns(
18
+ ctx: typer.Context,
19
+ status: str = typer.Option(None, "--status", help="Filter by status (comma-separated, e.g. draft,deployed)"),
20
+ limit: int = typer.Option(None, "--limit", help="Max number of campaigns to return"),
21
+ ):
22
+ """List campaigns."""
23
+ try:
24
+ client = get_client()
25
+ params: dict[str, str | int] = {}
26
+ if status is not None:
27
+ params["status"] = status
28
+ if limit is not None:
29
+ params["limit"] = limit
30
+ data = client.get("/api/campaigns", params=params)
31
+ out: OutputContext = ctx.obj
32
+ render(data, _LIST_COLUMNS, out)
33
+ except CliApiError as e:
34
+ handle_error(e)
35
+
36
+
37
+ @app.command()
38
+ def get(
39
+ ctx: typer.Context,
40
+ campaign_id: str = typer.Argument(..., help="Campaign ID"),
41
+ ):
42
+ """Get campaign details."""
43
+ try:
44
+ client = get_client()
45
+ data = client.get(f"/api/campaigns/{campaign_id}")
46
+ out: OutputContext = ctx.obj
47
+ render_detail(data, out)
48
+ except CliApiError as e:
49
+ handle_error(e)
50
+
51
+
52
+ @app.command()
53
+ def deploy(
54
+ ctx: typer.Context,
55
+ campaign_id: str = typer.Argument(..., help="Campaign ID"),
56
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
57
+ ):
58
+ """Deploy a draft campaign to GAM."""
59
+ try:
60
+ out: OutputContext = ctx.obj
61
+ # Merge local --yes with global --yes
62
+ effective_ctx = OutputContext(format=out.format, yes=out.yes or yes)
63
+ if not confirm(f"Deploy campaign {campaign_id}?", effective_ctx):
64
+ raise typer.Exit(code=0)
65
+
66
+ info(f"Deploying campaign {campaign_id}...")
67
+ client = get_client()
68
+ client.post(f"/api/campaigns/{campaign_id}/deploy")
69
+ success(f"Campaign {campaign_id} deployed successfully.")
70
+ except CliApiError as e:
71
+ handle_error(e)
72
+
73
+
74
+ @app.command()
75
+ def pause(
76
+ ctx: typer.Context,
77
+ campaign_id: str = typer.Argument(..., help="Campaign ID"),
78
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
79
+ ):
80
+ """Pause a deployed campaign."""
81
+ try:
82
+ out: OutputContext = ctx.obj
83
+ effective_ctx = OutputContext(format=out.format, yes=out.yes or yes)
84
+ if not confirm(f"Pause campaign {campaign_id}?", effective_ctx):
85
+ raise typer.Exit(code=0)
86
+
87
+ client = get_client()
88
+ client.post(f"/api/campaigns/{campaign_id}/pause")
89
+ success(f"Campaign {campaign_id} paused successfully.")
90
+ except CliApiError as e:
91
+ handle_error(e)
92
+
93
+
94
+ @app.command()
95
+ def archive(
96
+ ctx: typer.Context,
97
+ campaign_id: str = typer.Argument(..., help="Campaign ID"),
98
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
99
+ ):
100
+ """Archive a campaign."""
101
+ try:
102
+ out: OutputContext = ctx.obj
103
+ effective_ctx = OutputContext(format=out.format, yes=out.yes or yes)
104
+ if not confirm(f"Archive campaign {campaign_id}?", effective_ctx):
105
+ raise typer.Exit(code=0)
106
+
107
+ client = get_client()
108
+ client.post(f"/api/campaigns/{campaign_id}/archive")
109
+ success(f"Campaign {campaign_id} archived successfully.")
110
+ except CliApiError as e:
111
+ handle_error(e)