gitarsenal-cli 1.8.2 → 1.8.4

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.
@@ -38,7 +38,7 @@ if args.proxy_api_key:
38
38
  class PersistentShell:
39
39
  """A persistent bash shell using subprocess.Popen for executing commands with state persistence."""
40
40
 
41
- def __init__(self, working_dir="/root", timeout=240):
41
+ def __init__(self, working_dir="/root", timeout=60):
42
42
  self.working_dir = working_dir
43
43
  self.timeout = timeout
44
44
  self.process = None
@@ -51,6 +51,9 @@ class PersistentShell:
51
51
  self.command_counter = 0
52
52
  self.is_running = False
53
53
  self.virtual_env_path = None # Track activated virtual environment
54
+ self.suggested_alternative = None # Store suggested alternative commands
55
+ self.should_remove_command = False # Flag to indicate if a command should be removed
56
+ self.removal_reason = None # Reason for removing a command
54
57
 
55
58
  def start(self):
56
59
  """Start the persistent bash shell."""
@@ -288,7 +291,25 @@ class PersistentShell:
288
291
  for line in current_stderr:
289
292
  if line.strip(): # Skip empty lines
290
293
  command_stderr.append(line)
291
-
294
+
295
+ # Check if command is waiting for user input
296
+ if not found_marker and time.time() - start_time > 5: # Wait at least 5 seconds before checking
297
+ if self._is_waiting_for_input(command_stdout, command_stderr):
298
+ print("⚠️ Command appears to be waiting for user input")
299
+ # Try to handle the input requirement
300
+ input_handled = self._handle_input_requirement(command, command_stdout, command_stderr)
301
+
302
+ if input_handled is True and self.should_remove_command:
303
+ # If LLM suggested to remove the command
304
+ self._send_command_raw("\x03") # Send Ctrl+C
305
+ time.sleep(0.5)
306
+ return False, '\n'.join(command_stdout), f"Command removed - {self.removal_reason}"
307
+ elif not input_handled:
308
+ # If we couldn't handle the input, abort the command
309
+ self._send_command_raw("\x03") # Send Ctrl+C
310
+ time.sleep(0.5)
311
+ return False, '\n'.join(command_stdout), "Command aborted - requires user input"
312
+
292
313
  time.sleep(0.1)
293
314
 
294
315
  if not found_marker:
@@ -306,13 +327,12 @@ class PersistentShell:
306
327
 
307
328
  if success:
308
329
  if stdout_text:
309
- print("")
310
330
  print(f"✅ Output: {stdout_text}")
311
331
  # Track virtual environment activation
312
332
  if command.strip().startswith("source ") and "/bin/activate" in command:
313
333
  venv_path = command.replace("source ", "").replace("/bin/activate", "").strip()
314
334
  self.virtual_env_path = venv_path
315
- # print(f"✅ Virtual environment activated: {venv_path}")
335
+ print(f"✅ Virtual environment activated: {venv_path}")
316
336
  else:
317
337
  print(f"❌ Command failed with exit code: {exit_code}")
318
338
  if stderr_text:
@@ -323,6 +343,215 @@ class PersistentShell:
323
343
 
324
344
  return success, stdout_text, stderr_text
325
345
 
