snowglobe 0.4.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,736 @@
1
+ import asyncio
2
+ import hashlib
3
+ import importlib.util
4
+ import os
5
+ import signal
6
+ import sys
7
+ import threading
8
+ import time
9
+ import webbrowser
10
+ from typing import Optional, Tuple
11
+
12
+ import typer
13
+ import uvicorn
14
+ from fastapi import FastAPI, Request
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+
17
+ # Import start_client lazily inside the start command to avoid config initialization
18
+ from .cli_utils import (
19
+ check_auth_status,
20
+ cli_state,
21
+ console,
22
+ debug,
23
+ docs_link,
24
+ error,
25
+ get_api_key,
26
+ get_remote_applications,
27
+ graceful_shutdown,
28
+ info,
29
+ select_application_interactive,
30
+ select_stateful_interactive,
31
+ spinner,
32
+ success,
33
+ warning,
34
+ )
35
+ from .config import get_rc_file_path
36
+ from .models import CompletionRequest, SnowglobeData, SnowglobeMessage
37
+ from .project_manager import get_project_manager
38
+
39
+ CONTROL_PLANE_URL = os.environ.get(
40
+ "CONTROL_PLANE_URL", "https://api.snowglobe.guardrailsai.com"
41
+ )
42
+ UI_URL = os.environ.get("UI_URL", "https://snowglobe.guardrailsai.com")
43
+ SNOWGLOBE_AUTH_CONFIGURE_PORT = int(
44
+ os.environ.get("SNOWGLOBE_AUTH_CONFIGURE_PORT", 9001)
45
+ )
46
+
47
+
48
+ cli_app = typer.Typer(
49
+ help="โ„๏ธ Snowglobe CLI - Connect your applications to Snowglobe experiments",
50
+ add_completion=False,
51
+ rich_markup_mode="rich",
52
+ )
53
+
54
+
55
+ def setup_global_options(
56
+ ctx: typer.Context,
57
+ verbose: bool = False,
58
+ quiet: bool = False,
59
+ json_output: bool = False,
60
+ ):
61
+ """Setup global CLI options"""
62
+ cli_state.verbose = verbose
63
+ cli_state.quiet = quiet
64
+ cli_state.json_output = json_output
65
+
66
+
67
+ @cli_app.callback()
68
+ def main(
69
+ ctx: typer.Context,
70
+ verbose: bool = typer.Option(
71
+ False, "--verbose", "-v", help="Enable verbose output"
72
+ ),
73
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Minimize output"),
74
+ json_output: bool = typer.Option(False, "--json", help="Output in JSON format"),
75
+ ):
76
+ """
77
+ โ„๏ธ Snowglobe CLI - Connect your applications to Snowglobe experiments
78
+ """
79
+ setup_global_options(ctx, verbose, quiet, json_output)
80
+
81
+
82
+ # check if there is an override control plane URL in config file
83
+ rc_path = get_rc_file_path()
84
+
85
+ if os.path.exists(rc_path):
86
+ with open(rc_path, "r") as rc_file:
87
+ for line in rc_file:
88
+ if line.startswith("CONTROL_PLANE_URL="):
89
+ CONTROL_PLANE_URL = line.strip().split("=", 1)[1]
90
+ break
91
+
92
+
93
+ @cli_app.command()
94
+ def test(
95
+ filename: Optional[str] = typer.Argument(
96
+ None, help="Agent filename to test (if not provided, will prompt for selection)"
97
+ ),
98
+ ):
99
+ """
100
+ Test an agent wrapper implementation
101
+ """
102
+ console.print("\n[bold blue]๐Ÿงช Test Agent Wrapper[/bold blue]\n")
103
+
104
+ # Initialize project manager
105
+ pm = get_project_manager()
106
+
107
+ # Check if project is set up
108
+ is_valid, issues = pm.validate_project()
109
+ if not is_valid:
110
+ error("Project validation failed:")
111
+ for issue in issues:
112
+ console.print(f" - {issue}")
113
+ info("Run 'snowglobe-connect init' to set up a project first")
114
+ raise typer.Exit(1)
115
+
116
+ # If no filename provided, list available agents
117
+ if not filename:
118
+ agents = pm.list_agents()
119
+ if not agents:
120
+ error("No agents found in this project")
121
+ info("Run 'snowglobe-connect init' to create an agent first")
122
+ raise typer.Exit(1)
123
+
124
+ if len(agents) == 1:
125
+ filename, agent_info = agents[0]
126
+ info(f"Testing the only available agent: {filename}")
127
+ else:
128
+ # Multiple agents - let user choose
129
+ console.print("Available agents:")
130
+ for i, (fname, agent_info) in enumerate(agents, 1):
131
+ console.print(f" {i}. {fname} ({agent_info.get('name', 'Unknown')})")
132
+
133
+ try:
134
+ choice = typer.prompt("Select agent to test (number)")
135
+ idx = int(choice) - 1
136
+ if 0 <= idx < len(agents):
137
+ filename, agent_info = agents[idx]
138
+ else:
139
+ error("Invalid selection")
140
+ raise typer.Exit(1)
141
+ except (ValueError, KeyboardInterrupt):
142
+ error("Invalid selection or cancelled")
143
+ raise typer.Exit(1)
144
+ else:
145
+ # Validate provided filename
146
+ agent_info = pm.get_agent_by_filename(filename)
147
+ if not agent_info:
148
+ error(f"Agent not found: {filename}")
149
+ info("Available agents:")
150
+ for fname, agent_info in pm.list_agents():
151
+ console.print(f" - {fname}")
152
+ raise typer.Exit(1)
153
+
154
+ # Get agent info
155
+ app_id = agent_info.get("uuid")
156
+ app_name = agent_info.get("name", "Unknown")
157
+
158
+ console.print(f"Testing: [bold]{filename}[/bold] ({app_name})")
159
+
160
+ # Run the test
161
+ with spinner("Testing agent wrapper"):
162
+ is_connected, conn_message = test_agent_wrapper(filename, app_id, app_name)
163
+
164
+ if is_connected:
165
+ success(f"Test passed: {conn_message}")
166
+ info("Your agent wrapper is working correctly!")
167
+ console.print()
168
+ info("Next steps:")
169
+ console.print("Start the client:")
170
+ console.print(" [bold green]snowglobe-connect start[/bold green]")
171
+ else:
172
+ error(f"Test failed: {conn_message}")
173
+ if "default template response" in conn_message.lower():
174
+ info("This is expected with the default template.")
175
+ info(
176
+ "Please implement your application logic in the process_scenario function."
177
+ )
178
+ else:
179
+ info("Check your implementation and try again.")
180
+ docs_link(
181
+ "Troubleshooting guide", "https://docs.snowglobe.so/troubleshooting"
182
+ )
183
+ raise typer.Exit(1)
184
+
185
+
186
+ @cli_app.command()
187
+ def init(
188
+ name: Optional[str] = typer.Option(
189
+ None, "--name", help="Custom filename for the agent wrapper"
190
+ ),
191
+ skip_template: bool = typer.Option(
192
+ False, "--skip-template", help="Skip creating template file"
193
+ ),
194
+ # option for stateful agent
195
+ stateful: bool = typer.Option(
196
+ False, "--stateful", help="Initialize a stateful agent template"
197
+ )
198
+ ):
199
+ """
200
+ Initialize a new Snowglobe agent in the current directory
201
+ """
202
+ console.print("\n[bold blue]๐Ÿš€ Initialize Snowglobe Agent[/bold blue]\n")
203
+
204
+ # Initialize project manager
205
+ pm = get_project_manager()
206
+
207
+ # Check authentication first
208
+ with spinner("Checking authentication"):
209
+ is_auth, auth_message, _ = check_auth_status()
210
+
211
+ if not is_auth:
212
+ error("Authentication required to initialize agents")
213
+ info("Please run 'snowglobe-connect auth' first to set up authentication")
214
+ docs_link("Setup guide", "https://docs.snowglobe.so/setup")
215
+ raise typer.Exit(1)
216
+
217
+ success("Authenticated successfully")
218
+
219
+ # Fetch available applications
220
+ with spinner("Fetching your applications"):
221
+ success_fetch, applications, fetch_message = get_remote_applications()
222
+
223
+ if not success_fetch:
224
+ error(f"Failed to fetch applications: {fetch_message}")
225
+ if "401" in fetch_message or "authentication" in fetch_message.lower():
226
+ info(
227
+ "Your API key may have expired. Run 'snowglobe-connect auth' to re-authenticate"
228
+ )
229
+ raise typer.Exit(1)
230
+
231
+ # Interactive application selection
232
+ selected = select_application_interactive(applications)
233
+
234
+ if selected is None:
235
+ warning("No application selected. Exiting.")
236
+ raise typer.Exit(0)
237
+ elif selected == "new":
238
+ info("Creating new application not yet implemented in init command")
239
+ info(
240
+ "Please visit https://snowglobe.guardrails-ai.com/applications/create to create a new app"
241
+ )
242
+ info("Then run this command again to select it")
243
+ raise typer.Exit(0)
244
+
245
+ # Selected is an existing application
246
+ app_id = selected["id"]
247
+ app_name = selected["name"]
248
+
249
+ success(f"Selected application: {app_name}")
250
+
251
+ # prompt user to confirm if stateful agent
252
+ user_stateful = select_stateful_interactive(stateful)
253
+ # Set up project structure
254
+ with spinner("Setting up project structure"):
255
+ pm.ensure_project_structure()
256
+
257
+ # Determine filename
258
+ if name:
259
+ # User provided custom name
260
+ filename = name if name.endswith(".py") else f"{name}.py"
261
+ else:
262
+ # Generate from app name
263
+ filename = pm.sanitize_filename(app_name)
264
+
265
+ # Find available filename
266
+ filename = pm.find_available_filename(filename)
267
+ file_path = pm.project_root / filename
268
+
269
+ success(f"Using filename: {filename}")
270
+ # use the appopriate template based on stateful option
271
+ if user_stateful:
272
+ snowglobe_connect_template = stateful_snowglobe_connect_template
273
+ else:
274
+ snowglobe_connect_template = stateless_snowglobe_connect_template
275
+ # Create agent wrapper file
276
+ if not skip_template:
277
+ with spinner("Creating agent wrapper"):
278
+ with open(file_path, "w") as f:
279
+ f.write(snowglobe_connect_template)
280
+ success(f"Created agent wrapper: {filename}")
281
+
282
+ # Add to mapping
283
+ pm.add_agent_mapping(filename, app_id, app_name)
284
+ success("Added mapping to .snowglobe/agents.json")
285
+
286
+ console.print("\n[dim]Project structure:[/dim]")
287
+ console.print("[dim] .snowglobe/agents.json\t- UUID mappings[/dim]")
288
+ console.print(f"[dim] {filename}\t- Your agent wrapper[/dim]")
289
+
290
+ console.print()
291
+ info("Next steps:")
292
+ console.print("1. Edit the process_scenario function in your agent file:")
293
+ console.print(f" [bold cyan]{filename}[/bold cyan]")
294
+ console.print("2. Implement your application logic")
295
+ console.print("3. Test your agent:")
296
+ console.print(" [bold green]snowglobe-connect test[/bold green]")
297
+ console.print("4. Start the client:")
298
+ console.print(" [bold green]snowglobe-connect start[/bold green]")
299
+ else:
300
+ info(f"Skipped template creation. You'll need to create {filename} manually")
301
+ # Still add the mapping even if we skip template
302
+ pm.add_agent_mapping(filename, app_id, app_name)
303
+
304
+ console.print()
305
+ success(f"Agent '{app_name}' initialized successfully! ๐ŸŽ‰")
306
+
307
+
308
+ def test_agent_wrapper(filename: str, app_id: str, app_name: str) -> Tuple[bool, str]:
309
+ """Test if an agent wrapper is working"""
310
+ try:
311
+ file_path = os.path.join(os.getcwd(), filename)
312
+ if not os.path.exists(file_path):
313
+ return False, f"File not found: {filename}"
314
+
315
+ spec = importlib.util.spec_from_file_location("agent_wrapper", file_path)
316
+ agent_module = importlib.util.module_from_spec(spec)
317
+
318
+ # Add current directory to path
319
+ sys_path_backup = sys.path.copy()
320
+ current_dir = os.getcwd()
321
+ if current_dir not in sys.path:
322
+ sys.path.insert(0, current_dir)
323
+
324
+ try:
325
+ spec.loader.exec_module(agent_module)
326
+ finally:
327
+ sys.path = sys_path_backup
328
+
329
+ if not hasattr(agent_module, "process_scenario"):
330
+ return False, "process_scenario function not found"
331
+
332
+ process_scenario = agent_module.process_scenario
333
+ if not callable(process_scenario):
334
+ return False, "process_scenario is not callable"
335
+
336
+ # Test with a simple request
337
+
338
+ test_request = CompletionRequest(
339
+ messages=[
340
+ SnowglobeMessage(
341
+ role="user",
342
+ content="Test connection",
343
+ snowglobe_data=SnowglobeData(
344
+ conversation_id="test", test_id="test"
345
+ ),
346
+ )
347
+ ]
348
+ )
349
+
350
+ if asyncio.iscoroutinefunction(process_scenario):
351
+ response = asyncio.run(process_scenario(test_request))
352
+ else:
353
+ response = process_scenario(test_request)
354
+
355
+ if hasattr(response, "response") and isinstance(response.response, str):
356
+ if response.response == "Your response here":
357
+ return False, "Using default template response"
358
+ return True, "Connected"
359
+ else:
360
+ return False, "Invalid response format"
361
+
362
+ except Exception as e:
363
+ return False, f"Error: {str(e)}"
364
+
365
+
366
+ def status_code_to_actions(status_code: int) -> str:
367
+ """
368
+ Convert a status code to a string describing the action to take.
369
+ """
370
+ if status_code == 200:
371
+ return "200 - Success"
372
+ elif status_code == 400:
373
+ return "400 - Bad Request - Check your input parameters"
374
+ elif status_code == 401:
375
+ return "401 - Unauthorized - Check your SNOWGLOBE_API_KEY in .snowglobe/config.rc or environment variables"
376
+ elif status_code == 403:
377
+ return "403 - Forbidden - You do not have permission to access this resource"
378
+ elif status_code == 404:
379
+ return (
380
+ "404 - Not Found - The requested resource does not exist. Was it deleted?"
381
+ )
382
+ elif status_code == 500:
383
+ return "500 - Internal Server Error - Try again later or contact support"
384
+ else:
385
+ return f"Unexpected Status Code: {status_code} Please try again later or contact support"
386
+
387
+
388
+ def enhanced_error_handler(status_code: int, operation: str = "operation") -> None:
389
+ """Enhanced error handling with contextual help"""
390
+ if status_code == 401:
391
+ error("Authentication failed")
392
+ info("Your API key may be invalid or expired")
393
+ info("Run 'snowglobe-connect auth' to set up authentication")
394
+ docs_link("Authentication help", "https://docs.snowglobe.so/auth")
395
+ elif status_code == 403:
396
+ error("Access forbidden")
397
+ info("You don't have permission for this operation")
398
+ info("Contact your administrator or check your account permissions")
399
+ elif status_code == 404:
400
+ error("Resource not found")
401
+ info("The requested resource may have been deleted or moved")
402
+ info("Verify the resource ID and try again")
403
+ elif status_code == 429:
404
+ error("Rate limit exceeded")
405
+ info("Please wait a moment before trying again")
406
+ info("Consider reducing the frequency of your requests")
407
+ elif status_code >= 500:
408
+ error(f"Server error during {operation}")
409
+ info("This is likely a temporary issue")
410
+ info("Please try again in a few minutes")
411
+ docs_link("Status page", "https://status.snowglobe.so")
412
+ else:
413
+ error(f"Unexpected error during {operation}: {status_code}")
414
+ info("Please try again or contact support if the issue persists")
415
+
416
+
417
+ stateless_snowglobe_connect_template = """from snowglobe.client import CompletionRequest, CompletionFunctionOutputs
418
+
419
+
420
+ def process_scenario(request: CompletionRequest) -> CompletionFunctionOutputs:
421
+ \"\"\"
422
+ Process a scenario request from Snowglobe.
423
+
424
+ This function is called by the Snowglobe client to process requests. It should return a
425
+ CompletionFunctionOutputs object with the response content.
426
+
427
+ Example CompletionRequest:
428
+ CompletionRequest(
429
+ messages=[
430
+ SnowglobeMessage(role="user", content="Hello, how are you?", snowglobe_data=None),
431
+ ]
432
+ )
433
+
434
+ Example CompletionFunctionOutputs:
435
+ CompletionFunctionOutputs(response="This is a string response from your application")
436
+
437
+ Args:
438
+ request (CompletionRequest): The request object containing the messages.
439
+
440
+ Returns:
441
+ CompletionFunctionOutputs: The response object with the generated content.
442
+ \"\"\"
443
+
444
+ # Process the request using the messages. Example:
445
+ # messages = request.to_openai_messages()
446
+ # response = client.chat.completions.create(
447
+ # model="gpt-4o-mini",
448
+ # messages=messages
449
+ # )
450
+ return CompletionFunctionOutputs(response="Your response here")
451
+
452
+ """
453
+
454
+ stateful_snowglobe_connect_template = """
455
+ # This file is auto-generated by the API client.
456
+
457
+ from snowglobe.client import CompletionRequest, CompletionFunctionOutputs
458
+ import logging
459
+ import websockets
460
+ import json
461
+ LOGGER = logging.getLogger(__name__)
462
+ socket_cache = {}
463
+ async def completion_fn(request: CompletionRequest) -> CompletionFunctionOutputs:
464
+ # for debugging purposes
465
+ # print(f"Received request: {request.messages}")
466
+
467
+ completion_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
468
+
469
+ # check the socket cache for a socket for this conversation_id
470
+ conversation_id = request.messages[0].snowglobe_data.conversation_id
471
+ if conversation_id in socket_cache:
472
+ socket = socket_cache[conversation_id]
473
+ else:
474
+ # create a new socket connection
475
+ # this is talking to a local socket/stateful server that you implemented
476
+ # update this or implement a way to connect to your actual server
477
+ socket = await websockets.connect("ws://localhost:9000/ws")
478
+ socket_cache[conversation_id] = socket
479
+
480
+ # send the request to the socket
481
+ await socket.send(json.dumps({
482
+ "messages": completion_messages,
483
+ "conversation_id": conversation_id
484
+ }))
485
+
486
+ # wait for the response from the socket
487
+ response = await socket.recv()
488
+ response_data = json.loads(response)
489
+
490
+ # for debugging purposes
491
+ # print(f"Received response from socket: {response_data}")
492
+
493
+ # Example implementation, replace with actual logic
494
+ return CompletionFunctionOutputs(response=response_data.messages[0]["content"])
495
+ """
496
+
497
+ def _save_api_key_to_rc(api_key: str, rc_path: str) -> None:
498
+ """Save API key to config file"""
499
+ # Ensure directory exists
500
+ os.makedirs(os.path.dirname(rc_path), exist_ok=True)
501
+
502
+ if os.path.exists(rc_path):
503
+ # Update existing file
504
+ with open(rc_path, "r") as f:
505
+ lines = f.readlines()
506
+
507
+ # Find and replace existing key or append new one
508
+ updated = False
509
+ for idx, line in enumerate(lines):
510
+ if line.startswith("SNOWGLOBE_API_KEY="):
511
+ lines[idx] = f"SNOWGLOBE_API_KEY={api_key}\n"
512
+ updated = True
513
+ break
514
+
515
+ if not updated:
516
+ lines.append(f"SNOWGLOBE_API_KEY={api_key}\n")
517
+
518
+ with open(rc_path, "w") as f:
519
+ f.writelines(lines)
520
+ else:
521
+ # Create new file
522
+ with open(rc_path, "w") as f:
523
+ f.write(f"SNOWGLOBE_API_KEY={api_key}\n")
524
+
525
+
526
+ def _create_auth_server(config_key: str, rc_path: str) -> FastAPI:
527
+ """Create FastAPI server for OAuth callback"""
528
+ app = FastAPI()
529
+ app.add_middleware(
530
+ CORSMiddleware,
531
+ allow_origins=["*"],
532
+ allow_credentials=True,
533
+ allow_methods=["*"],
534
+ allow_headers=["*"],
535
+ )
536
+
537
+ @app.get("/")
538
+ async def root():
539
+ return {
540
+ "message": "Welcome to the Snowglobe CLI Auth Server! Please run snowglobe-connect auth to set up your API key."
541
+ }
542
+
543
+ @app.post("/auth-configure")
544
+ async def auth_configure(request: Request):
545
+ try:
546
+ # Verify authorization header
547
+ auth_header = request.headers.get("Authorization")
548
+ if not auth_header or auth_header != f"Bearer {config_key}":
549
+ return {
550
+ "error": "Unauthorized. Keys are only created and valid when snowglobe-connect auth is run."
551
+ }, 401
552
+
553
+ # Process API key
554
+ body = await request.json()
555
+ api_key = body.get("SNOWGLOBE_API_KEY")
556
+
557
+ if api_key:
558
+ info(f"Received API key: ...{api_key[-5:]}")
559
+ info(f"Writing API key to {rc_path}")
560
+ _save_api_key_to_rc(api_key, rc_path)
561
+
562
+ return {"written": True}
563
+ except Exception as e:
564
+ error(f"Failed to process key configuration: {e}")
565
+ return {"error": "Failed to process key configuration request"}
566
+
567
+ return app
568
+
569
+
570
+ def _show_auth_success_next_steps() -> None:
571
+ """Show helpful next steps after successful authentication"""
572
+ console.print()
573
+ info("Next steps:")
574
+ console.print("1. Initialize an agent connection:")
575
+ console.print(" [bold green]snowglobe-connect init[/bold green]")
576
+ console.print("2. Test your agent:")
577
+ console.print(" [bold green]snowglobe-connect test[/bold green]")
578
+ console.print("3. Start the client:")
579
+ console.print(" [bold green]snowglobe-connect start[/bold green]")
580
+ console.print()
581
+ docs_link("Getting started guide", "https://docs.snowglobe.so/getting-started")
582
+
583
+
584
+ def _poll_for_api_key(rc_path: str, timeout: int = 300) -> bool:
585
+ """Poll for API key in config file"""
586
+ start_time = os.times().elapsed
587
+
588
+ with spinner("Waiting for API key configuration"):
589
+ while True:
590
+ time.sleep(0.5)
591
+
592
+ # Check for API key
593
+ if os.path.exists(rc_path):
594
+ with open(rc_path, "r") as f:
595
+ for line in f:
596
+ if line.startswith("SNOWGLOBE_API_KEY="):
597
+ api_key = line.strip().split("=", 1)[1]
598
+ if api_key:
599
+ break
600
+ else:
601
+ continue
602
+ break
603
+
604
+ # Check timeout
605
+ if (os.times().elapsed - start_time) > timeout:
606
+ break
607
+
608
+ # Check if we found the API key
609
+ api_key = get_api_key()
610
+ if api_key:
611
+ success(f"SNOWGLOBE_API_KEY configured in {rc_path}")
612
+ _show_auth_success_next_steps()
613
+ return True
614
+ else:
615
+ error("Authentication timed out")
616
+ info("Please reach out to support or configure your API key manually")
617
+ info("Set SNOWGLOBE_API_KEY= in your .snowglobe/config.rc file")
618
+ return False
619
+
620
+
621
+ @cli_app.command()
622
+ def auth(
623
+ yes: bool = typer.Option(
624
+ False, "--yes", "-y", help="Skip prompts and proceed with default options."
625
+ ),
626
+ ):
627
+ """Authorize snowglobe client for test processing."""
628
+
629
+ console.print("\n[bold blue]๐Ÿ” Authenticate with Snowglobe[/bold blue]\n")
630
+
631
+ with spinner("Running preflight checks"):
632
+ time.sleep(0.5)
633
+
634
+ # Check if API key already exists
635
+ api_key = get_api_key()
636
+ rc_path = get_rc_file_path()
637
+
638
+ if api_key:
639
+ success(f"SNOWGLOBE_API_KEY found in {rc_path}")
640
+ _show_auth_success_next_steps()
641
+ return
642
+
643
+ info("Starting authentication process...")
644
+
645
+ # Start OAuth flow
646
+ config_key = hashlib.sha256(os.urandom(32)).hexdigest()
647
+ app = _create_auth_server(config_key, rc_path)
648
+
649
+ # Start server in background
650
+ def run_server():
651
+ uvicorn.run(
652
+ app,
653
+ host="0.0.0.0",
654
+ port=SNOWGLOBE_AUTH_CONFIGURE_PORT,
655
+ log_level="critical",
656
+ )
657
+
658
+ server_thread = threading.Thread(target=run_server, daemon=True)
659
+ server_thread.start()
660
+
661
+ # Show auth URL and poll for completion
662
+ auth_url = f"{UI_URL}/keys/client-connect?port={SNOWGLOBE_AUTH_CONFIGURE_PORT}&token={config_key}"
663
+
664
+ info("Opening authentication page in your browser...")
665
+ try:
666
+ webbrowser.open(auth_url)
667
+ success("Browser opened successfully")
668
+ except Exception as e:
669
+ warning("Could not open browser automatically")
670
+ debug(f"Browser error: {e}")
671
+
672
+ # Show fallback link
673
+ console.print()
674
+ info("If the browser didn't open, visit this link:")
675
+ console.print(f" [link={auth_url}]{UI_URL}/auth[/link]")
676
+ console.print()
677
+
678
+ _poll_for_api_key(rc_path)
679
+
680
+
681
+ @cli_app.command()
682
+ def start(
683
+ verbose: bool = typer.Option(
684
+ False, "--verbose", "-v", help="Show detailed technical logs"
685
+ ),
686
+ ):
687
+ """Start the Snowglobe client server to process requests."""
688
+
689
+ # Clean startup sequence
690
+ console.print("\n[bold blue]๐Ÿ”— Connecting to Snowglobe...[/bold blue]\n")
691
+
692
+ with spinner("Checking authentication"):
693
+ is_auth, auth_message, _ = check_auth_status()
694
+
695
+ if not is_auth:
696
+ error("Authentication failed")
697
+ info("Run 'snowglobe-connect auth' to set up authentication")
698
+ raise typer.Exit(1)
699
+
700
+ success("Authentication successful")
701
+
702
+ with spinner("Loading agents"):
703
+ time.sleep(0.5) # Brief pause for UI
704
+
705
+ console.print(
706
+ "[bold green]๐Ÿš€ Agent server is live! Processing scenarios...[/bold green]\n"
707
+ )
708
+
709
+ # Handle Ctrl+C gracefully
710
+ def signal_handler(sig, frame):
711
+ graceful_shutdown()
712
+
713
+ signal.signal(signal.SIGINT, signal_handler)
714
+
715
+ if not verbose:
716
+ console.print("[dim]Press Ctrl+C to stop[/dim]\n")
717
+
718
+ # Import start_client here to avoid config initialization at module import time
719
+ from .app import start_client
720
+
721
+ # Call the existing start_client function with verbose flag
722
+ try:
723
+ start_client(verbose=verbose)
724
+ except ValueError as e:
725
+ # Handle config errors gracefully
726
+ if "API key is required" in str(e):
727
+ console.print()
728
+ error("Authentication required")
729
+ info("No API key found in environment or .snowglobe/config.rc file")
730
+ console.print()
731
+ info("Get started by running:")
732
+ console.print(" [bold cyan]snowglobe-connect auth[/bold cyan]")
733
+ raise typer.Exit(1)
734
+ else:
735
+ # Re-raise other ValueErrors
736
+ raise