gitarsenal-cli 1.2.7 → 1.3.1

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,2742 @@
1
+ import os
2
+ import sys
3
+ import modal
4
+ import time
5
+ import subprocess
6
+ import json
7
+ import re
8
+ import datetime
9
+ import getpass
10
+ import requests
11
+ import secrets
12
+ import string
13
+
14
+ def handle_interactive_input(prompt, is_password=False):
15
+ """Handle interactive input from the user with optional password masking"""
16
+ print("\n" + "="*60)
17
+ print(f"{prompt}")
18
+ print("="*60)
19
+
20
+ try:
21
+ if is_password:
22
+ user_input = getpass.getpass("Input (hidden): ").strip()
23
+ else:
24
+ user_input = input("Input: ").strip()
25
+
26
+ if not user_input:
27
+ print("❌ No input provided.")
28
+ return None
29
+ print("✅ Input received successfully!")
30
+ return user_input
31
+ except KeyboardInterrupt:
32
+ print("\n❌ Input cancelled by user.")
33
+ return None
34
+ except Exception as e:
35
+ print(f"❌ Error getting input: {e}")
36
+ return None
37
+
38
+ def handle_wandb_login(sandbox, current_dir):
39
+ """Handle Weights & Biases login with proper API key input"""
40
+ # Define _to_str function locally to avoid NameError
41
+ def _to_str(maybe_bytes):
42
+ try:
43
+ return (maybe_bytes.decode('utf-8') if isinstance(maybe_bytes, (bytes, bytearray)) else maybe_bytes)
44
+ except UnicodeDecodeError:
45
+ # Handle non-UTF-8 bytes by replacing invalid characters
46
+ if isinstance(maybe_bytes, (bytes, bytearray)):
47
+ return maybe_bytes.decode('utf-8', errors='replace')
48
+ else:
49
+ return str(maybe_bytes)
50
+ except Exception:
51
+ # Last resort fallback
52
+ return str(maybe_bytes)
53
+
54
+ print("\n🔑 WEIGHTS & BIASES LOGIN")
55
+ print("="*60)
56
+ print("Setting up Weights & Biases credentials")
57
+ print("You can get your API key from: https://wandb.ai/authorize")
58
+
59
+ # Get API key from user
60
+ api_key = handle_interactive_input(
61
+ "🔑 WEIGHTS & BIASES API KEY REQUIRED\n" +
62
+ "Please paste your W&B API key below:\n" +
63
+ "(Your API key should be 40 characters long)",
64
+ is_password=True
65
+ )
66
+
67
+ if not api_key:
68
+ print("❌ No API key provided. Cannot continue with W&B login.")
69
+ return False, "", "No W&B API key provided"
70
+
71
+ # Validate API key length
72
+ if len(api_key) != 40:
73
+ print(f"⚠️ Warning: API key should be 40 characters long, yours was {len(api_key)}")
74
+ confirm = handle_interactive_input("Continue anyway? (yes/no)")
75
+ if not confirm or confirm.lower() not in ["yes", "y"]:
76
+ print("❌ W&B login cancelled.")
77
+ return False, "", "W&B login cancelled"
78
+
79
+ # Use non-interactive login
80
+ cmd = f"wandb login {api_key}"
81
+ print(f"🔄 Running non-interactive login command")
82
+
83
+ # Execute the command
84
+ result = sandbox.exec("bash", "-c", f"cd {current_dir} && {cmd}")
85
+
86
+ # Collect output
87
+ stdout_lines = []
88
+ stderr_lines = []
89
+
90
+ for line in result.stdout:
91
+ line_str = _to_str(line)
92
+ stdout_lines.append(line_str)
93
+ sys.stdout.write(line_str)
94
+ sys.stdout.flush()
95
+
96
+ for line in result.stderr:
97
+ line_str = _to_str(line)
98
+ stderr_lines.append(line_str)
99
+ sys.stderr.write(line_str)
100
+ sys.stderr.flush()
101
+
102
+ result.wait()
103
+ exit_code = result.returncode
104
+
105
+ stdout_buffer = ''.join(stdout_lines)
106
+ stderr_buffer = ''.join(stderr_lines)
107
+
108
+ if exit_code == 0:
109
+ print("✅ Weights & Biases login successful")
110
+ # Also set the environment variable for this session
111
+ os.environ["WANDB_API_KEY"] = api_key
112
+ print("✅ WANDB_API_KEY environment variable set")
113
+ else:
114
+ print(f"❌ Weights & Biases login failed with exit code {exit_code}")
115
+ if stderr_buffer:
116
+ print(f"Error: {stderr_buffer}")
117
+
118
+ return exit_code == 0, stdout_buffer, stderr_buffer
119
+
120
+ def handle_huggingface_login(sandbox, current_dir):
121
+ """Handle Hugging Face login with proper token input"""
122
+ # Define _to_str function locally to avoid NameError
123
+ def _to_str(maybe_bytes):
124
+ try:
125
+ return (maybe_bytes.decode('utf-8') if isinstance(maybe_bytes, (bytes, bytearray)) else maybe_bytes)
126
+ except UnicodeDecodeError:
127
+ # Handle non-UTF-8 bytes by replacing invalid characters
128
+ if isinstance(maybe_bytes, (bytes, bytearray)):
129
+ return maybe_bytes.decode('utf-8', errors='replace')
130
+ else:
131
+ return str(maybe_bytes)
132
+ except Exception:
133
+ # Last resort fallback
134
+ return str(maybe_bytes)
135
+
136
+ print("\n🔑 HUGGING FACE LOGIN")
137
+ print("="*60)
138
+ print("Setting up Hugging Face credentials")
139
+
140
+ # Get token from user
141
+ token = prompt_for_hf_token()
142
+ if not token:
143
+ print("❌ No token provided. Cannot continue with Hugging Face login.")
144
+ return False, "", "No Hugging Face token provided"
145
+
146
+ # Use non-interactive login
147
+ cmd = f"huggingface-cli login --token {token} --add-to-git-credential"
148
+ print(f"🔄 Running non-interactive login command")
149
+
150
+ # Execute the command
151
+ result = sandbox.exec("bash", "-c", f"cd {current_dir} && {cmd}")
152
+
153
+ # Collect output
154
+ stdout_lines = []
155
+ stderr_lines = []
156
+
157
+ for line in result.stdout:
158
+ line_str = _to_str(line)
159
+ stdout_lines.append(line_str)
160
+ sys.stdout.write(line_str)
161
+ sys.stdout.flush()
162
+
163
+ for line in result.stderr:
164
+ line_str = _to_str(line)
165
+ stderr_lines.append(line_str)
166
+ sys.stderr.write(line_str)
167
+ sys.stderr.flush()
168
+
169
+ result.wait()
170
+ exit_code = result.returncode
171
+
172
+ stdout_buffer = ''.join(stdout_lines)
173
+ stderr_buffer = ''.join(stderr_lines)
174
+
175
+ if exit_code == 0:
176
+ print("✅ Hugging Face login successful")
177
+ # Also set the environment variable for this session
178
+ os.environ["HF_TOKEN"] = token
179
+ print("✅ HF_TOKEN environment variable set")
180
+ else:
181
+ print(f"❌ Hugging Face login failed with exit code {exit_code}")
182
+ if stderr_buffer:
183
+ print(f"Error: {stderr_buffer}")
184
+
185
+ return exit_code == 0, stdout_buffer, stderr_buffer
186
+
187
+ def handle_interactive_command(cmd, sandbox, current_dir):
188
+ """Handle interactive commands by prompting the user for input"""
189
+ print(f"⚠️ Interactive command detected: {cmd}")
190
+ print("⚠️ Some prompts may not be visible. If the command appears stuck, it may be waiting for input.")
191
+
192
+ # This is a placeholder for more sophisticated interactive command handling
193
+ # In a real implementation, you would need to handle specific interactive commands differently
194
+ return None
195
+
196
+ def call_openai_for_debug(command, error_output, api_key=None, current_dir=None, sandbox=None):
197
+ """Call OpenAI to debug a failed command and suggest a fix"""
198
+ # Define _to_str function locally to avoid NameError
199
+ def _to_str(maybe_bytes):
200
+ try:
201
+ return (maybe_bytes.decode('utf-8') if isinstance(maybe_bytes, (bytes, bytearray)) else maybe_bytes)
202
+ except UnicodeDecodeError:
203
+ # Handle non-UTF-8 bytes by replacing invalid characters
204
+ if isinstance(maybe_bytes, (bytes, bytearray)):
205
+ return maybe_bytes.decode('utf-8', errors='replace')
206
+ else:
207
+ return str(maybe_bytes)
208
+ except Exception:
209
+ # Last resort fallback
210
+ return str(maybe_bytes)
211
+
212
+ # Skip debugging for certain commands that commonly return non-zero exit codes
213
+ # but aren't actually errors (like test commands)
214
+ if command.strip().startswith("test "):
215
+ print("🔍 Skipping debugging for test command - non-zero exit code is expected behavior")
216
+ return None
217
+
218
+ # Validate error_output - if it's empty, we can't debug effectively
219
+ if not error_output or not error_output.strip():
220
+ print("⚠️ Error output is empty. Cannot effectively debug the command.")
221
+ print("⚠️ Skipping OpenAI debugging due to lack of error information.")
222
+ return None
223
+
224
+ if not api_key:
225
+ # Try to get API key from environment
226
+ api_key = os.environ.get("OPENAI_API_KEY")
227
+
228
+ if not api_key:
229
+ print("\n" + "="*60)
230
+ print("🔑 OPENAI API KEY REQUIRED FOR DEBUGGING")
231
+ print("="*60)
232
+ print("To debug failed commands, an OpenAI API key is needed.")
233
+ print("📝 Please paste your OpenAI API key below:")
234
+ print(" (Your input will be hidden for security)")
235
+ print("-" * 60)
236
+
237
+ try:
238
+ api_key = getpass.getpass("OpenAI API Key: ").strip()
239
+ if not api_key:
240
+ print("❌ No API key provided. Skipping debugging.")
241
+ return None
242
+ print("✅ API key received successfully!")
243
+ except KeyboardInterrupt:
244
+ print("\n❌ API key input cancelled by user.")
245
+ return None
246
+ except Exception as e:
247
+ print(f"❌ Error getting API key: {e}")
248
+ return None
249
+
250
+ # Get current directory context
251
+ directory_context = ""
252
+ system_info = ""
253
+
254
+ if sandbox:
255
+ try:
256
+ print("🔍 Getting system information for better debugging...")
257
+
258
+ # Get OS information
259
+ os_info_cmd = """
260
+ echo "OS Information:"
261
+ cat /etc/os-release 2>/dev/null || echo "OS release info not available"
262
+ echo -e "\nKernel Information:"
263
+ uname -a
264
+ echo -e "\nPython Information:"
265
+ python --version
266
+ echo -e "\nPackage Manager:"
267
+ which apt 2>/dev/null && echo "apt available" || echo "apt not available"
268
+ which yum 2>/dev/null && echo "yum available" || echo "yum not available"
269
+ which dnf 2>/dev/null && echo "dnf available" || echo "dnf not available"
270
+ which apk 2>/dev/null && echo "apk available" || echo "apk not available"
271
+ echo -e "\nEnvironment Variables:"
272
+ env | grep -E "^(PATH|PYTHON|VIRTUAL_ENV|HOME|USER|SHELL|LANG)" || echo "No relevant env vars found"
273
+ """
274
+
275
+ os_result = sandbox.exec("bash", "-c", os_info_cmd)
276
+ os_output = ""
277
+ for line in os_result.stdout:
278
+ os_output += _to_str(line)
279
+ os_result.wait()
280
+
281
+ system_info = f"""
282
+ System Information:
283
+ {os_output}
284
+ """
285
+ print("✅ System information gathered successfully")
286
+ except Exception as e:
287
+ print(f"⚠️ Error getting system information: {e}")
288
+ system_info = "System information not available\n"
289
+
290
+ if current_dir and sandbox:
291
+ try:
292
+ print("🔍 Getting directory context for better debugging...")
293
+
294
+ # Get current directory contents
295
+ ls_result = sandbox.exec("bash", "-c", f"cd {current_dir} && ls -la")
296
+ ls_output = ""
297
+ for line in ls_result.stdout:
298
+ ls_output += _to_str(line)
299
+ ls_result.wait()
300
+
301
+ # Get parent directory contents if this isn't root
302
+ parent_context = ""
303
+ if current_dir != "/" and "/" in current_dir:
304
+ parent_dir = os.path.dirname(current_dir)
305
+ parent_result = sandbox.exec("bash", "-c", f"cd {parent_dir} && ls -la")
306
+ parent_ls = ""
307
+ for line in parent_result.stdout:
308
+ parent_ls += _to_str(line)
309
+ parent_result.wait()
310
+ parent_context = f"\nParent directory ({parent_dir}) contents:\n{parent_ls}"
311
+
312
+ directory_context = f"""
313
+ Current directory: {current_dir}
314
+
315
+ Directory contents:
316
+ {ls_output}
317
+ {parent_context}
318
+ """
319
+ print("✅ Directory context gathered successfully")
320
+ except Exception as e:
321
+ print(f"⚠️ Error getting directory context: {e}")
322
+ directory_context = f"\nCurrent directory: {current_dir}\n"
323
+
324
+ # Prepare the API request
325
+ headers = {
326
+ "Content-Type": "application/json",
327
+ "Authorization": f"Bearer {api_key}"
328
+ }
329
+
330
+ # Create a prompt for the LLM
331
+ print("\n" + "="*60)
332
+ print("DEBUG: ERROR_OUTPUT SENT TO LLM:")
333
+ print("="*60)
334
+ print(f"{error_output}")
335
+ print("="*60 + "\n")
336
+
337
+ prompt = f"""
338
+ I'm trying to run the following command in a Linux environment:
339
+
340
+ ```
341
+ {command}
342
+ ```
343
+
344
+ But it failed with this error:
345
+
346
+ ```
347
+ {error_output}
348
+ ```
349
+ {system_info}
350
+ {directory_context}
351
+ Please analyze the error and provide ONLY a single terminal command that would fix the issue.
352
+ Consider the current directory, system information, and directory contents carefully before suggesting a solution.
353
+
354
+ IMPORTANT: For any commands that might ask for yes/no confirmation, use the appropriate non-interactive flag:
355
+ - For apt/apt-get: use -y or --yes
356
+ - For pip: use --no-input
357
+ - For rm: use -f or --force
358
+ - For other commands: check their documentation for the appropriate non-interactive flag
359
+
360
+ Do not provide any explanations, just the exact command to run.
361
+ """
362
+
363
+ # Prepare the API request payload
364
+ payload = {
365
+ "model": "gpt-4.1",
366
+ "messages": [
367
+ {"role": "system", "content": "You are a debugging assistant. Provide only the terminal command to fix the issue, analyze the issue first understand why its happening and then provide the command to fix the issue. If you see missing pytest errors, suggest 'pip install pytest'. For wandb login issues, suggest 'wandb login YOUR_API_KEY' and the system will handle prompting for the actual key."},
368
+ {"role": "user", "content": prompt}
369
+ ],
370
+ "temperature": 0.2,
371
+ "max_tokens": 300
372
+ }
373
+
374
+ try:
375
+ print("🤖 Calling OpenAI to debug the failed command...")
376
+ response = requests.post(
377
+ "https://api.openai.com/v1/chat/completions",
378
+ headers=headers,
379
+ json=payload,
380
+ timeout=30
381
+ )
382
+
383
+ if response.status_code == 200:
384
+ result = response.json()
385
+ fix_command = result["choices"][0]["message"]["content"].strip()
386
+
387
+ # Extract just the command if it's wrapped in backticks or explanation
388
+ if "```" in fix_command:
389
+ # Extract content between backticks
390
+ import re
391
+ code_blocks = re.findall(r'```(?:bash|sh)?\s*(.*?)\s*```', fix_command, re.DOTALL)
392
+ if code_blocks:
393
+ fix_command = code_blocks[0].strip()
394
+
395
+ # If the response still has explanatory text, try to extract just the command
396
+ if len(fix_command.split('\n')) > 1:
397
+ # Take the shortest non-empty line as it's likely the command
398
+ lines = [line.strip() for line in fix_command.split('\n') if line.strip()]
399
+ if lines:
400
+ fix_command = min(lines, key=len)
401
+
402
+ print(f"🔧 Suggested fix: {fix_command}")
403
+ return fix_command
404
+ else:
405
+ print(f"❌ OpenAI API error: {response.status_code} - {response.text}")
406
+ return None
407
+ except Exception as e:
408
+ print(f"❌ Error calling OpenAI API: {e}")
409
+ return None
410
+
411
+ def prompt_for_hf_token():
412
+ """Prompt user for Hugging Face token when needed"""
413
+ print("\n" + "="*60)
414
+ print("🔑 HUGGING FACE TOKEN REQUIRED")
415
+ print("="*60)
416
+ print("The training script requires a valid Hugging Face token.")
417
+ print("You can get your token from: https://huggingface.co/settings/tokens")
418
+ print("📝 Please paste your Hugging Face token below:")
419
+ print(" (Your input will be hidden for security)")
420
+ print("-" * 60)
421
+
422
+ try:
423
+ token = getpass.getpass("HF Token: ").strip()
424
+ if not token:
425
+ print("❌ No token provided.")
426
+ return None
427
+ print("✅ Token received successfully!")
428
+ return token
429
+ except KeyboardInterrupt:
430
+ print("\n❌ Token input cancelled by user.")
431
+ return None
432
+ except Exception as e:
433
+ print(f"❌ Error getting token: {e}")
434
+ return None
435
+
436
+ def create_container(gpu_type, repo_url=None, repo_name=None, setup_commands=None, volume_name=None):
437
+ # Execution history for tracking all commands and their results in this session
438
+ execution_history = []
439
+
440
+ # Track session start time
441
+ session_start = datetime.datetime.now().isoformat()
442
+
443
+ # Track previous errors to detect repeated failures
444
+ previous_errors = {}
445
+
446
+ # Track Python version management
447
+ conda_installed = False
448
+ python_version_switched = False
449
+ current_python_version = None
450
+
451
+ # Generate a unique app name with timestamp to avoid conflicts
452
+ timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
453
+ app_name = f"sandbox-{timestamp}"
454
+
455
+ gpu_configs = {
456
+ 'A10G': {'gpu': 'A10G', 'memory': 24},
457
+ 'A100': {'gpu': 'A100-SXM4-40GB', 'memory': 40},
458
+ 'H100': {'gpu': 'H100', 'memory': 80},
459
+ 'T4': {'gpu': 'T4', 'memory': 16},
460
+ 'V100': {'gpu': 'V100-SXM2-16GB', 'memory': 16}
461
+ }
462
+
463
+ if gpu_type not in gpu_configs:
464
+ print(f"⚠️ Unknown GPU type: {gpu_type}. Using A10G as default.")
465
+ gpu_type = 'A10G'
466
+
467
+ gpu_spec = gpu_configs[gpu_type]
468
+ print(f"🚀 Creating Container with {gpu_spec['gpu']} GPU ({gpu_spec['memory']}GB VRAM)")
469
+
470
+ # Initialize uv_path variable
471
+ uv_path = ""
472
+
473
+ # Setup volume if specified
474
+ volume = None
475
+ volume_mount_path = "/persistent"
476
+
477
+ if volume_name:
478
+ print(f"📦 Setting up volume: {volume_name}")
479
+ try:
480
+ # Try to get existing volume or create new one
481
+ volume = modal.Volume.from_name(volume_name, create_if_missing=True)
482
+ print(f"✅ Volume '{volume_name}' ready for use")
483
+ except Exception as e:
484
+ print(f"⚠️ Could not setup volume '{volume_name}': {e}")
485
+ print("⚠️ Continuing without persistent volume")
486
+ volume = None
487
+ else:
488
+ # Create a default volume for this session
489
+ default_volume_name = f"sandbox-vol-{timestamp}"
490
+ print(f"📦 Creating default volume: {default_volume_name}")
491
+ try:
492
+ volume = modal.Volume.from_name(default_volume_name, create_if_missing=True)
493
+ volume_name = default_volume_name
494
+ print(f"✅ Default volume '{default_volume_name}' created")
495
+ except Exception as e:
496
+ print(f"⚠️ Could not create default volume: {e}")
497
+ print("⚠️ Continuing without persistent volume")
498
+ volume = None
499
+
500
+ # Enable output for image building
501
+ with modal.enable_output():
502
+ # Create a Modal app and sandbox
503
+ print(f"🚀 Creating Container with GPU: {gpu_type.lower()} (App: {app_name})...")
504
+ # Always use lookup with create_if_missing=True to properly initialize the app
505
+ app = modal.App.lookup(app_name, create_if_missing=True)
506
+ print(f"Created app: {app_name}")
507
+
508
+ # Create the sandbox with increased timeout for long-running operations
509
+ print("⏱️ Setting 30-minute timeout for long-running installations...")
510
+
511
+ # Setup volume mount if available
512
+ volumes = {}
513
+ if volume:
514
+ volumes[volume_mount_path] = volume
515
+ print(f"📦 Mounting volume '{volume_name}' at {volume_mount_path}")
516
+
517
+ cuda_image = modal.Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu24.04", add_python="3.12")
518
+
519
+ sandbox = modal.Sandbox.create(
520
+ "sleep", "infinity",
521
+ app=app,
522
+ gpu=gpu_type.lower(),
523
+ image=cuda_image,
524
+ timeout=3600, # 40 minutes instead of 15 minutes
525
+ volumes=volumes if volumes else None
526
+ )
527
+
528
+ # Get the sandbox ID for reference
529
+ sandbox_id = sandbox.object_id
530
+ print(f"📋 Sandbox ID: {sandbox_id}")
531
+
532
+ # Wait a moment for the container to be registered
533
+ print("⏳ Waiting for container to be registered...")
534
+ time.sleep(5) # Increased wait time
535
+
536
+ # Function to extract container ID from text output
537
+ def extract_container_id_from_text(output):
538
+ print("Extracting container ID from text output...")
539
+
540
+ # First, try to find lines with the app name
541
+ lines = output.split('\n')
542
+ app_lines = [line for line in lines if app_name in line]
543
+
544
+ if app_lines:
545
+ # Get the first line with the app name
546
+ app_line = app_lines[0]
547
+ print(f"Found line with app name: {app_line}")
548
+
549
+ # Try to extract the container ID
550
+ if '│' in app_line:
551
+ parts = app_line.split('│')
552
+ if len(parts) >= 2:
553
+ container_id_part = parts[1].strip()
554
+ if container_id_part.startswith('ta-'):
555
+ return container_id_part
556
+
557
+ # If that didn't work, try regex pattern matching
558
+ container_matches = re.findall(r'ta-[A-Z0-9]+', output)
559
+ if container_matches:
560
+ return container_matches[0]
561
+
562
+ return None
563
+
564
+ # Get the container ID using multiple approaches
565
+ print("📋 Getting container ID...")
566
+ container_id = None
567
+
568
+ # Approach 1: Use modal container list --json
569
+ try:
570
+ print("Trying JSON approach...")
571
+ result = subprocess.run(["modal", "container", "list", "--json"], capture_output=True, text=True)
572
+ output = result.stdout
573
+ print(f"JSON output: {output}")
574
+
575
+ import json
576
+ try:
577
+ containers = json.loads(output)
578
+ print(f"Parsed JSON: {containers}")
579
+ if containers and isinstance(containers, list) and len(containers) > 0:
580
+ # The container ID is in the "Container ID" field, not "id"
581
+ container_id = containers[0].get("Container ID")
582
+ if container_id:
583
+ print(f"📋 Found container ID from JSON: {container_id}")
584
+ else:
585
+ # Try lowercase keys as a fallback
586
+ container_id = containers[0].get("container_id") or containers[0].get("container id")
587
+ if container_id:
588
+ print(f"📋 Found container ID from JSON with lowercase keys: {container_id}")
589
+ except json.JSONDecodeError as json_err:
590
+ print(f"JSON parse error: {json_err}")
591
+ except Exception as e:
592
+ print(f"Error with JSON approach: {e}")
593
+
594
+ # Approach 2: Use modal container list with text parsing
595
+ if not container_id:
596
+ try:
597
+ print("Trying text output approach...")
598
+ result = subprocess.run(["modal", "container", "list"], capture_output=True, text=True)
599
+ output = result.stdout
600
+ print(container container list output:)
601
+ print(output)
602
+
603
+ container_id = extract_container_id_from_text(output)
604
+ if container_id:
605
+ print(f"📋 Found container ID from text: {container_id}")
606
+ except Exception as e:
607
+ print(f"Error with text approach: {e}")
608
+
609
+ # Approach 3: Use shell command to get first container
610
+ if not container_id:
611
+ try:
612
+ print("Trying shell command approach...")
613
+ cmd = "modal container list | grep -v Container | grep -v '─' | head -1 | awk '{print $1}'"
614
+ result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
615
+ output = result.stdout.strip()
616
+ print(f"Shell command output: {output}")
617
+
618
+ if output and output.startswith('ta-'):
619
+ container_id = output
620
+ print(f"📋 Found container ID from shell command: {container_id}")
621
+ except Exception as e:
622
+ print(f"Error with shell command approach: {e}")
623
+
624
+ # Approach 4: Get all containers and find the one with our app
625
+ if not container_id:
626
+ try:
627
+ print("Trying app matching approach...")
628
+ cmd = "modal container list"
629
+ result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
630
+ output = result.stdout
631
+
632
+ # Look for our app name in the output
633
+ if app_name in output:
634
+ print(f"Found {app_name} in container list")
635
+ # Try to get the container ID from the same line
636
+ lines = output.split('\n')
637
+ for line in lines:
638
+ if app_name in line:
639
+ print(f"Found line: {line}")
640
+ # Try to extract the first column
641
+ if '│' in line:
642
+ container_id_part = line.split('│')[1].strip()
643
+ if container_id_part.startswith('ta-'):
644
+ container_id = container_id_part
645
+ print(f"📋 Found container ID from app matching: {container_id}")
646
+ break
647
+ except Exception as e:
648
+ print(f"Error with app matching approach: {e}")
649
+
650
+ # Final fallback: Use sandbox ID to create a container ID
651
+ if not container_id:
652
+ print("⚠️ All approaches failed to find container ID")
653
+ # Use sandbox ID as container prefix
654
+ short_id = sandbox_id.split('-')[1][:8] if '-' in sandbox_id else sandbox_id[:8]
655
+ container_id = f"ta-{short_id.upper()}"
656
+ print(f"📋 Using derived container ID: {container_id}")
657
+
658
+ # Ensure we have a non-None container ID
659
+ if not container_id:
660
+ print("⚠️ Critical error: Failed to determine container ID")
661
+ print("⚠️ Using a placeholder container ID")
662
+ container_id = "ta-UNKNOWN"
663
+
664
+ # Try to verify the container ID exists
665
+ print("🔍 Verifying container ID...")
666
+ verify_cmd = f"modal container logs {container_id} --tail 1 2>/dev/null || echo 'Container not found'"
667
+ verify_result = subprocess.run(verify_cmd, shell=True, capture_output=True, text=True)
668
+ if "Container not found" in verify_result.stdout:
669
+ print(f"⚠️ Container ID verification failed: {container_id}")
670
+
671
+ # Last resort: Try to find any valid container
672
+ print("🔍 Looking for any valid container as last resort...")
673
+ list_cmd = "modal container list | grep -v Container | grep -v '─' | grep -v '┏' | grep -v '┃' | head -1"
674
+ list_result = subprocess.run(list_cmd, shell=True, capture_output=True, text=True)
675
+ if list_result.stdout.strip():
676
+ print(f"Found container line: {list_result.stdout.strip()}")
677
+ # Try to extract the ID from the first column
678
+ container_line = list_result.stdout.strip()
679
+ if '│' in container_line:
680
+ possible_id = container_line.split('│')[1].strip()
681
+ if possible_id.startswith('ta-'):
682
+ container_id = possible_id
683
+ print(f"📋 Using container ID from list as last resort: {container_id}")
684
+
685
+ # Verify this container
686
+ verify_cmd = f"modal container logs {container_id} --tail 1 2>/dev/null || echo 'Container not found'"
687
+ verify_result = subprocess.run(verify_cmd, shell=True, capture_output=True, text=True)
688
+ if "Container not found" not in verify_result.stdout:
689
+ print(f"✅ Last resort container ID verified: {container_id}")
690
+ else:
691
+ print("⚠️ Last resort container ID also failed verification")
692
+
693
+ print("⚠️ Container connection may fail. You may need to connect manually.")
694
+ else:
695
+ print(f"✅ Container ID verified: {container_id}")
696
+
697
+ # Function to convert bytes to string
698
+ def _to_str(maybe_bytes):
699
+ try:
700
+ return (maybe_bytes.decode('utf-8') if isinstance(maybe_bytes, (bytes, bytearray)) else maybe_bytes)
701
+ except UnicodeDecodeError:
702
+ # Handle non-UTF-8 bytes by replacing invalid characters
703
+ if isinstance(maybe_bytes, (bytes, bytearray)):
704
+ return maybe_bytes.decode('utf-8', errors='replace')
705
+ else:
706
+ return str(maybe_bytes)
707
+ except Exception:
708
+ # Last resort fallback
709
+ return str(maybe_bytes)
710
+
711
+ # Skip the persistent shell approach for now due to async stream complexity
712
+ print(🔍 container's async streams require complex async handling)
713
+ print("🔄 Switching to individual command execution approach for reliability...")
714
+
715
+ # Initialize state tracking variables
716
+ current_dir = "/"
717
+ execution_history = []
718
+
719
+ # Function to run commands using individual sandbox.exec calls
720
+ def run_command(cmd, show_output=True, retry_count=0, max_retries=3, debug_with_llm=True, timeout=600):
721
+ """
722
+ Execute a command in the sandbox with error handling and automatic retries.
723
+
724
+ When a command fails and is fixed by the LLM debugging system, the retry count
725
+ is reset to 0, so successful fixes don't count against the maximum retry limit.
726
+ This ensures that a command that's been fixed gets a fresh set of retry attempts.
727
+ """
728
+ # Use the outer scope variables
729
+ nonlocal current_dir, execution_history, sandbox, previous_errors
730
+ nonlocal conda_installed, python_version_switched, current_python_version
731
+
732
+ # Record command start time
733
+ command_start_time = datetime.datetime.now().isoformat()
734
+ start_time = time.time()
735
+
736
+ # Prevent infinite retry loops
737
+ if retry_count >= max_retries:
738
+ print(f"⚠️ Maximum retry count ({max_retries}) reached. Stopping retries.")
739
+ return False, "", f"Maximum retry count ({max_retries}) reached"
740
+
741
+ # Special handling for cd commands to prevent common navigation errors
742
+ if cmd.strip().startswith("cd "):
743
+ # Extract the target directory from the cd command
744
+ cd_parts = cmd.split(None, 1)
745
+ if len(cd_parts) >= 2:
746
+ target_dir = cd_parts[1].strip().strip('"\'')
747
+
748
+ # Check if this is a repo name that matches the end of current_dir
749
+ # This prevents errors like "cd repo-name" when already in "/root/repo-name"
750
+ # BUT we need to be careful about nested directories like /root/litex/litex
751
+ if (target_dir != "/" and target_dir != "." and target_dir != ".." and
752
+ not target_dir.startswith("/") and not target_dir.startswith("./") and
753
+ not target_dir.startswith("../") and current_dir.endswith("/" + target_dir)):
754
+
755
+ # Additional check: verify if there's actually a nested directory with this name
756
+ # This prevents skipping legitimate navigation to nested directories
757
+ test_cmd = f"test -d \"{target_dir}\""
758
+ test_result = sandbox.exec("bash", "-c", test_cmd)
759
+ test_result.wait()
760
+
761
+ if test_result.returncode == 0:
762
+ # The nested directory exists, so this is NOT redundant
763
+ print(f"🔍 Detected nested directory '{target_dir}' exists in current location")
764
+ print(f"📂 Current: {current_dir}")
765
+ print(f"🎯 Target: {target_dir}")
766
+ print(f"🔄 Proceeding with navigation to nested directory...")
767
+ else:
768
+ # No nested directory exists, so this is truly redundant
769
+ print(f"⚠️ Detected redundant directory navigation: {cmd}")
770
+ print(f"📂 Already in the correct directory: {current_dir}")
771
+ print(f"✅ Skipping unnecessary navigation command")
772
+ return True, f"Already in directory {current_dir}", ""
773
+
774
+ # Remove any parenthetical text that could cause syntax errors in bash
775
+ if '(' in cmd:
776
+ original_cmd = cmd
777
+ cmd = re.sub(r'\([^)]*\)', '', cmd).strip()
778
+ print(f"🔄 Removing parenthetical text:")
779
+ print(f" Original: {original_cmd}")
780
+ print(f" Cleaned: {cmd}")
781
+
782
+ # Convert pip install commands to use uv for faster installation
783
+ original_cmd = cmd
784
+ if 'uv_path' in globals() and uv_path and ('pip install' in cmd or 'pip3 install' in cmd) and not cmd.startswith(uv_path):
785
+ # Replace pip/pip3 install with uv pip install, but only if not already using uv
786
+ cmd = cmd.replace('pip install', f'{uv_path} pip install')
787
+ cmd = cmd.replace('pip3 install', f'{uv_path} pip install')
788
+ print(f"🚀 Converting to uv for faster installation:")
789
+ print(f" Original: {original_cmd}")
790
+ print(f" Converted: {cmd}")
791
+
792
+ print(f"\n▶ {cmd}\n")
793
+
794
+ # Check if this is a potentially long-running command
795
+ long_running_patterns = [
796
+ 'pip install', 'apt install', 'yum install',
797
+ 'wget', 'curl', 'git clone', 'npm install', 'yarn install',
798
+ 'cmake', 'make', 'gcc', 'g++', 'python setup.py'
799
+ ]
800
+
801
+ is_long_running = any(pattern in cmd.lower() for pattern in long_running_patterns)
802
+ if is_long_running:
803
+ print(f"⏱️ Detected potentially long-running command. This may take several minutes...")
804
+ print(f"📦 Large packages (like PyTorch) can take 5-10 minutes to download and install.")
805
+ print(f"🔄 The container has a 30-minute timeout to accommodate this.")
806
+
807
+ # Use the original command without modification for interactivity
808
+ cmd_to_execute = cmd
809
+
810
+ # Special handling for huggingface-cli login command
811
+ if "huggingface-cli login" in cmd_to_execute:
812
+ print("🔍 Detected huggingface-cli login command")
813
+ print("🔄 Using non-interactive login approach with token instead")
814
+
815
+ # Check if the command already has a token
816
+ if "--token" in cmd_to_execute:
817
+ print("✅ Command already includes token parameter")
818
+ else:
819
+ # Prompt for HF token
820
+ hf_token = prompt_for_hf_token()
821
+ if hf_token:
822
+ # Replace with non-interactive command
823
+ cmd_to_execute = f"huggingface-cli login --token {hf_token} --add-to-git-credential"
824
+ print(f"🔄 Using non-interactive command: {cmd_to_execute}")
825
+ else:
826
+ print("❌ No token provided. Cannot continue with Hugging Face login.")
827
+ return False, "", "No Hugging Face token provided"
828
+
829
+ # Special handling for wandb login command
830
+ elif "wandb login" in cmd_to_execute and "YOUR_API_KEY" not in cmd_to_execute:
831
+ print("🔍 Detected Weights & Biases login command")
832
+ print("🔄 Using API key approach for non-interactive login")
833
+
834
+ # Check if the command already includes an API key
835
+ has_api_key = False
836
+ cmd_parts = cmd_to_execute.split()
837
+ for part in cmd_parts:
838
+ if part != "wandb" and part != "login" and not part.startswith("-"):
839
+ has_api_key = True
840
+ break
841
+
842
+ if not has_api_key:
843
+ # Prompt for W&B API key
844
+ print("\n" + "="*60)
845
+ print("🔑 WEIGHTS & BIASES API KEY REQUIRED")
846
+ print("="*60)
847
+ print("You can get your API key from: https://wandb.ai/authorize")
848
+ print("📝 Please paste your W&B API key below:")
849
+ print(" (Your input will be hidden for security)")
850
+ print("-" * 60)
851
+
852
+ try:
853
+ api_key = getpass.getpass("W&B API Key: ").strip()
854
+ if not api_key:
855
+ print("❌ No API key provided. Cannot continue with W&B login.")
856
+ return False, "", "No W&B API key provided"
857
+
858
+ # Validate API key length (typically 40 characters)
859
+ if len(api_key) != 40:
860
+ print(f"⚠️ Warning: API key should be 40 characters long, yours was {len(api_key)}")
861
+ confirm = input("Continue anyway? (yes/no): ").strip().lower()
862
+ if confirm not in ["yes", "y"]:
863
+ print("❌ W&B login cancelled.")
864
+ return False, "", "W&B login cancelled"
865
+
866
+ print("✅ API key received successfully!")
867
+
868
+ # Replace with non-interactive command
869
+ cmd_to_execute = f"wandb login {api_key}"
870
+ print(f"🔄 Using non-interactive command: wandb login [API_KEY_HIDDEN]")
871
+ except KeyboardInterrupt:
872
+ print("\n❌ API key input cancelled by user.")
873
+ return False, "", "W&B API key input cancelled"
874
+ except Exception as e:
875
+ print(f"❌ Error getting API key: {e}")
876
+ return False, "", f"Error getting W&B API key: {e}"
877
+
878
+ # Validate the command before execution
879
+ if not cmd_to_execute or cmd_to_execute.strip() == "":
880
+ print("⚠️ Empty command detected, skipping execution")
881
+ return False, "", "Empty command"
882
+
883
+ # Sanitize command to prevent issues with special characters
884
+ # Remove any null bytes or other problematic characters
885
+ cmd_to_execute = cmd_to_execute.replace('\x00', '').strip()
886
+
887
+ if len(cmd_to_execute) > 10000: # Prevent extremely long commands
888
+ print("⚠️ Command too long, truncating")
889
+ cmd_to_execute = cmd_to_execute[:10000]
890
+
891
+ # Prepare the command with environment variables and error handling
892
+ full_command = f"""
893
+ # Change to current directory
894
+ cd "{current_dir}"
895
+
896
+ # Execute the command
897
+ {cmd_to_execute}
898
+ """
899
+
900
+ # Execute the command using sandbox.exec
901
+ try:
902
+ print(f"🔄 Executing command in directory: {current_dir}")
903
+
904
+ # Use sandbox.exec for individual command execution
905
+ result = sandbox.exec("bash", "-c", full_command.strip())
906
+
907
+ # Collect output in real-time - Modal streams are already set up for line-by-line streaming
908
+ stdout_lines = []
909
+ stderr_lines = []
910
+
911
+ # Process output streams in real-time - Modal handles this natively
912
+ # We don't need to use threading here as Modal's streams are designed to be consumed directly
913
+ if show_output:
914
+ print("\n--- Command Output ---")
915
+
916
+ # Track if we've shown timeout warnings
917
+ timeout_warnings = set()
918
+ last_output_time = time.time()
919
+
920
+ # Read stdout in real-time
921
+ for line in result.stdout:
922
+ # Check for timeout
923
+ current_time = time.time()
924
+ elapsed = current_time - start_time
925
+ time_since_output = current_time - last_output_time
926
+
927
+ # Show timeout warning every 30 seconds if no output for 30+ seconds
928
+ if time_since_output > 30 and int(time_since_output) // 30 not in timeout_warnings:
929
+ warning_time = int(time_since_output) // 30 * 30
930
+ timeout_warnings.add(int(time_since_output) // 30)
931
+ print(f"Still running after {int(elapsed)} seconds...")
932
+
933
+ # If total time exceeds timeout, break
934
+ if elapsed > timeout:
935
+ print(f"⚠️ Command timed out after {timeout} seconds")
936
+ # Force terminate the command
937
+ try:
938
+ result.terminate()
939
+ except:
940
+ pass
941
+ return False, "Command timed out", f"Command execution exceeded timeout of {timeout} seconds"
942
+
943
+ # Process the line
944
+ line_str = _to_str(line)
945
+ stdout_lines.append(line_str)
946
+ if show_output:
947
+ # Print immediately with flush to ensure real-time display
948
+ print(line_str, end="", flush=True)
949
+
950
+ # Update last output time
951
+ last_output_time = time.time()
952
+
953
+ # Read stderr in real-time
954
+ for line in result.stderr:
955
+ # Check for timeout
956
+ current_time = time.time()
957
+ elapsed = current_time - start_time
958
+ time_since_output = current_time - last_output_time
959
+
960
+ # Show timeout warning every 30 seconds if no output for 30+ seconds
961
+ if time_since_output > 30 and int(time_since_output) // 30 not in timeout_warnings:
962
+ warning_time = int(time_since_output) // 30 * 30
963
+ timeout_warnings.add(int(time_since_output) // 30)
964
+ print(f"Still running after {int(elapsed)} seconds...")
965
+
966
+ # If total time exceeds timeout, break
967
+ if elapsed > timeout:
968
+ print(f"⚠️ Command timed out after {timeout} seconds")
969
+ # Force terminate the command
970
+ try:
971
+ result.terminate()
972
+ except:
973
+ pass
974
+ return False, "Command timed out", f"Command execution exceeded timeout of {timeout} seconds"
975
+
976
+ # Process the line
977
+ line_str = _to_str(line)
978
+ stderr_lines.append(line_str)
979
+ if show_output:
980
+ # Print immediately with flush to ensure real-time display
981
+ print(line_str, end="", file=sys.stderr, flush=True)
982
+
983
+ # Update last output time
984
+ last_output_time = time.time()
985
+
986
+ if show_output:
987
+ print("--- End Output ---\n")
988
+
989
+ stdout_buffer = ''.join(stdout_lines)
990
+ stderr_buffer = ''.join(stderr_lines)
991
+
992
+ # Wait for the process to complete before accessing returncode
993
+ result.wait()
994
+ exit_code = result.returncode
995
+
996
+ except Exception as e:
997
+ print(f"❌ Error executing command: {e}")
998
+ return False, "", str(e)
999
+
1000
+ # Record command completion time
1001
+ command_end_time = datetime.datetime.now().isoformat()
1002
+
1003
+ # Calculate duration in seconds
1004
+ start_dt = datetime.datetime.fromisoformat(command_start_time)
1005
+ end_dt = datetime.datetime.fromisoformat(command_end_time)
1006
+ duration = (end_dt - start_dt).total_seconds()
1007
+
1008
+ # Record this command execution in history
1009
+ execution_record = {
1010
+ "command": cmd_to_execute,
1011
+ "original_command": cmd if cmd != cmd_to_execute else None,
1012
+ "start_time": command_start_time,
1013
+ "end_time": command_end_time,
1014
+ "duration_seconds": duration,
1015
+ "exit_code": exit_code,
1016
+ "stdout": stdout_buffer,
1017
+ "stderr": stderr_buffer,
1018
+ "directory": current_dir
1019
+ }
1020
+ execution_history.append(execution_record)
1021
+
1022
+ # Update current directory if this was a cd command and it succeeded
1023
+ if cmd_to_execute.strip().startswith("cd ") and exit_code == 0:
1024
+ # Extract the target directory from the cd command
1025
+ cd_parts = cmd_to_execute.split(None, 1)
1026
+ if len(cd_parts) >= 2:
1027
+ target_dir = cd_parts[1].strip('"\'')
1028
+
1029
+ # Store the previous directory for logging
1030
+ previous_dir = current_dir
1031
+
1032
+ # Handle different types of paths
1033
+ if target_dir.startswith('/'):
1034
+ # Absolute path
1035
+ current_dir = target_dir
1036
+ elif target_dir == '..':
1037
+ # Parent directory
1038
+ current_dir = '/'.join(current_dir.rstrip('/').split('/')[:-1]) or '/'
1039
+ elif target_dir == '.':
1040
+ # Current directory - no change
1041
+ pass
1042
+ else:
1043
+ # Relative path - handle special case where target is already at the end of current_dir
1044
+ if current_dir.endswith('/' + target_dir):
1045
+ print(f"📂 Already in directory {current_dir}, no change needed")
1046
+ else:
1047
+ current_dir = f"{current_dir.rstrip('/')}/{target_dir}"
1048
+
1049
+ print(f"📂 Updated current directory: {previous_dir} -> {current_dir}")
1050
+ execution_record["new_current_dir"] = current_dir
1051
+
1052
+ # Verify the directory actually exists
1053
+ verify_cmd = f"test -d \"{current_dir}\""
1054
+ verify_result = sandbox.exec("bash", "-c", verify_cmd)
1055
+ verify_result.wait()
1056
+
1057
+ if verify_result.returncode != 0:
1058
+ print(f"⚠️ Warning: Directory {current_dir} does not exist")
1059
+ print(f"⚠️ Reverting to previous directory: {previous_dir}")
1060
+ current_dir = previous_dir
1061
+ execution_record["new_current_dir"] = current_dir
1062
+
1063
+ # Check for errors and handle Hugging Face token issues
1064
+ if exit_code != 0:
1065
+ # Check for specific Hugging Face token errors
1066
+ hf_token_error_patterns = [
1067
+ "Token is required",
1068
+ "LocalTokenNotFoundError",
1069
+ "Invalid user token",
1070
+ "401 Client Error: Unauthorized",
1071
+ "Invalid credentials in Authorization header",
1072
+ "HF_TOKEN environment variable is invalid"
1073
+ ]
1074
+
1075
+ is_hf_token_error = any(pattern in stderr_buffer for pattern in hf_token_error_patterns)
1076
+
1077
+ if is_hf_token_error:
1078
+ print(f"🔑 Detected Hugging Face token authentication error!")
1079
+ print(f"🔍 Error details: {stderr_buffer}")
1080
+
1081
+ # Prompt for the real token
1082
+ real_token = prompt_for_hf_token()
1083
+
1084
+ if real_token:
1085
+ print(f"🔄 Setting HF_TOKEN and retrying command...")
1086
+
1087
+ # Retry with the token set
1088
+ token_command = f"export HF_TOKEN='{real_token}'; {cmd_to_execute}"
1089
+ return run_command(token_command, show_output, retry_count + 1, max_retries)
1090
+ else:
1091
+ print("❌ No token provided. Cannot continue with Hugging Face operations.")
1092
+ return False, stdout_buffer, "No Hugging Face token provided"
1093
+
1094
+ # Check for "No such file or directory" errors with cd commands
1095
+ if "cd " in cmd_to_execute and "No such file or directory" in stderr_buffer:
1096
+ print("⚠️ Directory navigation error detected")
1097
+
1098
+ # Extract the target directory from the cd command
1099
+ cd_parts = cmd_to_execute.split(None, 1)
1100
+ if len(cd_parts) >= 2:
1101
+ target_dir = cd_parts[1].strip('"\'')
1102
+
1103
+ # Check if this might be a repository name that's already in the path
1104
+ if not target_dir.startswith('/') and '/' + target_dir in current_dir:
1105
+ print(f"🔍 The directory '{target_dir}' appears to be part of the current path: {current_dir}")
1106
+ print(f"⚠️ This is likely a redundant navigation attempt")
1107
+
1108
+ # If we're already in a directory that ends with the target, consider it a success
1109
+ if current_dir.endswith('/' + target_dir):
1110
+ print(f"✅ Already in the correct directory: {current_dir}")
1111
+ return True, f"Already in directory {current_dir}", ""
1112
+
1113
+ print(f"⚠️ Command failed with exit code {exit_code}")
1114
+ if stderr_buffer.strip():
1115
+ print(f"Error output: {stderr_buffer}")
1116
+
1117
+ # If command failed and we're debugging with LLM
1118
+ if debug_with_llm:
1119
+ print("🔍 Attempting to debug the failed command with OpenAI...")
1120
+
1121
+ # Check if the command is a hanging huggingface-cli login
1122
+ if "huggingface-cli login" in cmd_to_execute and not stderr_buffer.strip():
1123
+ print("🔍 Detected hanging huggingface-cli login command")
1124
+ print("🔄 Using non-interactive login approach with HF_TOKEN instead")
1125
+
1126
+ # Prompt for HF token
1127
+ hf_token = prompt_for_hf_token()
1128
+ if hf_token:
1129
+ # Set the token as environment variable and create .huggingface folder
1130
+ print("✅ Token received, setting up non-interactive authentication")
1131
+ setup_commands = [
1132
+ "mkdir -p ~/.huggingface",
1133
+ f"echo '{hf_token}' > ~/.huggingface/token",
1134
+ f"export HF_TOKEN='{hf_token}'",
1135
+ "echo 'HF_TOKEN and token file have been set up'"
1136
+ ]
1137
+
1138
+ for setup_cmd in setup_commands:
1139
+ setup_success, setup_stdout, _ = run_command(setup_cmd, show_output=True, debug_with_llm=False)
1140
+ if not setup_success:
1141
+ print(f"⚠️ Setup command failed: {setup_cmd}")
1142
+
1143
+ print("✅ Hugging Face authentication set up non-interactively")
1144
+ return True, "Hugging Face authentication set up successfully", ""
1145
+ else:
1146
+ print("❌ No token provided. Cannot set up Hugging Face authentication.")
1147
+ return False, "", "No Hugging Face token provided"
1148
+
1149
+ # Check if the error is related to missing pytest
1150
+ if "ModuleNotFoundError: No module named 'pytest'" in stderr_buffer or "ImportError: No module named pytest" in stderr_buffer:
1151
+ print("🔍 Detected missing pytest module, installing it automatically...")
1152
+ pytest_install_success, _, _ = run_command("pip install pytest", show_output=True, debug_with_llm=False)
1153
+ if pytest_install_success:
1154
+ print("✅ Successfully installed pytest, retrying original command...")
1155
+ return run_command(cmd, show_output, retry_count + 1, max_retries)
1156
+
1157
+ # Check for Python version-specific errors
1158
+ python_version_errors = [
1159
+ # Python 3.13 distutils issue
1160
+ ("ModuleNotFoundError: No module named 'distutils'", "3.13"),
1161
+ # Add more version-specific error patterns here
1162
+ ("ImportError: cannot import name 'soft_unicode' from 'markupsafe'", None),
1163
+ ("AttributeError: module 'setuptools.dist' has no attribute 'check_specifier'", None)
1164
+ ]
1165
+
1166
+ # Check if any of the error patterns match
1167
+ for error_pattern, problematic_version in python_version_errors:
1168
+ if error_pattern in stderr_buffer:
1169
+ print(f"🔍 Detected Python version-specific error: {error_pattern}")
1170
+
1171
+ # Get current Python version if not already known
1172
+ if not current_python_version:
1173
+ version_cmd = "python --version"
1174
+ version_success, version_stdout, _ = run_command(version_cmd, show_output=False, debug_with_llm=False)
1175
+ if version_success:
1176
+ current_python_version = version_stdout.strip()
1177
+ print(f"🐍 Current Python version: {current_python_version}")
1178
+
1179
+ # Check if we've already tried switching Python versions
1180
+ if python_version_switched:
1181
+ print("⚠️ Already attempted to switch Python versions once, not trying again")
1182
+ break
1183
+
1184
+ print("🔄 Attempting to fix by switching Python version...")
1185
+
1186
+ # Install conda if not already installed
1187
+ if not conda_installed:
1188
+ print("📦 Installing Miniconda to manage Python versions...")
1189
+ conda_install_cmds = [
1190
+ "apt-get update -y",
1191
+ "apt-get install -y wget bzip2",
1192
+ "wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O /tmp/miniconda.sh",
1193
+ "bash /tmp/miniconda.sh -b -p /opt/conda",
1194
+ "rm /tmp/miniconda.sh",
1195
+ "echo 'export PATH=/opt/conda/bin:$PATH' >> ~/.bashrc",
1196
+ "export PATH=/opt/conda/bin:$PATH",
1197
+ "conda init bash",
1198
+ "source ~/.bashrc",
1199
+ "conda activate base"
1200
+ ]
1201
+
1202
+ for conda_cmd in conda_install_cmds:
1203
+ print(f"🔄 Running: {conda_cmd}")
1204
+ conda_success, _, _ = run_command(conda_cmd, show_output=True, debug_with_llm=False)
1205
+ if not conda_success:
1206
+ print("⚠️ Failed to install conda, continuing with system Python")
1207
+ break
1208
+
1209
+ # Check if conda was successfully installed
1210
+ conda_check_cmd = "conda --version"
1211
+ conda_check_success, conda_check_stdout, _ = run_command(conda_check_cmd, show_output=True, debug_with_llm=False)
1212
+ conda_installed = conda_check_success
1213
+
1214
+ if conda_installed:
1215
+ print(f"✅ Successfully installed conda: {conda_check_stdout.strip()}")
1216
+ else:
1217
+ print("⚠️ Failed to verify conda installation")
1218
+ break
1219
+
1220
+ # Determine target Python version
1221
+ target_version = "3.10" # Default to a stable version
1222
+ if problematic_version == "3.13":
1223
+ # If we're on 3.13 and having issues, go to 3.10
1224
+ target_version = "3.10"
1225
+ elif "3.13" in str(current_python_version):
1226
+ # If we're on 3.13 for any other error, try 3.10
1227
+ target_version = "3.10"
1228
+ elif "3.10" in str(current_python_version):
1229
+ # If we're on 3.10 and having issues, try 3.9
1230
+ target_version = "3.9"
1231
+
1232
+ print(f"🐍 Switching from {current_python_version} to Python {target_version}...")
1233
+
1234
+ # Create and activate a conda environment with the target Python version
1235
+ conda_cmds = [
1236
+ f"conda create -y -n py{target_version} python={target_version}",
1237
+ f"echo 'conda activate py{target_version}' >> ~/.bashrc",
1238
+ f"conda init bash",
1239
+ f"source ~/.bashrc",
1240
+ f"conda activate py{target_version}"
1241
+ ]
1242
+
1243
+ for conda_cmd in conda_cmds:
1244
+ print(f"🔄 Running: {conda_cmd}")
1245
+ conda_success, _, _ = run_command(conda_cmd, show_output=True, debug_with_llm=False)
1246
+ if not conda_success:
1247
+ print(f"⚠️ Failed to run conda command: {conda_cmd}")
1248
+
1249
+ # Verify Python version changed
1250
+ verify_cmd = "python --version"
1251
+ verify_success, verify_stdout, _ = run_command(verify_cmd, show_output=True, debug_with_llm=False)
1252
+
1253
+ if verify_success and target_version in verify_stdout:
1254
+ print(f"✅ Successfully switched to Python {verify_stdout.strip()}")
1255
+ python_version_switched = True
1256
+ current_python_version = verify_stdout.strip()
1257
+
1258
+ # Reinstall pip and setuptools in the new environment
1259
+ print("📦 Installing pip and setuptools in new environment...")
1260
+ run_command("pip install --upgrade pip setuptools wheel", show_output=True, debug_with_llm=False)
1261
+
1262
+ # Retry the original command with the new Python version
1263
+ print(f"🔄 Retrying original command with Python {target_version}...")
1264
+ # Reset the retry counter since we've made a significant change
1265
+ return run_command(cmd, show_output, 0, max_retries)
1266
+ else:
1267
+ print("⚠️ Failed to switch Python version, continuing with current version")
1268
+
1269
+ break
1270
+
1271
+ # Check if stderr is empty, try to use stdout as fallback
1272
+ debug_output = stderr_buffer
1273
+ if not debug_output or not debug_output.strip():
1274
+ print("⚠️ stderr is empty, checking if stdout contains error information...")
1275
+ if stdout_buffer and stdout_buffer.strip():
1276
+ print("✅ Using stdout for debugging as stderr is empty")
1277
+ debug_output = stdout_buffer
1278
+ else:
1279
+ print("⚠️ Both stderr and stdout are empty. Limited debugging information available.")
1280
+ debug_output = f"Command failed with exit code {exit_code}, but no error output was captured."
1281
+
1282
+ # Print debug output for verification
1283
+ print(f"🔍 Debug output to be sent to OpenAI ({len(debug_output)} chars):")
1284
+ print("="*60)
1285
+ print(debug_output if debug_output else "[EMPTY]")
1286
+ print("="*60)
1287
+
1288
+ fix_command = call_openai_for_debug(cmd_to_execute, debug_output, current_dir=current_dir, sandbox=sandbox)
1289
+
1290
+ if fix_command:
1291
+ print(f"🔧 OpenAI suggested fix command: {fix_command}")
1292
+
1293
+ # Check if the suggested command is "wandb login YOUR_API_KEY" or similar
1294
+ if "wandb login" in fix_command and ("YOUR_API_KEY" in fix_command or "[your_api_key]" in fix_command):
1295
+ print("🔍 Detected placeholder API key in suggested command")
1296
+ print("🔄 Prompting for actual W&B API key instead")
1297
+
1298
+ # Prompt for W&B API key
1299
+ print("\n" + "="*60)
1300
+ print("🔑 WEIGHTS & BIASES API KEY REQUIRED")
1301
+ print("="*60)
1302
+ print("You can get your API key from: https://wandb.ai/authorize")
1303
+ print("📝 Please paste your W&B API key below:")
1304
+ print(" (Your input will be hidden for security)")
1305
+ print("-" * 60)
1306
+
1307
+ try:
1308
+ api_key = getpass.getpass("W&B API Key: ").strip()
1309
+ if api_key:
1310
+ # Replace placeholder with actual API key
1311
+ fix_command = f"wandb login {api_key}"
1312
+ print(f"🔄 Using actual API key: wandb login [API_KEY_HIDDEN]")
1313
+ else:
1314
+ print("❌ No API key provided. Cannot continue with W&B login.")
1315
+ return False, stdout_buffer, stderr_buffer
1316
+ except Exception as e:
1317
+ print(f"❌ Error getting API key: {e}")
1318
+ return False, stdout_buffer, stderr_buffer
1319
+
1320
+ # Special handling for cd commands to prevent directory navigation loops
1321
+ if fix_command.strip().startswith("cd "):
1322
+ # Extract the target directory from the cd command
1323
+ cd_parts = fix_command.split(None, 1)
1324
+ if len(cd_parts) >= 2:
1325
+ target_dir = cd_parts[1].strip('"\'')
1326
+
1327
+ # Check if this is trying to navigate to a directory we're already in
1328
+ if target_dir.endswith(current_dir.split('/')[-1]) or current_dir.endswith('/' + target_dir):
1329
+ print(f"⚠️ Detected potential directory navigation loop")
1330
+ print(f"🔍 Current directory: {current_dir}")
1331
+ print(f"🔍 Suggested navigation: {target_dir}")
1332
+
1333
+ # Check if we're already in the target directory or a directory that contains it
1334
+ if current_dir.endswith('/' + target_dir) or ('/' + target_dir + '/' in current_dir):
1335
+ print(f"✅ Already in or past the target directory")
1336
+ print(f"🔄 Skipping redundant navigation and retrying the original command")
1337
+ return run_command(cmd, show_output, retry_count + 1, max_retries)
1338
+
1339
+ # Automatically run the fix command without asking for permission
1340
+ print(f"🔄 Running suggested fix command: {fix_command}")
1341
+ # Run the fix command with debugging disabled to prevent infinite loop
1342
+ fix_success, fix_stdout, fix_stderr = run_command(fix_command, show_output=True, debug_with_llm=False)
1343
+
1344
+ if fix_success:
1345
+ print("✅ Fix command succeeded!")
1346
+ # Retry the original command with reset retry count
1347
+ print(f"🔄 Retrying original command: {cmd}")
1348
+
1349
+ # Create a key for tracking this error
1350
+ error_key = f"{cmd}:{stderr_buffer[:100]}"
1351
+
1352
+ # Check if we've seen this error before
1353
+ if error_key in previous_errors:
1354
+ # We've seen this error before, don't reset the retry count
1355
+ previous_errors[error_key] += 1
1356
+ print(f"⚠️ Same error encountered {previous_errors[error_key]} times. Not resetting retry count.")
1357
+ return run_command(cmd, show_output, retry_count + 1, max_retries)
1358
+ else:
1359
+ # First time seeing this error, track it and reset retry count
1360
+ previous_errors[error_key] = 1
1361
+ print(f"🔄 Resetting retry count to 0 after successful fix")
1362
+ return run_command(cmd, show_output, 0, max_retries) # Reset retry count to 0
1363
+ else:
1364
+ print("❌ Fix command failed.")
1365
+ return False, stdout_buffer, stderr_buffer
1366
+
1367
+ return exit_code == 0, stdout_buffer, stderr_buffer
1368
+
1369
+ # Initialize the environment with basic commands
1370
+ print("🔄 Initializing environment...")
1371
+ init_commands = [
1372
+ "export PS1='$ '", # Set a simple prompt
1373
+ "export TERM=xterm-256color", # Set terminal type
1374
+ "source ~/.bashrc 2>/dev/null || true" # Source bashrc if available
1375
+ ]
1376
+
1377
+ # Add volume-specific initialization if volume is available
1378
+ if volume:
1379
+ volume_commands = [
1380
+ f"mkdir -p {volume_mount_path}/venvs", # Create virtual environments directory
1381
+ f"mkdir -p {volume_mount_path}/cache", # Create cache directory
1382
+ f"export PIP_CACHE_DIR={volume_mount_path}/cache/pip", # Pip cache
1383
+ f"export UV_CACHE_DIR={volume_mount_path}/cache/uv", # UV cache
1384
+ ]
1385
+ init_commands.extend(volume_commands)
1386
+ print(f"📦 Setting up persistent storage directories in {volume_mount_path}")
1387
+
1388
+ # Run initialization commands
1389
+ for i, init_cmd in enumerate(init_commands, 1):
1390
+ print(f"📋 Running init command {i}/{len(init_commands)}: {init_cmd}")
1391
+ success, stdout, stderr = run_command(init_cmd, show_output=False)
1392
+ if not success:
1393
+ print(f"⚠️ Init command failed: {stderr}")
1394
+
1395
+ print("✅ Environment initialization completed")
1396
+
1397
+ print("📦 Installing basic tools...")
1398
+ run_command("apt-get update && apt-get install -y git curl wget")
1399
+
1400
+ print("📦 Installing uv with pip...")
1401
+ run_command("pip install uv")
1402
+
1403
+ # Set uv path to system installation
1404
+ uv_path = "uv"
1405
+
1406
+ # Test if uv is available and working
1407
+ test_uv_cmd = f"{uv_path} --version || echo 'uv not found'"
1408
+ test_success, test_stdout, test_stderr = run_command(test_uv_cmd)
1409
+ if not test_success or 'uv not found' in test_stdout:
1410
+ print("⚠️ uv installation not found in system path, trying alternative installation...")
1411
+ # Try alternative installation method
1412
+ print("📦 Installing uv using the official installer...")
1413
+ run_command("curl -LsSf https://astral.sh/uv/install.sh | sh")
1414
+ run_command("source $HOME/.local/bin/env")
1415
+ run_command('export PATH="$HOME/.local/bin:$PATH"')
1416
+
1417
+ # Update path to the local installation
1418
+ uv_path = "$HOME/.local/bin/uv"
1419
+
1420
+ # Test again
1421
+ test_uv_cmd = f"{uv_path} --version || echo 'uv not found'"
1422
+ test_success, test_stdout, test_stderr = run_command(test_uv_cmd)
1423
+ if not test_success or 'uv not found' in test_stdout:
1424
+ print("⚠️ uv installation still failed, using standard pip")
1425
+ uv_path = ""
1426
+ else:
1427
+ print(f"✅ uv installed successfully via alternative method: {test_stdout.strip()}")
1428
+ else:
1429
+ print(f"✅ uv installed successfully via pip: {test_stdout.strip()}")
1430
+
1431
+ # Initialize repo_clone_dir for use throughout the function
1432
+ repo_clone_dir = "/root" # Always use home directory for repositories
1433
+
1434
+ # Clone repository if URL is provided
1435
+ if repo_url:
1436
+ try:
1437
+ # Extract repo name from URL
1438
+ repo_name_from_url = repo_name or repo_url.split('/')[-1].replace('.git', '')
1439
+
1440
+ print(f"📥 Cloning repository in Modal container: {repo_url}")
1441
+
1442
+ # Determine the best location for the repository
1443
+ repo_clone_dir = "/root" # Always use home directory for repositories
1444
+ print(f"📦 Using home directory for repository: {repo_clone_dir}")
1445
+
1446
+ # Ensure we're in the home directory and update current directory tracking
1447
+ cd_success, cd_stdout, cd_stderr = run_command(f"cd {repo_clone_dir}", show_output=False)
1448
+ if cd_success:
1449
+ current_dir = repo_clone_dir
1450
+ print(f"📂 Successfully changed to: {repo_clone_dir}")
1451
+ else:
1452
+ print(f"⚠️ Failed to change to {repo_clone_dir}: {cd_stderr}")
1453
+ current_dir = "/"
1454
+
1455
+ # First, list current directory contents for debugging
1456
+ print("📂 Current directory contents before cloning:")
1457
+ run_command("pwd && ls -la", show_output=True)
1458
+
1459
+ # Check if repository already exists in current location
1460
+ print(f"🔍 Checking if {repo_name_from_url} directory exists...")
1461
+
1462
+ # First ensure we're in the right directory and check with absolute path
1463
+ check_cmd = f"cd {repo_clone_dir} && test -d {repo_name_from_url}"
1464
+ success, stdout, stderr = run_command(check_cmd, show_output=False, retry_count=0, max_retries=0)
1465
+
1466
+ # The directory exists if the test command succeeds (exit code 0)
1467
+ repo_exists = success
1468
+ print(f"📂 Repository check result: exists={repo_exists} (exit code: {0 if success else 1})")
1469
+ print(f"📂 Checking in directory: {repo_clone_dir}/{repo_name_from_url}")
1470
+
1471
+ if repo_exists:
1472
+ print(f"📂 Repository directory already exists: {repo_name_from_url}")
1473
+ # Check if it's actually a git repository - disable retries to avoid bad debugging
1474
+ git_check_cmd = f"cd {repo_clone_dir}/{repo_name_from_url} && git status"
1475
+ git_check_success, git_stdout, git_stderr = run_command(git_check_cmd, show_output=False, retry_count=0, max_retries=0)
1476
+ if git_check_success:
1477
+ print(f"✅ Valid git repository found, using existing: {repo_name_from_url}")
1478
+ else:
1479
+ print(f"⚠️ Directory exists but is not a valid git repository, removing and re-cloning...")
1480
+ remove_cmd = f"cd {repo_clone_dir} && rm -rf {repo_name_from_url}"
1481
+ run_command(remove_cmd, show_output=False)
1482
+ repo_exists = False
1483
+
1484
+ if not repo_exists:
1485
+ print(f"📥 Repository does not exist, proceeding with clone...")
1486
+ print(f"📥 Cloning repository: {repo_url}")
1487
+ print(f"📥 Repository name will be: {repo_name_from_url}")
1488
+ print(f"📥 Clone location: {repo_clone_dir}")
1489
+
1490
+ # Ensure we're in the right directory before cloning
1491
+ run_command(f"cd {repo_clone_dir}", show_output=False)
1492
+
1493
+ # Execute the git clone command with verbose output - use absolute path, disable retries
1494
+ clone_cmd = f"cd {repo_clone_dir} && git clone {repo_url}"
1495
+ clone_success, clone_stdout, clone_stderr = run_command(clone_cmd, show_output=True, retry_count=0, max_retries=0)
1496
+
1497
+ print(f"📥 Clone command completed. Success: {clone_success}")
1498
+ if clone_stdout.strip():
1499
+ print(f"📥 Clone stdout: {clone_stdout.strip()}")
1500
+ if clone_stderr.strip():
1501
+ print(f"📥 Clone stderr: {clone_stderr.strip()}")
1502
+
1503
+ if not clone_success:
1504
+ print(f"❌ Failed to clone repository: {clone_stderr}")
1505
+ print("🔄 Trying alternative clone methods...")
1506
+
1507
+ # Try with different git options - use absolute path, disable retries
1508
+ print("🔄 Attempting shallow clone...")
1509
+ shallow_clone_cmd = f"cd {repo_clone_dir} && git clone --depth 1 {repo_url}"
1510
+ clone_success, clone_stdout, clone_stderr = run_command(shallow_clone_cmd, show_output=True, retry_count=0, max_retries=0)
1511
+
1512
+ print(f"📥 Shallow clone command completed. Success: {clone_success}")
1513
+ if clone_stdout.strip():
1514
+ print(f"📥 Shallow clone stdout: {clone_stdout.strip()}")
1515
+ if clone_stderr.strip():
1516
+ print(f"📥 Shallow clone stderr: {clone_stderr.strip()}")
1517
+
1518
+ if not clone_success:
1519
+ print(f"❌ Alternative clone also failed: {clone_stderr}")
1520
+ print("⚠️ Continuing without repository...")
1521
+ repo_name_from_url = None
1522
+ else:
1523
+ print(f"✅ Repository cloned successfully with shallow clone")
1524
+ else:
1525
+ print(f"✅ Repository cloned successfully")
1526
+ else:
1527
+ print(f"📂 Repository already exists, skipping clone")
1528
+
1529
+ # Verify repository directory exists and change to it
1530
+ if repo_name_from_url:
1531
+ print("📂 Verifying repository directory...")
1532
+
1533
+ # List available directories for debugging
1534
+ print("📂 Available directories after cloning:")
1535
+ run_command("ls -la", show_output=True)
1536
+
1537
+ # Check if the repository directory exists using simple test
1538
+ check_success, _, _ = run_command(f"test -d {repo_name_from_url}", show_output=False)
1539
+
1540
+ if check_success:
1541
+ print(f"📂 Repository directory confirmed: {repo_name_from_url}")
1542
+ # Change to the repository directory
1543
+ cd_success, cd_stdout, cd_stderr = run_command(f"cd {repo_name_from_url}")
1544
+ if cd_success:
1545
+ print(f"📂 Successfully changed to repository directory: {repo_name_from_url}")
1546
+ repo_dir_name = f"{repo_clone_dir}/{repo_name_from_url}" if repo_clone_dir != "/" else repo_name_from_url
1547
+ else:
1548
+ print(f"⚠️ Failed to change to repository directory: {cd_stderr}")
1549
+ repo_dir_name = repo_clone_dir
1550
+ else:
1551
+ print(f"⚠️ Repository directory not found after cloning: {repo_name_from_url}")
1552
+ print("🔍 Looking for alternative directories...")
1553
+
1554
+ # Look for any git repositories
1555
+ search_success, search_stdout, search_stderr = run_command("find . -maxdepth 1 -type d -name '.git' -exec dirname {} \\;", show_output=False)
1556
+
1557
+ if search_success and search_stdout.strip():
1558
+ found_dirs = [d.replace('./', '') for d in search_stdout.strip().split('\n') if d.strip() and d != '.']
1559
+ if found_dirs:
1560
+ repo_dir_name = f"{repo_clone_dir}/{found_dirs[0]}" if repo_clone_dir != "/" else found_dirs[0]
1561
+ print(f"📂 Found git repository: {repo_dir_name}")
1562
+ run_command(f"cd {found_dirs[0]}")
1563
+ else:
1564
+ repo_dir_name = repo_clone_dir
1565
+ print("📂 Using current directory")
1566
+ else:
1567
+ repo_dir_name = repo_clone_dir
1568
+ print("📂 Using current directory")
1569
+ else:
1570
+ repo_dir_name = repo_clone_dir
1571
+ print("📂 No valid repository, using current directory")
1572
+
1573
+ # Show final directory status
1574
+ print("📂 Final directory status:")
1575
+ run_command("pwd && ls -la", show_output=True)
1576
+
1577
+ except Exception as e:
1578
+ print(f"❌ Error during repository cloning: {e}")
1579
+ print(f"❌ Exception type: {type(e).__name__}")
1580
+ print("⚠️ Continuing without repository...")
1581
+ repo_dir_name = repo_clone_dir
1582
+ run_command("pwd && ls -la", show_output=True)
1583
+ else:
1584
+ repo_dir_name = repo_clone_dir
1585
+ print("📂 No repository URL provided, using current directory")
1586
+ run_command("pwd && ls -la", show_output=True)
1587
+
1588
+ # Run setup commands if provided - now we're already in the repository directory
1589
+ if setup_commands:
1590
+ print(⚙️ Running user setup commands in container container...)
1591
+
1592
+ # Check if git clone is already in the setup commands
1593
+ has_git_clone = any('git clone' in cmd for cmd in setup_commands)
1594
+
1595
+ # Only add git clone if:
1596
+ # 1. No git clone in setup commands AND
1597
+ # 2. We have a repo URL AND
1598
+ # 3. Repository was NOT already cloned successfully
1599
+ if not has_git_clone and repo_url and not repo_exists:
1600
+ print("📥 Git clone not found in setup commands and repository not yet cloned, adding it...")
1601
+ clone_cmd = f"git clone {repo_url}"
1602
+ setup_commands = [clone_cmd] + setup_commands
1603
+ print(f"📥 Added git clone command: {clone_cmd}")
1604
+ elif has_git_clone and repo_exists:
1605
+ print("⚠️ Repository already cloned successfully, removing duplicate git clone from setup commands...")
1606
+ # Remove git clone commands since repository is already cloned
1607
+ setup_commands = [cmd for cmd in setup_commands if 'git clone' not in cmd]
1608
+ print(f"📥 Removed duplicate git clone commands")
1609
+ elif repo_exists:
1610
+ print("📂 Repository already cloned successfully, skipping git clone in setup commands")
1611
+
1612
+ # Print all commands that will be executed
1613
+ print("📋 Setup commands to execute in container:")
1614
+ for i, cmd in enumerate(setup_commands, 1):
1615
+ print(f" {i}. {cmd}")
1616
+
1617
+ print(f"\n🚀 Executing commands in container directory: {repo_dir_name}")
1618
+
1619
+ # Ensure we start in the /root directory and reset current_dir
1620
+ current_dir = "/root"
1621
+ print(f"📂 Resetting working directory to: {current_dir}")
1622
+
1623
+ # Verify we can access /root directory
1624
+ verify_success, verify_output, _ = run_command("pwd", show_output=True)
1625
+ if verify_success:
1626
+ print(f"✅ Current directory verified: {verify_output.strip()}")
1627
+
1628
+ # Execute each command individually in the repository directory within the container
1629
+ for i, cmd in enumerate(setup_commands, 1):
1630
+ print(f"\n📋 Executing command {i}/{len(setup_commands)} in container: {cmd}")
1631
+
1632
+ # If this is a cd command, just run it directly
1633
+ if cmd.strip().startswith('cd '):
1634
+ # Execute the command directly (we're already in the right directory)
1635
+ success, stdout, stderr = run_command(cmd)
1636
+ continue
1637
+
1638
+ # For git clone commands, handle as before
1639
+ if 'git clone' in cmd:
1640
+ # Execute the command directly
1641
+ success, stdout, stderr = run_command(cmd)
1642
+
1643
+ if success:
1644
+ print(f"✅ Command executed successfully in container: {cmd}")
1645
+ if stdout.strip():
1646
+ print(f"📄 Output: {stdout.strip()}")
1647
+
1648
+ # Handle repository directory change as before
1649
+ print("📂 Git clone detected, attempting to change to repository directory...")
1650
+ # Extract repository name from the clone command
1651
+ parts = cmd.split()
1652
+ if len(parts) >= 3:
1653
+ clone_url = parts[2] # git clone <url>
1654
+ target_dir = clone_url.split('/')[-1].replace('.git', '')
1655
+
1656
+ # Check if we're already in the target directory
1657
+ if current_dir.endswith(f"/{target_dir}") or current_dir == f"/{target_dir}":
1658
+ print(f"📂 Already in target directory: {current_dir}")
1659
+ else:
1660
+ # The repository should now be at current_dir/target_dir
1661
+ repo_full_path = f"{current_dir.rstrip('/')}/{target_dir}"
1662
+
1663
+ # Check if directory exists using absolute path
1664
+ dir_check_success, _, _ = run_command(f"test -d '{repo_full_path}'", show_output=False)
1665
+ if dir_check_success:
1666
+ current_dir = repo_full_path
1667
+ print(f"📂 Successfully changed current directory to: {current_dir}")
1668
+ # Verify the change worked
1669
+ verify_success, verify_output, _ = run_command("pwd", show_output=True)
1670
+ if verify_success:
1671
+ print(f"✅ Directory change verified: {verify_output.strip()}")
1672
+ # List contents to confirm we're in the right place
1673
+ run_command("ls -la", show_output=True)
1674
+
1675
+ # Initialize git submodules if they exist
1676
+ print("📦 Checking for git submodules...")
1677
+ submodule_check_success, _, _ = run_command("test -f .gitmodules", show_output=False)
1678
+ if submodule_check_success:
1679
+ print("📦 Git submodules found, initializing...")
1680
+ run_command("git submodule update --init --recursive", show_output=True)
1681
+ print("✅ Git submodules initialized")
1682
+ else:
1683
+ print("📦 No git submodules found")
1684
+ else:
1685
+ print("⚠️ Directory change verification failed")
1686
+ else:
1687
+ print(f"⚠️ Repository directory {repo_full_path} not found after clone")
1688
+ print("🔍 Checking what was actually created:")
1689
+ run_command("find . -maxdepth 2 -name '*.git' -type d", show_output=True)
1690
+ run_command("ls -la", show_output=True)
1691
+ else:
1692
+ # For Python commands, make sure we're in the correct directory first
1693
+ if cmd.startswith('python '):
1694
+ # Fix the directory path issue - ensure we're in the correct repository directory
1695
+ # Check if we're in a nested directory that matches the repo name
1696
+ repo_dir_parts = current_dir.split('/')
1697
+ if len(repo_dir_parts) >= 2 and repo_dir_parts[-1] == repo_dir_parts[-2]:
1698
+ # We're in a nested directory like /root/nanoGPT/nanoGPT
1699
+ # Move up one level to /root/nanoGPT
1700
+ print(f"⚠️ Detected nested directory structure: {current_dir}")
1701
+ parent_dir = '/'.join(repo_dir_parts[:-1])
1702
+ print(f"🔄 Moving to parent directory: {parent_dir}")
1703
+ cd_success, _, _ = run_command(f"cd {parent_dir}", show_output=False)
1704
+ if cd_success:
1705
+ current_dir = parent_dir
1706
+ print(f"📂 Updated current directory to: {current_dir}")
1707
+
1708
+ # Execute the command directly (we're already in the right directory)
1709
+ success, stdout, stderr = run_command(cmd)
1710
+
1711
+ if success:
1712
+ print(f"✅ Command executed successfully in container: {cmd}")
1713
+ if stdout.strip():
1714
+ print(f"📄 Output: {stdout.strip()}")
1715
+ else:
1716
+ print(f"❌ Command failed in container: {cmd}")
1717
+ print(f"❌ Error: {stderr}")
1718
+ # Continue with next command even if this one failed
1719
+
1720
+ # Show final status of the repository directory in container
1721
+ print(f"\n📂 Final directory contents in container ({repo_dir_name}):")
1722
+ run_command("pwd && ls -la")
1723
+
1724
+ else:
1725
+ print("⚠️ No setup commands provided.")
1726
+
1727
+ # If no setup commands but we have a repo URL, at least try to clone it
1728
+ if repo_url and not repo_exists:
1729
+ print("📥 No setup commands provided, but cloning repository anyway...")
1730
+ clone_success, _, _ = run_command(f"git clone {repo_url}", show_output=True)
1731
+ if clone_success:
1732
+ print(f"✅ Repository cloned successfully")
1733
+ # Try to change to the repository directory
1734
+ if repo_name_from_url:
1735
+ run_command(f"cd {repo_name_from_url}")
1736
+ print("📂 Final directory status after clone:")
1737
+ run_command("pwd && ls -la", show_output=True)
1738
+
1739
+ # Write container ID to file for future reference
1740
+ with open(os.path.expanduser("~/.modal_last_container_id"), "w") as f:
1741
+ f.write(container_id)
1742
+
1743
+ # Print connection instructions
1744
+ print(f"✅ Container created successfully!")
1745
+ print(f"📋 Sandbox ID: {sandbox_id}")
1746
+ print(f"📋 Container ID: {container_id}")
1747
+ if volume:
1748
+ print(f"📦 Volume: {volume_name} (mounted at {volume_mount_path})")
1749
+ print(f"💾 Persistent storage available for pip and uv caches")
1750
+ print(f"📂 Repositories will be cloned in home directory (/root) for faster access")
1751
+ print("🔗 To connect to this container, run:")
1752
+ print(f"modal container exec --pty {container_id} bash")
1753
+ print("⏳ Sandbox will remain running until you terminate it with:")
1754
+ print(f"Container terminate {sandbox_id}")
1755
+
1756
+ # Try to open a new terminal window and connect to the container
1757
+ if container_id:
1758
+ print("🖥️ Attempting to open new terminal window...")
1759
+ # Use osascript to open a new terminal with the modal shell command
1760
+ terminal_script = f'''
1761
+ tell application "Terminal"
1762
+ do script "modal shell {container_id}"
1763
+ activate
1764
+ end tell
1765
+ '''
1766
+
1767
+ try:
1768
+ result = subprocess.run(['osascript', '-e', terminal_script],
1769
+ capture_output=True, text=True, timeout=30)
1770
+ if result.returncode == 0:
1771
+ print("✅ New terminal window opened successfully")
1772
+ else:
1773
+ print(f"⚠️ Failed to open terminal window: {result.stderr}")
1774
+
1775
+ # Try alternative approach with iTerm2 if Terminal failed
1776
+ print("🔄 Trying with iTerm2 instead...")
1777
+ iterm_script = f'''
1778
+ tell application "iTerm"
1779
+ create window with default profile
1780
+ tell current session of current window
1781
+ write text "modal shell {container_id}"
1782
+ end tell
1783
+ end tell
1784
+ '''
1785
+
1786
+ try:
1787
+ iterm_result = subprocess.run(['osascript', '-e', iterm_script],
1788
+ capture_output=True, text=True, timeout=30)
1789
+ if iterm_result.returncode == 0:
1790
+ print("✅ New iTerm2 window opened successfully")
1791
+ else:
1792
+ print(f"⚠️ Failed to open iTerm2 window: {iterm_result.stderr}")
1793
+ print("📝 You can manually connect using:")
1794
+ print(f" modal shell {container_id}")
1795
+ except Exception as e:
1796
+ print(f"⚠️ Error opening iTerm2: {e}")
1797
+ print("📝 You can manually connect using:")
1798
+ print(f" modal shell {container_id}")
1799
+ except subprocess.TimeoutExpired:
1800
+ print("⚠️ Terminal opening timed out")
1801
+ except Exception as e:
1802
+ print(f"⚠️ Error opening terminal: {e}")
1803
+ print("📝 You can manually connect using:")
1804
+ print(f" modal shell {container_id}")
1805
+
1806
+ # Also provide manual connection instructions
1807
+ print("\n" + "="*60)
1808
+ print("🚀 SANDBOX READY!")
1809
+ print("="*60)
1810
+ print(f"📋 Sandbox ID: {sandbox_id}")
1811
+ print(f"🆔 Container ID: {container_id}")
1812
+ if volume:
1813
+ print(f"💾 Volume: {volume_name} mounted at {volume_mount_path}")
1814
+ print("📁 Persistent storage available for caches and repositories")
1815
+ print("\n🔗 To connect to your container, run:")
1816
+ print(f" modal shell {container_id}")
1817
+ print("="*60)
1818
+ else:
1819
+ print("❌ No container ID available for connection")
1820
+
1821
+ return {
1822
+ "run_command": run_command,
1823
+ "current_dir": current_dir,
1824
+ "execution_history": execution_history,
1825
+ "container_id": container_id,
1826
+ "sandbox_id": sandbox_id
1827
+ }
1828
+
1829
+
1830
+ def handle_interactive_input(prompt, is_password=False):
1831
+ """Handle interactive input from the user with optional password masking"""
1832
+ print("\n" + "="*60)
1833
+ print(f"{prompt}")
1834
+ print("="*60)
1835
+
1836
+ try:
1837
+ if is_password:
1838
+ user_input = getpass.getpass("Input (hidden): ").strip()
1839
+ else:
1840
+ user_input = input("Input: ").strip()
1841
+
1842
+ if not user_input:
1843
+ print("❌ No input provided.")
1844
+ return None
1845
+ print("✅ Input received successfully!")
1846
+ return user_input
1847
+ except KeyboardInterrupt:
1848
+ print("\n❌ Input cancelled by user.")
1849
+ return None
1850
+ except Exception as e:
1851
+ print(f"❌ Error getting input: {e}")
1852
+ return None
1853
+
1854
+ def generate_random_password(length=16):
1855
+ """Generate a random password for SSH access"""
1856
+ alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
1857
+ password = ''.join(secrets.choice(alphabet) for i in range(length))
1858
+ return password
1859
+
1860
+
1861
+
1862
+ def create_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_commands=None,
1863
+ volume_name=None, timeout_minutes=60, ssh_password=None):
1864
+ """Create a Modal SSH container with GPU support"""
1865
+
1866
+ # Generate a unique app name with timestamp to avoid conflicts
1867
+ timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
1868
+ app_name = f"ssh-container-{timestamp}"
1869
+
1870
+ gpu_configs = {
1871
+ 'A10G': {'gpu': 'a10g', 'memory': 24},
1872
+ 'A100': {'gpu': 'a100', 'memory': 40},
1873
+ 'H100': {'gpu': 'h100', 'memory': 80},
1874
+ 'T4': {'gpu': 't4', 'memory': 16},
1875
+ 'V100': {'gpu': 'v100', 'memory': 16}
1876
+ }
1877
+
1878
+ if gpu_type not in gpu_configs:
1879
+ print(f"⚠️ Unknown GPU type: {gpu_type}. Using A10G as default.")
1880
+ gpu_type = 'A10G'
1881
+
1882
+ gpu_spec = gpu_configs[gpu_type]
1883
+ print(f"🚀 Creating Modal SSH container with {gpu_spec['gpu']} GPU ({gpu_spec['memory']}GB VRAM)")
1884
+
1885
+ # Generate or use provided SSH password
1886
+ if not ssh_password:
1887
+ ssh_password = generate_random_password()
1888
+ print(f"🔐 Generated SSH password: {ssh_password}")
1889
+
1890
+ # Setup volume if specified
1891
+ volume = None
1892
+ volume_mount_path = "/persistent"
1893
+
1894
+ if volume_name:
1895
+ print(f"📦 Setting up volume: {volume_name}")
1896
+ try:
1897
+ volume = modal.Volume.from_name(volume_name, create_if_missing=True)
1898
+ print(f"✅ Volume '{volume_name}' ready for use")
1899
+ except Exception as e:
1900
+ print(f"⚠️ Could not setup volume '{volume_name}': {e}")
1901
+ print("⚠️ Continuing without persistent volume")
1902
+ volume = None
1903
+ else:
1904
+ # Create a default volume for this session
1905
+ default_volume_name = f"ssh-vol-{timestamp}"
1906
+ print(f"📦 Creating default volume: {default_volume_name}")
1907
+ try:
1908
+ volume = modal.Volume.from_name(default_volume_name, create_if_missing=True)
1909
+ volume_name = default_volume_name
1910
+ print(f"✅ Default volume '{default_volume_name}' created")
1911
+ except Exception as e:
1912
+ print(f"⚠️ Could not create default volume: {e}")
1913
+ print("⚠️ Continuing without persistent volume")
1914
+ volume = None
1915
+
1916
+ # Create SSH-enabled image
1917
+ ssh_image = (
1918
+ modal.Image.debian_slim()
1919
+ .apt_install(
1920
+ "openssh-server", "sudo", "curl", "wget", "vim", "htop", "git",
1921
+ "python3", "python3-pip", "build-essential", "tmux", "screen", "nano",
1922
+ "gpg", "ca-certificates", "software-properties-common"
1923
+ )
1924
+ .pip_install("uv") # Fast Python package installer
1925
+ .run_commands(
1926
+ # Create SSH directory
1927
+ "mkdir -p /var/run/sshd",
1928
+ "mkdir -p /root/.ssh",
1929
+ "chmod 700 /root/.ssh",
1930
+
1931
+ # Configure SSH server
1932
+ "sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config",
1933
+ "sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config",
1934
+ "sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config",
1935
+
1936
+ # SSH keep-alive settings
1937
+ "echo 'ClientAliveInterval 60' >> /etc/ssh/sshd_config",
1938
+ "echo 'ClientAliveCountMax 3' >> /etc/ssh/sshd_config",
1939
+
1940
+ # Generate SSH host keys
1941
+ "ssh-keygen -A",
1942
+
1943
+ # Set up a nice bash prompt
1944
+ "echo 'export PS1=\"\\[\\e[1;32m\\]modal:\\[\\e[1;34m\\]\\w\\[\\e[0m\\]$ \"' >> /root/.bashrc",
1945
+ )
1946
+ )
1947
+
1948
+ # Create Modal app
1949
+ with modal.enable_output():
1950
+ print(f"🚀 Creating Modal app: {app_name}")
1951
+ app = modal.App.lookup(app_name, create_if_missing=True)
1952
+
1953
+ # Setup volume mount if available
1954
+ volumes = {}
1955
+ if volume:
1956
+ volumes[volume_mount_path] = volume
1957
+ print(f"📦 Mounting volume '{volume_name}' at {volume_mount_path}")
1958
+
1959
+ @app.function(
1960
+ image=ssh_image,
1961
+ timeout=timeout_minutes * 60, # Convert to seconds
1962
+ gpu=gpu_spec['gpu'],
1963
+ cpu=2,
1964
+ memory=8192,
1965
+ serialized=True,
1966
+ volumes=volumes if volumes else None,
1967
+ )
1968
+ def ssh_container():
1969
+ import subprocess
1970
+ import time
1971
+ import os
1972
+
1973
+ # Set root password
1974
+ subprocess.run(["bash", "-c", f"echo 'root:{ssh_password}' | chpasswd"], check=True)
1975
+
1976
+ # Start SSH service
1977
+ subprocess.run(["service", "ssh", "start"], check=True)
1978
+
1979
+ # Setup environment
1980
+ os.environ['PS1'] = r'\[\e[1;32m\]modal:\[\e[1;34m\]\w\[\e[0m\]$ '
1981
+
1982
+ # Clone repository if provided
1983
+ if repo_url:
1984
+ repo_name_from_url = repo_name or repo_url.split('/')[-1].replace('.git', '')
1985
+ print(f"📥 Cloning repository: {repo_url}")
1986
+
1987
+ try:
1988
+ subprocess.run(["git", "clone", repo_url], check=True, cwd="/root")
1989
+ print(f"✅ Repository cloned successfully: {repo_name_from_url}")
1990
+
1991
+ # Change to repository directory
1992
+ repo_dir = f"/root/{repo_name_from_url}"
1993
+ if os.path.exists(repo_dir):
1994
+ os.chdir(repo_dir)
1995
+ print(f"📂 Changed to repository directory: {repo_dir}")
1996
+
1997
+ except subprocess.CalledProcessError as e:
1998
+ print(f"❌ Failed to clone repository: {e}")
1999
+
2000
+ # Run setup commands if provided
2001
+ if setup_commands:
2002
+ print(f"⚙️ Running {len(setup_commands)} setup commands...")
2003
+ for i, cmd in enumerate(setup_commands, 1):
2004
+ print(f"📋 Executing command {i}/{len(setup_commands)}: {cmd}")
2005
+ try:
2006
+ result = subprocess.run(cmd, shell=True, check=True,
2007
+ capture_output=True, text=True)
2008
+ if result.stdout:
2009
+ print(f"✅ Output: {result.stdout}")
2010
+ except subprocess.CalledProcessError as e:
2011
+ print(f"❌ Command failed: {e}")
2012
+ if e.stderr:
2013
+ print(f"❌ Error: {e.stderr}")
2014
+
2015
+ # Get container info
2016
+ print("🔍 Container started successfully!")
2017
+ print(f"🆔 Container ID: {os.environ.get('MODAL_TASK_ID', 'unknown')}")
2018
+
2019
+ # Keep the container running
2020
+ while True:
2021
+ time.sleep(30)
2022
+ # Check if SSH service is still running
2023
+ try:
2024
+ subprocess.run(["service", "ssh", "status"], check=True,
2025
+ capture_output=True)
2026
+ except subprocess.CalledProcessError:
2027
+ print("⚠️ SSH service stopped, restarting...")
2028
+ subprocess.run(["service", "ssh", "start"], check=True)
2029
+
2030
+ # Start the container
2031
+ print("🚀 Starting SSH container...")
2032
+
2033
+ # Use spawn to run the container in the background
2034
+ container_handle = ssh_container.spawn()
2035
+
2036
+ # Wait a moment for the container to start
2037
+ print("⏳ Waiting for container to initialize...")
2038
+ time.sleep(10)
2039
+
2040
+ # Get container information
2041
+ try:
2042
+ # Try to get the container ID from Modal
2043
+ container_id = None
2044
+
2045
+ # Get container list to find our container
2046
+ print("🔍 Looking for container information...")
2047
+ result = subprocess.run(["modal", "container", "list", "--json"],
2048
+ capture_output=True, text=True)
2049
+
2050
+ if result.returncode == 0:
2051
+ try:
2052
+ containers = json.loads(result.stdout)
2053
+ if containers and isinstance(containers, list):
2054
+ # Find the most recent container
2055
+ for container in containers:
2056
+ if container.get("App") == app_name:
2057
+ container_id = container.get("Container ID")
2058
+ break
2059
+
2060
+ if not container_id and containers:
2061
+ # Fall back to the first container
2062
+ container_id = containers[0].get("Container ID")
2063
+
2064
+ except json.JSONDecodeError:
2065
+ pass
2066
+
2067
+ if not container_id:
2068
+ # Try text parsing
2069
+ result = subprocess.run(["modal", "container", "list"],
2070
+ capture_output=True, text=True)
2071
+ if result.returncode == 0:
2072
+ lines = result.stdout.split('\n')
2073
+ for line in lines:
2074
+ if app_name in line or ('ta-' in line and '│' in line):
2075
+ parts = line.split('│')
2076
+ if len(parts) >= 2:
2077
+ possible_id = parts[1].strip()
2078
+ if possible_id.startswith('ta-'):
2079
+ container_id = possible_id
2080
+ break
2081
+
2082
+ if container_id:
2083
+ print(f"📋 Container ID: {container_id}")
2084
+
2085
+ # Get the external IP for SSH access
2086
+ print("🔍 Getting container connection info...")
2087
+
2088
+ # Try to get SSH connection details
2089
+ try:
2090
+ # Modal containers typically expose SSH on port 22
2091
+ ssh_info = f"ssh root@{container_id}.modal.run"
2092
+
2093
+ print("\n" + "="*80)
2094
+ print("🚀 SSH CONTAINER READY!")
2095
+ print("="*80)
2096
+ print(f"🆔 Container ID: {container_id}")
2097
+ print(f"🔐 SSH Password: {ssh_password}")
2098
+ print(f"📱 App Name: {app_name}")
2099
+ if volume:
2100
+ print(f"💾 Volume: {volume_name} (mounted at {volume_mount_path})")
2101
+ print("\n🔗 SSH Connection:")
2102
+ print(f" {ssh_info}")
2103
+ print(f" Password: {ssh_password}")
2104
+ print("\n💡 Alternative connection methods:")
2105
+ print(f" modal container exec --pty {container_id} bash")
2106
+ print(f" modal shell {container_id}")
2107
+ print("="*80)
2108
+
2109
+ # Try to open SSH connection in a new terminal
2110
+ try:
2111
+ terminal_script = f'''
2112
+ tell application "Terminal"
2113
+ do script "echo 'Connecting to Modal SSH container...'; echo 'Password: {ssh_password}'; {ssh_info}"
2114
+ activate
2115
+ end tell
2116
+ '''
2117
+
2118
+ subprocess.run(['osascript', '-e', terminal_script],
2119
+ capture_output=True, text=True, timeout=30)
2120
+ print("✅ New terminal window opened with SSH connection")
2121
+
2122
+ except Exception as e:
2123
+ print(f"⚠️ Could not open terminal window: {e}")
2124
+ print("📝 You can manually connect using the SSH command above")
2125
+
2126
+ except Exception as e:
2127
+ print(f"⚠️ Error getting SSH connection info: {e}")
2128
+ print("📝 You can connect using:")
2129
+ print(f" modal container exec --pty {container_id} bash")
2130
+ else:
2131
+ print("⚠️ Could not determine container ID")
2132
+ print(📝 Check running containers with: container container list)
2133
+
2134
+ except Exception as e:
2135
+ print(f"❌ Error getting container information: {e}")
2136
+
2137
+ # Return container information
2138
+ return {
2139
+ "container_handle": container_handle,
2140
+ "container_id": container_id,
2141
+ "app_name": app_name,
2142
+ "ssh_password": ssh_password,
2143
+ "volume_name": volume_name,
2144
+ "volume_mount_path": volume_mount_path if volume else None
2145
+ }
2146
+
2147
+ def fetch_setup_commands_from_api(repo_url):
2148
+ """Fetch setup commands from the GitIngest API using real repository analysis."""
2149
+ import tempfile
2150
+ import subprocess
2151
+ import os
2152
+ import shutil
2153
+ import json
2154
+
2155
+ api_url = "http://localhost:3000/api/analyze-with-gitingest"
2156
+
2157
+ print(f"🔍 Fetching setup commands from API for repository: {repo_url}")
2158
+
2159
+ # Check if gitingest command line tool is available - try multiple possible command names
2160
+ has_gitingest_cli = False
2161
+ gitingest_cmd_name = None
2162
+
2163
+ # Try the standard command name first
2164
+ try:
2165
+ print(f"🔍 Checking for GitIngest CLI tool...")
2166
+ result = subprocess.run(["gitingest", "--help"], check=True, capture_output=True, text=True)
2167
+ has_gitingest_cli = True
2168
+ gitingest_cmd_name = "gitingest"
2169
+ print(f"✅ GitIngest CLI tool found")
2170
+ except (subprocess.SubprocessError, FileNotFoundError) as e:
2171
+ print(f" - GitIngest command not found: {str(e)}")
2172
+
2173
+ # Create a temporary directory for output
2174
+ temp_dir = tempfile.mkdtemp(prefix="repo_analysis_")
2175
+ output_file = os.path.join(temp_dir, "digest.json")
2176
+
2177
+ try:
2178
+ if has_gitingest_cli:
2179
+ # Use gitingest CLI tool to analyze the repository directly from URL
2180
+ print(f"🔎 Running GitIngest analysis on {repo_url}...")
2181
+
2182
+ # Based on the help output, the correct format is:
2183
+ # gitingest [OPTIONS] [SOURCE]
2184
+ # With options:
2185
+ # -o, --output TEXT Output file path
2186
+ # --format TEXT Output format (json)
2187
+
2188
+ # Run gitingest command with proper parameters
2189
+ gitingest_run_cmd = [
2190
+ gitingest_cmd_name,
2191
+ repo_url,
2192
+ "-o", output_file, # Use -o for output file
2193
+ ]
2194
+
2195
+ print(f"🔄 Executing: {' '.join(gitingest_run_cmd)}")
2196
+
2197
+ result = subprocess.run(gitingest_run_cmd, capture_output=True, text=True)
2198
+
2199
+ if result.returncode != 0:
2200
+ print(f"⚠️ GitIngest CLI failed with exit code {result.returncode}")
2201
+ print(f"⚠️ Error output: {result.stderr}")
2202
+ print("Falling back to basic analysis")
2203
+ gitingest_data = generate_basic_repo_analysis_from_url(repo_url)
2204
+ else:
2205
+ print(f"✅ GitIngest analysis completed successfully")
2206
+
2207
+ # Read the output file - note that the default format might not be JSON
2208
+ try:
2209
+ # First try to parse as JSON
2210
+ try:
2211
+ with open(output_file, 'r', encoding='utf-8') as f:
2212
+ content = f.read()
2213
+ try:
2214
+ gitingest_data = json.loads(content)
2215
+ print(f"✅ GitIngest data loaded as JSON from {output_file}")
2216
+ except json.JSONDecodeError:
2217
+ # If not JSON, convert the text output to a basic structure
2218
+ print(f"⚠️ GitIngest output is not in JSON format, converting text to structure")
2219
+ gitingest_data = {
2220
+ "system_info": {
2221
+ "detected_language": "Unknown",
2222
+ "detected_technologies": [],
2223
+ },
2224
+ "repository_analysis": {
2225
+ "summary": content[:5000], # First 5000 chars as summary
2226
+ "content_preview": content[:10000] # First 10000 chars as preview
2227
+ },
2228
+ "success": True
2229
+ }
2230
+ except FileNotFoundError:
2231
+ print(f"⚠️ Output file not found at {output_file}")
2232
+ gitingest_data = generate_basic_repo_analysis_from_url(repo_url)
2233
+ except Exception as e:
2234
+ print(f"⚠️ Error reading GitIngest output: {e}")
2235
+ gitingest_data = generate_basic_repo_analysis_from_url(repo_url)
2236
+ else:
2237
+ # Fall back to basic analysis if gitingest CLI is not available
2238
+ gitingest_data = generate_basic_repo_analysis_from_url(repo_url)
2239
+
2240
+ # Prepare the request payload with GitIngest data
2241
+ payload = {
2242
+ "repoUrl": repo_url,
2243
+ "gitingestData": gitingest_data,
2244
+ "userRequest": "Setup and run the repository"
2245
+ }
2246
+
2247
+ print(f"📤 API Request payload prepared (GitIngest data size: {len(json.dumps(gitingest_data))} bytes)")
2248
+
2249
+ # Make the API request
2250
+ print(f"🌐 Making POST request to: {api_url}")
2251
+ response = requests.post(api_url, json=payload, timeout=60)
2252
+
2253
+ print(f"📥 API Response status code: {response.status_code}")
2254
+
2255
+ if response.status_code == 200:
2256
+ try:
2257
+ data = response.json()
2258
+ print(f"📄 API Response data received")
2259
+
2260
+ # Extract setup commands from the response
2261
+ if "setupInstructions" in data and "commands" in data["setupInstructions"]:
2262
+ commands = data["setupInstructions"]["commands"]
2263
+ print(f"✅ Successfully fetched {len(commands)} setup commands from API")
2264
+
2265
+ # Print the commands for reference
2266
+ for i, cmd in enumerate(commands, 1):
2267
+ print(f" {i}. {cmd}")
2268
+
2269
+ return commands
2270
+ else:
2271
+ print("⚠️ API response did not contain setupInstructions.commands field")
2272
+ print("📋 Available fields in response:")
2273
+ for key in data.keys():
2274
+ print(f" - {key}")
2275
+ return []
2276
+ except json.JSONDecodeError as e:
2277
+ print(f"❌ Failed to parse API response as JSON: {e}")
2278
+ print(f"Raw response: {response.text[:500]}...")
2279
+ return []
2280
+ else:
2281
+ print(f"❌ API request failed with status code: {response.status_code}")
2282
+ print(f"Error response: {response.text[:500]}...")
2283
+ return []
2284
+ except requests.exceptions.ConnectionError:
2285
+ print(f"❌ Connection error: Could not connect to {api_url}")
2286
+ print("⚠️ Make sure the API server is running at localhost:3000")
2287
+ return []
2288
+ except Exception as e:
2289
+ print(f"❌ Error fetching setup commands from API: {e}")
2290
+ import traceback
2291
+ traceback.print_exc()
2292
+ return []
2293
+ finally:
2294
+ # Clean up the temporary directory
2295
+ print(f"🧹 Cleaning up temporary directory...")
2296
+ shutil.rmtree(temp_dir, ignore_errors=True)
2297
+
2298
+ def generate_basic_repo_analysis_from_url(repo_url):
2299
+ """Generate basic repository analysis data from a repository URL."""
2300
+ import tempfile
2301
+ import subprocess
2302
+ import os
2303
+ import shutil
2304
+
2305
+ # Create a temporary directory for cloning
2306
+ temp_dir = tempfile.mkdtemp(prefix="repo_basic_analysis_")
2307
+
2308
+ try:
2309
+ print(f"📥 Cloning repository to {temp_dir} for basic analysis...")
2310
+ clone_result = subprocess.run(
2311
+ ["git", "clone", "--depth", "1", repo_url, temp_dir],
2312
+ capture_output=True,
2313
+ text=True
2314
+ )
2315
+
2316
+ if clone_result.returncode != 0:
2317
+ print(f"❌ Failed to clone repository: {clone_result.stderr}")
2318
+ return {
2319
+ "system_info": {
2320
+ "platform": "linux",
2321
+ "python_version": "3.10",
2322
+ "detected_language": "Unknown",
2323
+ "detected_technologies": [],
2324
+ "file_count": 0,
2325
+ "repo_stars": 0,
2326
+ "repo_forks": 0,
2327
+ "primary_package_manager": "Unknown",
2328
+ "complexity_level": "low"
2329
+ },
2330
+ "repository_analysis": {
2331
+ "summary": f"Repository analysis for {repo_url}",
2332
+ "tree": "Failed to clone repository",
2333
+ "content_preview": "No content available"
2334
+ },
2335
+ "success": False
2336
+ }
2337
+
2338
+ print(f"✅ Repository cloned successfully for basic analysis")
2339
+
2340
+ # Use the existing generate_basic_repo_analysis function
2341
+ return generate_basic_repo_analysis(temp_dir)
2342
+ finally:
2343
+ # Clean up the temporary directory
2344
+ print(f"🧹 Cleaning up temporary directory for basic analysis...")
2345
+ shutil.rmtree(temp_dir, ignore_errors=True)
2346
+
2347
+ def generate_basic_repo_analysis(repo_dir):
2348
+ """Generate basic repository analysis when GitIngest is not available."""
2349
+ import os
2350
+ import subprocess
2351
+
2352
+ # Detect language and technologies based on file extensions
2353
+ file_extensions = {}
2354
+ file_count = 0
2355
+
2356
+ for root, _, files in os.walk(repo_dir):
2357
+ for file in files:
2358
+ file_count += 1
2359
+ ext = os.path.splitext(file)[1].lower()
2360
+ if ext:
2361
+ file_extensions[ext] = file_extensions.get(ext, 0) + 1
2362
+
2363
+ # Determine primary language
2364
+ language_map = {
2365
+ '.py': 'Python',
2366
+ '.js': 'JavaScript',
2367
+ '.ts': 'TypeScript',
2368
+ '.jsx': 'JavaScript',
2369
+ '.tsx': 'TypeScript',
2370
+ '.java': 'Java',
2371
+ '.cpp': 'C++',
2372
+ '.c': 'C',
2373
+ '.go': 'Go',
2374
+ '.rs': 'Rust',
2375
+ '.rb': 'Ruby',
2376
+ '.php': 'PHP',
2377
+ '.swift': 'Swift',
2378
+ '.kt': 'Kotlin',
2379
+ '.cs': 'C#'
2380
+ }
2381
+
2382
+ # Count files by language
2383
+ language_counts = {}
2384
+ for ext, count in file_extensions.items():
2385
+ if ext in language_map:
2386
+ lang = language_map[ext]
2387
+ language_counts[lang] = language_counts.get(lang, 0) + count
2388
+
2389
+ # Determine primary language
2390
+ primary_language = max(language_counts.items(), key=lambda x: x[1])[0] if language_counts else "Unknown"
2391
+
2392
+ # Detect package managers
2393
+ package_managers = []
2394
+ package_files = {
2395
+ 'requirements.txt': 'pip',
2396
+ 'setup.py': 'pip',
2397
+ 'pyproject.toml': 'pip',
2398
+ 'package.json': 'npm',
2399
+ 'yarn.lock': 'yarn',
2400
+ 'pnpm-lock.yaml': 'pnpm',
2401
+ 'Cargo.toml': 'cargo',
2402
+ 'go.mod': 'go',
2403
+ 'Gemfile': 'bundler',
2404
+ 'pom.xml': 'maven',
2405
+ 'build.gradle': 'gradle',
2406
+ 'composer.json': 'composer'
2407
+ }
2408
+
2409
+ for file, manager in package_files.items():
2410
+ if os.path.exists(os.path.join(repo_dir, file)):
2411
+ package_managers.append(manager)
2412
+
2413
+ primary_package_manager = package_managers[0] if package_managers else "Unknown"
2414
+
2415
+ # Get README content
2416
+ readme_content = ""
2417
+ for readme_name in ['README.md', 'README', 'README.txt', 'readme.md']:
2418
+ readme_path = os.path.join(repo_dir, readme_name)
2419
+ if os.path.exists(readme_path):
2420
+ with open(readme_path, 'r', encoding='utf-8', errors='ignore') as f:
2421
+ readme_content = f.read()
2422
+ break
2423
+
2424
+ # Try to get repository info
2425
+ repo_info = {}
2426
+ try:
2427
+ # Get remote origin URL
2428
+ cmd = ["git", "config", "--get", "remote.origin.url"]
2429
+ result = subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True)
2430
+ if result.returncode == 0:
2431
+ repo_info["url"] = result.stdout.strip()
2432
+
2433
+ # Get commit count as a proxy for activity
2434
+ cmd = ["git", "rev-list", "--count", "HEAD"]
2435
+ result = subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True)
2436
+ if result.returncode == 0:
2437
+ repo_info["commit_count"] = int(result.stdout.strip())
2438
+ except Exception:
2439
+ pass
2440
+
2441
+ # Build the analysis data
2442
+ return {
2443
+ "system_info": {
2444
+ "platform": "linux", # Assuming Linux for container environment
2445
+ "python_version": "3.10", # Common Python version
2446
+ "detected_language": primary_language,
2447
+ "detected_technologies": list(language_counts.keys()),
2448
+ "file_count": file_count,
2449
+ "repo_stars": repo_info.get("stars", 0),
2450
+ "repo_forks": repo_info.get("forks", 0),
2451
+ "primary_package_manager": primary_package_manager,
2452
+ "complexity_level": "medium" # Default assumption
2453
+ },
2454
+ "repository_analysis": {
2455
+ "summary": f"Repository analysis for {repo_dir}",
2456
+ "readme_content": readme_content[:5000] if readme_content else "No README found",
2457
+ "package_managers": package_managers,
2458
+ "file_extensions": list(file_extensions.keys())
2459
+ },
2460
+ "success": True
2461
+ }
2462
+
2463
+ def get_setup_commands_from_local_api(repo_url, gitingest_data):
2464
+ """Try to get setup commands from the local API."""
2465
+ api_url = "http://localhost:3000/api/analyze-with-gitingest"
2466
+
2467
+ # Prepare the request payload
2468
+ payload = {
2469
+ "repoUrl": repo_url,
2470
+ "gitingestData": gitingest_data,
2471
+ "userRequest": "Setup and run the repository"
2472
+ }
2473
+
2474
+ try:
2475
+ # Make the API request
2476
+ print(f"🌐 Making POST request to local API: {api_url}")
2477
+ response = requests.post(api_url, json=payload, timeout=60)
2478
+
2479
+ if response.status_code == 200:
2480
+ data = response.json()
2481
+ if "setupInstructions" in data and "commands" in data["setupInstructions"]:
2482
+ commands = data["setupInstructions"]["commands"]
2483
+ print(f"✅ Successfully fetched {len(commands)} setup commands from local API")
2484
+ for i, cmd in enumerate(commands, 1):
2485
+ print(f" {i}. {cmd}")
2486
+ return commands
2487
+ except Exception as e:
2488
+ print(f"❌ Error connecting to local API: {e}")
2489
+
2490
+ return None
2491
+
2492
+ if __name__ == "__main__":
2493
+ # Parse command line arguments when script is run directly
2494
+ import argparse
2495
+
2496
+ parser = argparse.ArgumentParser(description='Create a Modal SSH container with GPU')
2497
+ parser.add_argument('--gpu', type=str, default='A10G', help='GPU type (default: A10G)')
2498
+ parser.add_argument('--repo-url', type=str, help='Repository URL to clone')
2499
+ parser.add_argument('--repo-name', type=str, help='Repository name override')
2500
+ parser.add_argument('--setup-commands', type=str, nargs='+', help='Setup commands to run (deprecated)')
2501
+ parser.add_argument('--setup-commands-json', type=str, help='Setup commands as JSON array')
2502
+ parser.add_argument('--commands-file', type=str, help='Path to file containing setup commands (one per line)')
2503
+ parser.add_argument('--setup-script', type=str, help='Path to bash script containing setup commands')
2504
+ parser.add_argument('--working-dir', type=str, help='Working directory for the setup script')
2505
+ parser.add_argument('--volume-name', type=str, help='Name of the Modal volume for persistent storage')
2506
+ parser.add_argument('--timeout', type=int, default=60, help='Container timeout in minutes (default: 60)')
2507
+ parser.add_argument('--ssh-password', type=str, help='SSH password (random if not provided)')
2508
+ parser.add_argument('--use-api', action='store_true', help='Fetch setup commands from API')
2509
+
2510
+ args = parser.parse_args()
2511
+
2512
+ # Get setup commands from file if specified
2513
+ setup_commands = args.setup_commands or []
2514
+
2515
+ # If --use-api flag is set and repo_url is provided, fetch setup commands from API
2516
+ if args.use_api and args.repo_url:
2517
+ print("🔄 Using API to fetch setup commands")
2518
+ api_commands = fetch_setup_commands_from_api(args.repo_url)
2519
+ if api_commands:
2520
+ setup_commands = api_commands
2521
+ print(f"📋 Using {len(setup_commands)} commands from API")
2522
+ else:
2523
+ print("⚠️ Failed to get commands from API, no fallback commands will be used")
2524
+ # Do not fall back to basic setup commands
2525
+ setup_commands = []
2526
+
2527
+ # Parse setup commands from JSON if provided
2528
+ if args.setup_commands_json:
2529
+ try:
2530
+ json_commands = json.loads(args.setup_commands_json)
2531
+ if isinstance(json_commands, list):
2532
+ setup_commands = json_commands
2533
+ print(f"📋 Parsed {len(setup_commands)} commands from JSON:")
2534
+ for i, cmd in enumerate(setup_commands, 1):
2535
+ print(f" {i}. {cmd}")
2536
+ else:
2537
+ print(f"⚠️ Invalid JSON format for setup commands: not a list")
2538
+ except json.JSONDecodeError as e:
2539
+ print(f"⚠️ Error parsing JSON setup commands: {e}")
2540
+ print(f"Received JSON string: {args.setup_commands_json}")
2541
+
2542
+ # Print received setup commands for debugging
2543
+ if setup_commands:
2544
+ print(f"📋 Using {len(setup_commands)} setup commands:")
2545
+ for i, cmd in enumerate(setup_commands, 1):
2546
+ print(f" {i}. {cmd}")
2547
+
2548
+ # Load commands from file if specified
2549
+ if args.commands_file and os.path.exists(args.commands_file):
2550
+ try:
2551
+ with open(args.commands_file, 'r') as f:
2552
+ # Check if the file contains JSON or line-by-line commands
2553
+ content = f.read().strip()
2554
+
2555
+ if content.startswith('[') and content.endswith(']'):
2556
+ # JSON format
2557
+ try:
2558
+ json_commands = json.loads(content)
2559
+ if isinstance(json_commands, list):
2560
+ setup_commands.extend(json_commands)
2561
+ print(f"📋 Loaded {len(json_commands)} commands from JSON file {args.commands_file}")
2562
+ else:
2563
+ print(f"⚠️ Invalid JSON format in commands file: not a list")
2564
+ except json.JSONDecodeError as json_err:
2565
+ print(f"⚠️ Error parsing JSON commands file: {json_err}")
2566
+ # Fall back to line-by-line parsing
2567
+ file_commands = [line.strip() for line in content.split('\n') if line.strip()]
2568
+ setup_commands.extend(file_commands)
2569
+ print(f"📋 Loaded {len(file_commands)} commands from file (line-by-line fallback)")
2570
+ else:
2571
+ # Line-by-line format
2572
+ file_commands = [line.strip() for line in content.split('\n') if line.strip()]
2573
+ setup_commands.extend(file_commands)
2574
+ print(f"📋 Loaded {len(file_commands)} commands from file (line-by-line format)")
2575
+
2576
+ except Exception as e:
2577
+ print(f"⚠️ Error reading commands file: {e}")
2578
+
2579
+ # Execute setup script if provided
2580
+ if args.setup_script:
2581
+ print(f"📜 Setup script path: {args.setup_script}")
2582
+
2583
+ # Verify script exists
2584
+ if os.path.exists(args.setup_script):
2585
+ print(f"✅ Script exists at: {args.setup_script}")
2586
+
2587
+ # Check if script is executable
2588
+ if not os.access(args.setup_script, os.X_OK):
2589
+ print(f"⚠️ Script is not executable, setting permissions...")
2590
+ try:
2591
+ os.chmod(args.setup_script, 0o755)
2592
+ print(f"✅ Set executable permissions on script")
2593
+ except Exception as e:
2594
+ print(f"❌ Failed to set permissions: {e}")
2595
+
2596
+ working_dir = args.working_dir or os.getcwd()
2597
+ print(f"📂 Using working directory: {working_dir}")
2598
+
2599
+ # Execute the script directly instead of through container
2600
+ try:
2601
+ print(f"🔄 Executing script directly: bash {args.setup_script} {working_dir}")
2602
+ result = subprocess.run(['bash', args.setup_script, working_dir],
2603
+ capture_output=True, text=True)
2604
+
2605
+ print(f"📋 Script output:")
2606
+ print(result.stdout)
2607
+
2608
+ if result.returncode != 0:
2609
+ print(f"❌ Script execution failed with error code {result.returncode}")
2610
+ print(f"Error output: {result.stderr}")
2611
+ else:
2612
+ print(f"✅ Script executed successfully")
2613
+
2614
+ # Skip the regular setup commands since we executed the script directly
2615
+ setup_commands = []
2616
+ except Exception as e:
2617
+ print(f"❌ Failed to execute script: {e}")
2618
+ # Fall back to running the script through container
2619
+ setup_commands = [f"bash {args.setup_script} {working_dir}"]
2620
+ print("🔄 Falling back to running script through container")
2621
+ else:
2622
+ print(f"❌ Script not found at: {args.setup_script}")
2623
+ # Try to find the script in common locations
2624
+ possible_paths = [
2625
+ os.path.join(os.path.expanduser('~'), os.path.basename(args.setup_script)),
2626
+ os.path.join('/tmp', os.path.basename(args.setup_script)),
2627
+ os.path.join('/var/tmp', os.path.basename(args.setup_script))
2628
+ ]
2629
+
2630
+ found = False
2631
+ for test_path in possible_paths:
2632
+ if os.path.exists(test_path):
2633
+ print(f"🔍 Found script at alternative location: {test_path}")
2634
+ setup_commands = [f"bash {test_path} {args.working_dir or os.getcwd()}"]
2635
+ found = True
2636
+ break
2637
+
2638
+ if not found:
2639
+ print("❌ Could not find script in any location")
2640
+ setup_commands = []
2641
+
2642
+ try:
2643
+ result = create_modal_ssh_container(
2644
+ args.gpu,
2645
+ args.repo_url,
2646
+ args.repo_name,
2647
+ setup_commands,
2648
+ getattr(args, 'volume_name', None),
2649
+ args.timeout,
2650
+ args.ssh_password
2651
+ )
2652
+
2653
+ print("\n⏳ Keeping the SSH container alive. Press Ctrl+C to exit (container will continue running)...")
2654
+ try:
2655
+ while True:
2656
+ time.sleep(90)
2657
+ print(".", end="", flush=True)
2658
+ except KeyboardInterrupt:
2659
+ print("\n👋 Script exited. The SSH container will continue running.")
2660
+ if 'result' in locals() and result:
2661
+ container_id = None
2662
+ ssh_password = None
2663
+
2664
+ # Try to get container ID and SSH password from the result dictionary
2665
+ if isinstance(result, dict):
2666
+ container_id = result.get('container_id')
2667
+ ssh_password = result.get('ssh_password')
2668
+ elif hasattr(result, 'container_id'):
2669
+ container_id = result.container_id
2670
+ ssh_password = getattr(result, 'ssh_password', None)
2671
+
2672
+ # If we still don't have the container ID, try to read it from the file
2673
+ if not container_id:
2674
+ try:
2675
+ with open(os.path.expanduser("~/.modal_last_container_id"), "r") as f:
2676
+ container_id = f.read().strip()
2677
+ print(f"📋 Retrieved container ID from file: {container_id}")
2678
+ except Exception as e:
2679
+ print(f"⚠️ Could not read container ID from file: {e}")
2680
+
2681
+ if container_id:
2682
+ print(f"🚀 SSH connection information:")
2683
+ print(f" ssh root@{container_id}.modal.run")
2684
+ if ssh_password:
2685
+ print(f" Password: {ssh_password}")
2686
+
2687
+ # Try to open a new terminal window with SSH connection
2688
+ try:
2689
+ terminal_script = f'''
2690
+ tell application "Terminal"
2691
+ do script "echo 'Connecting to Modal SSH container...'; echo 'Password: {ssh_password or 'unknown'}'; ssh root@{container_id}.modal.run"
2692
+ activate
2693
+ end tell
2694
+ '''
2695
+
2696
+ subprocess.run(['osascript', '-e', terminal_script],
2697
+ capture_output=True, text=True, timeout=30)
2698
+ print("✅ New terminal window opened with SSH connection")
2699
+
2700
+ except Exception as e:
2701
+ print(f"⚠️ Failed to open terminal window: {e}")
2702
+
2703
+ # Try alternative approach with iTerm2
2704
+ try:
2705
+ iterm_script = f'''
2706
+ tell application "iTerm"
2707
+ create window with default profile
2708
+ tell current session of current window
2709
+ write text "echo 'Connecting to Modal SSH container...'; echo 'Password: {ssh_password or 'unknown'}'; ssh root@{container_id}.modal.run"
2710
+ end tell
2711
+ end tell
2712
+ '''
2713
+
2714
+ subprocess.run(['osascript', '-e', iterm_script],
2715
+ capture_output=True, text=True, timeout=30)
2716
+ print("✅ New iTerm2 window opened with SSH connection")
2717
+
2718
+ except Exception as e2:
2719
+ print(f"⚠️ Failed to open iTerm2 window: {e2}")
2720
+ print("📝 You can manually connect using:")
2721
+ print(f" ssh root@{container_id}.modal.run")
2722
+ if ssh_password:
2723
+ print(f" Password: {ssh_password}")
2724
+ print( Or use container exec:)
2725
+ print(f" modal container exec --pty {container_id} bash")
2726
+ else:
2727
+ print("⚠️ Could not determine container ID")
2728
+ print("📝 You can manually connect using:")
2729
+ print( container container list)
2730
+ print( container container exec --pty <CONTAINER_ID> bash)
2731
+
2732
+ # Exit cleanly
2733
+ sys.exit(0)
2734
+
2735
+ except KeyboardInterrupt:
2736
+ # Handle Ctrl+C during container creation
2737
+ print("\n👋 Script interrupted during container creation.")
2738
+ print(📝 You may need to check if a container was created with: container container list)
2739
+ sys.exit(0)
2740
+ except Exception as e:
2741
+ print(f"❌ Error: {e}")
2742
+ sys.exit(1)