346
+ def _is_waiting_for_input(self, stdout_lines, stderr_lines):
347
+ """Detect if a command is waiting for user input."""
348
+ # Common patterns that indicate waiting for user input
349
+ input_patterns = [
350
+ r'(?i)(y/n|yes/no)\??\s*$', # Yes/No prompts
351
+ r'(?i)password:?\s*$', # Password prompts
352
+ r'(?i)continue\??\s*$', # Continue prompts
353
+ r'(?i)proceed\??\s*$', # Proceed prompts
354
+ r'\[\s*[Yy]/[Nn]\s*\]\s*$', # [Y/n] style prompts
355
+ r'(?i)username:?\s*$', # Username prompts
356
+ r'(?i)token:?\s*$', # Token prompts
357
+ r'(?i)api key:?\s*$', # API key prompts
358
+ r'(?i)press enter to continue', # Press enter prompts
359
+ r'(?i)select an option:?\s*$', # Selection prompts
360
+ r'(?i)choose an option:?\s*$', # Choice prompts
361
+ ]
362
+
363
+ # Check the last few lines of stdout and stderr for input patterns
364
+ last_lines = []
365
+ if stdout_lines:
366
+ last_lines.extend(stdout_lines[-3:]) # Check last 3 lines of stdout
367
+ if stderr_lines:
368
+ last_lines.extend(stderr_lines[-3:]) # Check last 3 lines of stderr
369
+
370
+ for line in last_lines:
371
+ for pattern in input_patterns:
372
+ if re.search(pattern, line):
373
+ print(f"🔍 Detected input prompt: {line}")
374
+ return True
375
+
376
+ # Check if there's no output for a while but the command is still running
377
+ if len(stdout_lines) == 0 and len(stderr_lines) == 0:
378
+ # This might be a command waiting for input without a prompt
379
+ # We'll be cautious and only return True if we're sure
380
+ return False
381
+
382
+ return False
383
+
384
+ def _handle_input_requirement(self, command, stdout_lines, stderr_lines):
385
+ """Attempt to handle commands that require input."""
386
+ # Extract the last few lines to analyze what kind of input is needed
387
+ last_lines = []
388
+ if stdout_lines:
389
+ last_lines.extend(stdout_lines[-3:])
390
+ if stderr_lines:
391
+ last_lines.extend(stderr_lines[-3:])
392
+
393
+ last_line = last_lines[-1] if last_lines else ""
394
+
395
+ # Try to determine what kind of input is needed
396
+ if re.search(r'(?i)(y/n|yes/no|\[y/n\])', last_line):
397
+ # For yes/no prompts, usually 'yes' is safer
398
+ print("🔧 Auto-responding with 'y' to yes/no prompt")
399
+ self._send_command_raw("y")
400
+ return True
401
+
402
+ elif re.search(r'(?i)password', last_line):
403
+ # For password prompts, check if we have stored credentials
404
+ stored_creds = get_stored_credentials()
405
+ if stored_creds and 'ssh_password' in stored_creds:
406
+ print("🔧 Auto-responding with stored SSH password")
407
+ self._send_command_raw(stored_creds['ssh_password'])
408
+ return True
409
+ else:
410
+ print("⚠️ Password prompt detected but no stored password available")
411
+ return False
412
+
413
+ elif re.search(r'(?i)token|api.key', last_line):
414
+ # For token/API key prompts
415
+ stored_creds = get_stored_credentials()
416
+ if stored_creds:
417
+ if 'openai_api_key' in stored_creds and re.search(r'(?i)openai|api.key', last_line):
418
+ print("🔧 Auto-responding with stored OpenAI API key")
419
+ self._send_command_raw(stored_creds['openai_api_key'])
420
+ return True
421
+ elif 'hf_token' in stored_creds and re.search(r'(?i)hugg|hf|token', last_line):
422
+ print("🔧 Auto-responding with stored Hugging Face token")
423
+ self._send_command_raw(stored_creds['hf_token'])
424
+ return True
425
+
426
+ print("⚠️ Token/API key prompt detected but no matching stored credentials")
427
+ return False
428
+
429
+ elif re.search(r'(?i)press enter|continue|proceed', last_line):
430
+ # For "press enter to continue" prompts
431
+ print("🔧 Auto-responding with Enter to continue")
432
+ self._send_command_raw("") # Empty string sends just Enter
433
+ return True
434
+
435
+ # If we can't determine the type of input needed
436
+ print("⚠️ Couldn't determine the type of input needed")
437
+
438
+ # Try to use LLM to suggest an alternative command
439
+ try:
440
+ # Get current working directory for context
441
+ cwd = self.get_cwd()
442
+
443
+ # Reset command removal flags
444
+ self.should_remove_command = False
445
+ self.removal_reason = None
446
+
447
+ # Call LLM to suggest an alternative
448
+ alternative = self._suggest_alternative_command(command, stdout_lines, stderr_lines, cwd)
449
+
450
+ # Check if LLM suggested to remove the command
451
+ if self.should_remove_command:
452
+ print(f"🚫 Command will be removed: {self.removal_reason}")
453
+ return True # Return True to indicate the command has been handled (by removing it)
454
+
455
+ if alternative:
456
+ print(f"🔧 LLM suggested alternative command: {alternative}")
457
+ # We don't execute the alternative here, but return False so the calling code
458
+ # can handle it (e.g., by adding it to the command list)
459
+
460
+ # Store the suggested alternative for later use
461
+ self.suggested_alternative = alternative
462
+ return False
463
+ except Exception as e:
464
+ print(f"⚠️ Error getting LLM suggestion: {e}")
465
+
466
+ return False
467
+
468
+ def _suggest_alternative_command(self, command, stdout_lines, stderr_lines, current_dir):
469
+ """Use LLM to suggest an alternative command that doesn't require user input."""
470
+ try:
471
+ # Get API key
472
+ api_key = os.environ.get("OPENAI_API_KEY")
473
+ if not api_key:
474
+ # Try to load from saved file
475
+ key_file = os.path.expanduser("~/.gitarsenal/openai_key")
476
+ if os.path.exists(key_file):
477
+ with open(key_file, "r") as f:
478
+ api_key = f.read().strip()
479
+
480
+ if not api_key:
481
+ print("⚠️ No OpenAI API key available for suggesting alternative command")
482
+ return None
483
+
484
+ # Prepare the prompt
485
+ stdout_text = '\n'.join(stdout_lines[-10:]) if stdout_lines else ""
486
+ stderr_text = '\n'.join(stderr_lines[-10:]) if stderr_lines else ""
487
+
488
+ prompt = f"""
489
+ The command '{command}' appears to be waiting for user input.
490
+
491
+ Current directory: {current_dir}
492
+
493
+ Last stdout output:
494
+ {stdout_text}
495
+
496
+ Last stderr output:
497
+ {stderr_text}
498
+
499
+ Please analyze this command and determine if it's useful to continue with it.
500
+ If it's useful, suggest an alternative command that achieves the same goal but doesn't require user input.
501
+ For example, add flags like -y, --yes, --no-input, etc., or provide the required input in the command.
502
+
503
+ If the command is not useful or cannot be executed non-interactively, respond with "REMOVE_COMMAND" and explain why.
504
+
505
+ Format your response as:
506
+ ALTERNATIVE: <alternative command>
507
+ or
508
+ REMOVE_COMMAND: <reason>
509
+ """
510
+
511
+ # Call OpenAI API
512
+ import openai
513
+ client = openai.OpenAI(api_key=api_key)
514
+
515
+ response = client.chat.completions.create(
516
+ model="gpt-4o-mini",
517
+ messages=[
518
+ {"role": "system", "content": "You are a helpful assistant that suggests alternative commands that don't require user input."},
519
+ {"role": "user", "content": prompt}
520
+ ],
521
+ max_tokens=150,
522
+ temperature=0.7
523
+ )
524
+
525
+ response_text = response.choices[0].message.content.strip()
526
+
527
+ # Check if the response suggests removing the command
528
+ if response_text.startswith("REMOVE_COMMAND:"):
529
+ reason = response_text.replace("REMOVE_COMMAND:", "").strip()
530
+ print(f"🚫 LLM suggests removing command: {reason}")
531
+ self.should_remove_command = True
532
+ self.removal_reason = reason
533
+ return None
534
+
535
+ # Extract the alternative command
536
+ if response_text.startswith("ALTERNATIVE:"):
537
+ alternative_command = response_text.replace("ALTERNATIVE:", "").strip()
538
+ else:
539
+ # Try to extract the command from a free-form response
540
+ lines = response_text.split('\n')
541
+ for line in lines:
542
+ line = line.strip()
543
+ if line and not line.startswith(('Here', 'I', 'You', 'The', 'This', 'Use', 'Try')):
544
+ alternative_command = line
545
+ break
546
+ else:
547
+ alternative_command = lines[0].strip()
548
+
549
+ return alternative_command
550
+
551
+ except Exception as e:
552
+ print(f"⚠️ Error suggesting alternative command: {e}")
553
+ return None
554
+
326
555
  def _clear_lines(self):
