deepcell-cli 0.6.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 (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,610 @@
1
+ """Authentication commands: login, register, logout, whoami."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from urllib.parse import urlparse
8
+
9
+ import click
10
+
11
+ from deepcell_cli.config import (
12
+ clear_credentials,
13
+ client_headers,
14
+ get_api_url,
15
+ get_refresh_token,
16
+ load_config,
17
+ load_credentials,
18
+ save_config,
19
+ save_credentials,
20
+ warn_if_insecure,
21
+ )
22
+ from deepcell_cli.errors import APIError
23
+ from deepcell_cli.errors import ConnectionError as CLIConnectionError
24
+ from deepcell_cli.context import Ctx, pass_ctx
25
+ from deepcell_cli.output import echo_error, echo_info, echo_success, echo_warning, output
26
+
27
+
28
+ def _api_post(url: str, **kwargs: object) -> "httpx.Response":
29
+ """POST with user-friendly connection error handling.
30
+
31
+ Deliberately not routed through ``DeepCellClient``: these are the
32
+ pre-credential calls (register, login, the device flow), and that client
33
+ would try to mint an anonymous session to satisfy them. It must still
34
+ announce the calling surface, though — ``user_registered``,
35
+ ``demo_signup_converted`` and ``login_success`` are funnel milestones, and
36
+ they were the whole login stage reading NULL because this path built its
37
+ own request.
38
+ """
39
+ import httpx
40
+
41
+ headers = {**client_headers(), **(kwargs.pop("headers", None) or {})} # type: ignore[dict-item]
42
+ try:
43
+ return httpx.post(url, timeout=30.0, headers=headers, **kwargs)
44
+ except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as exc:
45
+ base = url.rsplit("/auth/", 1)[0] if "/auth/" in url else url
46
+ raise CLIConnectionError(base, exc) from exc
47
+
48
+
49
+ # ── anonymous-work claim ────────────────────────────────────
50
+
51
+
52
+ # Key under which a failed claim's device_id survives in the (account)
53
+ # credentials file, so the next `deepcell login` can retry the claim instead
54
+ # of orphaning the anonymous workspace.
55
+ _PENDING_CLAIM_KEY = "pending_anon_claim"
56
+
57
+ _CLAIM_RETRY_NOTE = (
58
+ "Note: your earlier work could not be moved automatically. "
59
+ "It will be retried on your next `deepcell login`."
60
+ )
61
+
62
+ # Refresh tokens whose server-side revocation failed after local logout. They
63
+ # live in the private (0600) config rather than credentials.json: keeping them
64
+ # in credentials would let DeepCellClient refresh the supposedly logged-out
65
+ # session on the next command. A later logout retries this list through the
66
+ # unauthenticated RFC-7009-style endpoint.
67
+ _PENDING_REVOCATIONS_KEY = "pending_refresh_revocations"
68
+
69
+
70
+ def _snapshot_anon_credentials() -> dict | None:
71
+ """Capture the anonymous session (if any) before login overwrites it.
72
+
73
+ Also resurrects a claim that failed on a previous login: the stashed
74
+ ``pending_anon_claim`` device_id becomes a token-less snapshot, which
75
+ forces :func:`_claim_anonymous_work` down its re-mint path.
76
+ """
77
+ creds = load_credentials()
78
+ if creds.get("anonymous") and creds.get("device_id"):
79
+ return creds
80
+ pending = creds.get(_PENDING_CLAIM_KEY)
81
+ if pending:
82
+ return {"device_id": pending, "expires_at": 0}
83
+ return None
84
+
85
+
86
+ def _stash_pending_claim(device_id: str | None) -> None:
87
+ """Persist the anon device_id onto the just-saved account credentials.
88
+
89
+ The in-memory snapshot is the only copy once login has overwritten the
90
+ credentials file — without this, a transiently failed claim would orphan
91
+ the anonymous workspace permanently.
92
+ """
93
+ if not device_id:
94
+ return
95
+ creds = load_credentials()
96
+ creds[_PENDING_CLAIM_KEY] = device_id
97
+ save_credentials(creds)
98
+
99
+
100
+ def _register_claim_proof(prior: dict | None) -> tuple[str, str] | None:
101
+ """``(device_id, live anon access token)`` for an in-place register claim.
102
+
103
+ Same two factors ``POST /auth/claim-demo`` requires in its body variant,
104
+ gathered before login overwrites the credentials file. Returns None when
105
+ there is nothing to claim or no live token can be obtained — register then
106
+ proceeds as an ordinary signup and the post-hoc claim path still runs, so a
107
+ failure here costs the promotion, never the work.
108
+ """
109
+ if not prior:
110
+ return None
111
+ device_id = prior.get("device_id")
112
+ if not device_id:
113
+ return None
114
+ anon_token = prior.get("access_token")
115
+ if prior.get("expires_at", 0) < time.time() + 30:
116
+ anon_token = None
117
+ try:
118
+ resp = _api_post(
119
+ f"{get_api_url()}/demo/session",
120
+ json={"client": "cli", "device_id": device_id},
121
+ )
122
+ if resp.status_code == 200:
123
+ anon_token = resp.json()["access_token"]
124
+ except Exception:
125
+ return None
126
+ if not anon_token:
127
+ return None
128
+ return str(device_id), str(anon_token)
129
+
130
+
131
+ def _claim_anonymous_work(prior: dict | None) -> None:
132
+ """Move a prior anonymous session's work into the just-authenticated account.
133
+
134
+ Proof mirrors POST /auth/claim-demo's body variant: the stored
135
+ ``device_id`` plus a live anon access token — re-minted first when the
136
+ stored one is stale. Every failure is non-fatal (login already
137
+ succeeded), but never silent: without a live token the claim is skipped
138
+ entirely (a stale token would 404 indistinguishably from "nothing to
139
+ claim"), and any failure stashes the device_id back into the credentials
140
+ file so the next login retries the claim.
141
+ """
142
+ if not prior:
143
+ return
144
+ device_id = prior.get("device_id")
145
+ anon_token = prior.get("access_token")
146
+
147
+ if prior.get("expires_at", 0) < time.time() + 30:
148
+ anon_token = None
149
+ try:
150
+ resp = _api_post(
151
+ f"{get_api_url()}/demo/session",
152
+ json={"client": "cli", "device_id": device_id},
153
+ )
154
+ if resp.status_code == 200:
155
+ anon_token = resp.json()["access_token"]
156
+ except Exception:
157
+ pass
158
+
159
+ if not anon_token:
160
+ _stash_pending_claim(device_id)
161
+ echo_info(_CLAIM_RETRY_NOTE)
162
+ return
163
+
164
+ from deepcell_cli.client import DeepCellClient
165
+
166
+ client = DeepCellClient()
167
+ try:
168
+ data = client.post(
169
+ "/auth/claim-demo",
170
+ json={"device_id": device_id, "anon_access_token": anon_token},
171
+ )
172
+ except APIError as exc:
173
+ if exc.status_code == 404:
174
+ # Presented a live token, so 404 genuinely means nothing to
175
+ # claim (row already merged or expired server-side) — stay quiet.
176
+ return
177
+ _stash_pending_claim(device_id)
178
+ echo_info(_CLAIM_RETRY_NOTE)
179
+ return
180
+ except Exception:
181
+ _stash_pending_claim(device_id)
182
+ echo_info(_CLAIM_RETRY_NOTE)
183
+ return
184
+ finally:
185
+ client.close()
186
+
187
+ moved = int(data.get("workspaces_moved", 0) or 0)
188
+ echo_success(
189
+ f"Moved {moved} workspace{'s' if moved != 1 else ''} into your account."
190
+ )
191
+
192
+
193
+ # ── login ───────────────────────────────────────────────────
194
+
195
+
196
+ @click.command()
197
+ @click.option("--email", help="Email for direct login (headless/CI).")
198
+ @click.option("--password", help="Password for direct login (headless/CI).")
199
+ @pass_ctx
200
+ def login(ctx: Ctx, email: str | None, password: str | None) -> None:
201
+ """Authenticate with the DeepCell API.
202
+
203
+ Without flags, opens a browser-based authorization flow (like `gh auth login`).
204
+ No account yet? Sign up right on that page (email or Google) — no separate
205
+ `register` step needed.
206
+
207
+ Use `--email` (password will be prompted securely) for headless environments.
208
+ """
209
+ if password and not email:
210
+ raise click.UsageError("--password requires --email.")
211
+ prior_anon = _snapshot_anon_credentials()
212
+ if email:
213
+ if not password:
214
+ password = click.prompt("Password", hide_input=True)
215
+ _login_password(ctx, email, password)
216
+ else:
217
+ _login_device_flow(ctx)
218
+ _claim_anonymous_work(prior_anon)
219
+
220
+
221
+ def _login_password(ctx: Ctx, email: str, password: str) -> None:
222
+ """Direct email/password login."""
223
+ api_url = get_api_url()
224
+ warn_if_insecure(api_url)
225
+
226
+ resp = _api_post(
227
+ f"{api_url}/auth/login",
228
+ json={"email": email, "password": password},
229
+ )
230
+ if resp.status_code != 200:
231
+ try:
232
+ detail = resp.json().get("detail", resp.text)
233
+ except Exception:
234
+ detail = resp.text
235
+ raise APIError(resp.status_code, str(detail))
236
+
237
+ data = resp.json()
238
+ creds: dict[str, object] = {
239
+ "access_token": data["access_token"],
240
+ "expires_at": time.time() + data["expires_in"],
241
+ "user_id": data["user"]["id"],
242
+ "email": data["user"]["email"],
243
+ }
244
+ if data.get("refresh_token"):
245
+ creds["refresh_token"] = data["refresh_token"]
246
+ save_credentials(creds)
247
+ echo_success(f"Authenticated as {data['user']['email']}")
248
+ echo_info(" Token saved to ~/.deepcell/credentials.json")
249
+
250
+
251
+ def _is_safe_verification_url(url: str, api_url: str) -> bool:
252
+ """Return True if *url* is safe to open in a browser.
253
+
254
+ Rules:
255
+ - Scheme must be ``https`` (or ``http`` only for localhost).
256
+ - Host must match the API host.
257
+ """
258
+ try:
259
+ parsed = urlparse(url)
260
+ api_parsed = urlparse(api_url)
261
+ except Exception:
262
+ return False
263
+
264
+ if parsed.scheme not in ("http", "https"):
265
+ return False
266
+
267
+ url_host = parsed.hostname or ""
268
+ api_host = api_parsed.hostname or ""
269
+
270
+ if url_host != api_host:
271
+ return False
272
+
273
+ # Allow http only for localhost
274
+ if parsed.scheme == "http" and url_host not in ("localhost", "127.0.0.1", "::1"):
275
+ return False
276
+
277
+ return True
278
+
279
+
280
+ def _login_device_flow(ctx: Ctx) -> None:
281
+ """Browser-based device code login flow."""
282
+ api_url = get_api_url()
283
+ warn_if_insecure(api_url)
284
+
285
+ # Step 1: Request device code, carrying this session's anonymous identity
286
+ # when it has one. If the user signs UP in the browser during this flow,
287
+ # that lets their new account adopt the row whose workspace already holds
288
+ # their work — otherwise they land on an empty personal workspace with the
289
+ # work beside it. Nothing here is required: an unproven or absent identity
290
+ # simply yields an ordinary device flow.
291
+ proof = _register_claim_proof(_snapshot_anon_credentials())
292
+ device_payload: dict[str, object] = {}
293
+ device_headers: dict[str, str] = {}
294
+ if proof:
295
+ device_payload["device_id"] = proof[0]
296
+ device_headers["Authorization"] = f"Bearer {proof[1]}"
297
+
298
+ resp = _api_post(
299
+ f"{api_url}/auth/device/code",
300
+ json=device_payload,
301
+ headers=device_headers or None,
302
+ )
303
+ if resp.status_code != 200:
304
+ try:
305
+ detail = resp.json().get("detail", resp.text)
306
+ except Exception:
307
+ detail = resp.text
308
+ raise APIError(resp.status_code, str(detail))
309
+
310
+ data = resp.json()
311
+ device_code = data["device_code"]
312
+ verification_url = data["verification_url"]
313
+ expires_in = data.get("expires_in", 900)
314
+ interval = data.get("interval", 5)
315
+
316
+ # Step 2: Show URL and try to open browser
317
+ echo_info("To authenticate, visit this URL in your browser:\n")
318
+ echo_info(f" {verification_url}\n")
319
+
320
+ if _is_safe_verification_url(verification_url, api_url):
321
+ try:
322
+ import webbrowser
323
+
324
+ webbrowser.open(verification_url)
325
+ echo_info("(Browser opened automatically)")
326
+ except Exception:
327
+ pass
328
+ else:
329
+ echo_info("(URL not opened automatically — copy and paste it into your browser)")
330
+
331
+ # Step 3: Poll for authorization
332
+ deadline = time.time() + expires_in
333
+ spinner_chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
334
+ spin_idx = 0
335
+
336
+ while time.time() < deadline:
337
+ click.echo(
338
+ f"\rWaiting for authorization... {spinner_chars[spin_idx % len(spinner_chars)]}",
339
+ nl=False,
340
+ err=True,
341
+ )
342
+ spin_idx += 1
343
+ time.sleep(interval)
344
+
345
+ try:
346
+ poll_resp = _api_post(
347
+ f"{api_url}/auth/device/token",
348
+ json={"device_code": device_code},
349
+ )
350
+ except CLIConnectionError:
351
+ continue
352
+
353
+ if poll_resp.status_code == 200:
354
+ poll_data = poll_resp.json()
355
+ if poll_data.get("status") == "pending":
356
+ continue
357
+
358
+ # Got tokens
359
+ click.echo("\r" + " " * 50 + "\r", nl=False, err=True)
360
+ creds: dict[str, object] = {
361
+ "access_token": poll_data["access_token"],
362
+ "expires_at": time.time() + poll_data["expires_in"],
363
+ "user_id": poll_data["user"]["id"],
364
+ "email": poll_data["user"]["email"],
365
+ }
366
+ if poll_data.get("refresh_token"):
367
+ creds["refresh_token"] = poll_data["refresh_token"]
368
+ save_credentials(creds)
369
+ echo_success(f"Authenticated as {poll_data['user']['email']}")
370
+ echo_info(" Token saved to ~/.deepcell/credentials.json")
371
+ return
372
+ elif poll_resp.status_code == 404:
373
+ click.echo("\r" + " " * 50 + "\r", nl=False, err=True)
374
+ echo_error("Device code expired or not found.")
375
+ raise SystemExit(1)
376
+
377
+ click.echo("\r" + " " * 50 + "\r", nl=False, err=True)
378
+ echo_error("Authorization timed out. Please try again.")
379
+ raise SystemExit(1)
380
+
381
+
382
+ # ── verify-email ──────────────────────────────────────────────
383
+
384
+
385
+ def _run_verification_flow(ctx: Ctx) -> bool:
386
+ """Run the email verification flow interactively.
387
+
388
+ Returns True on success, False on failure.
389
+ """
390
+ echo_info("Sending verification email…")
391
+ try:
392
+ data = ctx.client.post("/auth/send-verification")
393
+ except APIError as exc:
394
+ echo_error(f"Failed to send verification email: {exc.detail}")
395
+ return False
396
+
397
+ if data.get("message") == "Email is already verified":
398
+ echo_success("Email is already verified!")
399
+ return True
400
+
401
+ # In dev mode the server returns the token directly
402
+ token_from_server = data.get("token")
403
+ if token_from_server:
404
+ echo_info(f" [dev mode] Verification token: {token_from_server}")
405
+
406
+ token = click.prompt("Enter the verification token from your email")
407
+
408
+ try:
409
+ ctx.client.post("/auth/verify-email", json={"token": token}, auth=False)
410
+ except APIError as exc:
411
+ echo_error(f"Verification failed: {exc.detail}")
412
+ return False
413
+
414
+ echo_success("Email verified successfully!")
415
+ return True
416
+
417
+
418
+ @click.command("verify-email")
419
+ @pass_ctx
420
+ def verify_email(ctx: Ctx) -> None:
421
+ """Verify your email address.
422
+
423
+ Sends a verification token to your registered email. In development mode
424
+ the token is displayed directly. Enter the token when prompted.
425
+ """
426
+ if not _run_verification_flow(ctx):
427
+ raise SystemExit(1)
428
+
429
+
430
+ # ── register ─────────────────────────────────────────────────
431
+
432
+
433
+ @click.command()
434
+ @click.option("--email", prompt=True, help="Account email.")
435
+ @click.option(
436
+ "--password",
437
+ prompt=True,
438
+ hide_input=True,
439
+ confirmation_prompt=True,
440
+ help="Account password (min 8 chars).",
441
+ )
442
+ @click.option("--name", "display_name", prompt="Display name", help="Your display name.")
443
+ @click.option("--locale", default="zh-CN", help="Locale (default: zh-CN).")
444
+ def register(email: str, password: str, display_name: str, locale: str) -> None:
445
+ """Create a new DeepCell account (headless/CI).
446
+
447
+ For interactive use, prefer `deepcell login` — it opens the browser,
448
+ where you can sign up (email or Google) and authorize the CLI in one flow.
449
+ """
450
+ api_url = get_api_url()
451
+ warn_if_insecure(api_url)
452
+ prior_anon = _snapshot_anon_credentials()
453
+
454
+ # Hand the anonymous identity to /auth/register so it upgrades that row IN
455
+ # PLACE, which is what makes the scratch workspace become the new account's
456
+ # personal workspace. Without it register creates an empty personal
457
+ # workspace first and the scratch one arrives afterwards as an ordinary
458
+ # second workspace — the work survives, beside a home that is not it.
459
+ #
460
+ # Both halves are required server-side, so send both or neither: the
461
+ # device_id proves which row, the live anon token proves we held its
462
+ # session. A stale token is re-minted first, exactly as the claim path does.
463
+ claim_proof = _register_claim_proof(prior_anon)
464
+ payload: dict[str, object] = {
465
+ "email": email,
466
+ "password": password,
467
+ "display_name": display_name,
468
+ "locale": locale,
469
+ }
470
+ headers: dict[str, str] = {}
471
+ if claim_proof:
472
+ payload["device_id"] = claim_proof[0]
473
+ headers["Authorization"] = f"Bearer {claim_proof[1]}"
474
+
475
+ resp = _api_post(
476
+ f"{api_url}/auth/register",
477
+ json=payload,
478
+ headers=headers or None,
479
+ )
480
+ if resp.status_code == 409:
481
+ echo_error("An account with that email already exists.")
482
+ raise SystemExit(1)
483
+ if resp.status_code != 201:
484
+ try:
485
+ detail = resp.json().get("detail", resp.text)
486
+ except Exception:
487
+ detail = resp.text
488
+ raise APIError(resp.status_code, str(detail))
489
+
490
+ data = resp.json()
491
+ creds: dict[str, object] = {
492
+ "access_token": data["access_token"],
493
+ "expires_at": time.time() + data["expires_in"],
494
+ "user_id": data["user"]["id"],
495
+ "email": data["user"]["email"],
496
+ }
497
+ if data.get("refresh_token"):
498
+ creds["refresh_token"] = data["refresh_token"]
499
+ save_credentials(creds)
500
+ echo_success(f"Account created! Authenticated as {data['user']['email']}")
501
+ echo_info(" Token saved to ~/.deepcell/credentials.json")
502
+ if claim_proof:
503
+ # Register already merged it, in place. Calling claim-demo now would
504
+ # find no anonymous row for this device_id and — because the stored
505
+ # token is stale by then — re-mint a BRAND NEW anonymous user via
506
+ # /demo/session just to claim its empty self. Harmless but it litters,
507
+ # so skip it when we know the work already moved.
508
+ echo_success("Moved your workspace and its files into your account.")
509
+ else:
510
+ _claim_anonymous_work(prior_anon)
511
+
512
+ if not data["user"].get("email_verified", False):
513
+ # Don't block onboarding on verification — features that need a
514
+ # verified email will say so; `deepcell verify-email` handles it then.
515
+ echo_info(
516
+ "\nSome features require a verified email. "
517
+ "Run `deepcell verify-email` when you need them."
518
+ )
519
+
520
+
521
+ # ── logout ──────────────────────────────────────────────────
522
+
523
+
524
+ @click.command()
525
+ @pass_ctx
526
+ def logout(ctx: Ctx) -> None:
527
+ """Log out and remove stored credentials."""
528
+ from deepcell_cli.config import is_anonymous_session
529
+
530
+ if is_anonymous_session():
531
+ # The device_id is the only key to the anonymous demo workspace —
532
+ # discarding it orphans that work permanently.
533
+ click.confirm(
534
+ "This discards your local session and its workspace. Continue?",
535
+ abort=True,
536
+ )
537
+ # No server-side revocation: there is no anon-audience logout route,
538
+ # and the anon access token is short-lived (~15 min) — discarding the
539
+ # device_id locally is what actually severs access to the workspace.
540
+ clear_credentials()
541
+ echo_success("Session discarded.")
542
+ return
543
+ # /auth/logout reads the refresh token from a COOKIE, and the CLI holds no
544
+ # cookie jar (login posts with a bare httpx call), so it revoked nothing
545
+ # and returned 204 — `logout` reported success while a copied
546
+ # credentials.json stayed valid. /auth/revoke-token takes the token in the
547
+ # body precisely for non-browser callers.
548
+ config = load_config()
549
+ pending = config.get(_PENDING_REVOCATIONS_KEY) or []
550
+ if not isinstance(pending, list):
551
+ pending = []
552
+ refresh_token = get_refresh_token()
553
+ tokens = list(dict.fromkeys(
554
+ [str(token) for token in pending if token]
555
+ + ([refresh_token] if refresh_token else [])
556
+ ))
557
+
558
+ failed_tokens: list[str] = []
559
+ failure_messages: list[str] = []
560
+ for token in tokens:
561
+ try:
562
+ # The endpoint treats possession of the refresh token as
563
+ # authorization and intentionally needs no Bearer token. Using
564
+ # ctx.client here made a retry impossible after credentials were
565
+ # cleared because the client tried to authenticate first.
566
+ response = _api_post(
567
+ f"{get_api_url()}/auth/revoke-token",
568
+ json={"refresh_token": token},
569
+ )
570
+ if response.status_code != 200:
571
+ raise APIError(response.status_code, response.text)
572
+ except Exception as exc: # noqa: BLE001 — retained and reported
573
+ failed_tokens.append(token)
574
+ failure_messages.append(str(exc))
575
+ clear_credentials()
576
+
577
+ if failed_tokens:
578
+ config[_PENDING_REVOCATIONS_KEY] = failed_tokens
579
+ save_config(config)
580
+ echo_warning(
581
+ "Local credentials removed, but the server did not confirm "
582
+ f"revocation ({'; '.join(failure_messages)}) — {len(failed_tokens)} "
583
+ "refresh token(s) may still be valid. Retry `deepcell logout` once "
584
+ "you are back online; the pending token(s) were retained privately "
585
+ "for that retry."
586
+ )
587
+ raise click.exceptions.Exit(1)
588
+ else:
589
+ if _PENDING_REVOCATIONS_KEY in config:
590
+ config.pop(_PENDING_REVOCATIONS_KEY, None)
591
+ save_config(config)
592
+ echo_success("Logged out. Credentials removed.")
593
+
594
+
595
+ # ── whoami ──────────────────────────────────────────────────
596
+
597
+
598
+ @click.command()
599
+ @pass_ctx
600
+ def whoami(ctx: Ctx) -> None:
601
+ """Show the currently authenticated user."""
602
+ from deepcell_cli.config import is_anonymous_session
603
+
604
+ if is_anonymous_session():
605
+ # /auth/me is full-user only; report the anonymous state locally.
606
+ click.echo("Not signed in.")
607
+ click.echo("Run `deepcell login` to sign in.")
608
+ return
609
+ data = ctx.client.get("/auth/me")
610
+ output(data, ctx.fmt)