Skip to content

Instantly share code, notes, and snippets.

@alehatsman
Created February 5, 2026 09:48
Show Gist options
  • Select an option

  • Save alehatsman/a9b270145fe6cf99e500e8e159bfc5ce to your computer and use it in GitHub Desktop.

Select an option

Save alehatsman/a9b270145fe6cf99e500e8e159bfc5ce to your computer and use it in GitHub Desktop.
claude_autoapprove.py
#!/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