327
556
  """Clear both output line lists."""
328
557
  with self.stdout_lock:
@@ -678,6 +907,337 @@ class CommandListManager:
678
907
 
679
908
  print(f"🔧 Added {len(added_fixes)} LLM-suggested fixes to command list")
680
909
  return added_fixes
910
+
911
+ def should_skip_original_command(self, original_command, fix_command, fix_stdout, fix_stderr, api_key=None):
912
+ """
913
+ Use LLM to determine if the original command should be skipped after a successful fix.
914
+
915
+ Args:
916
+ original_command: The original command that failed
917
+ fix_command: The fix command that succeeded
918
+ fix_stdout: The stdout from the fix command
919
+ fix_stderr: The stderr from the fix command
920
+ api_key: OpenAI API key
921
+
922
+ Returns:
923
+ tuple: (should_skip, reason)
924
+ """
925
+ try:
926
+ # Get API key if not provided
927
+ if not api_key:
928
+ api_key = os.environ.get("OPENAI_API_KEY")
929
+ if not api_key:
930
+ # Try to load from saved file
931
+ key_file = os.path.expanduser("~/.gitarsenal/openai_key")
932
+ if os.path.exists(key_file):
933
+ with open(key_file, "r") as f:
934
+ api_key = f.read().strip()
935
+
936
+ if not api_key:
937
+ print("⚠️ No OpenAI API key available for command list analysis")
938
+ return False, "No API key available"
939
+
940
+ # Get all commands for context
941
+ all_commands = self.get_all_commands()
942
+ commands_context = "\n".join([f"{i+1}. {cmd['command']} - {cmd['status']}" for i, cmd in enumerate(all_commands)])
943
+
944
+ # Prepare the prompt
945
+ prompt = f"""
946
+ I need to determine if an original command should be skipped after a successful fix command.
947
+
948
+ Original command (failed): {original_command}
949
+ Fix command (succeeded): {fix_command}
950
+
951
+ Fix command stdout:
952
+ {fix_stdout}
953
+
954
+ Fix command stderr:
955
+ {fix_stderr}
956
+
957
+ Current command list:
958
+ {commands_context}
959
+
960
+ Based on this information, should I skip running the original command again?
961
+ Consider:
962
+ 1. If the fix command already accomplished what the original command was trying to do
963
+ 2. If running the original command again would be redundant or cause errors
964
+ 3. If the original command is still necessary after the fix
965
+
966
+ Respond with ONLY:
967
+ SKIP: <reason>
968
+ or
969
+ RUN: <reason>
970
+ """
971
+
972
+ # Call OpenAI API
973
+ import openai
974
+ client = openai.OpenAI(api_key=api_key)
975
+
976
+ print("🔍 Analyzing if original command should be skipped...")
977
+
978
+ response = client.chat.completions.create(
979
+ model="gpt-3.5-turbo",
980
+ messages=[
981
+ {"role": "system", "content": "You are a helpful assistant that analyzes command execution."},
982
+ {"role": "user", "content": prompt}
983
+ ],
984
+ max_tokens=100,
985
+ temperature=0.3
986
+ )
987
+
988
+ response_text = response.choices[0].message.content.strip()
989
+
990
+ # Parse the response
991
+ if response_text.startswith("SKIP:"):
992
+ reason = response_text.replace("SKIP:", "").strip()
993
+ print(f"🔍 LLM suggests skipping original command: {reason}")
994
+ return True, reason
995
+ elif response_text.startswith("RUN:"):
996
+ reason = response_text.replace("RUN:", "").strip()
997
+ print(f"🔍 LLM suggests running original command: {reason}")
998
+ return False, reason
999
+ else:
1000
+ # Try to interpret a free-form response
1001
+ if "skip" in response_text.lower() and "should" in response_text.lower():
1002
+ print(f"🔍 Interpreting response as SKIP: {response_text}")
1003
+ return True, response_text
1004
+ else:
1005
+ print(f"🔍 Interpreting response as RUN: {response_text}")
1006
+ return False, response_text
1007
+
1008
+ except Exception as e:
1009
+ print(f"⚠️ Error analyzing command skip decision: {e}")
1010
+ return False, f"Error: {e}"
1011
+
1012
+ def replace_command(self, command_index, new_command, reason=""):
1013
+ """
1014
+ Replace a command in the list with a new command.
1015
+
1016
+ Args:
1017
+ command_index: The index of the command to replace
1018
+ new_command: The new command to use
1019
+ reason: The reason for the replacement
1020
+
1021
+ Returns:
1022
+ bool: True if the command was replaced, False otherwise
1023
+ """
1024
+ if 0 <= command_index < len(self.commands):
1025
+ old_command = self.commands[command_index]['command']
1026
+ self.commands[command_index]['command'] = new_command
1027
+ self.commands[command_index]['status'] = 'pending' # Reset status
1028
+ self.commands[command_index]['stdout'] = ''
1029
+ self.commands[command_index]['stderr'] = ''
1030
+ self.commands[command_index]['execution_time'] = None
1031
+ self.commands[command_index]['replacement_reason'] = reason
1032
+
1033
+ print(f"🔄 Replaced command {command_index + 1}: '{old_command}' with '{new_command}'")
1034
+ print(f"🔍 Reason: {reason}")
1035
+ return True
1036
+ else:
1037
+ print(f"❌ Invalid command index for replacement: {command_index}")
1038
+ return False
1039
+
1040
+ def update_command_list_with_llm(self, api_key=None):
1041
+ """
1042
+ Use LLM to analyze and update the entire command list.
1043
+
1044
+ Args:
1045
+ api_key: OpenAI API key
1046
+
1047
+ Returns:
1048
+ bool: True if the list was updated, False otherwise
1049
+ """
1050
+ try:
1051
+ # Get API key if not provided
1052
+ if not api_key:
1053
+ api_key = os.environ.get("OPENAI_API_KEY")
1054
+ if not api_key:
1055
+ # Try to load from saved file
1056
+ key_file = os.path.expanduser("~/.gitarsenal/openai_key")
1057
+ if os.path.exists(key_file):
1058
+ with open(key_file, "r") as f:
1059
+ api_key = f.read().strip()
1060
+
1061
+ if not api_key:
1062
+ print("⚠️ No OpenAI API key available for command list analysis")
1063
+ return False
1064
+
1065
+ # Get all commands for context
1066
+ all_commands = self.get_all_commands()
1067
+ commands_context = "\n".join([f"{i+1}. {cmd['command']} - {cmd['status']}"
1068
+ for i, cmd in enumerate(all_commands)])
1069
+
1070
+ # Get executed commands with their outputs for context
1071
+ executed_context = ""
1072
+ for cmd in self.executed_commands:
1073
+ executed_context += f"Command: {cmd['command']}\n"
1074
+ executed_context += f"Status: {cmd['status']}\n"
1075
+ if cmd['stdout']:
1076
+ executed_context += f"Stdout: {cmd['stdout'][:500]}...\n" if len(cmd['stdout']) > 500 else f"Stdout: {cmd['stdout']}\n"
1077
+ if cmd['stderr']:
1078
+ executed_context += f"Stderr: {cmd['stderr'][:500]}...\n" if len(cmd['stderr']) > 500 else f"Stderr: {cmd['stderr']}\n"
1079
+ executed_context += "\n"
1080
+
1081
+ # Prepare the prompt
1082
+ prompt = f"""
1083
+ I need you to analyze and optimize this command list. Some commands have been executed,
1084
+ and some are still pending. Based on what has already been executed, I need you to:
1085
+
1086
+ 1. Identify any pending commands that are now redundant or unnecessary
1087
+ 2. Identify any pending commands that should be modified based on previous command results
1088
+ 3. Suggest any new commands that should be added
1089
+
1090
+ Current command list:
1091
+ {commands_context}
1092
+
1093
+ Details of executed commands:
1094
+ {executed_context}
1095
+
1096
+ For each pending command (starting from the next command to be executed), tell me if it should be:
1097
+ 1. KEEP: Keep the command as is
1098
+ 2. SKIP: Skip the command (mark as completed without running)
1099
+ 3. MODIFY: Modify the command (provide the new command)
1100
+ 4. ADD_AFTER: Add a new command after this one
1101
+
1102
+ Format your response as a JSON array of actions:
1103
+ [
1104
+ {{
1105
+ "command_index": <index>,
1106
+ "action": "KEEP|SKIP|MODIFY|ADD_AFTER",
1107
+ "new_command": "<new command if MODIFY or ADD_AFTER>",
1108
+ "reason": "<reason for this action>"
1109
+ }},
1110
+ ...
1111
+ ]
1112
+
1113
+ Only include commands that need changes (SKIP, MODIFY, ADD_AFTER), not KEEP actions.
1114
+ """
1115
+
1116
+ # Call OpenAI API
1117
+ import openai
1118
+ import json
1119
+ client = openai.OpenAI(api_key=api_key)
1120
+
1121
+ print("🔍 Analyzing command list for optimizations...")
1122
+
1123
+ response = client.chat.completions.create(
1124
+ model="gpt-4o-mini", # Use a more capable model for this complex task
1125
+ messages=[
1126
+ {"role": "system", "content": "You are a helpful assistant that analyzes and optimizes command lists."},
1127
+ {"role": "user", "content": prompt}
1128
+ ],
1129
+ max_tokens=1000,
1130
+ temperature=0.2
1131
+ )
1132
+
1133
+ response_text = response.choices[0].message.content.strip()
1134
+
1135
+ # Extract JSON from the response
1136
+ try:
1137
+ # Find JSON array in the response
1138
+ json_match = re.search(r'\[\s*\{.*\}\s*\]', response_text, re.DOTALL)
1139
+ if json_match:
1140
+ json_str = json_match.group(0)
1141
+ actions = json.loads(json_str)
1142
+ else:
1143
+ # Try to parse the entire response as JSON
1144
+ actions = json.loads(response_text)
1145
+
1146
+ if not isinstance(actions, list):
1147
+ print("❌ Invalid response format from LLM - not a list")
1148
+ return False
1149
+
1150
+ # Apply the suggested changes
1151
+ changes_made = 0
1152
+ commands_added = 0
1153
+
1154
+ # Process in reverse order to avoid index shifting issues
1155
+ for action in sorted(actions, key=lambda x: x.get('command_index', 0), reverse=True):
1156
+ cmd_idx = action.get('command_index')
1157
+ action_type = action.get('action')
1158
+ new_cmd = action.get('new_command', '')
1159
+ reason = action.get('reason', 'No reason provided')
1160
+
1161
+ if cmd_idx is None or action_type is None:
1162
+ continue
1163
+
1164
+ # Convert to 0-based index if needed
1165
+ if cmd_idx > 0: # Assume 1-based index from LLM
1166
+ cmd_idx -= 1
1167
+
1168
+ # Skip if the command index is invalid
1169
+ if cmd_idx < 0 or cmd_idx >= len(self.commands):
1170
+ print(f"❌ Invalid command index: {cmd_idx}")
1171
+ continue
1172
+
1173
+ # Skip if the command has already been executed
1174
+ if self.commands[cmd_idx]['status'] != 'pending':
1175
+ print(f"⚠️ Command {cmd_idx + 1} already executed, skipping action")
1176
+ continue
1177
+
1178
+ if action_type == "SKIP":
1179
+ # Mark the command as successful without running it
1180
+ self.mark_command_executed(
1181
+ cmd_idx, 'main', True,
1182
+ f"Command skipped: {reason}",
1183
+ "", 0
1184
+ )
1185
+ print(f"🔄 Skipped command {cmd_idx + 1}: {reason}")
1186
+ changes_made += 1
1187
+
1188
+ elif action_type == "MODIFY":
1189
+ if new_cmd:
1190
+ if self.replace_command(cmd_idx, new_cmd, reason):
1191
+ changes_made += 1
1192
+ else:
1193
+ print(f"❌ No new command provided for MODIFY action on command {cmd_idx + 1}")
1194
+
1195
+ elif action_type == "ADD_AFTER":
1196
+ if new_cmd:
1197
+ # Add new command after the current one
1198
+ insert_idx = cmd_idx + 1
1199
+ new_cmd_obj = {
1200
+ 'command': new_cmd,
1201
+ 'status': 'pending',
1202
+ 'index': insert_idx,
1203
+ 'stdout': '',
1204
+ 'stderr': '',
1205
+ 'execution_time': None,
1206
+ 'fix_attempts': 0,
1207
+ 'max_fix_attempts': 3,
1208
+ 'added_reason': reason
1209
+ }
1210
+
1211
+ # Insert the new command
1212
+ self.commands.insert(insert_idx, new_cmd_obj)
1213
+
1214
+ # Update indices for all commands after insertion
1215
+ for i in range(insert_idx + 1, len(self.commands)):
1216
+ self.commands[i]['index'] = i
1217
+
1218
+ print(f"➕ Added new command after {cmd_idx + 1}: '{new_cmd}'")
1219
+ print(f"🔍 Reason: {reason}")
1220
+ commands_added += 1
1221
+ else:
1222
+ print(f"❌ No new command provided for ADD_AFTER action on command {cmd_idx + 1}")
1223
+
1224
+ # Update total commands count
1225
+ self.total_commands = len(self.commands)
1226
+
1227
+ print(f"✅ Command list updated: {changes_made} changes made, {commands_added} commands added")
1228
+ return changes_made > 0 or commands_added > 0
1229
+
1230
+ except json.JSONDecodeError as e:
1231
+ print(f"❌ Failed to parse LLM response as JSON: {e}")
1232
+ print(f"Raw response: {response_text}")
1233
+ return False
1234
+ except Exception as e:
1235
+ print(f"❌ Error updating command list: {e}")
1236
+ return False
1237
+
1238
+ except Exception as e:
1239
+ print(f"⚠️ Error analyzing command list: {e}")
1240
+ return False
681
1241
 
