/ tools / r-wrapper / 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  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()