/ run.py
run.py
1 #!/usr/bin/env python3 2 """ 3 Universal Proxy Wrapper for Claude Commands. 4 5 This is the main entry point for ALL commands executed by Claude. 6 It wraps any command, executes it, and returns token-compressed output. 7 8 Usage: 9 python run.py <command> [args...] 10 11 Examples: 12 python run.py cargo build 13 python run.py npm test 14 python run.py python script.py 15 python run.py ls -la 16 17 Output Format: 18 - Success: "." per completed unit, "[S:PASS]" at end 19 - Errors: "!ERR:CODE description" with "@ file:line" locations 20 - Passthrough: Non-boilerplate output preserved 21 """ 22 import sys 23 import subprocess 24 import re 25 import os 26 import glob as globmodule 27 28 29 def strip_ansi(text): 30 """Remove ANSI escape codes from text.""" 31 ansi_escape = re.compile(r'\x1B(?:[@-Z\-_]|[0-?]*[@-~])') 32 return ansi_escape.sub('', text) 33 34 35 def detect_stacks(): 36 """Auto-detect project stack(s) from current directory.""" 37 stacks = set() 38 cwd = os.getcwd() 39 40 # File-based detection 41 indicators = { 42 'rust': ['Cargo.toml'], 43 'node': ['package.json'], 44 'go': ['go.mod'], 45 'python': ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py'], 46 'java': ['pom.xml', 'build.gradle', 'build.gradle.kts'], 47 } 48 49 for stack, files in indicators.items(): 50 for f in files: 51 if os.path.exists(os.path.join(cwd, f)): 52 stacks.add(stack) 53 break 54 55 # Extension-based fallback 56 if 'python' not in stacks and globmodule.glob(os.path.join(cwd, '*.py')): 57 stacks.add('python') 58 if 'rust' not in stacks and globmodule.glob(os.path.join(cwd, 'src', '*.rs')): 59 stacks.add('rust') 60 61 # CI/Workflow detection 62 workflow_files = globmodule.glob(os.path.join(cwd, '.forgejo', 'workflows', '*.yml')) + \ 63 globmodule.glob(os.path.join(cwd, '.github', 'workflows', '*.yml')) 64 65 for wf in workflow_files: 66 try: 67 with open(wf, 'r', encoding='utf-8') as f: 68 content = f.read() 69 if re.search(r'\bcargo\b', content): 70 stacks.add('rust') 71 if re.search(r'\b(npm|yarn|pnpm|bun)\b', content): 72 stacks.add('node') 73 if re.search(r'\b(go build|go test)\b', content): 74 stacks.add('go') 75 if re.search(r'\b(pip|python|pytest|poetry)\b', content): 76 stacks.add('python') 77 except Exception: 78 pass 79 80 if not stacks: 81 stacks.add('generic') 82 83 return stacks 84 85 86 # Compile patterns once for performance 87 SUPPRESS_PATTERNS = re.compile( 88 r'^(Downloading|Compiling|Fetching|Running|Created|Started|Updated|' 89 r'Installing|Waiting|Unpacking|Resolving|Building|Linking|Generating)\b', 90 re.IGNORECASE 91 ) 92 SUCCESS_PATTERNS = re.compile( 93 r'^(ok|passed|finished|success|completed|done|built|installed)$', 94 re.IGNORECASE 95 ) 96 RUST_ERROR = re.compile(r'error\[(E\d+)\]') 97 RUST_LOCATION = re.compile(r'-->\s+(.+:\d+)') 98 NODE_ERROR = re.compile(r'^(\w+Error):') 99 NODE_LOCATION = re.compile(r'\((.+:\d+:\d+)\)') 100 GO_ERROR = re.compile(r'^(.+\.go:\d+:\d+):\s+(.+)') 101 PYTHON_TRACEBACK = 'Traceback (most recent call last):' 102 PYTHON_LOCATION = re.compile(r'File "([^"]+)", line (\d+)(?:, in (.+))?') 103 PYTHON_ERROR = re.compile(r'^(\w+Error):') 104 GENERIC_ERROR = re.compile(r'\b(Error|Exception|Fail|Fatal)\b', re.IGNORECASE) 105 FILE_LOCATION = re.compile(r'(\S+\.\w+:\d+)') 106 107 108 def process_line(line, stacks): 109 """Process a single line of output, returning compressed form or None.""" 110 clean = strip_ansi(line).strip() 111 if not clean: 112 return None 113 114 # Suppress boilerplate 115 if SUPPRESS_PATTERNS.match(clean): 116 return None 117 118 # Success patterns -> dot 119 if SUCCESS_PATTERNS.match(clean): 120 return '.' 121 122 # Stack-specific error extraction 123 if 'rust' in stacks: 124 err_match = RUST_ERROR.search(clean) 125 if err_match: 126 return f"!ERR:{err_match.group(1)} {clean[err_match.end():].strip()}" 127 loc_match = RUST_LOCATION.search(clean) 128 if loc_match: 129 return f"@ {loc_match.group(1)}" 130 131 if 'node' in stacks: 132 err_match = NODE_ERROR.match(clean) 133 if err_match: 134 return f"!ERR:{err_match.group(1)} {clean[err_match.end():].strip()}" 135 loc_match = NODE_LOCATION.search(clean) 136 if loc_match: 137 return f"@ {loc_match.group(1)}" 138 139 if 'go' in stacks: 140 match = GO_ERROR.match(clean) 141 if match: 142 return f"!ERR:GoError @ {match.group(1)} {match.group(2)}" 143 144 if 'python' in stacks: 145 if clean.startswith(PYTHON_TRACEBACK): 146 return "!ERR:Traceback" 147 loc_match = PYTHON_LOCATION.match(clean) 148 if loc_match: 149 file_path = loc_match.group(1) 150 line_num = loc_match.group(2) 151 context = loc_match.group(3) or "" 152 return f"@ {file_path}:{line_num} {context}" 153 err_match = PYTHON_ERROR.match(clean) 154 if err_match: 155 return f"!ERR:{err_match.group(1)} {clean[err_match.end():].strip()}" 156 157 # Generic error fallback 158 if GENERIC_ERROR.search(clean): 159 file_match = FILE_LOCATION.search(clean) 160 loc = f" @ {file_match.group(1)}" if file_match else "" 161 return f"!ERR:GENERIC{loc} {clean}" 162 163 return clean 164 165 166 def main(): 167 if len(sys.argv) < 2: 168 print("Usage: python run.py <command> [args...]") 169 print("Universal proxy wrapper for Claude command execution.") 170 sys.exit(0) 171 172 command = sys.argv[1:] 173 174 # Docker restriction (create .allow_docker to override) 175 if command and command[0] == 'docker': 176 if not os.path.exists(os.path.join(os.getcwd(), '.allow_docker')): 177 sys.stderr.write("!ERR:RESTRICTED Docker forbidden (Native Runner). Create .allow_docker to override.\n") 178 sys.exit(1) 179 180 stacks = detect_stacks() 181 182 process = None 183 dot_count = 0 184 185 try: 186 process = subprocess.Popen( 187 command, 188 stdout=subprocess.PIPE, 189 stderr=subprocess.STDOUT, 190 text=True, 191 bufsize=1, 192 encoding='utf-8', 193 errors='replace' 194 ) 195 196 while True: 197 line = process.stdout.readline() 198 if not line and process.poll() is not None: 199 break 200 if not line: 201 continue 202 203 result = process_line(line, stacks) 204 if result: 205 if result == '.': 206 dot_count += 1 207 # Batch dots for cleaner output 208 if dot_count % 10 == 0: 209 sys.stdout.write('.') 210 sys.stdout.flush() 211 elif result.startswith('!ERR'): 212 # Flush pending dots before error 213 if dot_count % 10 != 0: 214 sys.stdout.write('.') 215 sys.stdout.write('\n' + result + '\n') 216 sys.stdout.flush() 217 elif result.startswith('@'): 218 sys.stdout.write(result + '\n') 219 sys.stdout.flush() 220 else: 221 sys.stdout.write(result + '\n') 222 sys.stdout.flush() 223 224 process.wait() 225 226 # Final status 227 if process.returncode == 0: 228 sys.stdout.write('\n[S:PASS]\n') 229 else: 230 sys.stdout.write(f'\n[S:FAIL] exit={process.returncode}\n') 231 232 sys.exit(process.returncode) 233 234 except FileNotFoundError: 235 sys.stderr.write(f"!ERR:NOTFOUND Command not found: {command[0]}\n") 236 sys.exit(127) 237 except KeyboardInterrupt: 238 if process: 239 process.terminate() 240 sys.exit(130) 241 except Exception as e: 242 sys.stderr.write(f"!ERR:PROXY {e}\n") 243 sys.exit(1) 244 245 246 if __name__ == "__main__": 247 main()