682
1242
 
683
1243
  # Import the fetch_modal_tokens module
@@ -1056,7 +1616,6 @@ Consider the current directory, system information, directory contents, and avai
1056
1616
  IMPORTANT GUIDELINES:
1057
1617
  1. For any commands that might ask for yes/no confirmation, use the appropriate non-interactive flag:
1058
1618
  - For apt/apt-get: use -y or --yes
1059
- - For pip: use --no-input
1060
1619
  - For rm: use -f or --force
1061
1620
 
1062
1621
  2. If the error indicates a file is not found:
@@ -1138,7 +1697,7 @@ Do not provide any explanations, just the exact command to run.
1138
1697
  "max_tokens": 300
1139
1698
  }
1140
1699
 
1141
- # print(f"🔍 DEBUG: Payload prepared, prompt length: {len(prompt)}")
1700
+ print(f"🔍 DEBUG: Payload prepared, prompt length: {len(prompt)}")
1142
1701
 
1143
1702
  # Add specific handling for common errors
1144
1703
  last_error = None
@@ -1150,8 +1709,8 @@ Do not provide any explanations, just the exact command to run.
1150
1709
  print(f"⏱️ Retrying in {wait_time:.1f} seconds... (attempt {attempt+1}/{retries+1})")
1151
1710
  time.sleep(wait_time)
