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.
- snowglobe/client/__init__.py +17 -0
- snowglobe/client/src/app.py +732 -0
- snowglobe/client/src/cli.py +736 -0
- snowglobe/client/src/cli_utils.py +361 -0
- snowglobe/client/src/config.py +213 -0
- snowglobe/client/src/models.py +37 -0
- snowglobe/client/src/project_manager.py +290 -0
- snowglobe/client/src/stats.py +53 -0
- snowglobe/client/src/utils.py +117 -0
- snowglobe-0.4.0.dist-info/METADATA +128 -0
- snowglobe-0.4.0.dist-info/RECORD +15 -0
- snowglobe-0.4.0.dist-info/WHEEL +5 -0
- snowglobe-0.4.0.dist-info/entry_points.txt +2 -0
- snowglobe-0.4.0.dist-info/licenses/LICENSE +21 -0
- snowglobe-0.4.0.dist-info/top_level.txt +1 -0
@@ -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
|