recce-cloud 1.32.0__py3-none-any.whl → 1.33.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.
recce_cloud/cli.py CHANGED
@@ -7,6 +7,7 @@ import logging
7
7
  import os
8
8
  import subprocess
9
9
  import sys
10
+ from typing import Optional
10
11
 
11
12
  import click
12
13
  from rich.console import Console
@@ -15,6 +16,7 @@ from rich.logging import RichHandler
15
16
  from recce_cloud import __version__
16
17
  from recce_cloud.artifact import get_adapter_type, verify_artifacts_path
17
18
  from recce_cloud.ci_providers import CIDetector
19
+ from recce_cloud.commands.diagnostics import doctor
18
20
  from recce_cloud.delete import (
19
21
  delete_existing_session,
20
22
  delete_with_platform_apis,
@@ -49,12 +51,403 @@ def cloud_cli():
49
51
  pass
50
52
 
51
53
 
54
+ # Register commands from command modules
55
+ cloud_cli.add_command(doctor)
56
+
57
+
52
58
  @cloud_cli.command()
53
59
  def version():
54
60
  """Show the version of recce-cloud."""
55
61
  click.echo(__version__)
56
62
 
57
63
 
64
+ @cloud_cli.command()
65
+ @click.option(
66
+ "--token",
67
+ default=None,
68
+ help="API token for authentication (for headless/CI environments)",
69
+ )
70
+ @click.option(
71
+ "--status",
72
+ is_flag=True,
73
+ help="Check current login status without modifying credentials",
74
+ )
75
+ def login(token, status):
76
+ """
77
+ Authenticate with Recce Cloud.
78
+
79
+ By default, opens a browser for OAuth authentication. The browser flow
80
+ securely exchanges credentials using RSA encryption.
81
+
82
+ \b
83
+ Examples:
84
+ # Browser-based OAuth login (recommended)
85
+ recce-cloud login
86
+
87
+ # Check if already logged in
88
+ recce-cloud login --status
89
+
90
+ # Direct token authentication (for headless/CI environments)
91
+ recce-cloud login --token <your-api-token>
92
+ """
93
+ from recce_cloud.auth.login import (
94
+ check_login_status,
95
+ get_api_token,
96
+ login_with_browser,
97
+ login_with_token,
98
+ )
99
+
100
+ console = Console()
101
+
102
+ # Status check mode
103
+ if status:
104
+ is_logged_in, email = check_login_status()
105
+ if is_logged_in:
106
+ console.print(f"[green]✓[/green] Logged in as [cyan]{email or 'Unknown'}[/cyan]")
107
+ token_value = get_api_token()
108
+ if token_value:
109
+ masked = f"{token_value[:8]}...{token_value[-4:]}" if len(token_value) > 12 else "***"
110
+ console.print(f" Token: {masked} (valid)")
111
+ else:
112
+ console.print("[yellow]Not logged in[/yellow]")
113
+ console.print("Run 'recce-cloud login' to authenticate")
114
+ sys.exit(0 if is_logged_in else 1)
115
+
116
+ # Check if already logged in
117
+ is_logged_in, email = check_login_status()
118
+ if is_logged_in:
119
+ console.print(f"[green]✓[/green] Already logged in as [cyan]{email or 'Unknown'}[/cyan]")
120
+ if not click.confirm("Do you want to re-authenticate?", default=False):
121
+ sys.exit(0)
122
+
123
+ # Direct token authentication mode
124
+ if token:
125
+ if login_with_token(token):
126
+ sys.exit(0)
127
+ else:
128
+ sys.exit(1)
129
+
130
+ # Browser OAuth flow
131
+ if login_with_browser():
132
+ sys.exit(0)
133
+ else:
134
+ console.print()
135
+ console.print("[yellow]Tip:[/yellow] For headless environments, use 'recce-cloud login --token <token>'")
136
+ sys.exit(1)
137
+
138
+
139
+ @cloud_cli.command()
140
+ def logout():
141
+ """
142
+ Remove stored Recce Cloud credentials.
143
+
144
+ Clears the API token from ~/.recce/profile.yml.
145
+ """
146
+ from recce_cloud.auth.login import logout as do_logout
147
+ from recce_cloud.auth.profile import get_profile_path
148
+
149
+ console = Console()
150
+
151
+ do_logout()
152
+ console.print("[green]✓[/green] Logged out successfully")
153
+ console.print(f" Credentials removed from {get_profile_path()}")
154
+
155
+
156
+ @cloud_cli.command()
157
+ @click.option(
158
+ "--org",
159
+ help="Organization name or slug to bind to",
160
+ )
161
+ @click.option(
162
+ "--project",
163
+ help="Project name or slug to bind to",
164
+ )
165
+ @click.option(
166
+ "--status",
167
+ is_flag=True,
168
+ help="Show current project binding without modifying",
169
+ )
170
+ @click.option(
171
+ "--clear",
172
+ is_flag=True,
173
+ help="Remove current project binding",
174
+ )
175
+ def init(org, project, status, clear):
176
+ """
177
+ Bind current directory to a Recce Cloud project.
178
+
179
+ Creates a .recce/config file that stores the org/project binding.
180
+ Subsequent commands will auto-detect this binding.
181
+
182
+ \b
183
+ Examples:
184
+ # Interactive mode: Select org and project
185
+ recce-cloud init
186
+
187
+ # Explicit mode: Direct binding (for scripts/CI)
188
+ recce-cloud init --org myorg --project my-dbt-project
189
+
190
+ # Check current binding
191
+ recce-cloud init --status
192
+
193
+ # Remove binding
194
+ recce-cloud init --clear
195
+ """
196
+ from recce_cloud.api.client import RecceCloudClient
197
+ from recce_cloud.api.exceptions import RecceCloudException
198
+ from recce_cloud.auth.login import get_user_info
199
+ from recce_cloud.auth.profile import get_api_token
200
+ from recce_cloud.config.project_config import (
201
+ add_to_gitignore,
202
+ clear_project_binding,
203
+ get_config_path,
204
+ get_project_binding,
205
+ save_project_binding,
206
+ )
207
+
208
+ console = Console()
209
+
210
+ # Check authentication first
211
+ token = get_api_token()
212
+ if not token:
213
+ console.print("[red]Error:[/red] Not logged in")
214
+ console.print("Run 'recce-cloud login' first")
215
+ sys.exit(1)
216
+
217
+ # Status check mode
218
+ if status:
219
+ binding = get_project_binding()
220
+ if binding:
221
+ console.print(f"[green]✓[/green] Bound to [cyan]{binding['org']}/{binding['project']}[/cyan]")
222
+ if binding.get("bound_at"):
223
+ console.print(f" Bound at: {binding['bound_at']}")
224
+ if binding.get("bound_by"):
225
+ console.print(f" Bound by: {binding['bound_by']}")
226
+ console.print(f" Config file: {get_config_path()}")
227
+ else:
228
+ console.print("[yellow]Not bound to any project[/yellow]")
229
+ console.print("Run 'recce-cloud init' to bind this directory")
230
+ sys.exit(0 if binding else 1)
231
+
232
+ # Clear mode
233
+ if clear:
234
+ if clear_project_binding():
235
+ console.print("[green]✓[/green] Project binding removed")
236
+ else:
237
+ console.print("[yellow]No project binding to remove[/yellow]")
238
+ sys.exit(0)
239
+
240
+ # Validate flag combinations
241
+ if (org and not project) or (project and not org):
242
+ console.print("[red]Error:[/red] Both --org and --project must be provided together")
243
+ sys.exit(1)
244
+
245
+ # Get user email for binding metadata
246
+ user_info = get_user_info(token)
247
+ user_email = user_info.get("email") if user_info else None
248
+
249
+ # Explicit mode: Direct binding
250
+ if org and project:
251
+ # Validate org/project exist
252
+ try:
253
+ api = RecceCloudClient(token)
254
+ org_obj = api.get_organization(org)
255
+ if not org_obj:
256
+ console.print(f"[red]Error:[/red] Organization '{org}' not found")
257
+ sys.exit(1)
258
+
259
+ # Use org ID for project lookup (API requires ID)
260
+ project_obj = api.get_project(org_obj.get("id"), project)
261
+ if not project_obj:
262
+ console.print(f"[red]Error:[/red] Project '{project}' not found in organization '{org}'")
263
+ sys.exit(1)
264
+
265
+ # Use slug for storage (more stable than name)
266
+ org_slug = org_obj.get("slug", org)
267
+ project_slug = project_obj.get("slug", project)
268
+
269
+ save_project_binding(org_slug, project_slug, user_email)
270
+ console.print(f"[green]✓[/green] Bound to [cyan]{org_slug}/{project_slug}[/cyan]")
271
+ console.print(f" Config saved to {get_config_path()}")
272
+
273
+ # Offer to add to .gitignore
274
+ if click.confirm("Add .recce/ to .gitignore?", default=True):
275
+ if add_to_gitignore():
276
+ console.print("[green]✓[/green] Added .recce/ to .gitignore")
277
+ else:
278
+ console.print(" .recce/ already in .gitignore")
279
+
280
+ sys.exit(0)
281
+
282
+ except RecceCloudException as e:
283
+ console.print(f"[red]Error:[/red] {e}")
284
+ sys.exit(1)
285
+
286
+ # Interactive mode: Select org → project
287
+ try:
288
+ api = RecceCloudClient(token)
289
+
290
+ # List organizations
291
+ console.print("Fetching organizations...")
292
+ orgs = api.list_organizations()
293
+
294
+ if not orgs:
295
+ console.print("[yellow]No organizations found[/yellow]")
296
+ console.print("Please create an organization at https://cloud.datarecce.io first")
297
+ sys.exit(1)
298
+
299
+ # Build org choices: (id for API, name for config, display_name for UI)
300
+ org_choices = []
301
+ for o in orgs:
302
+ org_id = o.get("id")
303
+ org_name = o.get("name") or o.get("slug") or str(org_id)
304
+ display_name = o.get("display_name") or org_name
305
+ org_choices.append((org_id, org_name, display_name))
306
+
307
+ # Select organization
308
+ console.print()
309
+ console.print("[cyan]Select organization:[/cyan]")
310
+ for i, (_, _, display_name) in enumerate(org_choices, 1):
311
+ console.print(f" {i}. {display_name}")
312
+
313
+ org_idx = click.prompt("Enter number", type=click.IntRange(1, len(org_choices)))
314
+ selected_org_id, selected_org_name, selected_org_display = org_choices[org_idx - 1]
315
+
316
+ # List projects (use org_id for API call)
317
+ console.print()
318
+ console.print(f"Fetching projects for {selected_org_display}...")
319
+ projects = api.list_projects(selected_org_id)
320
+
321
+ if not projects:
322
+ console.print(f"[yellow]No projects found in {selected_org_display}[/yellow]")
323
+ console.print("Please create a project at https://cloud.datarecce.io first")
324
+ sys.exit(1)
325
+
326
+ # Build project choices: (name for config, display_name for UI)
327
+ # Filter out archived projects
328
+ project_choices = []
329
+ for p in projects:
330
+ # Skip archived projects (check status field and archived flags)
331
+ if p.get("status") == "archived" or p.get("archived") or p.get("is_archived"):
332
+ continue
333
+ project_name = p.get("name") or p.get("slug") or str(p.get("id"))
334
+ display_name = p.get("display_name") or project_name
335
+ project_choices.append((project_name, display_name))
336
+
337
+ if not project_choices:
338
+ console.print(f"[yellow]No active projects found in {selected_org_display}[/yellow]")
339
+ console.print("Please create a project at https://cloud.datarecce.io first")
340
+ sys.exit(1)
341
+
342
+ # Select project
343
+ console.print()
344
+ console.print("[cyan]Select project:[/cyan]")
345
+ for i, (_, display_name) in enumerate(project_choices, 1):
346
+ console.print(f" {i}. {display_name}")
347
+
348
+ project_idx = click.prompt("Enter number", type=click.IntRange(1, len(project_choices)))
349
+ selected_project_name, selected_project_display = project_choices[project_idx - 1]
350
+
351
+ # Save binding (use names for config, not IDs)
352
+ save_project_binding(selected_org_name, selected_project_name, user_email)
353
+ console.print()
354
+ console.print(f"[green]✓[/green] Bound to [cyan]{selected_org_name}/{selected_project_name}[/cyan]")
355
+ console.print(f" Config saved to {get_config_path()}")
356
+
357
+ # Offer to add to .gitignore
358
+ if click.confirm("Add .recce/ to .gitignore?", default=True):
359
+ if add_to_gitignore():
360
+ console.print("[green]✓[/green] Added .recce/ to .gitignore")
361
+ else:
362
+ console.print(" .recce/ already in .gitignore")
363
+
364
+ except RecceCloudException as e:
365
+ console.print(f"[red]Error:[/red] Failed to fetch data from Recce Cloud: {e}")
366
+ sys.exit(1)
367
+ except Exception as e:
368
+ logger.debug("Unexpected error during init: %s", e, exc_info=True)
369
+ console.print(f"[red]Error:[/red] An unexpected error occurred: {e}")
370
+ console.print(" Try running 'recce-cloud login' again or check your network connection.")
371
+ sys.exit(1)
372
+
373
+
374
+ def _get_production_session_id(console: Console, token: str) -> Optional[str]:
375
+ """
376
+ Fetch the production session ID from Recce Cloud.
377
+
378
+ Returns the session ID if found, None otherwise (with error message printed).
379
+ """
380
+ from recce_cloud.api.client import RecceCloudClient
381
+ from recce_cloud.api.exceptions import RecceCloudException
382
+ from recce_cloud.config.project_config import get_project_binding
383
+
384
+ # Get project binding
385
+ binding = get_project_binding()
386
+ if not binding:
387
+ # Check environment variables as fallback
388
+ env_org = os.environ.get("RECCE_ORG")
389
+ env_project = os.environ.get("RECCE_PROJECT")
390
+ if env_org and env_project:
391
+ binding = {"org": env_org, "project": env_project}
392
+ else:
393
+ console.print("[red]Error:[/red] No project binding found")
394
+ console.print("Run 'recce-cloud init' to bind this directory to a project")
395
+ return None
396
+
397
+ org_slug = binding.get("org")
398
+ project_slug = binding.get("project")
399
+
400
+ try:
401
+ client = RecceCloudClient(token)
402
+
403
+ # Get org and project IDs
404
+ org_info = client.get_organization(org_slug)
405
+ if not org_info:
406
+ console.print(f"[red]Error:[/red] Organization '{org_slug}' not found")
407
+ return None
408
+ org_id = org_info.get("id")
409
+ if not org_id:
410
+ console.print(f"[red]Error:[/red] Organization '{org_slug}' response missing ID")
411
+ return None
412
+
413
+ project_info = client.get_project(org_id, project_slug)
414
+ if not project_info:
415
+ console.print(f"[red]Error:[/red] Project '{project_slug}' not found")
416
+ return None
417
+ project_id = project_info.get("id")
418
+ if not project_id:
419
+ console.print(f"[red]Error:[/red] Project '{project_slug}' response missing ID")
420
+ return None
421
+
422
+ # List sessions and find production session
423
+ sessions = client.list_sessions(org_id, project_id)
424
+ for session in sessions:
425
+ if session.get("is_base"):
426
+ session_id = session.get("id")
427
+ if not session_id:
428
+ console.print("[red]Error:[/red] Production session found but has no ID")
429
+ return None
430
+ session_name = session.get("name") or "(unnamed)"
431
+ session_id_display = session_id[:8] if len(session_id) >= 8 else session_id
432
+ console.print(
433
+ f"[cyan]Info:[/cyan] Found production session '{session_name}' (ID: {session_id_display}...)"
434
+ )
435
+ return session_id
436
+
437
+ console.print("[red]Error:[/red] No production session found")
438
+ console.print("Create a production session first using 'recce-cloud upload --type prod' or via CI pipeline")
439
+ return None
440
+
441
+ except RecceCloudException as e:
442
+ console.print(f"[red]Error:[/red] Failed to fetch sessions: {e}")
443
+ return None
444
+ except Exception as e:
445
+ logger.debug("Unexpected error in _get_production_session_id: %s", e, exc_info=True)
446
+ console.print(f"[red]Error:[/red] Unexpected error: {e}")
447
+ console.print(" Check your network connection and try again.")
448
+ return None
449
+
450
+
58
451
  @cloud_cli.command()
59
452
  @click.option(
60
453
  "--target-path",
@@ -68,6 +461,18 @@ def version():
68
461
  help="Recce Cloud session ID to upload artifacts to (or use RECCE_SESSION_ID env var). "
69
462
  "If not provided, session will be created automatically using platform-specific APIs (GitHub/GitLab).",
70
463
  )
464
+ @click.option(
465
+ "--session-name",
466
+ help="Session name to look up or create. If a session with this name exists, "
467
+ "uploads to it; otherwise prompts to create a new session (use --yes to skip prompt).",
468
+ )
469
+ @click.option(
470
+ "--yes",
471
+ "-y",
472
+ "skip_confirmation",
473
+ is_flag=True,
474
+ help="Skip confirmation prompts (auto-create session if not found).",
475
+ )
71
476
  @click.option(
72
477
  "--cr",
73
478
  type=int,
@@ -84,13 +489,13 @@ def version():
84
489
  is_flag=True,
85
490
  help="Show what would be uploaded without actually uploading",
86
491
  )
87
- def upload(target_path, session_id, cr, session_type, dry_run):
492
+ def upload(target_path, session_id, session_name, skip_confirmation, cr, session_type, dry_run):
88
493
  """
89
494
  Upload dbt artifacts (manifest.json, catalog.json) to Recce Cloud.
90
495
 
91
496
  \b
92
497
  Authentication (auto-detected):
93
- - RECCE_API_TOKEN (for --session-id workflow)
498
+ - RECCE_API_TOKEN env var or 'recce-cloud login' profile (for --session-id/--session-name workflow)
94
499
  - GITHUB_TOKEN (GitHub Actions)
95
500
  - CI_JOB_TOKEN (GitLab CI)
96
501
 
@@ -102,9 +507,15 @@ def upload(target_path, session_id, cr, session_type, dry_run):
102
507
  # Upload production metadata from main branch
103
508
  recce-cloud upload --type prod
104
509
 
105
- # Upload to specific session
510
+ # Upload to specific session by ID
106
511
  recce-cloud upload --session-id abc123
107
512
 
513
+ # Upload by session name (creates if not exists)
514
+ recce-cloud upload --session-name "my-evaluation-session"
515
+
516
+ # Auto-create session without confirmation
517
+ recce-cloud upload --session-name "new-session" --yes
518
+
108
519
  # Custom target path
109
520
  recce-cloud upload --target-path custom-target
110
521
  """
@@ -204,8 +615,15 @@ def upload(target_path, session_id, cr, session_type, dry_run):
204
615
  # Display upload summary
205
616
  console.print("[cyan]Upload Workflow:[/cyan]")
206
617
  if session_id:
207
- console.print(" • Upload to existing session")
618
+ console.print(" • Upload to existing session by ID")
208
619
  console.print(f" • Session ID: {session_id}")
620
+ elif session_name:
621
+ console.print(" • Upload by session name (lookup or create)")
622
+ console.print(f" • Session Name: {session_name}")
623
+ if skip_confirmation:
624
+ console.print(" • Auto-create if not exists (--yes flag)")
625
+ else:
626
+ console.print(" • Will prompt before creating if not exists")
209
627
  else:
210
628
  console.print(" • Auto-create session and upload")
211
629
  if ci_info and ci_info.platform in ["github-actions", "gitlab-ci"]:
@@ -223,32 +641,236 @@ def upload(target_path, session_id, cr, session_type, dry_run):
223
641
  console.print("[green]✓[/green] Dry run completed successfully")
224
642
  sys.exit(0)
225
643
 
226
- # 5. Choose upload workflow based on whether session_id is provided
644
+ # 5. Choose upload workflow based on provided options
645
+ # Priority: --session-id > --session-name > platform-specific auto-detection
227
646
  if session_id:
228
647
  # Generic workflow: Upload to existing session using session ID
229
- # This workflow requires RECCE_API_TOKEN
230
- token = os.getenv("RECCE_API_TOKEN")
648
+ # This workflow requires RECCE_API_TOKEN or logged-in profile
649
+ from recce_cloud.auth.profile import get_api_token
650
+
651
+ token = os.getenv("RECCE_API_TOKEN") or get_api_token()
231
652
  if not token:
232
- console.print("[red]Error:[/red] No RECCE_API_TOKEN provided")
233
- console.print("Set RECCE_API_TOKEN environment variable for session-based upload")
653
+ console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
654
+ console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
234
655
  sys.exit(2)
235
656
 
236
657
  upload_to_existing_session(console, token, session_id, manifest_path, catalog_path, adapter_type, target_path)
658
+ elif session_name:
659
+ # Session name workflow: Look up session by name, create if not exists
660
+ # This workflow requires RECCE_API_TOKEN or logged-in profile, plus org/project config
661
+ from recce_cloud.auth.profile import get_api_token
662
+ from recce_cloud.upload import upload_with_session_name
663
+
664
+ token = os.getenv("RECCE_API_TOKEN") or get_api_token()
665
+ if not token:
666
+ console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
667
+ console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
668
+ sys.exit(2)
669
+
670
+ upload_with_session_name(
671
+ console,
672
+ token,
673
+ session_name,
674
+ manifest_path,
675
+ catalog_path,
676
+ adapter_type,
677
+ target_path,
678
+ skip_confirmation=skip_confirmation,
679
+ )
237
680
  else:
238
- # Platform-specific workflow: Use platform APIs to create session and upload
681
+ # GitHub Action or GitLab CI/CD workflow: Use platform APIs to create session and upload
239
682
  # This workflow MUST use CI job tokens (CI_JOB_TOKEN or GITHUB_TOKEN)
240
683
  if not ci_info or not ci_info.access_token:
241
- console.print("[red]Error:[/red] Platform-specific upload requires CI environment")
242
- console.print("Either run in GitHub Actions/GitLab CI or provide --session-id for generic upload")
684
+ # If --type prod is specified outside CI, fetch the production session and upload to it
685
+ if session_type == "prod":
686
+ from recce_cloud.auth.profile import get_api_token
687
+
688
+ token = os.getenv("RECCE_API_TOKEN") or get_api_token()
689
+ if not token:
690
+ console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
691
+ console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
692
+ sys.exit(2)
693
+
694
+ # Fetch the production session ID
695
+ prod_session_id = _get_production_session_id(console, token)
696
+ if not prod_session_id:
697
+ sys.exit(2)
698
+
699
+ upload_to_existing_session(
700
+ console, token, prod_session_id, manifest_path, catalog_path, adapter_type, target_path
701
+ )
702
+ else:
703
+ console.print("[red]Error:[/red] Platform-specific upload requires CI environment")
704
+ console.print(
705
+ "Either run in GitHub Actions/GitLab CI or provide --session-id/--session-name for generic upload"
706
+ )
707
+ sys.exit(2)
708
+ else:
709
+ token = ci_info.access_token
710
+ if ci_info.platform == "github-actions":
711
+ console.print("[cyan]Info:[/cyan] Using GITHUB_TOKEN for platform-specific authentication")
712
+ elif ci_info.platform == "gitlab-ci":
713
+ console.print("[cyan]Info:[/cyan] Using CI_JOB_TOKEN for platform-specific authentication")
714
+
715
+ upload_with_platform_apis(console, token, ci_info, manifest_path, catalog_path, adapter_type, target_path)
716
+
717
+
718
+ @cloud_cli.command(name="list")
719
+ @click.option(
720
+ "--type",
721
+ "session_type",
722
+ type=click.Choice(["cr", "prod", "dev"]),
723
+ help="Filter by session type (prod=base, cr=has PR link, dev=other)",
724
+ )
725
+ @click.option(
726
+ "--json",
727
+ "output_json",
728
+ is_flag=True,
729
+ help="Output in JSON format",
730
+ )
731
+ def list_sessions_cmd(session_type, output_json):
732
+ """
733
+ List sessions in the configured Recce Cloud project.
734
+
735
+ \b
736
+ Requires:
737
+ - RECCE_API_TOKEN env var or 'recce-cloud login'
738
+ - Project binding via 'recce-cloud init' or RECCE_ORG/RECCE_PROJECT env vars
739
+
740
+ \b
741
+ Examples:
742
+ # List all sessions
743
+ recce-cloud list
744
+
745
+ # List only production sessions
746
+ recce-cloud list --type prod
747
+
748
+ # Output as JSON
749
+ recce-cloud list --json
750
+ """
751
+ import json
752
+
753
+ from rich.table import Table
754
+
755
+ from recce_cloud.api.client import RecceCloudClient
756
+ from recce_cloud.auth.profile import get_api_token
757
+ from recce_cloud.config.resolver import ConfigurationError, resolve_config
758
+
759
+ console = Console()
760
+
761
+ # 1. Get API token
762
+ token = os.getenv("RECCE_API_TOKEN") or get_api_token()
763
+ if not token:
764
+ console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
765
+ console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
766
+ sys.exit(2)
767
+
768
+ # 2. Resolve org/project configuration
769
+ try:
770
+ config = resolve_config()
771
+ org = config.org
772
+ project = config.project
773
+ except ConfigurationError as e:
774
+ console.print("[red]Error:[/red] Could not resolve org/project configuration")
775
+ console.print(f"Reason: {e}")
776
+ console.print()
777
+ console.print("Run 'recce-cloud init' to bind this directory to a project,")
778
+ console.print("or set RECCE_ORG and RECCE_PROJECT environment variables")
779
+ sys.exit(2)
780
+
781
+ # 3. Initialize client and resolve IDs
782
+ try:
783
+ client = RecceCloudClient(token)
784
+
785
+ org_info = client.get_organization(org)
786
+ if not org_info:
787
+ console.print(f"[red]Error:[/red] Organization '{org}' not found or you don't have access")
788
+ sys.exit(2)
789
+ org_id = org_info.get("id")
790
+ if not org_id:
791
+ console.print(f"[red]Error:[/red] Organization '{org}' response missing ID")
243
792
  sys.exit(2)
244
793
 
245
- token = ci_info.access_token
246
- if ci_info.platform == "github-actions":
247
- console.print("[cyan]Info:[/cyan] Using GITHUB_TOKEN for platform-specific authentication")
248
- elif ci_info.platform == "gitlab-ci":
249
- console.print("[cyan]Info:[/cyan] Using CI_JOB_TOKEN for platform-specific authentication")
794
+ project_info = client.get_project(org_id, project)
795
+ if not project_info:
796
+ console.print(f"[red]Error:[/red] Project '{project}' not found in organization '{org}'")
797
+ sys.exit(2)
798
+ project_id = project_info.get("id")
799
+ if not project_id:
800
+ console.print(f"[red]Error:[/red] Project '{project}' response missing ID")
801
+ sys.exit(2)
802
+
803
+ except Exception as e:
804
+ logger.debug("Failed to initialize client for list_sessions: %s", e, exc_info=True)
805
+ console.print(f"[red]Error:[/red] Failed to initialize: {e}")
806
+ console.print(" Check your authentication and network connection.")
807
+ sys.exit(2)
808
+
809
+ # Helper to derive session type from fields:
810
+ # - prod: is_base = True
811
+ # - cr: pr_link is not null
812
+ # - dev: everything else
813
+ def get_session_type(s):
814
+ if s.get("is_base"):
815
+ return "prod"
816
+ elif s.get("pr_link"):
817
+ return "cr"
818
+ else:
819
+ return "dev"
250
820
 
251
- upload_with_platform_apis(console, token, ci_info, manifest_path, catalog_path, adapter_type, target_path)
821
+ # 4. List sessions
822
+ try:
823
+ sessions = client.list_sessions(org_id, project_id)
824
+
825
+ if session_type:
826
+ sessions = [s for s in sessions if get_session_type(s) == session_type]
827
+ except Exception as e:
828
+ console.print(f"[red]Error:[/red] Failed to list sessions: {e}")
829
+ sys.exit(2)
830
+
831
+ # 5. Output results
832
+ if output_json:
833
+ console.print(json.dumps(sessions, indent=2, default=str))
834
+ sys.exit(0)
835
+
836
+ if not sessions:
837
+ console.print("[yellow]No sessions found[/yellow]")
838
+ if session_type:
839
+ console.print(f"(filtered by type: {session_type})")
840
+ sys.exit(0)
841
+
842
+ # Display as table
843
+ console.print(f"[cyan]Organization:[/cyan] {org}")
844
+ console.print(f"[cyan]Project:[/cyan] {project}")
845
+ console.print()
846
+
847
+ table = Table(title=f"Sessions ({len(sessions)} total)")
848
+ table.add_column("Name", style="cyan", no_wrap=True)
849
+ table.add_column("ID", style="dim")
850
+ table.add_column("Type", style="green")
851
+ table.add_column("Created At")
852
+ table.add_column("Adapter")
853
+
854
+ for session in sessions:
855
+ name = session.get("name", "-")
856
+ session_id = session.get("id", "-")
857
+ s_type = get_session_type(session)
858
+ created_at = session.get("created_at", "-")
859
+ if created_at and created_at != "-":
860
+ # Format datetime if present
861
+ try:
862
+ from datetime import datetime
863
+
864
+ dt = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
865
+ created_at = dt.strftime("%Y-%m-%d %H:%M")
866
+ except (ValueError, AttributeError):
867
+ pass
868
+ adapter = session.get("adapter_type", "-")
869
+
870
+ table.add_row(name or "(unnamed)", session_id, s_type, created_at, adapter or "-")
871
+
872
+ console.print(table)
873
+ sys.exit(0)
252
874
 
253
875
 
254
876
  @cloud_cli.command()
@@ -286,7 +908,7 @@ def download(target_path, session_id, prod, dry_run, force):
286
908
 
287
909
  \b
288
910
  Authentication (auto-detected):
289
- - RECCE_API_TOKEN (for --session-id workflow)
911
+ - RECCE_API_TOKEN env var or 'recce-cloud login' profile (for --session-id workflow)
290
912
  - GITHUB_TOKEN (GitHub Actions)
291
913
  - CI_JOB_TOKEN (GitLab CI)
292
914
 
@@ -411,11 +1033,13 @@ def download(target_path, session_id, prod, dry_run, force):
411
1033
  # 3. Choose download workflow based on whether session_id is provided
412
1034
  if session_id:
413
1035
  # Generic workflow: Download from existing session using session ID
414
- # This workflow requires RECCE_API_TOKEN
415
- token = os.getenv("RECCE_API_TOKEN")
1036
+ # This workflow requires RECCE_API_TOKEN or logged-in profile
1037
+ from recce_cloud.auth.profile import get_api_token
1038
+
1039
+ token = os.getenv("RECCE_API_TOKEN") or get_api_token()
416
1040
  if not token:
417
- console.print("[red]Error:[/red] No RECCE_API_TOKEN provided")
418
- console.print("Set RECCE_API_TOKEN environment variable for session-based download")
1041
+ console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
1042
+ console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
419
1043
  sys.exit(2)
420
1044
 
421
1045
  download_from_existing_session(console, token, session_id, target_path, force)
@@ -463,7 +1087,7 @@ def delete(session_id, dry_run, force):
463
1087
 
464
1088
  \b
465
1089
  Authentication (auto-detected):
466
- - RECCE_API_TOKEN (for --session-id workflow)
1090
+ - RECCE_API_TOKEN env var or 'recce-cloud login' profile (for --session-id workflow)
467
1091
  - GITHUB_TOKEN (GitHub Actions)
468
1092
  - CI_JOB_TOKEN (GitLab CI)
469
1093
 
@@ -576,11 +1200,13 @@ def delete(session_id, dry_run, force):
576
1200
  # 4. Choose delete workflow based on whether session_id is provided
577
1201
  if session_id:
578
1202
  # Generic workflow: Delete from existing session using session ID
579
- # This workflow requires RECCE_API_TOKEN
580
- token = os.getenv("RECCE_API_TOKEN")
1203
+ # This workflow requires RECCE_API_TOKEN or logged-in profile
1204
+ from recce_cloud.auth.profile import get_api_token
1205
+
1206
+ token = os.getenv("RECCE_API_TOKEN") or get_api_token()
581
1207
  if not token:
582
- console.print("[red]Error:[/red] No RECCE_API_TOKEN provided")
583
- console.print("Set RECCE_API_TOKEN environment variable for session-based delete")
1208
+ console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
1209
+ console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
584
1210
  sys.exit(2)
585
1211
 
586
1212
  delete_existing_session(console, token, session_id)
@@ -648,7 +1274,7 @@ def report(repo, since, until, base_branch, merged_only, output):
648
1274
 
649
1275
  \b
650
1276
  Authentication:
651
- - Requires RECCE_API_TOKEN environment variable
1277
+ - Requires RECCE_API_TOKEN env var or 'recce-cloud login' profile
652
1278
 
653
1279
  \b
654
1280
  Examples:
@@ -669,11 +1295,13 @@ def report(repo, since, until, base_branch, merged_only, output):
669
1295
  """
670
1296
  console = Console()
671
1297
 
672
- # Check for API token
673
- token = os.getenv("RECCE_API_TOKEN")
1298
+ # Check for API token (env var or logged-in profile)
1299
+ from recce_cloud.auth.profile import get_api_token
1300
+
1301
+ token = os.getenv("RECCE_API_TOKEN") or get_api_token()
674
1302
  if not token:
675
- console.print("[red]Error:[/red] RECCE_API_TOKEN environment variable is required")
676
- console.print("Set RECCE_API_TOKEN to your Recce Cloud API token")
1303
+ console.print("[red]Error:[/red] No RECCE_API_TOKEN provided and not logged in")
1304
+ console.print("Either set RECCE_API_TOKEN environment variable or run 'recce-cloud login' first")
677
1305
  sys.exit(2)
678
1306
 
679
1307
  # Auto-detect repo from git remote if not provided