1152
1711
 
1153
- # print(f"🤖 Calling OpenAI with {model_name} model to debug the failed command...")
1154
- # print(f"🔍 DEBUG: Making POST request to OpenAI API...")
1712
+ print(f"🤖 Calling OpenAI with {model_name} model to debug the failed command...")
1713
+ print(f"🔍 DEBUG: Making POST request to OpenAI API...")
1155
1714
  response = requests.post(
1156
1715
  "https://api.openai.com/v1/chat/completions",
1157
1716
  headers=headers,
@@ -1159,8 +1718,41 @@ Do not provide any explanations, just the exact command to run.
1159
1718
  timeout=45 # Increased timeout for reliability
1160
1719
  )
1161
1720
 
1162
- # print(f"🔍 DEBUG: Response received, status code: {response.status_code}")
1721
+ print(f"🔍 DEBUG: Response received, status code: {response.status_code}")
1163
1722
 
1723
+ # Handle specific status codes
1724
+ if response.status_code == 200:
1725
+ print(f"🔍 DEBUG: Success! Response length: {len(response.text)}")
1726
+ return response.json(), None
1727
+ elif response.status_code == 401:
1728
+ error_msg = "Authentication error: Invalid API key"
1729
+ print(f"❌ {error_msg}")
1730
+ print(f"🔍 DEBUG: Response text: {response.text}")
1731
+ # Don't retry auth errors
1732
+ return None, error_msg
1733
+ elif response.status_code == 429:
1734
+ error_msg = "Rate limit exceeded or quota reached"
1735
+ print(f"⚠️ {error_msg}")
1736
+ print(f"🔍 DEBUG: Response text: {response.text}")
1737
+ # Always retry rate limit errors with increasing backoff
1738
+ last_error = error_msg
1739
+ continue
1740
+ elif response.status_code == 500:
1741
+ error_msg = "OpenAI server error"
1742
+ print(f"⚠️ {error_msg}")
1743
+ print(f"🔍 DEBUG: Response text: {response.text}")
1744
+ # Retry server errors
1745
+ last_error = error_msg
1746
+ continue
1747
+ else:
1748
+ error_msg = f"Status code: {response.status_code}, Response: {response.text}"
1749
+ print(f"⚠️ OpenAI API error: {error_msg}")
1750
+ print(f"🔍 DEBUG: Full response text: {response.text}")
1751
+ last_error = error_msg
1752
+ # Only retry if we have attempts left
1753
+ if attempt < retries:
1754
+ continue
1755
+ return None, error_msg
1164
1756
  except requests.exceptions.Timeout:
1165
1757
  error_msg = "Request timed out"
1166
1758
  # print(f"⚠️ {error_msg}")
@@ -1780,20 +2372,26 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
1780
2372
  # Start SSH service
1781
2373
  subprocess.run(["service", "ssh", "start"], check=True)
1782
2374
 
1783
- # Run setup commands if provided using PersistentShell and CommandListManager
2375
+ # Preprocess setup commands using LLM to inject credentials
1784
2376
  if setup_commands:
1785
- print(f"⚙️ Running {len(setup_commands)} setup commands with dynamic command list...")
2377
+ print(f"🔧 Preprocessing {len(setup_commands)} setup commands with LLM to inject credentials...")
2378
+ api_key = os.environ.get("OPENAI_API_KEY")
2379
+ processed_commands = preprocess_commands_with_llm(setup_commands, stored_credentials, api_key)
2380
+ print(f"⚙️ Running {len(processed_commands)} preprocessed setup commands with dynamic command list...")
1786
2381
 
1787
- # Create command list manager
1788
- cmd_manager = CommandListManager(setup_commands)
2382
+ # Create command list manager with processed commands
2383
+ cmd_manager = CommandListManager(processed_commands)
1789
2384
 
1790
2385
  # Create persistent shell instance starting in /root
1791
- shell = PersistentShell(working_dir="/root", timeout=240)
2386
+ shell = PersistentShell(working_dir="/root", timeout=300)
1792
2387
 
1793
2388
  try:
1794
2389
  # Start the persistent shell
1795
2390
  shell.start()
1796
2391
 
2392
+ # Track how many commands have been executed since last analysis
2393
+ commands_since_analysis = 0
2394
+
1797
2395
  # Execute commands using the command list manager
1798
2396
  while cmd_manager.has_pending_commands():
1799
2397
  # Get next command to execute
@@ -1805,6 +2403,13 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
1805
2403
  # Print status before executing
1806
2404
  cmd_manager.print_status()
1807
2405
 
2406
+ # Periodically analyze and update the command list
2407
+ if commands_since_analysis >= 3 and cmd_type == 'main':
2408
+ print("\n🔍 Periodic command list analysis...")
2409
+ api_key = os.environ.get("OPENAI_API_KEY")
2410
+ cmd_manager.update_command_list_with_llm(api_key)
2411
+ commands_since_analysis = 0
2412
+
1808
2413
  # Execute the command
1809
2414
  if cmd_type == 'main':
1810
2415
  cmd_text = next_cmd['command']
@@ -1812,14 +2417,33 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
1812
2417
  print(f"📋 Executing main command {cmd_index + 1}/{cmd_manager.total_commands}: {cmd_text}")
1813
2418
 
1814
2419
  start_time = time.time()
1815
- success, stdout, stderr = shell.execute(cmd_text, timeout=240)
2420
+ success, stdout, stderr = shell.execute(cmd_text, timeout=300)
1816
2421
  execution_time = time.time() - start_time
1817
2422
 
2423
+ # Check if the command was aborted due to waiting for input and an alternative was suggested
2424
+ if not success and "Command aborted - requires user input" in stderr and shell.suggested_alternative:
2425
+ alternative_cmd = shell.suggested_alternative
2426
+ print(f"🔄 Command aborted due to input requirement. Adding suggested alternative: {alternative_cmd}")
2427
+
2428
+ # Add the alternative command with high priority
2429
+ cmd_manager.add_command_dynamically(alternative_cmd, priority='high')
2430
+
2431
+ # Clear the suggested alternative
2432
+ shell.suggested_alternative = None
2433
+ # Check if the command should be removed as suggested by LLM
2434
+ elif not success and stderr.startswith("Command removed -"):
2435
+ reason = stderr.replace("Command removed -", "").strip()
2436
+ print(f"🚫 Removed command as suggested by LLM: {reason}")
2437
+ # We don't need to do anything else, just mark it as executed and move on
2438
+
1818
2439
  # Mark command as executed
1819
2440
  cmd_manager.mark_command_executed(
1820
2441
  cmd_index, 'main', success, stdout, stderr, execution_time
1821
2442
  )
1822
2443
 
2444
+ # Increment counter for periodic analysis
2445
+ commands_since_analysis += 1
2446
+
1823
2447
  if not success:
1824
2448
  print(f"⚠️ Command failed, attempting LLM debugging...")
1825
2449
 
@@ -1840,7 +2464,7 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
1840
2464
  # Execute the fix command
1841
2465
  print(f"🔄 Running suggested fix command: {fix_command}")
1842
2466
  fix_start_time = time.time()
1843
- fix_success, fix_stdout, fix_stderr = shell.execute(fix_command, timeout=240)
2467
+ fix_success, fix_stdout, fix_stderr = shell.execute(fix_command, timeout=300)
1844
2468
  fix_execution_time = time.time() - fix_start_time
1845
2469
 
1846
2470
  # Mark fix command as executed
@@ -1851,21 +2475,49 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
1851
2475
  if fix_success:
1852
2476
  print(f"✅ Fix command succeeded")
1853
2477
 
