applied-cli 0.1.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,739 @@
1
+ import json
2
+ import os
3
+ import time
4
+ import uuid
5
+ import webbrowser
6
+ from typing import Optional
7
+ from urllib.parse import urlsplit, urlunsplit
8
+
9
+ import typer
10
+
11
+ from applied_cli.auth_store import (
12
+ clear_credentials,
13
+ get_active_profile_name,
14
+ list_profiles,
15
+ load_credentials,
16
+ save_credentials,
17
+ )
18
+ from applied_cli.config import (
19
+ DEFAULT_BASE_URL,
20
+ Credentials,
21
+ mask_token,
22
+ normalize_base_url,
23
+ )
24
+ from applied_cli.error_reporting import render_api_error
25
+ from applied_cli.http import (
26
+ APIError,
27
+ list_accessible_shops,
28
+ poll_cli_device_login,
29
+ start_cli_device_login,
30
+ validate_api_token,
31
+ )
32
+
33
+ app = typer.Typer(
34
+ help=(
35
+ "Authenticate applied-cli with an API token.\n\n"
36
+ "Login requires a human to approve in the browser:\n"
37
+ " applied-cli auth login\n\n"
38
+ "After login, check status and switch shops:\n"
39
+ " applied-cli auth status # who you're logged in as and which shop is active\n"
40
+ " applied-cli auth shops # list all shops your token can access\n"
41
+ " applied-cli auth use-shop \"<name>\" # switch the active shop"
42
+ )
43
+ )
44
+
45
+
46
+ def _select_shop_interactively(shops: list[dict[str, object]]) -> tuple[str, str]:
47
+ """Return (shop_id, shop_name) after prompting the user to pick a shop."""
48
+ if not shops:
49
+ raise typer.BadParameter(
50
+ "No accessible shops returned for this token. Pass --shop-id explicitly."
51
+ )
52
+ if len(shops) == 1:
53
+ only = shops[0]
54
+ selected = str(only.get("id") or "")
55
+ name = str(only.get("name") or "(unnamed)")
56
+ if selected:
57
+ typer.echo(f"Using shop: {name} ({selected})")
58
+ return selected, name
59
+ raise typer.BadParameter(
60
+ "Shop response did not include an id. Pass --shop-id explicitly."
61
+ )
62
+
63
+ typer.echo("Select a default shop:")
64
+ for idx, shop in enumerate(shops, start=1):
65
+ shop_id = str(shop.get("id") or "")
66
+ name = str(shop.get("name") or "(unnamed)")
67
+ typer.echo(f"{idx}) {name} ({shop_id})")
68
+ choice = typer.prompt("Enter number", default="1").strip()
69
+ try:
70
+ selected_index = int(choice)
71
+ except ValueError as exc:
72
+ raise typer.BadParameter("Selection must be a number.") from exc
73
+ if selected_index < 1 or selected_index > len(shops):
74
+ raise typer.BadParameter("Selection out of range.")
75
+ selected_shop = shops[selected_index - 1]
76
+ selected_shop_id = str(selected_shop.get("id") or "")
77
+ selected_shop_name = str(selected_shop.get("name") or "(unnamed)")
78
+ if not selected_shop_id:
79
+ raise typer.BadParameter(
80
+ "Selected shop does not include an id. Pass --shop-id explicitly."
81
+ )
82
+ return selected_shop_id, selected_shop_name
83
+
84
+
85
+ def _resolve_base_url_for_auth(
86
+ *, endpoint: Optional[str], base_url: Optional[str], existing: Optional[Credentials]
87
+ ) -> str:
88
+ raw_base_url = (
89
+ base_url
90
+ or endpoint
91
+ or os.getenv("APPLIED_BASE_URL")
92
+ or os.getenv("APPLIED_ENDPOINT")
93
+ or (existing.base_url if existing else "")
94
+ or DEFAULT_BASE_URL
95
+ )
96
+ return normalize_base_url(raw_base_url)
97
+
98
+
99
+ def _looks_like_uuid(value: str) -> bool:
100
+ try:
101
+ uuid.UUID(value)
102
+ return True
103
+ except ValueError:
104
+ return False
105
+
106
+
107
+ def _resolve_profile_name(profile: Optional[str]) -> str:
108
+ return (profile or os.getenv("APPLIED_PROFILE") or get_active_profile_name() or "default").strip()
109
+
110
+
111
+ def _default_token_page_url(base_url: str) -> str:
112
+ parsed = urlsplit(base_url)
113
+ host = parsed.hostname or ""
114
+ if host in {"localhost", "127.0.0.1"}:
115
+ local_host = "localhost" if host == "localhost" else "127.0.0.1"
116
+ return f"http://{local_host}:3000/settings/account/api-tokens"
117
+ return urlunsplit((parsed.scheme, parsed.netloc, "/settings/account/api-tokens", "", ""))
118
+
119
+
120
+ @app.command("login")
121
+ def login(
122
+ endpoint: Optional[str] = typer.Option(
123
+ None,
124
+ "--endpoint",
125
+ help="Endpoint alias or URL: prod, dev, local, or full host.",
126
+ envvar="APPLIED_ENDPOINT",
127
+ ),
128
+ shop_id: Optional[str] = typer.Option(
129
+ None, "--shop-id", help="Pre-select a shop UUID instead of prompting.", envvar="APPLIED_SHOP_ID"
130
+ ),
131
+ token: Optional[str] = typer.Option(
132
+ None, "--token", help="Skip browser auth and use this API token directly.", envvar="APPLIED_API_TOKEN"
133
+ ),
134
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
135
+ # Hidden power-user / compat options
136
+ profile: Optional[str] = typer.Option(None, "--profile", envvar="APPLIED_PROFILE", hidden=True),
137
+ base_url: Optional[str] = typer.Option(None, envvar="APPLIED_BASE_URL", hidden=True),
138
+ token_page_url: Optional[str] = typer.Option(None, envvar="APPLIED_TOKEN_PAGE_URL", hidden=True),
139
+ device: bool = typer.Option(True, "--device/--no-device", hidden=True),
140
+ no_browser: bool = typer.Option(False, "--no-browser", hidden=True),
141
+ device_timeout: float = typer.Option(180.0, hidden=True),
142
+ poll_interval: float = typer.Option(2.0, hidden=True),
143
+ timeout: float = typer.Option(10.0, hidden=True),
144
+ ) -> None:
145
+ """Sign in or switch to a different shop — opens a browser for human approval, then saves credentials."""
146
+ profile_name = _resolve_profile_name(profile)
147
+ existing = load_credentials(profile=profile_name)
148
+ resolved_base_url = _resolve_base_url_for_auth(
149
+ endpoint=endpoint,
150
+ base_url=base_url,
151
+ existing=existing,
152
+ )
153
+ explicit_shop_id = shop_id or os.getenv("APPLIED_SHOP_ID")
154
+ resolved_shop_id = explicit_shop_id or ""
155
+ resolved_shop_name = ""
156
+
157
+ token_page = token_page_url or _default_token_page_url(resolved_base_url)
158
+ browser_opened = False
159
+ resolved_token = token or os.getenv("APPLIED_API_TOKEN")
160
+ if not resolved_token and device:
161
+ try:
162
+ device_start = start_cli_device_login(
163
+ base_url=resolved_base_url,
164
+ timeout_seconds=timeout,
165
+ )
166
+ except APIError as exc:
167
+ if exc.status_code == 404:
168
+ if output_json:
169
+ typer.echo(
170
+ json.dumps(
171
+ {
172
+ "error": "endpoint_not_found",
173
+ "message": f"Device login endpoint not found at {resolved_base_url}.",
174
+ "hint": (
175
+ "Specify an endpoint: "
176
+ "applied-cli auth login --endpoint prod "
177
+ "or applied-cli auth login --endpoint local"
178
+ ),
179
+ },
180
+ indent=2,
181
+ ),
182
+ err=True,
183
+ )
184
+ else:
185
+ typer.echo(
186
+ f"Device login endpoint not found at {resolved_base_url}.\n"
187
+ "Is the endpoint correct? Try:\n"
188
+ " applied-cli auth login --endpoint prod\n"
189
+ " applied-cli auth login --endpoint local",
190
+ err=True,
191
+ )
192
+ else:
193
+ typer.echo(render_api_error(exc, action="start device login"), err=True)
194
+ raise typer.Exit(code=1) from exc
195
+ token_page = str(
196
+ device_start.get("verification_uri_complete")
197
+ or device_start.get("verification_uri")
198
+ or device_start.get("verification_url")
199
+ or token_page
200
+ ).strip()
201
+ device_code = str(device_start.get("device_code") or "").strip()
202
+ user_code = str(device_start.get("user_code") or "").strip()
203
+ expires_in = int(device_start.get("expires_in") or 180)
204
+ poll_seconds = float(device_start.get("interval") or poll_interval or 2.0)
205
+ if output_json:
206
+ # Emit approval info immediately so an agent can relay URL+code to the
207
+ # human before blocking on the poll loop. Final result is a second JSON line.
208
+ typer.echo(
209
+ json.dumps(
210
+ {
211
+ "status": "pending_approval",
212
+ "approval_url": token_page,
213
+ "user_code": user_code or None,
214
+ "expires_in": expires_in,
215
+ }
216
+ )
217
+ )
218
+ else:
219
+ typer.echo(f"Approval URL: {token_page}")
220
+ if user_code:
221
+ typer.echo(f"Verification code: {user_code}")
222
+ typer.echo("Enter this code in the browser when prompted.")
223
+ if no_browser:
224
+ pass # URL already printed above
225
+ else:
226
+ browser_opened = webbrowser.open(token_page)
227
+ if not output_json:
228
+ if browser_opened:
229
+ typer.echo("(Browser opened automatically.)")
230
+ else:
231
+ typer.echo("(Could not open browser — open the URL above manually.)")
232
+ if not device_code:
233
+ err = APIError(
234
+ "Device login start response missing device code.",
235
+ code="DEVICE_LOGIN_INVALID_RESPONSE",
236
+ hint="Retry `applied-cli auth login`.",
237
+ retryable=True,
238
+ )
239
+ typer.echo(render_api_error(err, action="start device login"), err=True)
240
+ raise typer.Exit(code=1)
241
+ if not output_json:
242
+ typer.echo("Waiting for browser approval...")
243
+ deadline = time.monotonic() + min(device_timeout, float(expires_in))
244
+ _auth_start = time.monotonic()
245
+ _last_progress = _auth_start
246
+ while time.monotonic() < deadline:
247
+ try:
248
+ poll = poll_cli_device_login(
249
+ base_url=resolved_base_url,
250
+ device_code=device_code,
251
+ timeout_seconds=timeout,
252
+ )
253
+ except APIError as exc:
254
+ typer.echo(render_api_error(exc, action="poll device login"), err=True)
255
+ raise typer.Exit(code=1) from exc
256
+ status_value = str(poll.get("status") or "pending").strip().lower()
257
+ if status_value == "approved":
258
+ resolved_token = str(poll.get("api_token") or "").strip()
259
+ discovered_shop_id = str(poll.get("shop_id") or "").strip()
260
+ if discovered_shop_id and not resolved_shop_id:
261
+ resolved_shop_id = discovered_shop_id
262
+ break
263
+ if status_value in {"expired", "denied"}:
264
+ err = APIError(
265
+ f"Device login {status_value}.",
266
+ code=f"DEVICE_LOGIN_{status_value.upper()}",
267
+ hint="Run `applied-cli auth login` and approve in browser again.",
268
+ retryable=False,
269
+ )
270
+ typer.echo(render_api_error(err, action="log in"), err=True)
271
+ raise typer.Exit(code=1)
272
+ _now = time.monotonic()
273
+ if not output_json and _now - _last_progress >= 15:
274
+ _elapsed = int(_now - _auth_start)
275
+ typer.echo(
276
+ f"Still waiting for browser approval... ({_elapsed}s elapsed)"
277
+ )
278
+ _last_progress = _now
279
+ time.sleep(max(0.5, poll_seconds))
280
+ if not resolved_token:
281
+ err = APIError(
282
+ "Timed out waiting for browser approval.",
283
+ code="DEVICE_LOGIN_TIMEOUT",
284
+ hint="Approve login in browser sooner or increase --device-timeout.",
285
+ retryable=True,
286
+ )
287
+ typer.echo(render_api_error(err, action="log in"), err=True)
288
+ raise typer.Exit(code=1)
289
+ elif not resolved_token:
290
+ if no_browser:
291
+ if not output_json:
292
+ typer.echo(f"Open this page to create a token:\n{token_page}")
293
+ else:
294
+ browser_opened = webbrowser.open(token_page)
295
+ if not output_json:
296
+ if browser_opened:
297
+ typer.echo(f"Opened token page:\n{token_page}")
298
+ else:
299
+ typer.echo(f"Could not open browser. Create token here:\n{token_page}")
300
+ resolved_token = typer.prompt(
301
+ "Paste API token",
302
+ hide_input=True,
303
+ confirmation_prompt=False,
304
+ ).strip()
305
+
306
+ # Best-effort: if we already have a shop_id but no name (e.g. from device auth poll),
307
+ # try to resolve the name from the accessible-shops list now that we have a token.
308
+ if resolved_shop_id and not resolved_shop_name:
309
+ try:
310
+ shops_for_name = list_accessible_shops(
311
+ base_url=resolved_base_url,
312
+ api_token=resolved_token,
313
+ timeout_seconds=timeout,
314
+ )
315
+ name_match = next(
316
+ (r for r in shops_for_name if str(r.get("id")) == resolved_shop_id), None
317
+ )
318
+ if name_match:
319
+ resolved_shop_name = str(name_match.get("name") or "")
320
+ except APIError:
321
+ pass # Name is nice-to-have; continue without it
322
+
323
+ used_existing_shop_fallback = False
324
+ if not resolved_shop_id:
325
+ try:
326
+ shops = list_accessible_shops(
327
+ base_url=resolved_base_url,
328
+ api_token=resolved_token,
329
+ timeout_seconds=timeout,
330
+ )
331
+ except APIError:
332
+ shops = []
333
+ if shops:
334
+ if output_json:
335
+ if len(shops) == 1 and shops[0].get("id"):
336
+ resolved_shop_id = str(shops[0]["id"])
337
+ resolved_shop_name = str(shops[0].get("name") or "")
338
+ else:
339
+ raise APIError(
340
+ "Multiple shops available; choose one with --shop-id.",
341
+ code="MISSING_SHOP_ID",
342
+ hint="Pass --shop-id explicitly or run interactive auth login.",
343
+ retryable=False,
344
+ )
345
+ else:
346
+ resolved_shop_id, resolved_shop_name = _select_shop_interactively(shops)
347
+ if not resolved_shop_id:
348
+ # Last resort fallback: use profile default if available, else prompt.
349
+ if existing and existing.shop_id:
350
+ resolved_shop_id = existing.shop_id
351
+ used_existing_shop_fallback = True
352
+ if not output_json:
353
+ typer.echo(
354
+ f"Using existing profile default shop: {resolved_shop_id}"
355
+ )
356
+ else:
357
+ resolved_shop_id = typer.prompt("Shop ID").strip()
358
+
359
+ if not output_json:
360
+ typer.echo("Validating token against Applied API...")
361
+ try:
362
+ validate_api_token(
363
+ base_url=resolved_base_url,
364
+ shop_id=resolved_shop_id,
365
+ api_token=resolved_token,
366
+ timeout_seconds=timeout,
367
+ )
368
+ except APIError as exc:
369
+ # Common local UX issue: stored default shop does not match pasted token.
370
+ # If we auto-selected the existing shop and auth fails, prompt once for a new shop.
371
+ if (
372
+ not output_json
373
+ and used_existing_shop_fallback
374
+ and exc.status_code in {401, 403}
375
+ and not explicit_shop_id
376
+ ):
377
+ typer.echo(
378
+ "Token is not valid for the current default shop. "
379
+ "Please enter a shop ID for this token."
380
+ )
381
+ resolved_shop_id = typer.prompt("Shop ID").strip()
382
+ if not resolved_shop_id:
383
+ typer.echo(render_api_error(exc, action="log in"), err=True)
384
+ raise typer.Exit(code=1) from exc
385
+ if not output_json:
386
+ typer.echo("Re-validating token with provided shop...")
387
+ try:
388
+ validate_api_token(
389
+ base_url=resolved_base_url,
390
+ shop_id=resolved_shop_id,
391
+ api_token=resolved_token,
392
+ timeout_seconds=timeout,
393
+ )
394
+ except APIError as retry_exc:
395
+ typer.echo(render_api_error(retry_exc, action="log in"), err=True)
396
+ raise typer.Exit(code=1) from retry_exc
397
+ else:
398
+ typer.echo(render_api_error(exc, action="log in"), err=True)
399
+ raise typer.Exit(code=1) from exc
400
+
401
+ save_credentials(
402
+ Credentials(
403
+ base_url=resolved_base_url,
404
+ shop_id=resolved_shop_id,
405
+ api_token=resolved_token,
406
+ ),
407
+ profile=profile_name,
408
+ set_active=True,
409
+ )
410
+ shop_label = (
411
+ f"{resolved_shop_name} ({resolved_shop_id})" if resolved_shop_name else resolved_shop_id
412
+ )
413
+ if output_json:
414
+ _login_result: dict = {
415
+ "logged_in": True,
416
+ "endpoint": resolved_base_url,
417
+ "shop_id": resolved_shop_id,
418
+ "token_masked": mask_token(resolved_token),
419
+ }
420
+ if resolved_shop_name:
421
+ _login_result["shop_name"] = resolved_shop_name
422
+ typer.echo(json.dumps(_login_result, indent=2))
423
+ return
424
+ typer.echo("Login successful.")
425
+ typer.echo(f"- shop: {shop_label}")
426
+ typer.echo(f"- endpoint: {resolved_base_url}")
427
+ typer.echo(f"- token: {mask_token(resolved_token)}")
428
+ typer.echo("")
429
+ typer.echo("To see all shops: applied-cli auth shops")
430
+ typer.echo("To switch shops: applied-cli auth use-shop \"<shop name or uuid>\"")
431
+
432
+
433
+ @app.command("status")
434
+ def status(
435
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
436
+ # Hidden options
437
+ profile: Optional[str] = typer.Option(None, "--profile", envvar="APPLIED_PROFILE", hidden=True),
438
+ verify: bool = typer.Option(True, "--verify/--no-verify", hidden=True),
439
+ timeout: float = typer.Option(10.0, hidden=True),
440
+ ) -> None:
441
+ """Show current authentication state and active shop."""
442
+ profile_name = _resolve_profile_name(profile)
443
+ creds = load_credentials(profile=profile_name)
444
+ if not creds:
445
+ if output_json:
446
+ typer.echo(
447
+ json.dumps(
448
+ {"logged_in": False, "verified": False, "message": "Not logged in."},
449
+ indent=2,
450
+ )
451
+ )
452
+ else:
453
+ typer.echo("Not logged in. Run: applied-cli auth login")
454
+ raise typer.Exit(code=1)
455
+
456
+ if not output_json:
457
+ typer.echo("Logged in:")
458
+ typer.echo(f"- endpoint: {creds.base_url}")
459
+ typer.echo(f"- shop_id: {creds.shop_id}")
460
+ typer.echo(f"- token: {mask_token(creds.api_token)}")
461
+
462
+ if not verify:
463
+ if output_json:
464
+ typer.echo(
465
+ json.dumps(
466
+ {
467
+ "logged_in": True,
468
+ "endpoint": creds.base_url,
469
+ "shop_id": creds.shop_id,
470
+ "token_masked": mask_token(creds.api_token),
471
+ "verified": False,
472
+ },
473
+ indent=2,
474
+ )
475
+ )
476
+ return
477
+
478
+ if not output_json:
479
+ typer.echo("Verifying token...")
480
+ try:
481
+ validate_api_token(
482
+ base_url=creds.base_url,
483
+ shop_id=creds.shop_id,
484
+ api_token=creds.api_token,
485
+ timeout_seconds=timeout,
486
+ )
487
+ except APIError as exc:
488
+ typer.echo(render_api_error(exc, action="check auth status"), err=True)
489
+ raise typer.Exit(code=1) from exc
490
+
491
+ if output_json:
492
+ typer.echo(
493
+ json.dumps(
494
+ {
495
+ "logged_in": True,
496
+ "endpoint": creds.base_url,
497
+ "shop_id": creds.shop_id,
498
+ "token_masked": mask_token(creds.api_token),
499
+ "verified": True,
500
+ },
501
+ indent=2,
502
+ )
503
+ )
504
+ return
505
+ typer.echo("Token is valid.")
506
+ typer.echo("")
507
+ typer.echo("To see all shops: applied-cli auth shops")
508
+ typer.echo("To switch shops: applied-cli auth use-shop \"<shop name or uuid>\"")
509
+
510
+
511
+ @app.command("shops")
512
+ def shops(
513
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
514
+ # Hidden options
515
+ profile: Optional[str] = typer.Option(None, "--profile", envvar="APPLIED_PROFILE", hidden=True),
516
+ endpoint: Optional[str] = typer.Option(None, "--endpoint", envvar="APPLIED_ENDPOINT", hidden=True),
517
+ base_url: Optional[str] = typer.Option(None, envvar="APPLIED_BASE_URL", hidden=True),
518
+ token: Optional[str] = typer.Option(None, "--token", envvar="APPLIED_API_TOKEN", hidden=True),
519
+ timeout: float = typer.Option(10.0, hidden=True),
520
+ ) -> None:
521
+ """List all shops your token can access."""
522
+ profile_name = _resolve_profile_name(profile)
523
+ creds = load_credentials(profile=profile_name)
524
+ resolved_base_url = _resolve_base_url_for_auth(
525
+ endpoint=endpoint,
526
+ base_url=base_url,
527
+ existing=creds,
528
+ )
529
+ resolved_token = token or os.getenv("APPLIED_API_TOKEN") or (
530
+ creds.api_token if creds else ""
531
+ )
532
+ if not resolved_token:
533
+ raise typer.BadParameter(
534
+ "Not logged in. Run `applied-cli auth login` first."
535
+ )
536
+ try:
537
+ shops_rows = list_accessible_shops(
538
+ base_url=resolved_base_url,
539
+ api_token=resolved_token,
540
+ timeout_seconds=timeout,
541
+ )
542
+ except APIError as exc:
543
+ if exc.status_code in {401, 403}:
544
+ if output_json:
545
+ typer.echo(
546
+ json.dumps(
547
+ {
548
+ "error": "permission_denied",
549
+ "message": "Your token does not have permission to list shops.",
550
+ "hint": (
551
+ "To switch shops, re-authenticate: applied-cli auth login "
552
+ "or if you know the shop UUID: applied-cli auth use-shop <uuid>"
553
+ ),
554
+ },
555
+ indent=2,
556
+ ),
557
+ err=True,
558
+ )
559
+ else:
560
+ typer.echo(
561
+ "Your token does not have permission to list shops.\n"
562
+ "To switch shops, re-authenticate and select the shop in the browser:\n"
563
+ " applied-cli auth login\n"
564
+ "Or if you know the shop UUID:\n"
565
+ " applied-cli auth use-shop <uuid>",
566
+ err=True,
567
+ )
568
+ else:
569
+ typer.echo(render_api_error(exc, action="list accessible shops"), err=True)
570
+ raise typer.Exit(code=1) from exc
571
+
572
+ if output_json:
573
+ typer.echo(
574
+ json.dumps(
575
+ {
576
+ "endpoint": resolved_base_url,
577
+ "count": len(shops_rows),
578
+ "shops": shops_rows,
579
+ },
580
+ indent=2,
581
+ )
582
+ )
583
+ return
584
+ if not shops_rows:
585
+ typer.echo("No accessible shops returned for this token.")
586
+ return
587
+ current_shop_id = creds.shop_id if creds else ""
588
+ for row in shops_rows:
589
+ active_marker = " (active)" if str(row.get("id")) == current_shop_id else ""
590
+ typer.echo(f"id={row.get('id')} | name={row.get('name') or '(unnamed)'}{active_marker}")
591
+ typer.echo("")
592
+ typer.echo("To switch: applied-cli auth use-shop \"<shop name or uuid>\"")
593
+
594
+
595
+ @app.command("use-shop")
596
+ def use_shop(
597
+ shop: str = typer.Argument(..., help="Shop name or UUID."),
598
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
599
+ # Hidden options
600
+ profile: Optional[str] = typer.Option(None, "--profile", envvar="APPLIED_PROFILE", hidden=True),
601
+ endpoint: Optional[str] = typer.Option(None, "--endpoint", envvar="APPLIED_ENDPOINT", hidden=True),
602
+ base_url: Optional[str] = typer.Option(None, envvar="APPLIED_BASE_URL", hidden=True),
603
+ token: Optional[str] = typer.Option(None, "--token", envvar="APPLIED_API_TOKEN", hidden=True),
604
+ timeout: float = typer.Option(10.0, hidden=True),
605
+ ) -> None:
606
+ """Switch the active shop by name or UUID."""
607
+ profile_name = _resolve_profile_name(profile)
608
+ existing = load_credentials(profile=profile_name)
609
+ if not existing and not token:
610
+ raise typer.BadParameter(
611
+ "Not logged in. Run `applied-cli auth login` first."
612
+ )
613
+ resolved_base_url = _resolve_base_url_for_auth(
614
+ endpoint=endpoint,
615
+ base_url=base_url,
616
+ existing=existing,
617
+ )
618
+ resolved_token = token or os.getenv("APPLIED_API_TOKEN") or (
619
+ existing.api_token if existing else ""
620
+ )
621
+ if not resolved_token:
622
+ raise typer.BadParameter("Not logged in. Run `applied-cli auth login` first.")
623
+
624
+ selected_shop_id = ""
625
+ selected_shop_name = ""
626
+ try:
627
+ shops_rows = list_accessible_shops(
628
+ base_url=resolved_base_url,
629
+ api_token=resolved_token,
630
+ timeout_seconds=timeout,
631
+ )
632
+ except APIError:
633
+ shops_rows = []
634
+
635
+ if shops_rows:
636
+ if _looks_like_uuid(shop):
637
+ matched = next((row for row in shops_rows if str(row.get("id")) == shop), None)
638
+ else:
639
+ matched = next(
640
+ (
641
+ row
642
+ for row in shops_rows
643
+ if str(row.get("name") or "").strip().lower() == shop.strip().lower()
644
+ ),
645
+ None,
646
+ )
647
+ if matched:
648
+ selected_shop_id = str(matched.get("id") or "")
649
+ selected_shop_name = str(matched.get("name") or "")
650
+ else:
651
+ raise typer.BadParameter(
652
+ "Shop not found in accessible shops for this token. "
653
+ "Run `applied-cli auth shops` to see valid choices."
654
+ )
655
+ else:
656
+ if not _looks_like_uuid(shop):
657
+ raise typer.BadParameter(
658
+ "Could not fetch shops from API. Pass a shop UUID instead of name."
659
+ )
660
+ selected_shop_id = shop
661
+
662
+ # Validate the token works for the selected shop before committing the change.
663
+ try:
664
+ validate_api_token(
665
+ base_url=resolved_base_url,
666
+ shop_id=selected_shop_id,
667
+ api_token=resolved_token,
668
+ timeout_seconds=timeout,
669
+ )
670
+ except APIError as exc:
671
+ if exc.status_code in {401, 403}:
672
+ if output_json:
673
+ typer.echo(
674
+ json.dumps(
675
+ {
676
+ "error": "token_invalid_for_shop",
677
+ "message": f"Your current token is not valid for shop {selected_shop_id}.",
678
+ "hint": "Run `applied-cli auth login` to re-authenticate for a different shop.",
679
+ },
680
+ indent=2,
681
+ ),
682
+ err=True,
683
+ )
684
+ else:
685
+ typer.echo(
686
+ f"Your current token is not valid for shop {selected_shop_id}.\n"
687
+ "To switch shops, re-authenticate and select the shop in the browser:\n"
688
+ " applied-cli auth login",
689
+ err=True,
690
+ )
691
+ else:
692
+ typer.echo(render_api_error(exc, action="validate shop access"), err=True)
693
+ raise typer.Exit(code=1) from exc
694
+
695
+ save_credentials(
696
+ Credentials(
697
+ base_url=resolved_base_url,
698
+ shop_id=selected_shop_id,
699
+ api_token=resolved_token,
700
+ ),
701
+ profile=profile_name,
702
+ set_active=True,
703
+ )
704
+
705
+ shop_label = (
706
+ f"{selected_shop_name} ({selected_shop_id})" if selected_shop_name else selected_shop_id
707
+ )
708
+ if output_json:
709
+ _use_shop_result: dict = {
710
+ "updated": True,
711
+ "shop_id": selected_shop_id,
712
+ }
713
+ if selected_shop_name:
714
+ _use_shop_result["shop_name"] = selected_shop_name
715
+ typer.echo(json.dumps(_use_shop_result, indent=2))
716
+ return
717
+ typer.echo(f"Active shop: {shop_label}")
718
+ typer.echo("")
719
+ typer.echo("To see all shops: applied-cli auth shops")
720
+ typer.echo("To switch again: applied-cli auth use-shop \"<shop name or uuid>\"")
721
+
722
+
723
+ @app.command("logout")
724
+ def logout(
725
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
726
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON output."),
727
+ ) -> None:
728
+ """Remove all stored credentials and log out."""
729
+ had_credentials = bool(list_profiles())
730
+ if had_credentials and not yes and not output_json:
731
+ if not typer.confirm("Remove stored credentials and log out?"):
732
+ raise typer.Exit(code=1)
733
+ clear_credentials()
734
+ if output_json:
735
+ typer.echo(
736
+ json.dumps({"logged_out": True}, indent=2)
737
+ )
738
+ return
739
+ typer.echo("Logged out.")