claude-code-tools 0.1.8__py3-none-any.whl
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.
Potentially problematic release.
This version of claude-code-tools might be problematic. Click here for more details.
- claude_code_tools/__init__.py +3 -0
- claude_code_tools/dotenv_vault.py +268 -0
- claude_code_tools/find_claude_session.py +523 -0
- claude_code_tools/tmux_cli_controller.py +705 -0
- claude_code_tools-0.1.8.dist-info/METADATA +313 -0
- claude_code_tools-0.1.8.dist-info/RECORD +8 -0
- claude_code_tools-0.1.8.dist-info/WHEEL +4 -0
- claude_code_tools-0.1.8.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tmux CLI Controller for Claude Code
|
|
4
|
+
This script provides functions to interact with CLI applications running in tmux panes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import re
|
|
10
|
+
from typing import Optional, List, Dict, Tuple, Callable, Union
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import hashlib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TmuxCLIController:
|
|
18
|
+
"""Controller for interacting with CLI applications in tmux panes."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, session_name: Optional[str] = None, window_name: Optional[str] = None):
|
|
21
|
+
"""
|
|
22
|
+
Initialize the controller.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
session_name: Name of tmux session (defaults to current)
|
|
26
|
+
window_name: Name of tmux window (defaults to current)
|
|
27
|
+
"""
|
|
28
|
+
self.session_name = session_name
|
|
29
|
+
self.window_name = window_name
|
|
30
|
+
self.target_pane = None
|
|
31
|
+
|
|
32
|
+
def _run_tmux_command(self, command: List[str]) -> Tuple[str, int]:
|
|
33
|
+
"""
|
|
34
|
+
Run a tmux command and return output and exit code.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
command: List of command components
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tuple of (output, exit_code)
|
|
41
|
+
"""
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
['tmux'] + command,
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True
|
|
46
|
+
)
|
|
47
|
+
return result.stdout.strip(), result.returncode
|
|
48
|
+
|
|
49
|
+
def get_current_session(self) -> Optional[str]:
|
|
50
|
+
"""Get the name of the current tmux session."""
|
|
51
|
+
output, code = self._run_tmux_command(['display-message', '-p', '#{session_name}'])
|
|
52
|
+
return output if code == 0 else None
|
|
53
|
+
|
|
54
|
+
def get_current_window(self) -> Optional[str]:
|
|
55
|
+
"""Get the name of the current tmux window."""
|
|
56
|
+
output, code = self._run_tmux_command(['display-message', '-p', '#{window_name}'])
|
|
57
|
+
return output if code == 0 else None
|
|
58
|
+
|
|
59
|
+
def get_current_pane(self) -> Optional[str]:
|
|
60
|
+
"""Get the ID of the current tmux pane."""
|
|
61
|
+
output, code = self._run_tmux_command(['display-message', '-p', '#{pane_id}'])
|
|
62
|
+
return output if code == 0 else None
|
|
63
|
+
|
|
64
|
+
def list_panes(self) -> List[Dict[str, str]]:
|
|
65
|
+
"""
|
|
66
|
+
List all panes in the current window.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List of dicts with pane info (id, index, title, active, size)
|
|
70
|
+
"""
|
|
71
|
+
target = f"{self.session_name}:{self.window_name}" if self.session_name and self.window_name else ""
|
|
72
|
+
|
|
73
|
+
output, code = self._run_tmux_command([
|
|
74
|
+
'list-panes',
|
|
75
|
+
'-t', target,
|
|
76
|
+
'-F', '#{pane_id}|#{pane_index}|#{pane_title}|#{pane_active}|#{pane_width}x#{pane_height}'
|
|
77
|
+
] if target else [
|
|
78
|
+
'list-panes',
|
|
79
|
+
'-F', '#{pane_id}|#{pane_index}|#{pane_title}|#{pane_active}|#{pane_width}x#{pane_height}'
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
if code != 0:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
panes = []
|
|
86
|
+
for line in output.split('\n'):
|
|
87
|
+
if line:
|
|
88
|
+
parts = line.split('|')
|
|
89
|
+
panes.append({
|
|
90
|
+
'id': parts[0],
|
|
91
|
+
'index': parts[1],
|
|
92
|
+
'title': parts[2],
|
|
93
|
+
'active': parts[3] == '1',
|
|
94
|
+
'size': parts[4]
|
|
95
|
+
})
|
|
96
|
+
return panes
|
|
97
|
+
|
|
98
|
+
def create_pane(self, vertical: bool = True, size: Optional[int] = None,
|
|
99
|
+
start_command: Optional[str] = None) -> Optional[str]:
|
|
100
|
+
"""
|
|
101
|
+
Create a new pane in the current window.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
vertical: If True, split vertically (side by side), else horizontally
|
|
105
|
+
size: Size percentage for the new pane (e.g., 50 for 50%)
|
|
106
|
+
start_command: Command to run in the new pane
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Pane ID of the created pane
|
|
110
|
+
"""
|
|
111
|
+
cmd = ['split-window']
|
|
112
|
+
|
|
113
|
+
if vertical:
|
|
114
|
+
cmd.append('-h')
|
|
115
|
+
else:
|
|
116
|
+
cmd.append('-v')
|
|
117
|
+
|
|
118
|
+
if size:
|
|
119
|
+
cmd.extend(['-p', str(size)])
|
|
120
|
+
|
|
121
|
+
cmd.extend(['-P', '-F', '#{pane_id}'])
|
|
122
|
+
|
|
123
|
+
if start_command:
|
|
124
|
+
cmd.append(start_command)
|
|
125
|
+
|
|
126
|
+
output, code = self._run_tmux_command(cmd)
|
|
127
|
+
|
|
128
|
+
if code == 0:
|
|
129
|
+
self.target_pane = output
|
|
130
|
+
return output
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def select_pane(self, pane_id: Optional[str] = None, pane_index: Optional[int] = None):
|
|
134
|
+
"""
|
|
135
|
+
Select a pane as the target for operations.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
pane_id: Pane ID (e.g., %0, %1)
|
|
139
|
+
pane_index: Pane index (0-based)
|
|
140
|
+
"""
|
|
141
|
+
if pane_id:
|
|
142
|
+
self.target_pane = pane_id
|
|
143
|
+
elif pane_index is not None:
|
|
144
|
+
panes = self.list_panes()
|
|
145
|
+
for pane in panes:
|
|
146
|
+
if int(pane['index']) == pane_index:
|
|
147
|
+
self.target_pane = pane['id']
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
def send_keys(self, text: str, pane_id: Optional[str] = None, enter: bool = True,
|
|
151
|
+
delay_enter: Union[bool, float] = True):
|
|
152
|
+
"""
|
|
153
|
+
Send keystrokes to a pane.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
text: Text to send
|
|
157
|
+
pane_id: Target pane (uses self.target_pane if not specified)
|
|
158
|
+
enter: Whether to send Enter key after text
|
|
159
|
+
delay_enter: If True, use 1.0s delay; if float, use that delay in seconds (default: True)
|
|
160
|
+
"""
|
|
161
|
+
target = pane_id or self.target_pane
|
|
162
|
+
if not target:
|
|
163
|
+
raise ValueError("No target pane specified")
|
|
164
|
+
|
|
165
|
+
if enter and delay_enter:
|
|
166
|
+
# Send text without Enter first
|
|
167
|
+
cmd = ['send-keys', '-t', target, text]
|
|
168
|
+
self._run_tmux_command(cmd)
|
|
169
|
+
|
|
170
|
+
# Determine delay duration
|
|
171
|
+
if isinstance(delay_enter, bool):
|
|
172
|
+
delay = 1.0 # Default delay
|
|
173
|
+
else:
|
|
174
|
+
delay = float(delay_enter)
|
|
175
|
+
|
|
176
|
+
# Apply delay
|
|
177
|
+
time.sleep(delay)
|
|
178
|
+
|
|
179
|
+
# Then send just Enter
|
|
180
|
+
cmd = ['send-keys', '-t', target, 'Enter']
|
|
181
|
+
self._run_tmux_command(cmd)
|
|
182
|
+
else:
|
|
183
|
+
# Original behavior
|
|
184
|
+
cmd = ['send-keys', '-t', target, text]
|
|
185
|
+
if enter:
|
|
186
|
+
cmd.append('Enter')
|
|
187
|
+
self._run_tmux_command(cmd)
|
|
188
|
+
|
|
189
|
+
def capture_pane(self, pane_id: Optional[str] = None, lines: Optional[int] = None) -> str:
|
|
190
|
+
"""
|
|
191
|
+
Capture the contents of a pane.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
pane_id: Target pane (uses self.target_pane if not specified)
|
|
195
|
+
lines: Number of lines to capture from bottom (captures all if None)
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Captured text content
|
|
199
|
+
"""
|
|
200
|
+
target = pane_id or self.target_pane
|
|
201
|
+
if not target:
|
|
202
|
+
raise ValueError("No target pane specified")
|
|
203
|
+
|
|
204
|
+
cmd = ['capture-pane', '-t', target, '-p']
|
|
205
|
+
|
|
206
|
+
if lines:
|
|
207
|
+
cmd.extend(['-S', f'-{lines}'])
|
|
208
|
+
|
|
209
|
+
output, _ = self._run_tmux_command(cmd)
|
|
210
|
+
return output
|
|
211
|
+
|
|
212
|
+
def wait_for_prompt(self, prompt_pattern: str, pane_id: Optional[str] = None,
|
|
213
|
+
timeout: int = 10, check_interval: float = 0.5) -> bool:
|
|
214
|
+
"""
|
|
215
|
+
Wait for a specific prompt pattern to appear in the pane.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
prompt_pattern: Regex pattern to match
|
|
219
|
+
pane_id: Target pane
|
|
220
|
+
timeout: Maximum seconds to wait
|
|
221
|
+
check_interval: Seconds between checks
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if prompt found, False if timeout
|
|
225
|
+
"""
|
|
226
|
+
target = pane_id or self.target_pane
|
|
227
|
+
if not target:
|
|
228
|
+
raise ValueError("No target pane specified")
|
|
229
|
+
|
|
230
|
+
pattern = re.compile(prompt_pattern)
|
|
231
|
+
start_time = time.time()
|
|
232
|
+
|
|
233
|
+
while time.time() - start_time < timeout:
|
|
234
|
+
content = self.capture_pane(target, lines=50)
|
|
235
|
+
if pattern.search(content):
|
|
236
|
+
return True
|
|
237
|
+
time.sleep(check_interval)
|
|
238
|
+
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
def wait_for_idle(self, pane_id: Optional[str] = None, idle_time: float = 2.0,
|
|
242
|
+
check_interval: float = 0.5, timeout: Optional[int] = None) -> bool:
|
|
243
|
+
"""
|
|
244
|
+
Wait for a pane to become idle (no output changes for idle_time seconds).
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
pane_id: Target pane
|
|
248
|
+
idle_time: Seconds of no change to consider idle
|
|
249
|
+
check_interval: Seconds between checks
|
|
250
|
+
timeout: Maximum seconds to wait (None for no timeout)
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
True if idle detected, False if timeout
|
|
254
|
+
"""
|
|
255
|
+
target = pane_id or self.target_pane
|
|
256
|
+
if not target:
|
|
257
|
+
raise ValueError("No target pane specified")
|
|
258
|
+
|
|
259
|
+
start_time = time.time()
|
|
260
|
+
last_change_time = time.time()
|
|
261
|
+
last_hash = ""
|
|
262
|
+
|
|
263
|
+
while True:
|
|
264
|
+
if timeout and (time.time() - start_time > timeout):
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
content = self.capture_pane(target)
|
|
268
|
+
content_hash = hashlib.md5(content.encode()).hexdigest()
|
|
269
|
+
|
|
270
|
+
if content_hash != last_hash:
|
|
271
|
+
last_hash = content_hash
|
|
272
|
+
last_change_time = time.time()
|
|
273
|
+
elif time.time() - last_change_time >= idle_time:
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
time.sleep(check_interval)
|
|
277
|
+
|
|
278
|
+
def kill_pane(self, pane_id: Optional[str] = None):
|
|
279
|
+
"""
|
|
280
|
+
Kill a pane.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
pane_id: Target pane (uses self.target_pane if not specified)
|
|
284
|
+
"""
|
|
285
|
+
target = pane_id or self.target_pane
|
|
286
|
+
if not target:
|
|
287
|
+
raise ValueError("No target pane specified")
|
|
288
|
+
|
|
289
|
+
# Safety check: prevent killing own pane ONLY when explicitly specified
|
|
290
|
+
# If using target_pane (a pane we created), it should be safe to kill
|
|
291
|
+
if pane_id is not None: # Only check when pane_id was explicitly provided
|
|
292
|
+
current_pane = self.get_current_pane()
|
|
293
|
+
if current_pane and target == current_pane:
|
|
294
|
+
raise ValueError("Error: Cannot kill own pane! This would terminate your session.")
|
|
295
|
+
|
|
296
|
+
self._run_tmux_command(['kill-pane', '-t', target])
|
|
297
|
+
|
|
298
|
+
if target == self.target_pane:
|
|
299
|
+
self.target_pane = None
|
|
300
|
+
|
|
301
|
+
def resize_pane(self, direction: str, amount: int = 5, pane_id: Optional[str] = None):
|
|
302
|
+
"""
|
|
303
|
+
Resize a pane.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
direction: One of 'up', 'down', 'left', 'right'
|
|
307
|
+
amount: Number of cells to resize
|
|
308
|
+
pane_id: Target pane
|
|
309
|
+
"""
|
|
310
|
+
target = pane_id or self.target_pane
|
|
311
|
+
if not target:
|
|
312
|
+
raise ValueError("No target pane specified")
|
|
313
|
+
|
|
314
|
+
direction_map = {
|
|
315
|
+
'up': '-U',
|
|
316
|
+
'down': '-D',
|
|
317
|
+
'left': '-L',
|
|
318
|
+
'right': '-R'
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if direction not in direction_map:
|
|
322
|
+
raise ValueError(f"Invalid direction: {direction}")
|
|
323
|
+
|
|
324
|
+
self._run_tmux_command(['resize-pane', '-t', target, direction_map[direction], str(amount)])
|
|
325
|
+
|
|
326
|
+
def focus_pane(self, pane_id: Optional[str] = None):
|
|
327
|
+
"""
|
|
328
|
+
Focus (select) a pane.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
pane_id: Target pane
|
|
332
|
+
"""
|
|
333
|
+
target = pane_id or self.target_pane
|
|
334
|
+
if not target:
|
|
335
|
+
raise ValueError("No target pane specified")
|
|
336
|
+
|
|
337
|
+
self._run_tmux_command(['select-pane', '-t', target])
|
|
338
|
+
|
|
339
|
+
def send_interrupt(self, pane_id: Optional[str] = None):
|
|
340
|
+
"""
|
|
341
|
+
Send Ctrl+C to a pane.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
pane_id: Target pane
|
|
345
|
+
"""
|
|
346
|
+
target = pane_id or self.target_pane
|
|
347
|
+
if not target:
|
|
348
|
+
raise ValueError("No target pane specified")
|
|
349
|
+
|
|
350
|
+
self._run_tmux_command(['send-keys', '-t', target, 'C-c'])
|
|
351
|
+
|
|
352
|
+
def send_escape(self, pane_id: Optional[str] = None):
|
|
353
|
+
"""
|
|
354
|
+
Send Escape key to a pane.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
pane_id: Target pane
|
|
358
|
+
"""
|
|
359
|
+
target = pane_id or self.target_pane
|
|
360
|
+
if not target:
|
|
361
|
+
raise ValueError("No target pane specified")
|
|
362
|
+
|
|
363
|
+
self._run_tmux_command(['send-keys', '-t', target, 'Escape'])
|
|
364
|
+
|
|
365
|
+
def clear_pane(self, pane_id: Optional[str] = None):
|
|
366
|
+
"""
|
|
367
|
+
Clear the pane screen.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
pane_id: Target pane
|
|
371
|
+
"""
|
|
372
|
+
target = pane_id or self.target_pane
|
|
373
|
+
if not target:
|
|
374
|
+
raise ValueError("No target pane specified")
|
|
375
|
+
|
|
376
|
+
self._run_tmux_command(['send-keys', '-t', target, 'C-l'])
|
|
377
|
+
|
|
378
|
+
def launch_cli(self, command: str, vertical: bool = True, size: int = 50) -> Optional[str]:
|
|
379
|
+
"""
|
|
380
|
+
Convenience method to launch a CLI application in a new pane.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
command: Command to launch
|
|
384
|
+
vertical: Split direction
|
|
385
|
+
size: Pane size percentage
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Pane ID of the created pane
|
|
389
|
+
"""
|
|
390
|
+
return self.create_pane(vertical=vertical, size=size, start_command=command)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class CLI:
|
|
394
|
+
"""Unified CLI interface that auto-detects tmux environment.
|
|
395
|
+
|
|
396
|
+
Automatically uses:
|
|
397
|
+
- TmuxCLIController when inside tmux (for pane management)
|
|
398
|
+
- RemoteTmuxController when outside tmux (for window management)
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(self, session: Optional[str] = None):
|
|
402
|
+
"""Initialize with auto-detection of tmux environment.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
session: Optional session name for remote mode (ignored in local mode)
|
|
406
|
+
"""
|
|
407
|
+
self.in_tmux = bool(os.environ.get('TMUX'))
|
|
408
|
+
|
|
409
|
+
if self.in_tmux:
|
|
410
|
+
# Inside tmux - use local controller
|
|
411
|
+
self.controller = TmuxCLIController()
|
|
412
|
+
self.mode = 'local'
|
|
413
|
+
else:
|
|
414
|
+
# Outside tmux - use remote controller
|
|
415
|
+
from .tmux_remote_controller import RemoteTmuxController
|
|
416
|
+
session_name = session or "remote-cli-session"
|
|
417
|
+
self.controller = RemoteTmuxController(session_name=session_name)
|
|
418
|
+
self.mode = 'remote'
|
|
419
|
+
|
|
420
|
+
def list_panes(self):
|
|
421
|
+
"""List all panes in current window."""
|
|
422
|
+
panes = self.controller.list_panes()
|
|
423
|
+
print(json.dumps(panes, indent=2))
|
|
424
|
+
|
|
425
|
+
def launch(self, command: str, vertical: bool = True, size: int = 50, name: Optional[str] = None):
|
|
426
|
+
"""Launch a command in a new pane/window.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
command: Command to launch
|
|
430
|
+
vertical: Split direction (only used in local mode)
|
|
431
|
+
size: Pane size percentage (only used in local mode)
|
|
432
|
+
name: Window name (only used in remote mode)
|
|
433
|
+
"""
|
|
434
|
+
if self.mode == 'local':
|
|
435
|
+
pane_id = self.controller.launch_cli(command, vertical=vertical, size=size)
|
|
436
|
+
print(f"Launched in pane: {pane_id}")
|
|
437
|
+
else:
|
|
438
|
+
# Remote mode
|
|
439
|
+
pane_id = self.controller.launch_cli(command, name=name)
|
|
440
|
+
print(f"Launched in window: {pane_id}")
|
|
441
|
+
return pane_id
|
|
442
|
+
|
|
443
|
+
def send(self, text: str, pane: Optional[str] = None, enter: bool = True,
|
|
444
|
+
delay_enter: Union[bool, float] = True):
|
|
445
|
+
"""Send text to a pane.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
text: Text to send
|
|
449
|
+
pane: Target pane ID or index
|
|
450
|
+
enter: Whether to send Enter key after text
|
|
451
|
+
delay_enter: If True, use 1.0s delay; if float, use that delay in seconds (default: True)
|
|
452
|
+
"""
|
|
453
|
+
if self.mode == 'local':
|
|
454
|
+
# Local mode - use select_pane
|
|
455
|
+
if pane:
|
|
456
|
+
if pane.isdigit():
|
|
457
|
+
self.controller.select_pane(pane_index=int(pane))
|
|
458
|
+
else:
|
|
459
|
+
self.controller.select_pane(pane_id=pane)
|
|
460
|
+
self.controller.send_keys(text, enter=enter, delay_enter=delay_enter)
|
|
461
|
+
else:
|
|
462
|
+
# Remote mode - pass pane_id directly
|
|
463
|
+
self.controller.send_keys(text, pane_id=pane, enter=enter,
|
|
464
|
+
delay_enter=delay_enter)
|
|
465
|
+
print("Text sent")
|
|
466
|
+
|
|
467
|
+
def capture(self, pane: Optional[str] = None, lines: Optional[int] = None):
|
|
468
|
+
"""Capture and print pane content."""
|
|
469
|
+
if self.mode == 'local':
|
|
470
|
+
# Local mode - use select_pane
|
|
471
|
+
if pane:
|
|
472
|
+
if pane.isdigit():
|
|
473
|
+
self.controller.select_pane(pane_index=int(pane))
|
|
474
|
+
else:
|
|
475
|
+
self.controller.select_pane(pane_id=pane)
|
|
476
|
+
content = self.controller.capture_pane(lines=lines)
|
|
477
|
+
else:
|
|
478
|
+
# Remote mode - pass pane_id directly
|
|
479
|
+
content = self.controller.capture_pane(pane_id=pane, lines=lines)
|
|
480
|
+
print(content)
|
|
481
|
+
return content
|
|
482
|
+
|
|
483
|
+
def interrupt(self, pane: Optional[str] = None):
|
|
484
|
+
"""Send Ctrl+C to a pane."""
|
|
485
|
+
if self.mode == 'local':
|
|
486
|
+
# Local mode - use select_pane
|
|
487
|
+
if pane:
|
|
488
|
+
if pane.isdigit():
|
|
489
|
+
self.controller.select_pane(pane_index=int(pane))
|
|
490
|
+
else:
|
|
491
|
+
self.controller.select_pane(pane_id=pane)
|
|
492
|
+
self.controller.send_interrupt()
|
|
493
|
+
else:
|
|
494
|
+
# Remote mode - resolve and pass pane_id
|
|
495
|
+
target = self.controller._resolve_pane_id(pane)
|
|
496
|
+
self.controller.send_interrupt(pane_id=target)
|
|
497
|
+
print("Sent interrupt signal")
|
|
498
|
+
|
|
499
|
+
def escape(self, pane: Optional[str] = None):
|
|
500
|
+
"""Send Escape key to a pane."""
|
|
501
|
+
if self.mode == 'local':
|
|
502
|
+
# Local mode - use select_pane
|
|
503
|
+
if pane:
|
|
504
|
+
if pane.isdigit():
|
|
505
|
+
self.controller.select_pane(pane_index=int(pane))
|
|
506
|
+
else:
|
|
507
|
+
self.controller.select_pane(pane_id=pane)
|
|
508
|
+
self.controller.send_escape()
|
|
509
|
+
else:
|
|
510
|
+
# Remote mode - resolve and pass pane_id
|
|
511
|
+
target = self.controller._resolve_pane_id(pane)
|
|
512
|
+
self.controller.send_escape(pane_id=target)
|
|
513
|
+
print("Sent escape key")
|
|
514
|
+
|
|
515
|
+
def kill(self, pane: Optional[str] = None):
|
|
516
|
+
"""Kill a pane/window."""
|
|
517
|
+
if self.mode == 'local':
|
|
518
|
+
# Local mode - kill pane
|
|
519
|
+
if pane:
|
|
520
|
+
if pane.isdigit():
|
|
521
|
+
self.controller.select_pane(pane_index=int(pane))
|
|
522
|
+
else:
|
|
523
|
+
self.controller.select_pane(pane_id=pane)
|
|
524
|
+
try:
|
|
525
|
+
self.controller.kill_pane()
|
|
526
|
+
print("Pane killed")
|
|
527
|
+
except ValueError as e:
|
|
528
|
+
print(str(e))
|
|
529
|
+
else:
|
|
530
|
+
# Remote mode - kill window
|
|
531
|
+
try:
|
|
532
|
+
self.controller.kill_window(window_id=pane)
|
|
533
|
+
print("Window killed")
|
|
534
|
+
except ValueError as e:
|
|
535
|
+
print(str(e))
|
|
536
|
+
|
|
537
|
+
def wait_idle(self, pane: Optional[str] = None, idle_time: float = 2.0,
|
|
538
|
+
timeout: Optional[int] = None):
|
|
539
|
+
"""Wait for pane to become idle (no output changes)."""
|
|
540
|
+
if self.mode == 'local':
|
|
541
|
+
# Local mode - use select_pane
|
|
542
|
+
if pane:
|
|
543
|
+
if pane.isdigit():
|
|
544
|
+
self.controller.select_pane(pane_index=int(pane))
|
|
545
|
+
else:
|
|
546
|
+
self.controller.select_pane(pane_id=pane)
|
|
547
|
+
target = None
|
|
548
|
+
else:
|
|
549
|
+
# Remote mode - resolve pane_id
|
|
550
|
+
target = self.controller._resolve_pane_id(pane)
|
|
551
|
+
|
|
552
|
+
print(f"Waiting for pane to become idle (no changes for {idle_time}s)...")
|
|
553
|
+
if self.controller.wait_for_idle(pane_id=target, idle_time=idle_time, timeout=timeout):
|
|
554
|
+
print("Pane is idle")
|
|
555
|
+
return True
|
|
556
|
+
else:
|
|
557
|
+
print("Timeout waiting for idle")
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
def attach(self):
|
|
561
|
+
"""Attach to the managed session (remote mode only)."""
|
|
562
|
+
if self.mode == 'local':
|
|
563
|
+
print("Attach is only available in remote mode (when outside tmux)")
|
|
564
|
+
return
|
|
565
|
+
self.controller.attach_session()
|
|
566
|
+
|
|
567
|
+
def cleanup(self):
|
|
568
|
+
"""Kill the entire managed session (remote mode only)."""
|
|
569
|
+
if self.mode == 'local':
|
|
570
|
+
print("Cleanup is only available in remote mode (when outside tmux)")
|
|
571
|
+
return
|
|
572
|
+
self.controller.cleanup_session()
|
|
573
|
+
|
|
574
|
+
def list_windows(self):
|
|
575
|
+
"""List all windows in the session (remote mode only)."""
|
|
576
|
+
if self.mode == 'local':
|
|
577
|
+
print("List_windows is only available in remote mode. Use list_panes instead.")
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
windows = self.controller.list_windows()
|
|
581
|
+
if not windows:
|
|
582
|
+
print(f"No windows in session '{self.controller.session_name}'")
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
print(f"Windows in session '{self.controller.session_name}':")
|
|
586
|
+
for w in windows:
|
|
587
|
+
active = " (active)" if w['active'] else ""
|
|
588
|
+
print(f" {w['index']}: {w['name']}{active} - pane {w['pane_id']}")
|
|
589
|
+
|
|
590
|
+
def demo(self):
|
|
591
|
+
"""Run a demo showing tmux CLI control capabilities."""
|
|
592
|
+
print("Running demo...")
|
|
593
|
+
|
|
594
|
+
if self.mode == 'local':
|
|
595
|
+
# Original local demo
|
|
596
|
+
print("\nCurrent panes:")
|
|
597
|
+
panes = self.controller.list_panes()
|
|
598
|
+
for pane in panes:
|
|
599
|
+
print(f" Pane {pane['index']}: {pane['id']} - {pane['title']}")
|
|
600
|
+
|
|
601
|
+
# Create a new pane with Python REPL
|
|
602
|
+
print("\nCreating new pane with Python...")
|
|
603
|
+
pane_id = self.controller.launch_cli('python3')
|
|
604
|
+
print(f"Created pane: {pane_id}")
|
|
605
|
+
|
|
606
|
+
# Wait for Python prompt
|
|
607
|
+
time.sleep(1)
|
|
608
|
+
if self.controller.wait_for_prompt('>>>', timeout=5):
|
|
609
|
+
print("Python prompt detected")
|
|
610
|
+
|
|
611
|
+
# Send a command
|
|
612
|
+
print("\nSending Python command...")
|
|
613
|
+
self.controller.send_keys('print("Hello from tmux!")')
|
|
614
|
+
time.sleep(0.5)
|
|
615
|
+
|
|
616
|
+
# Capture output
|
|
617
|
+
output = self.controller.capture_pane(lines=10)
|
|
618
|
+
print(f"\nCaptured output:\n{output}")
|
|
619
|
+
|
|
620
|
+
# Clean up
|
|
621
|
+
print("\nCleaning up...")
|
|
622
|
+
self.controller.send_keys('exit()')
|
|
623
|
+
time.sleep(0.5)
|
|
624
|
+
self.controller.kill_pane()
|
|
625
|
+
print("Demo complete!")
|
|
626
|
+
else:
|
|
627
|
+
print("Failed to detect Python prompt")
|
|
628
|
+
self.controller.kill_pane()
|
|
629
|
+
else:
|
|
630
|
+
# Remote demo
|
|
631
|
+
print("\nCreating new window with Python...")
|
|
632
|
+
pane_id = self.launch('python3', name='demo-python')
|
|
633
|
+
|
|
634
|
+
# Wait for idle (Python prompt)
|
|
635
|
+
time.sleep(1)
|
|
636
|
+
if self.wait_idle(pane=pane_id, idle_time=1.0, timeout=5):
|
|
637
|
+
print("Python is ready")
|
|
638
|
+
|
|
639
|
+
# Send a command
|
|
640
|
+
print("\nSending Python command...")
|
|
641
|
+
self.send('print("Hello from remote tmux!")', pane=pane_id)
|
|
642
|
+
time.sleep(0.5)
|
|
643
|
+
|
|
644
|
+
# Capture output
|
|
645
|
+
print("\nCaptured output:")
|
|
646
|
+
self.capture(pane=pane_id, lines=10)
|
|
647
|
+
|
|
648
|
+
# Clean up
|
|
649
|
+
print("\nCleaning up...")
|
|
650
|
+
self.send('exit()', pane=pane_id)
|
|
651
|
+
time.sleep(0.5)
|
|
652
|
+
self.kill(pane=pane_id)
|
|
653
|
+
print("Demo complete!")
|
|
654
|
+
else:
|
|
655
|
+
print("Failed to wait for Python")
|
|
656
|
+
self.kill(pane=pane_id)
|
|
657
|
+
|
|
658
|
+
def help(self):
|
|
659
|
+
"""Display tmux-cli usage instructions."""
|
|
660
|
+
# Find the instructions file relative to this module
|
|
661
|
+
module_dir = Path(__file__).parent.parent
|
|
662
|
+
instructions_file = module_dir / "docs" / "tmux-cli-instructions.md"
|
|
663
|
+
|
|
664
|
+
# Add mode-specific header
|
|
665
|
+
mode_info = f"\n{'='*60}\n"
|
|
666
|
+
if self.mode == 'local':
|
|
667
|
+
mode_info += "MODE: LOCAL (inside tmux) - Managing panes in current window\n"
|
|
668
|
+
else:
|
|
669
|
+
mode_info += f"MODE: REMOTE (outside tmux) - Managing windows in session '{self.controller.session_name}'\n"
|
|
670
|
+
mode_info += f"{'='*60}\n"
|
|
671
|
+
|
|
672
|
+
print(mode_info)
|
|
673
|
+
|
|
674
|
+
if instructions_file.exists():
|
|
675
|
+
print(instructions_file.read_text())
|
|
676
|
+
else:
|
|
677
|
+
print("Error: tmux-cli-instructions.md not found")
|
|
678
|
+
print(f"Expected location: {instructions_file}")
|
|
679
|
+
|
|
680
|
+
if self.mode == 'remote':
|
|
681
|
+
print("\n" + "="*60)
|
|
682
|
+
print("REMOTE MODE SPECIFIC COMMANDS:")
|
|
683
|
+
print("- tmux-cli attach: Attach to the managed session to view live")
|
|
684
|
+
print("- tmux-cli cleanup: Kill the entire managed session")
|
|
685
|
+
print("- tmux-cli list_windows: List all windows in the session")
|
|
686
|
+
print("\nNote: In remote mode, 'panes' are actually windows for better isolation.")
|
|
687
|
+
print("="*60)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def main():
|
|
691
|
+
"""Main entry point using fire."""
|
|
692
|
+
import fire
|
|
693
|
+
import sys
|
|
694
|
+
|
|
695
|
+
# Check for --help flag
|
|
696
|
+
if '--help' in sys.argv:
|
|
697
|
+
cli = CLI()
|
|
698
|
+
cli.help()
|
|
699
|
+
sys.exit(0)
|
|
700
|
+
|
|
701
|
+
fire.Fire(CLI)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
if __name__ == '__main__':
|
|
705
|
+
main()
|