1854
- # Retry the original command
1855
- print(f"🔄 Retrying original command: {cmd_text}")
1856
- retry_start_time = time.time()
1857
- retry_success, retry_stdout, retry_stderr = shell.execute(cmd_text, timeout=240)
1858
- retry_execution_time = time.time() - retry_start_time
1859
-
1860
- # Update the original command status
1861
- cmd_manager.mark_command_executed(
1862
- cmd_index, 'main', retry_success, retry_stdout, retry_stderr, retry_execution_time
2478
+ # Check if we should skip the original command
2479
+ api_key = os.environ.get("OPENAI_API_KEY")
2480
+ should_skip, skip_reason = cmd_manager.should_skip_original_command(
2481
+ cmd_text, fix_command, fix_stdout, fix_stderr, api_key
1863
2482
  )
1864
2483
 
1865
- if retry_success:
1866
- print(f" Original command succeeded after fix!")
2484
+ if should_skip:
2485
+ print(f"🔄 Skipping original command: {skip_reason}")
2486
+
2487
+ # Mark the original command as successful without running it
2488
+ cmd_manager.mark_command_executed(
2489
+ cmd_index, 'main', True,
2490
+ f"Command skipped after successful fix: {skip_reason}",
2491
+ "", time.time() - start_time
2492
+ )
2493
+
2494
+ print(f"✅ Original command marked as successful (skipped)")
2495
+
2496
+ # After a successful fix and skipping the original command,
2497
+ # analyze and update the entire command list
2498
+ print("\n🔍 Analyzing and updating remaining commands based on fix results...")
2499
+ cmd_manager.update_command_list_with_llm(api_key)
1867
2500
  else:
1868
- print(f"⚠️ Original command still failed after fix, continuing...")
2501
+ # Retry the original command
2502
+ print(f"🔄 Retrying original command: {cmd_text}")
2503
+ retry_start_time = time.time()
2504
+ retry_success, retry_stdout, retry_stderr = shell.execute(cmd_text, timeout=300)
2505
+ retry_execution_time = time.time() - retry_start_time
2506
+
2507
+ # Update the original command status
2508
+ cmd_manager.mark_command_executed(
2509
+ cmd_index, 'main', retry_success, retry_stdout, retry_stderr, retry_execution_time
2510
+ )
2511
+
2512
+ if retry_success:
2513
+ print(f"✅ Original command succeeded after fix!")
2514
+
2515
+ # After a successful fix and successful retry,
2516
+ # analyze and update the entire command list
2517
+ print("\n🔍 Analyzing and updating remaining commands based on fix results...")
2518
+ cmd_manager.update_command_list_with_llm(api_key)
2519
+ else:
2520
+ print(f"⚠️ Original command still failed after fix, continuing...")
1869
2521
  else:
1870
2522
  print(f"❌ Fix command failed: {fix_stderr}")
1871
2523
  print(f"⚠️ Continuing with remaining commands...")
@@ -1883,9 +2535,25 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
1883
2535
  print(f"🔧 Executing fix command {cmd_index + 1}: {cmd_text}")
1884
2536
 
1885
2537
  start_time = time.time()
1886
- success, stdout, stderr = shell.execute(cmd_text, timeout=240)
2538
+ success, stdout, stderr = shell.execute(cmd_text, timeout=300)
1887
2539
  execution_time = time.time() - start_time
1888
2540
 
2541
+ # Check if the fix command was aborted due to waiting for input and an alternative was suggested
2542
+ if not success and "Command aborted - requires user input" in stderr and shell.suggested_alternative:
2543
+ alternative_cmd = shell.suggested_alternative
2544
+ print(f"🔄 Fix command aborted due to input requirement. Adding suggested alternative: {alternative_cmd}")
2545
+
2546
+ # Add the alternative command with high priority
2547
+ cmd_manager.add_command_dynamically(alternative_cmd, priority='high')
2548
+
2549
+ # Clear the suggested alternative
2550
+ shell.suggested_alternative = None
2551
+ # Check if the fix command should be removed as suggested by LLM
2552
+ elif not success and stderr.startswith("Command removed -"):
2553
+ reason = stderr.replace("Command removed -", "").strip()
2554
+ print(f"🚫 Removed fix command as suggested by LLM: {reason}")
2555
+ # We don't need to do anything else, just mark it as executed and move on
2556
+
1889
2557
  # Mark fix command as executed
1890
2558
  cmd_manager.mark_command_executed(
1891
2559
  cmd_index, 'fix', success, stdout, stderr, execution_time
@@ -1911,9 +2579,25 @@ def create_modal_ssh_container(gpu_type, repo_url=None, repo_name=None, setup_co
1911
2579
  print(f"🔧 Executing additional fix: {cmd_text}")
1912
2580
 
1913
2581
  start_time = time.time()
1914
- success, stdout, stderr = shell.execute(cmd_text, timeout=240)
2582
+ success, stdout, stderr = shell.execute(cmd_text, timeout=300)
1915
2583
  execution_time = time.time() - start_time
1916
2584
 
2585
+ # Check if the fix command was aborted due to waiting for input and an alternative was suggested
2586
+ if not success and "Command aborted - requires user input" in stderr and shell.suggested_alternative:
2587
+ alternative_cmd = shell.suggested_alternative
2588
+ print(f"🔄 Additional fix command aborted due to input requirement. Adding suggested alternative: {alternative_cmd}")
2589
+
2590
+ # Add the alternative command with high priority
2591
+ cmd_manager.add_command_dynamically(alternative_cmd, priority='high')
2592
+
2593
+ # Clear the suggested alternative
2594
+ shell.suggested_alternative = None
2595
+ # Check if the additional fix command should be removed as suggested by LLM
2596
+ elif not success and stderr.startswith("Command removed -"):
2597
+ reason = stderr.replace("Command removed -", "").strip()
2598
+ print(f"🚫 Removed additional fix command as suggested by LLM: {reason}")
2599
+ # We don't need to do anything else, just mark it as executed and move on
2600
+
1917
2601
  # Mark fix command as executed
1918
2602
  cmd_manager.mark_command_executed(
1919
2603
  fix_index, 'fix', success, stdout, stderr, execution_time
@@ -3175,6 +3859,152 @@ def prompt_for_gpu():
3175
3859
  print("✅ Using default GPU: A10G")
3176
3860
  return "A10G"
3177
3861
 
3862
+
3863
+
3864
+ def preprocess_commands_with_llm(setup_commands, stored_credentials, api_key=None):
3865
+ """
3866
+ Use LLM to preprocess setup commands and inject available credentials.
3867
+
3868
+ Args:
3869
+ setup_commands: List of setup commands
3870
+ stored_credentials: Dictionary of stored credentials
3871
+ api_key: OpenAI API key for LLM calls
3872
+
3873
+ Returns:
3874
+ List of processed commands with credentials injected
3875
+ """
3876
+ if not setup_commands or not stored_credentials:
3877
+ return setup_commands
3878
+
3879
+ try:
3880
+ # Create context for the LLM
3881
+ credentials_info = "\n".join([f"- {key}: {value[:8]}..." for key, value in stored_credentials.items()])
3882
+
3883
+ prompt = f"""
3884
+ You are a command preprocessing assistant. Your task is to modify setup commands to use available credentials and make them non-interactive.
3885
+
3886
+ AVAILABLE CREDENTIALS:
3887
+ {credentials_info}
3888
+
3889
+ ORIGINAL COMMANDS:
3890
+ {chr(10).join([f"{i+1}. {cmd}" for i, cmd in enumerate(setup_commands)])}
3891
+
3892
+ INSTRUCTIONS:
3893
+ 1. Replace any authentication commands with token-based versions using available credentials
3894
+ 2. Make all commands non-interactive (add --yes, --no-input, -y flags where needed)
3895
+ 3. Use environment variables or direct token injection where appropriate
3896
+ 4. Skip commands that cannot be made non-interactive due to missing credentials
3897
+ 5. Add any necessary environment variable exports
3898
+
3899
+ Return the modified commands as a JSON array of strings. If a command should be skipped, prefix it with "# SKIPPED: ".
3900
+
3901
+ Example transformations:
3902
+ - "huggingface-cli login" → "huggingface-cli login --token $HUGGINGFACE_TOKEN"
3903
+ - "npm install" → "npm install --yes"
3904
+
3905
+ Return only the JSON array, no other text.
3906
+ """
3907
+
3908
+ if not api_key:
3909
+ print("⚠️ No OpenAI API key available for command preprocessing")
3910
+ return setup_commands
3911
+
3912
+ # Call OpenAI API
3913
+ import openai
3914
+ client = openai.OpenAI(api_key=api_key)
3915
+
3916
+ response = client.chat.completions.create(
3917
+ model="gpt-3.5-turbo",
3918
+ messages=[
3919
+ {"role": "system", "content": "You are a command preprocessing assistant that modifies setup commands to use available credentials and make them non-interactive."},
3920
+ {"role": "user", "content": prompt}
3921
+ ],
3922
+ temperature=0.1,
3923
+ max_tokens=2000
3924
+ )
3925
+
3926
+ result = response.choices[0].message.content.strip()
3927
+
3928
+ # Debug: Print the raw response
3929
+ print(f"🔍 LLM Response: {result[:200]}...")
3930
+
3931
+ # Parse the JSON response
3932
+ import json
3933
+ try:
3934
+ processed_commands = json.loads(result)
3935
+ if isinstance(processed_commands, list):
3936
+ print(f"🔧 LLM preprocessed {len(processed_commands)} commands")
3937
+ for i, cmd in enumerate(processed_commands):
3938
+ if cmd != setup_commands[i]:
3939
+ print(f" {i+1}. {setup_commands[i]} → {cmd}")
3940
+ return processed_commands
3941
+ else:
3942
+ print("⚠️ LLM returned invalid format, using fallback preprocessing")
3943
+ return fallback_preprocess_commands(setup_commands, stored_credentials)
3944
+ except json.JSONDecodeError as e:
3945
+ print(f"⚠️ Failed to parse LLM response: {e}")
3946
+ print("🔄 Using fallback preprocessing...")
3947
+ return fallback_preprocess_commands(setup_commands, stored_credentials)
3948
+
3949
+ except Exception as e:
3950
+ print(f"⚠️ LLM preprocessing failed: {e}")
3951
+ print("🔄 Using fallback preprocessing...")
3952
+ return fallback_preprocess_commands(setup_commands, stored_credentials)
3953
+
3954
+ def fallback_preprocess_commands(setup_commands, stored_credentials):
3955
+ """
3956
+ Fallback preprocessing function that manually handles common credential injection patterns.
3957
+
3958
+ Args:
3959
+ setup_commands: List of setup commands
3960
+ stored_credentials: Dictionary of stored credentials
3961
+
3962
+ Returns:
3963
+ List of processed commands with credentials injected
3964
+ """
3965
+ if not setup_commands or not stored_credentials:
3966
+ return setup_commands
3967
+
3968
+ processed_commands = []
3969
+
3970
+ for i, command in enumerate(setup_commands):
3971
+ processed_command = command
3972
+
3973
+ # Handle Hugging Face login
3974
+ if 'huggingface-cli login' in command and '--token' not in command:
3975
+ if 'HUGGINGFACE_TOKEN' in stored_credentials:
3976
+ processed_command = f"huggingface-cli login --token $HUGGINGFACE_TOKEN"
3977
+ print(f"🔧 Fallback: Injected HF token into command {i+1}")
3978
+ else:
3979
+ processed_command = f"# SKIPPED: {command} (no HF token available)"
3980
+ print(f"🔧 Fallback: Skipped command {i+1} (no HF token)")
3981
+
3982
+ # Handle OpenAI API key
3983
+ elif 'openai' in command.lower() and 'api_key' not in command.lower():
3984
+ if 'OPENAI_API_KEY' in stored_credentials:
3985
+ processed_command = f"export OPENAI_API_KEY=$OPENAI_API_KEY && {command}"
3986
+ print(f"🔧 Fallback: Added OpenAI API key export to command {i+1}")
3987
+
3988
+ # Handle npm install
3989
+ elif 'npm install' in command and '--yes' not in command and '--no-interactive' not in command:
3990
+ processed_command = command.replace('npm install', 'npm install --yes')
3991
+ print(f"🔧 Fallback: Made npm install non-interactive in command {i+1}")
3992
+
3993
+ # Handle git clone
3994
+ elif command.strip().startswith('git clone') and '--depth 1' not in command:
3995
+ processed_command = command.replace('git clone', 'git clone --depth 1')
3996
+ print(f"🔧 Fallback: Made git clone non-interactive in command {i+1}")
3997
+
3998
+ # Handle apt-get install
3999
+ elif 'apt-get install' in command and '-y' not in command:
4000
+ processed_command = command.replace('apt-get install', 'apt-get install -y')
4001
+ print(f"🔧 Fallback: Made apt-get install non-interactive in command {i+1}")
4002
+
4003
+ processed_commands.append(processed_command)
4004
+
4005
+ print(f"🔧 Fallback preprocessing completed: {len(processed_commands)} commands")
4006
+ return processed_commands
4007
+
3178
4008
  # Replace the existing GPU argument parsing in the main section
3179
4009
  if __name__ == "__main__":
3180
4010
  # Parse command line arguments when script is run directly
@@ -3459,4 +4289,4 @@ if __name__ == "__main__":
3459
4289
  # print(f"\n❌ Error: {e}")
3460
4290
  # print("🧹 Cleaning up resources...")
3461
4291
  cleanup_modal_token()
3462
- sys.exit(1)
4292
+ sys.exit(1)