Created
February 5, 2026 09:48
-
-
Save alehatsman/a9b270145fe6cf99e500e8e159bfc5ce to your computer and use it in GitHub Desktop.
claude_autoapprove.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Claude Code Auto-Approve Wrapper | |
| This wrapper starts Claude Code and: | |
| - Auto-approves permission prompts after a 5-second countdown | |
| - Presents actual user questions for real input | |
| - Allows normal typing into the input box | |
| """ | |
| import sys | |
| import os | |
| import select | |
| import threading | |
| import time | |
| import re | |
| from subprocess import Popen, PIPE, STDOUT | |
| import termios | |
| import tty | |
| import pty | |
| # ANSI escape code patterns | |
| ANSI_ESCAPE_PATTERN = re.compile(r'\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)') | |
| # Pattern for cursor movement codes specifically (should be replaced with space) | |
| ANSI_CURSOR_PATTERN = re.compile(r'\x1b\[[\d]*[ABCDEFG]') | |
| class ClaudeWrapper: | |
| def __init__(self, auto_approve_delay=5, debug=False): | |
| self.auto_approve_delay = auto_approve_delay | |
| self.debug = debug | |
| self.master_fd = None | |
| self.process = None | |
| self.original_tty = None | |
| self._countdown_running = False | |
| self._countdown_cancelled = threading.Event() | |
| self._countdown_thread = None | |
| def strip_ansi(self, text): | |
| """Remove ANSI escape codes from text, replacing cursor movements with spaces""" | |
| # First replace cursor movement codes with spaces | |
| text = ANSI_CURSOR_PATTERN.sub(' ', text) | |
| # Then remove all other ANSI codes | |
| text = ANSI_ESCAPE_PATTERN.sub('', text) | |
| # Clean up multiple spaces | |
| text = re.sub(r' +', ' ', text) | |
| return text | |
| def is_permission_prompt(self, text): | |
| """Detect if text is a permission prompt""" | |
| # Strip ANSI codes for cleaner matching | |
| clean_text = self.strip_ansi(text) | |
| # Claude Code shows "Permission rule" text before permission prompts | |
| if 'Permission rule' in clean_text: | |
| return True | |
| # Also check for the standard permission question format | |
| if 'Do you want to proceed?' in clean_text and ('1. Yes' in clean_text or '2. No' in clean_text): | |
| return True | |
| # Check for the help text that appears in permission prompts | |
| if 'Esc to cancel' in clean_text and 'Tab to amend' in clean_text: | |
| return True | |
| # Fallback patterns for other permission types | |
| permission_patterns = [ | |
| r'Allow.*\?\s*\(y/n\)', | |
| r'Proceed.*\?\s*\(y/n\)', | |
| r'Continue.*\?\s*\(y/n\)', | |
| r'Approve.*\?\s*\(y/n\)', | |
| ] | |
| for pattern in permission_patterns: | |
| if re.search(pattern, clean_text, re.IGNORECASE): | |
| return True | |
| return False | |
| def is_question_prompt(self, text): | |
| """Detect if text is an actual question (not a permission)""" | |
| # Questions from AskUserQuestion usually have multiple choice options | |
| # and don't match permission patterns | |
| if self.is_permission_prompt(text): | |
| return False | |
| question_patterns = [ | |
| r'\d+\)\s+', # Numbered options like "1) Option A" | |
| r'Select an option', | |
| r'Choose.*:', | |
| r'\[.*\].*\?', # [Option] format | |
| ] | |
| for pattern in question_patterns: | |
| if re.search(pattern, text, re.IGNORECASE): | |
| return True | |
| return False | |
| def countdown_and_approve(self, seconds): | |
| """Show countdown and auto-approve, cancellable by user input""" | |
| # Write to stderr to avoid interfering with stdout | |
| for i in range(seconds, 0, -1): | |
| # Check if cancelled | |
| if self._countdown_cancelled.is_set(): | |
| sys.stderr.write(f"\r\033[90m✗ Auto-approve cancelled" + " " * 30 + "\033[0m\n") | |
| sys.stderr.flush() | |
| return | |
| sys.stderr.write(f"\r\033[33m⏱ Auto-approving in {i} seconds... (any key=cancel, Ctrl+A=restart after cancel)\033[0m") | |
| sys.stderr.flush() | |
| time.sleep(1) | |
| # Final check before approving | |
| if self._countdown_cancelled.is_set(): | |
| sys.stderr.write(f"\r\033[90m✗ Auto-approve cancelled" + " " * 30 + "\033[0m\n") | |
| sys.stderr.flush() | |
| return | |
| sys.stderr.write("\r\033[32m✓ Auto-approved" + " " * 50 + "\033[0m\n") | |
| sys.stderr.flush() | |
| # Send '1' followed by Enter to select "Yes" option | |
| if self.debug: | |
| with open('/tmp/claude_wrapper_debug.log', 'a') as f: | |
| f.write(f"\n=== Sending approval ===\n") | |
| f.write(f"Writing to fd: {self.master_fd}\n") | |
| # Send the '1' key | |
| time.sleep(0.1) # Small delay to ensure prompt is ready | |
| bytes_written = os.write(self.master_fd, b'1') | |
| if self.debug: | |
| with open('/tmp/claude_wrapper_debug.log', 'a') as f: | |
| f.write(f"Wrote {bytes_written} bytes (character '1')\n") | |
| time.sleep(0.1) | |
| # Send Enter - try \r (carriage return) which is what terminals usually use | |
| bytes_written = os.write(self.master_fd, b'\r') | |
| if self.debug: | |
| with open('/tmp/claude_wrapper_debug.log', 'a') as f: | |
| f.write(f"Wrote {bytes_written} bytes (Enter key)\n") | |
| def handle_output(self, buffer): | |
| """Process output buffer and detect prompts""" | |
| if self.debug: | |
| # Write debug info to a log file instead of stdout | |
| clean_text = self.strip_ansi(buffer) | |
| with open('/tmp/claude_wrapper_debug.log', 'a') as f: | |
| f.write(f"\n=== Buffer Check ===\n") | |
| f.write(f"Buffer length: {len(buffer)}\n") | |
| f.write(f"Last 200 chars raw: {repr(buffer[-200:])}\n") | |
| f.write(f"Last 200 chars clean: {repr(clean_text[-200:])}\n") | |
| f.write(f"Contains 'Permission rule': {'Permission rule' in clean_text}\n") | |
| f.write(f"Contains 'Do you want to proceed': {'Do you want to proceed' in clean_text}\n") | |
| f.write(f"Contains 'Esc to cancel': {'Esc to cancel' in clean_text}\n") | |
| f.write(f"Is permission prompt: {self.is_permission_prompt(buffer)}\n") | |
| # Check if it's a permission prompt | |
| if self.is_permission_prompt(buffer): | |
| # Only start countdown if one isn't already running | |
| if not self._countdown_running: | |
| self._countdown_running = True | |
| self._countdown_cancelled.clear() # Reset cancellation flag | |
| def countdown_wrapper(): | |
| self.countdown_and_approve(self.auto_approve_delay) | |
| self._countdown_running = False | |
| # Start countdown in a separate thread | |
| self._countdown_thread = threading.Thread(target=countdown_wrapper) | |
| self._countdown_thread.daemon = True | |
| self._countdown_thread.start() | |
| return True | |
| return False | |
| def cancel_countdown(self): | |
| """Cancel any running countdown""" | |
| if self._countdown_running: | |
| self._countdown_cancelled.set() | |
| if self.debug: | |
| with open('/tmp/claude_wrapper_debug.log', 'a') as f: | |
| f.write(f"\n=== Countdown cancelled by user input ===\n") | |
| def run(self, args=None): | |
| """Run Claude Code with the wrapper""" | |
| # Build command | |
| cmd = ['claude'] | |
| if args: | |
| cmd.extend(args) | |
| # Save original terminal settings | |
| if sys.stdin.isatty(): | |
| self.original_tty = termios.tcgetattr(sys.stdin) | |
| try: | |
| # Create pseudo-terminal | |
| self.master_fd, slave_fd = pty.openpty() | |
| # Start Claude process | |
| self.process = Popen( | |
| cmd, | |
| stdin=slave_fd, | |
| stdout=slave_fd, | |
| stderr=slave_fd, | |
| close_fds=True | |
| ) | |
| os.close(slave_fd) | |
| # Set terminal to raw mode for pass-through | |
| if sys.stdin.isatty(): | |
| tty.setraw(sys.stdin.fileno()) | |
| output_buffer = "" | |
| while True: | |
| # Check if process is still alive | |
| if self.process.poll() is not None: | |
| break | |
| # Use select to monitor both stdin and master_fd | |
| r, w, e = select.select([sys.stdin, self.master_fd], [], [], 0.1) | |
| # Handle input from user | |
| if sys.stdin in r: | |
| try: | |
| char = os.read(sys.stdin.fileno(), 1024) | |
| if char: | |
| # Check for special commands | |
| # Ctrl+A (0x01) = restart auto-approve countdown | |
| if char == b'\x01' and not self._countdown_running: | |
| sys.stderr.write("\r\033[36m↻ Restarting auto-approve countdown...\033[0m\n") | |
| sys.stderr.flush() | |
| # Restart countdown | |
| self._countdown_running = True | |
| self._countdown_cancelled.clear() | |
| def countdown_wrapper(): | |
| self.countdown_and_approve(self.auto_approve_delay) | |
| self._countdown_running = False | |
| self._countdown_thread = threading.Thread(target=countdown_wrapper) | |
| self._countdown_thread.daemon = True | |
| self._countdown_thread.start() | |
| continue # Don't forward Ctrl+A to Claude | |
| # Cancel countdown if user presses any other key | |
| if self._countdown_running: | |
| self.cancel_countdown() | |
| # Forward the input to Claude | |
| os.write(self.master_fd, char) | |
| except OSError: | |
| break | |
| # Handle output from Claude | |
| if self.master_fd in r: | |
| try: | |
| data = os.read(self.master_fd, 1024) | |
| if not data: | |
| break | |
| # Write to stdout | |
| os.write(sys.stdout.fileno(), data) | |
| # Add to buffer for prompt detection | |
| output_buffer += data.decode('utf-8', errors='ignore') | |
| # Keep only last 1000 chars in buffer | |
| if len(output_buffer) > 1000: | |
| output_buffer = output_buffer[-1000:] | |
| # Check for prompts | |
| self.handle_output(output_buffer) | |
| except OSError: | |
| break | |
| # Wait for process to finish | |
| self.process.wait() | |
| return self.process.returncode | |
| finally: | |
| # Restore terminal settings | |
| if self.original_tty and sys.stdin.isatty(): | |
| termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.original_tty) | |
| if self.master_fd: | |
| os.close(self.master_fd) | |
| def main(): | |
| """Main entry point""" | |
| import argparse | |
| parser = argparse.ArgumentParser( | |
| description='Claude Code wrapper with auto-approve for permissions' | |
| ) | |
| parser.add_argument( | |
| '--delay', | |
| type=int, | |
| default=5, | |
| help='Seconds to wait before auto-approving (default: 5)' | |
| ) | |
| parser.add_argument( | |
| '--debug', | |
| action='store_true', | |
| help='Enable debug logging to /tmp/claude_wrapper_debug.log' | |
| ) | |
| parser.add_argument( | |
| 'claude_args', | |
| nargs='*', | |
| help='Arguments to pass to Claude Code' | |
| ) | |
| args = parser.parse_args() | |
| if args.debug: | |
| # Clear debug log | |
| with open('/tmp/claude_wrapper_debug.log', 'w') as f: | |
| f.write("=== Claude Wrapper Debug Log ===\n") | |
| wrapper = ClaudeWrapper(auto_approve_delay=args.delay, debug=args.debug) | |
| try: | |
| exit_code = wrapper.run(args.claude_args) | |
| sys.exit(exit_code if exit_code is not None else 0) | |
| except KeyboardInterrupt: | |
| print("\n\nInterrupted by user") | |
| sys.exit(130) | |
| except Exception as e: | |
| print(f"\n\nError: {e}", file=sys.stderr) | |
| sys.exit(1) | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment