minivibe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/pty-wrapper.py ADDED
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PTY wrapper for Claude Code.
4
+ Creates a pseudo-terminal and runs Claude, allowing both terminal and piped input.
5
+ Also captures permission prompts and outputs them as JSON to a separate FD.
6
+ """
7
+ import os
8
+ import sys
9
+ import pty
10
+ import select
11
+ import signal
12
+ import termios
13
+ import tty
14
+ import struct
15
+ import fcntl
16
+ import re
17
+ import json
18
+
19
+ # Buffer for detecting permission prompts
20
+ output_buffer = ""
21
+ PROMPT_FD = 3 # File descriptor for permission prompt output
22
+
23
+ def set_winsize(fd, rows, cols):
24
+ """Set terminal window size."""
25
+ winsize = struct.pack('HHHH', rows, cols, 0, 0)
26
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
27
+
28
+ def strip_ansi(text):
29
+ """Remove ANSI escape codes from text."""
30
+ ansi_pattern = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b[PX^_].*?\x1b\\')
31
+ return ansi_pattern.sub('', text)
32
+
33
+ def detect_permission_prompt(text):
34
+ """
35
+ Detect Claude's permission prompt in terminal output.
36
+ Returns parsed prompt data or None.
37
+ """
38
+ clean = strip_ansi(text)
39
+
40
+ # Look for the permission prompt pattern
41
+ # Claude shows: "Do you want to proceed?" or similar, followed by numbered options
42
+ lines = clean.split('\n')
43
+
44
+ options = []
45
+ question = None
46
+
47
+ for i, line in enumerate(lines):
48
+ line = line.strip()
49
+
50
+ # Detect question line
51
+ if 'want to' in line.lower() or 'allow' in line.lower() or 'proceed' in line.lower():
52
+ if '?' in line:
53
+ question = line
54
+
55
+ # Detect numbered options (1. Yes, 2. Yes and don't ask, 3. Type here)
56
+ match = re.match(r'^[›\s]*(\d+)\.\s+(.+)$', line)
57
+ if match:
58
+ opt_id = int(match.group(1))
59
+ opt_label = match.group(2).strip()
60
+ options.append({
61
+ 'id': opt_id,
62
+ 'label': opt_label,
63
+ 'requiresInput': 'type' in opt_label.lower() or 'tell' in opt_label.lower()
64
+ })
65
+
66
+ # Only return if we found valid options
67
+ if len(options) >= 2:
68
+ return {
69
+ 'type': 'permission_prompt',
70
+ 'question': question or 'Permission required',
71
+ 'options': options
72
+ }
73
+
74
+ return None
75
+
76
+ def send_prompt_to_fd(prompt_data):
77
+ """Send detected prompt to FD 3 if available."""
78
+ try:
79
+ # Check if FD 3 is open
80
+ os.fstat(PROMPT_FD)
81
+ json_line = json.dumps(prompt_data) + '\n'
82
+ os.write(PROMPT_FD, json_line.encode('utf-8'))
83
+ # Debug: also write to stderr so we can see it
84
+ sys.stderr.write(f"[PTY] Detected prompt: {prompt_data.get('question', 'unknown')}, options: {len(prompt_data.get('options', []))}\n")
85
+ sys.stderr.flush()
86
+ except OSError:
87
+ # FD 3 not available, skip
88
+ pass
89
+
90
+ def main():
91
+ # Get command to run
92
+ if len(sys.argv) < 2:
93
+ print("Usage: pty-wrapper.py <command> [args...]", file=sys.stderr)
94
+ sys.exit(1)
95
+
96
+ cmd = sys.argv[1:]
97
+
98
+ # Save original terminal settings if stdin is a tty
99
+ stdin_is_tty = os.isatty(sys.stdin.fileno())
100
+ if stdin_is_tty:
101
+ old_settings = termios.tcgetattr(sys.stdin)
102
+
103
+ # Create pseudo-terminal
104
+ master_fd, slave_fd = pty.openpty()
105
+
106
+ # Get terminal size and apply to PTY
107
+ if stdin_is_tty:
108
+ try:
109
+ rows, cols = os.get_terminal_size()
110
+ set_winsize(slave_fd, rows, cols)
111
+ except OSError:
112
+ set_winsize(slave_fd, 24, 80)
113
+ else:
114
+ set_winsize(slave_fd, 24, 80)
115
+
116
+ # Fork process
117
+ pid = os.fork()
118
+
119
+ if pid == 0:
120
+ # Child process
121
+ os.close(master_fd)
122
+
123
+ # Create new session and set controlling terminal
124
+ os.setsid()
125
+ fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
126
+
127
+ # Redirect stdio to slave PTY
128
+ os.dup2(slave_fd, 0)
129
+ os.dup2(slave_fd, 1)
130
+ os.dup2(slave_fd, 2)
131
+
132
+ if slave_fd > 2:
133
+ os.close(slave_fd)
134
+
135
+ # Execute command
136
+ os.execvp(cmd[0], cmd)
137
+
138
+ # Parent process
139
+ os.close(slave_fd)
140
+
141
+ # Set stdin to raw mode if it's a tty
142
+ if stdin_is_tty:
143
+ tty.setraw(sys.stdin.fileno())
144
+
145
+ # Handle window resize
146
+ def handle_sigwinch(signum, frame):
147
+ if stdin_is_tty:
148
+ try:
149
+ rows, cols = os.get_terminal_size()
150
+ set_winsize(master_fd, rows, cols)
151
+ except OSError:
152
+ pass
153
+
154
+ signal.signal(signal.SIGWINCH, handle_sigwinch)
155
+
156
+ exit_code = 0
157
+ try:
158
+ while True:
159
+ # Wait for data from stdin or master PTY
160
+ rlist = [sys.stdin.fileno(), master_fd]
161
+ try:
162
+ readable, _, _ = select.select(rlist, [], [], 0.1)
163
+ except select.error:
164
+ continue
165
+
166
+ if sys.stdin.fileno() in readable:
167
+ # Data from stdin -> send to PTY
168
+ try:
169
+ data = os.read(sys.stdin.fileno(), 1024)
170
+ if not data:
171
+ break
172
+ os.write(master_fd, data)
173
+ except OSError:
174
+ break
175
+
176
+ if master_fd in readable:
177
+ # Data from PTY -> send to stdout and check for permission prompts
178
+ global output_buffer
179
+ try:
180
+ data = os.read(master_fd, 1024)
181
+ if not data:
182
+ break
183
+ os.write(sys.stdout.fileno(), data)
184
+ sys.stdout.flush()
185
+
186
+ # Buffer output for prompt detection
187
+ try:
188
+ text = data.decode('utf-8', errors='replace')
189
+ output_buffer += text
190
+ # Keep buffer reasonable size (last 2KB)
191
+ if len(output_buffer) > 2048:
192
+ output_buffer = output_buffer[-2048:]
193
+
194
+ # Try to detect permission prompt
195
+ prompt = detect_permission_prompt(output_buffer)
196
+ if prompt:
197
+ send_prompt_to_fd(prompt)
198
+ # Clear buffer after sending prompt
199
+ output_buffer = ""
200
+ except Exception:
201
+ pass
202
+ except OSError:
203
+ break
204
+
205
+ # Check if child has exited
206
+ result = os.waitpid(pid, os.WNOHANG)
207
+ if result[0] != 0:
208
+ exit_code = os.WEXITSTATUS(result[1]) if os.WIFEXITED(result[1]) else 1
209
+ break
210
+
211
+ except KeyboardInterrupt:
212
+ # Send SIGINT to child
213
+ os.kill(pid, signal.SIGINT)
214
+ os.waitpid(pid, 0)
215
+
216
+ finally:
217
+ # Restore terminal settings
218
+ if stdin_is_tty:
219
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
220
+ os.close(master_fd)
221
+
222
+ sys.exit(exit_code)
223
+
224
+ if __name__ == '__main__':
225
+ main()