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 Supports alias expansion from cspec command templates. 8 9 Usage: 10 r <command> [args...] 11 r <alias> [key=value...] 12 r --list # List available aliases 13 r --resolve <alias> # Show what alias expands to 14 15 Examples: 16 r cargo build # Direct command 17 r connect-ci # Alias -> ssh -p 2584 devops@ci.ac-dc.network 18 r ci-status repo=alphavm # Alias with placeholder 19 20 Output Format: 21 - Success: "." per completed unit, "[S:PASS]" at end 22 - Errors: "!ERR:CODE description" with "@ file:line" locations 23 - Passthrough: Non-boilerplate output preserved 24 """ 25 import sys 26 import subprocess 27 import re 28 import os 29 import glob as globmodule 30 31 # Import alias resolution (lazy to avoid circular imports) 32 _aliases_module = None 33 34 def get_aliases_module(): 35 """Lazy load aliases module.""" 36 global _aliases_module 37 if _aliases_module is None: 38 try: 39 import aliases as am 40 _aliases_module = am 41 except ImportError: 42 _aliases_module = False 43 return _aliases_module if _aliases_module else None 44 45 46 def strip_ansi(text): 47 """Remove ANSI escape codes from text.""" 48 ansi_escape = re.compile(r'\x1B(?:[@-Z\-_]|[0-?]*[@-~])') 49 return ansi_escape.sub('', text) 50 51 52 def detect_stacks(): 53 """Auto-detect project stack(s) from current directory.""" 54 stacks = set() 55 cwd = os.getcwd() 56 57 # File-based detection 58 indicators = { 59 'rust': ['Cargo.toml'], 60 'node': ['package.json'], 61 'go': ['go.mod'], 62 'python': ['requirements.txt', 'pyproject.toml', 'Pipfile', 'setup.py'], 63 'java': ['pom.xml', 'build.gradle', 'build.gradle.kts'], 64 } 65 66 for stack, files in indicators.items(): 67 for f in files: 68 if os.path.exists(os.path.join(cwd, f)): 69 stacks.add(stack) 70 break 71 72 # Extension-based fallback 73 if 'python' not in stacks and globmodule.glob(os.path.join(cwd, '*.py')): 74 stacks.add('python') 75 if 'rust' not in stacks and globmodule.glob(os.path.join(cwd, 'src', '*.rs')): 76 stacks.add('rust') 77 78 # CI/Workflow detection 79 workflow_files = globmodule.glob(os.path.join(cwd, '.forgejo', 'workflows', '*.yml')) + \ 80 globmodule.glob(os.path.join(cwd, '.github', 'workflows', '*.yml')) 81 82 for wf in workflow_files: 83 try: 84 with open(wf, 'r', encoding='utf-8') as f: 85 content = f.read() 86 if re.search(r'\bcargo\b', content): 87 stacks.add('rust') 88 if re.search(r'\b(npm|yarn|pnpm|bun)\b', content): 89 stacks.add('node') 90 if re.search(r'\b(go build|go test)\b', content): 91 stacks.add('go') 92 if re.search(r'\b(pip|python|pytest|poetry)\b', content): 93 stacks.add('python') 94 except Exception: 95 pass 96 97 if not stacks: 98 stacks.add('generic') 99 100 return stacks 101 102 103 # Compile patterns once for performance 104 SUPPRESS_PATTERNS = re.compile( 105 r'^(Downloading|Compiling|Fetching|Running|Created|Started|Updated|' 106 r'Installing|Waiting|Unpacking|Resolving|Building|Linking|Generating)\b', 107 re.IGNORECASE 108 ) 109 SUCCESS_PATTERNS = re.compile( 110 r'^(ok|passed|finished|success|completed|done|built|installed)$', 111 re.IGNORECASE 112 ) 113 RUST_ERROR = re.compile(r'error\[(E\d+)\]') 114 RUST_LOCATION = re.compile(r'-->\s+(.+:\d+)') 115 NODE_ERROR = re.compile(r'^(\w+Error):') 116 NODE_LOCATION = re.compile(r'\((.+:\d+:\d+)\)') 117 GO_ERROR = re.compile(r'^(.+\.go:\d+:\d+):\s+(.+)') 118 PYTHON_TRACEBACK = 'Traceback (most recent call last):' 119 PYTHON_LOCATION = re.compile(r'File "([^"]+)", line (\d+)(?:, in (.+))?') 120 PYTHON_ERROR = re.compile(r'^(\w+Error):') 121 GENERIC_ERROR = re.compile(r'\b(Error|Exception|Fail|Fatal)\b', re.IGNORECASE) 122 FILE_LOCATION = re.compile(r'(\S+\.\w+:\d+)') 123 124 125 def process_line(line, stacks): 126 """Process a single line of output, returning compressed form or None.""" 127 clean = strip_ansi(line).strip() 128 if not clean: 129 return None 130 131 # Suppress boilerplate 132 if SUPPRESS_PATTERNS.match(clean): 133 return None 134 135 # Success patterns -> dot 136 if SUCCESS_PATTERNS.match(clean): 137 return '.' 138 139 # Stack-specific error extraction 140 if 'rust' in stacks: 141 err_match = RUST_ERROR.search(clean) 142 if err_match: 143 return f"!ERR:{err_match.group(1)} {clean[err_match.end():].strip()}" 144 loc_match = RUST_LOCATION.search(clean) 145 if loc_match: 146 return f"@ {loc_match.group(1)}" 147 148 if 'node' in stacks: 149 err_match = NODE_ERROR.match(clean) 150 if err_match: 151 return f"!ERR:{err_match.group(1)} {clean[err_match.end():].strip()}" 152 loc_match = NODE_LOCATION.search(clean) 153 if loc_match: 154 return f"@ {loc_match.group(1)}" 155 156 if 'go' in stacks: 157 match = GO_ERROR.match(clean) 158 if match: 159 return f"!ERR:GoError @ {match.group(1)} {match.group(2)}" 160 161 if 'python' in stacks: 162 if clean.startswith(PYTHON_TRACEBACK): 163 return "!ERR:Traceback" 164 loc_match = PYTHON_LOCATION.match(clean) 165 if loc_match: 166 file_path = loc_match.group(1) 167 line_num = loc_match.group(2) 168 context = loc_match.group(3) or "" 169 return f"@ {file_path}:{line_num} {context}" 170 err_match = PYTHON_ERROR.match(clean) 171 if err_match: 172 return f"!ERR:{err_match.group(1)} {clean[err_match.end():].strip()}" 173 174 # Generic error fallback 175 if GENERIC_ERROR.search(clean): 176 file_match = FILE_LOCATION.search(clean) 177 loc = f" @ {file_match.group(1)}" if file_match else "" 178 return f"!ERR:GENERIC{loc} {clean}" 179 180 return clean 181 182 183 def try_resolve_alias(args): 184 """ 185 Check if first arg is an alias and resolve it. 186 Returns (resolved_command_list, was_alias) or (original_args, False). 187 """ 188 if not args: 189 return (args, False) 190 191 aliases = get_aliases_module() 192 if not aliases: 193 return (args, False) 194 195 # Parse alias and any key=value params 196 alias_name, params = aliases.parse_alias_invocation(args) 197 198 # Try to resolve 199 resolved = aliases.resolve_alias(alias_name, params) 200 if resolved: 201 # Check for unresolved placeholders 202 remaining = re.findall(r'\{\{(\w+)\}\}', resolved) 203 if remaining: 204 sys.stderr.write(f"!ERR:ALIAS Missing required args: {', '.join(remaining)}\n") 205 sys.stderr.write(f" Usage: r {alias_name} {' '.join(f'{p}=<value>' for p in remaining)}\n") 206 sys.exit(1) 207 208 # Split resolved command for execution 209 # Handle multi-line commands by joining with && 210 lines = [l.strip() for l in resolved.strip().split('\n') if l.strip()] 211 if len(lines) > 1: 212 # Multi-line: execute as shell command 213 return ([' && '.join(lines)], True) 214 else: 215 # Single line: split into args 216 import shlex 217 try: 218 return (shlex.split(resolved), True) 219 except ValueError: 220 return ([resolved], True) 221 222 return (args, False) 223 224 225 def main(): 226 if len(sys.argv) < 2: 227 print("Usage: r <command> [args...]") 228 print(" r <alias> [key=value...]") 229 print(" r --list # List available aliases") 230 print(" r --resolve <alias> # Show alias expansion") 231 print("\nUniversal proxy wrapper for Claude command execution.") 232 print("Supports alias expansion from cspec command templates.") 233 sys.exit(0) 234 235 # Handle special flags 236 if sys.argv[1] == '--list': 237 aliases = get_aliases_module() 238 if aliases: 239 print(aliases.list_aliases()) 240 else: 241 sys.stderr.write("!ERR:NOMODULE aliases.py not found\n") 242 sys.exit(1) 243 sys.exit(0) 244 245 if sys.argv[1] == '--resolve': 246 if len(sys.argv) < 3: 247 sys.stderr.write("!ERR:USAGE r --resolve <alias> [key=value...]\n") 248 sys.exit(1) 249 aliases = get_aliases_module() 250 if aliases: 251 alias_name, params = aliases.parse_alias_invocation(sys.argv[2:]) 252 resolved = aliases.resolve_alias(alias_name, params) 253 if resolved: 254 print(f"{alias_name} -> {resolved}") 255 else: 256 sys.stderr.write(f"!ERR:NOTFOUND Alias '{alias_name}' not found\n") 257 sys.exit(1) 258 else: 259 sys.stderr.write("!ERR:NOMODULE aliases.py not found\n") 260 sys.exit(1) 261 sys.exit(0) 262 263 if sys.argv[1] == '--generate': 264 aliases = get_aliases_module() 265 if aliases: 266 count = aliases.generate_aliases_json() 267 print(f"[S:PASS] Generated aliases.json with {count} aliases.") 268 else: 269 sys.stderr.write("!ERR:NOMODULE aliases.py not found\n") 270 sys.exit(1) 271 sys.exit(0) 272 273 # Try alias resolution first 274 command, was_alias = try_resolve_alias(sys.argv[1:]) 275 276 if was_alias: 277 sys.stderr.write(f"[ALIAS] -> {' '.join(command)}\n") 278 279 # Docker restriction (create .allow_docker to override) 280 if command and command[0] == 'docker': 281 if not os.path.exists(os.path.join(os.getcwd(), '.allow_docker')): 282 sys.stderr.write("!ERR:RESTRICTED Docker forbidden (Native Runner). Create .allow_docker to override.\n") 283 sys.exit(1) 284 285 stacks = detect_stacks() 286 287 process = None 288 dot_count = 0 289 290 # Determine if we need shell execution 291 # (for aliases with pipes, &&, or other shell constructs) 292 use_shell = was_alias and len(command) == 1 and any( 293 c in command[0] for c in ['|', '&&', '||', ';', '>', '<', '$'] 294 ) 295 296 try: 297 if use_shell: 298 process = subprocess.Popen( 299 command[0], 300 stdout=subprocess.PIPE, 301 stderr=subprocess.STDOUT, 302 text=True, 303 bufsize=1, 304 encoding='utf-8', 305 errors='replace', 306 shell=True 307 ) 308 else: 309 process = subprocess.Popen( 310 command, 311 stdout=subprocess.PIPE, 312 stderr=subprocess.STDOUT, 313 text=True, 314 bufsize=1, 315 encoding='utf-8', 316 errors='replace' 317 ) 318 319 while True: 320 line = process.stdout.readline() 321 if not line and process.poll() is not None: 322 break 323 if not line: 324 continue 325 326 result = process_line(line, stacks) 327 if result: 328 if result == '.': 329 dot_count += 1 330 # Batch dots for cleaner output 331 if dot_count % 10 == 0: 332 sys.stdout.write('.') 333 sys.stdout.flush() 334 elif result.startswith('!ERR'): 335 # Flush pending dots before error 336 if dot_count % 10 != 0: 337 sys.stdout.write('.') 338 sys.stdout.write('\n' + result + '\n') 339 sys.stdout.flush() 340 elif result.startswith('@'): 341 sys.stdout.write(result + '\n') 342 sys.stdout.flush() 343 else: 344 sys.stdout.write(result + '\n') 345 sys.stdout.flush() 346 347 process.wait() 348 349 # Final status 350 if process.returncode == 0: 351 sys.stdout.write('\n[S:PASS]\n') 352 else: 353 sys.stdout.write(f'\n[S:FAIL] exit={process.returncode}\n') 354 355 sys.exit(process.returncode) 356 357 except FileNotFoundError: 358 sys.stderr.write(f"!ERR:NOTFOUND Command not found: {command[0]}\n") 359 sys.exit(127) 360 except KeyboardInterrupt: 361 if process: 362 process.terminate() 363 sys.exit(130) 364 except Exception as e: 365 sys.stderr.write(f"!ERR:PROXY {e}\n") 366 sys.exit(1) 367 368 369 if __name__ == "__main__": 370 main()