sshplex 1.0.0__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.
sshplex/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """SSHplex - SSH Connection Multiplexer"""
2
+ __version__ = "1.0.0"
3
+ __author__ = "MJAHED Sabri"
4
+ __email__ = "contact@sabrimjahed.com"
sshplex/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ """CLI debug interface for SSHplex (for pip-installed package)"""
2
+
3
+ import sys
4
+ import argparse
5
+ from typing import Any
6
+
7
+ from .lib.config import load_config
8
+ from .lib.logger import setup_logging, get_logger
9
+ from .lib.sot.netbox import NetBoxProvider
10
+
11
+
12
+ def main() -> int:
13
+ """CLI debug entry point for installed SSHplex package."""
14
+
15
+ try:
16
+ # Parse command line arguments
17
+ parser = argparse.ArgumentParser(description="SSHplex CLI: Debug interface for NetBox connectivity testing.")
18
+ parser.add_argument('--config', type=str, default=None, help='Path to the configuration file (default: ~/.config/sshplex/sshplex.yaml)')
19
+ parser.add_argument('--version', action='version', version='SSHplex 1.0.0')
20
+ args = parser.parse_args()
21
+
22
+ # Load configuration (will use default path if none specified)
23
+ print("SSHplex CLI Debug Mode - Loading configuration...")
24
+ config = load_config(args.config)
25
+
26
+ # Setup logging
27
+ setup_logging(
28
+ log_level=config.logging.level,
29
+ log_file=config.logging.file,
30
+ enabled=config.logging.enabled
31
+ )
32
+
33
+ logger = get_logger()
34
+ logger.info("SSHplex CLI debug mode started")
35
+
36
+ return debug_mode(config, logger)
37
+
38
+ except FileNotFoundError as e:
39
+ print(f"Error: {e}")
40
+ print("Please ensure config.yaml exists and is properly configured")
41
+ print("Note: This is the CLI debug interface. For the full TUI application,")
42
+ print("run the main sshplex.py script from the source repository.")
43
+ return 1
44
+ except ValueError as e:
45
+ print(f"Configuration Error: {e}")
46
+ return 1
47
+ except KeyboardInterrupt:
48
+ print("\nSSHplex CLI interrupted by user")
49
+ return 1
50
+ except Exception as e:
51
+ print(f"Unexpected error: {e}")
52
+ return 1
53
+
54
+
55
+ def debug_mode(config: Any, logger: Any) -> int:
56
+ """Run debug mode - NetBox connection and host listing test."""
57
+ logger.info("Running CLI debug mode - NetBox connectivity test")
58
+
59
+ # Initialize NetBox provider
60
+ logger.info("Initializing NetBox provider")
61
+ netbox = NetBoxProvider(
62
+ url=config.netbox.url,
63
+ token=config.netbox.token,
64
+ verify_ssl=config.netbox.verify_ssl,
65
+ timeout=config.netbox.timeout
66
+ )
67
+
68
+ # Test connection
69
+ logger.info("Testing NetBox connection...")
70
+ if not netbox.connect():
71
+ logger.error("Failed to connect to NetBox")
72
+ print("āŒ Failed to connect to NetBox")
73
+ print("Check your configuration and network connectivity")
74
+ return 1
75
+
76
+ print("āœ… Successfully connected to NetBox")
77
+
78
+ # Retrieve VMs with filters
79
+ logger.info("Retrieving VMs from NetBox...")
80
+ hosts = netbox.get_hosts(filters=config.netbox.default_filters)
81
+
82
+ # Display results
83
+ if hosts:
84
+ logger.info(f"Successfully retrieved {len(hosts)} VMs")
85
+ print(f"\nšŸ“‹ Found {len(hosts)} hosts matching filters:")
86
+ print("-" * 60)
87
+ for i, host in enumerate(hosts, 1):
88
+ status = host.metadata.get('status', 'unknown')
89
+ print(f"{i:3d}. {host.name:<30} {host.ip:<15} [{status}]")
90
+ print("-" * 60)
91
+ else:
92
+ logger.warning("No VMs found matching the filters")
93
+ print("āš ļø No hosts found matching the configured filters")
94
+ print("Check your NetBox filters in the configuration")
95
+
96
+ logger.info("SSHplex CLI debug mode completed successfully")
97
+ print(f"\nāœ… CLI debug mode completed successfully")
98
+ print("Note: For the full TUI interface, run the main application")
99
+ return 0
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())
@@ -0,0 +1,35 @@
1
+ # SSHplex Configuration - Phase 2 with tmux support
2
+ sshplex:
3
+ version: "1.0.0"
4
+ session_prefix: "sshplex"
5
+
6
+ netbox:
7
+ url: "https://netbox.lan/"
8
+ token: "CHANGE_TOKEN_HERE"
9
+ verify_ssl: false
10
+ timeout: 30
11
+ default_filters:
12
+ status: "active"
13
+ role: "virtual-machine"
14
+ has_primary_ip: "true"
15
+
16
+ ssh:
17
+ username: "admin"
18
+ key_path: "~/.ssh/id_ed25519"
19
+ timeout: 10
20
+ port: 22
21
+
22
+ tmux:
23
+ layout: "tiled" # tiled, even-horizontal, even-vertical
24
+ broadcast: false # Start with broadcast off
25
+ window_name: "sshplex"
26
+
27
+ ui:
28
+ show_log_panel: false
29
+ log_panel_height: 20
30
+ table_columns: ["name", "ip", "cluster", "tags", "description"]
31
+
32
+ logging:
33
+ enabled: false
34
+ level: "DEBUG" # DEBUG, INFO, WARNING, ERROR
35
+ file: "logs/sshplex.log"
@@ -0,0 +1 @@
1
+ # The logs directory is intentionally left blank.
sshplex/lib/config.py ADDED
@@ -0,0 +1,196 @@
1
+ """SSHplex configuration management with pydantic validation - Phase 3."""
2
+
3
+ from pathlib import Path
4
+ from typing import Dict, Any, Optional
5
+ import yaml
6
+ import shutil
7
+ import os
8
+ from pydantic import BaseModel, Field, validator
9
+
10
+
11
+ class SSHplexConfig(BaseModel):
12
+ """SSHplex main configuration."""
13
+ version: str = "1.0.0"
14
+ session_prefix: str = "sshplex"
15
+
16
+
17
+ class NetBoxConfig(BaseModel):
18
+ """NetBox connection configuration."""
19
+ url: str = Field(..., description="NetBox instance URL")
20
+ token: str = Field(..., description="NetBox API token")
21
+ verify_ssl: bool = True
22
+ timeout: int = 30
23
+ default_filters: Dict[str, str] = Field(default_factory=dict)
24
+
25
+ @validator('url')
26
+ def validate_url(cls, v: str) -> str:
27
+ """Validate NetBox URL format."""
28
+ if not v.startswith(('http://', 'https://')):
29
+ raise ValueError('NetBox URL must start with http:// or https://')
30
+ return v
31
+
32
+
33
+ class LoggingConfig(BaseModel):
34
+ """Logging configuration."""
35
+ enabled: bool = True
36
+ level: str = "INFO"
37
+ file: str = "logs/sshplex.log"
38
+
39
+ @validator('level')
40
+ def validate_level(cls, v: str) -> str:
41
+ """Validate logging level."""
42
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR"]
43
+ if v.upper() not in valid_levels:
44
+ raise ValueError(f'Log level must be one of: {valid_levels}')
45
+ return v.upper()
46
+
47
+
48
+ class UIConfig(BaseModel):
49
+ """User interface configuration."""
50
+ show_log_panel: bool = True
51
+ log_panel_height: int = 20 # Percentage of screen height
52
+ table_columns: list = Field(default_factory=lambda: ["name", "ip", "cluster", "role", "tags"])
53
+
54
+
55
+ class SSHConfig(BaseModel):
56
+ """SSH connection configuration."""
57
+ username: str = Field(default="admin", description="Default SSH username")
58
+ key_path: str = Field(default="~/.ssh/id_rsa", description="Path to SSH private key")
59
+ timeout: int = 10
60
+ port: int = 22
61
+
62
+
63
+ class TmuxConfig(BaseModel):
64
+ """tmux configuration."""
65
+ layout: str = "tiled" # tiled, even-horizontal, even-vertical
66
+ broadcast: bool = False # Start with broadcast off
67
+ window_name: str = "sshplex"
68
+
69
+
70
+ class Config(BaseModel):
71
+ """Main SSHplex configuration model."""
72
+ sshplex: SSHplexConfig = Field(default_factory=SSHplexConfig)
73
+ netbox: NetBoxConfig
74
+ ssh: SSHConfig = Field(default_factory=SSHConfig)
75
+ tmux: TmuxConfig = Field(default_factory=TmuxConfig)
76
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
77
+ ui: UIConfig = Field(default_factory=UIConfig)
78
+
79
+
80
+ def get_default_config_path() -> Path:
81
+ """Get the default configuration file path in ~/.config/sshplex/sshplex.yaml"""
82
+ return Path.home() / ".config" / "sshplex" / "sshplex.yaml"
83
+
84
+
85
+ def get_template_config_path() -> Path:
86
+ """Get the path to the config template file."""
87
+ # Get the directory where this config.py file is located
88
+ lib_dir = Path(__file__).parent
89
+ # Go up to sshplex directory and find config-template.yaml
90
+ sshplex_dir = lib_dir.parent
91
+ return sshplex_dir / "config-template.yaml"
92
+
93
+
94
+ def ensure_config_directory() -> Path:
95
+ """Ensure the ~/.config/sshplex directory exists."""
96
+ config_dir = Path.home() / ".config" / "sshplex"
97
+ config_dir.mkdir(parents=True, exist_ok=True)
98
+ return config_dir
99
+
100
+
101
+ def initialize_default_config() -> Path:
102
+ """Initialize default configuration by copying template to ~/.config/sshplex/sshplex.yaml"""
103
+ from .logger import get_logger
104
+
105
+ logger = get_logger()
106
+ config_path = get_default_config_path()
107
+ template_path = get_template_config_path()
108
+
109
+ # Ensure config directory exists
110
+ ensure_config_directory()
111
+
112
+ if not template_path.exists():
113
+ raise FileNotFoundError(f"SSHplex: Template config file not found: {template_path}")
114
+
115
+ # Copy template to default config location
116
+ shutil.copy2(template_path, config_path)
117
+ logger.info(f"SSHplex: Created default configuration at {config_path}")
118
+ logger.info(f"SSHplex: Please edit {config_path} with your NetBox details")
119
+
120
+ return config_path
121
+
122
+
123
+ def load_config(config_path: Optional[str] = None) -> Config:
124
+ """Load and validate configuration from YAML file.
125
+
126
+ Phase 3: Uses ~/.config/sshplex/sshplex.yaml as default location.
127
+ Creates config directory and copies template on first run.
128
+
129
+ Args:
130
+ config_path: Path to configuration file (optional, defaults to ~/.config/sshplex/sshplex.yaml)
131
+
132
+ Returns:
133
+ Validated configuration object
134
+
135
+ Raises:
136
+ FileNotFoundError: If config file doesn't exist and template can't be found
137
+ ValueError: If config validation fails
138
+ """
139
+ from .logger import get_logger
140
+
141
+ # Use default config path if none provided
142
+ if config_path is None:
143
+ config_file = get_default_config_path()
144
+
145
+ # If default config doesn't exist, initialize it from template
146
+ if not config_file.exists():
147
+ try:
148
+ config_file = initialize_default_config()
149
+ print(f"āœ… SSHplex: First run detected - created configuration at {config_file}")
150
+ print(f"šŸ“ Please edit {config_file} with your NetBox details before running SSHplex again")
151
+ print(f"šŸ”§ Key settings to configure:")
152
+ print(f" - netbox.url: Your NetBox instance URL")
153
+ print(f" - netbox.token: Your NetBox API token")
154
+ print(f" - ssh.username: Your SSH username")
155
+ print(f" - ssh.key_path: Path to your SSH private key")
156
+ print(f"\nšŸš€ Run 'sshplex' again after configuration is complete!")
157
+ # Exit gracefully to let user configure
158
+ import sys
159
+ sys.exit(0)
160
+ except Exception as e:
161
+ raise FileNotFoundError(f"SSHplex: Could not initialize default config: {e}")
162
+ else:
163
+ config_file = Path(config_path)
164
+
165
+ if not config_file.exists():
166
+ raise FileNotFoundError(f"SSHplex: Configuration file not found: {config_file}")
167
+
168
+ try:
169
+ logger = get_logger()
170
+ logger.info(f"SSHplex: Loading configuration from {config_file}")
171
+
172
+ with open(config_file, 'r') as f:
173
+ config_data = yaml.safe_load(f)
174
+
175
+ config = Config(**config_data)
176
+ logger.info("SSHplex: Configuration loaded and validated successfully")
177
+ return config
178
+
179
+ except yaml.YAMLError as e:
180
+ raise ValueError(f"SSHplex: Invalid YAML in config file: {e}")
181
+ except Exception as e:
182
+ raise ValueError(f"SSHplex: Configuration validation failed: {e}")
183
+
184
+
185
+ def get_config_info() -> Dict[str, Any]:
186
+ """Get information about SSHplex configuration paths and status."""
187
+ default_path = get_default_config_path()
188
+ template_path = get_template_config_path()
189
+
190
+ return {
191
+ "default_config_path": str(default_path),
192
+ "default_config_exists": default_path.exists(),
193
+ "template_path": str(template_path),
194
+ "template_exists": template_path.exists(),
195
+ "config_dir_exists": default_path.parent.exists()
196
+ }
sshplex/lib/logger.py ADDED
@@ -0,0 +1,54 @@
1
+ """SSHplex logging configuration using loguru."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from loguru import logger
7
+
8
+
9
+ def setup_logging(log_level: str = "INFO", log_file: str = "logs/sshplex.log", enabled: bool = True) -> None:
10
+ """Set up logging for SSHplex with file rotation.
11
+
12
+ Args:
13
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
14
+ log_file: Path to log file
15
+ enabled: Whether logging is enabled (if False, only console errors will be shown)
16
+ """
17
+ # Remove default logger
18
+ logger.remove()
19
+
20
+ if not enabled:
21
+ # When logging is disabled, only show ERROR and CRITICAL messages to console
22
+ logger.add(
23
+ sink=lambda msg: print(msg, end=""),
24
+ level="ERROR",
25
+ format="<red>ERROR</red> | <cyan>SSHplex</cyan> | {message}"
26
+ )
27
+ return
28
+
29
+ # Create logs directory if it doesn't exist
30
+ log_path = Path(log_file)
31
+ log_path.parent.mkdir(parents=True, exist_ok=True)
32
+
33
+ # Add console logging
34
+ logger.add(
35
+ sink=lambda msg: print(msg, end=""),
36
+ level=log_level,
37
+ format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>SSHplex</cyan> | {message}"
38
+ )
39
+
40
+ # Add file logging with rotation
41
+ logger.add(
42
+ log_file,
43
+ rotation="10 MB",
44
+ retention="30 days",
45
+ level=log_level,
46
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {module}:{line} | SSHplex | {message}"
47
+ )
48
+
49
+ logger.info(f"SSHplex logging initialized - Level: {log_level}, File: {log_file}")
50
+
51
+
52
+ def get_logger() -> Any:
53
+ """Get the configured logger instance."""
54
+ return logger
@@ -0,0 +1 @@
1
+ # The logs directory is intentionally left blank.
@@ -0,0 +1,48 @@
1
+ """Base class for terminal multiplexers in SSHplex."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import List, Dict, Any, Optional
5
+
6
+
7
+ class MultiplexerBase(ABC):
8
+ """Abstract base class for terminal multiplexers."""
9
+
10
+ def __init__(self, session_name: str):
11
+ """Initialize the multiplexer with a session name."""
12
+ self.session_name = session_name
13
+ self.panes: Dict[str, Any] = {}
14
+
15
+ @abstractmethod
16
+ def create_session(self) -> bool:
17
+ """Create a new multiplexer session."""
18
+ pass
19
+
20
+ @abstractmethod
21
+ def create_pane(self, hostname: str, command: Optional[str] = None) -> bool:
22
+ """Create a new pane for the given hostname."""
23
+ pass
24
+
25
+ @abstractmethod
26
+ def set_pane_title(self, pane_id: str, title: str) -> bool:
27
+ """Set the title of a specific pane."""
28
+ pass
29
+
30
+ @abstractmethod
31
+ def send_command(self, pane_id: str, command: str) -> bool:
32
+ """Send a command to a specific pane."""
33
+ pass
34
+
35
+ @abstractmethod
36
+ def broadcast_command(self, command: str) -> bool:
37
+ """Send a command to all panes."""
38
+ pass
39
+
40
+ @abstractmethod
41
+ def close_session(self) -> None:
42
+ """Close the multiplexer session."""
43
+ pass
44
+
45
+ @abstractmethod
46
+ def attach_to_session(self) -> None:
47
+ """Attach to the multiplexer session."""
48
+ pass
@@ -0,0 +1,227 @@
1
+ """SSHplex tmux multiplexer implementation."""
2
+
3
+ import libtmux
4
+ from typing import Optional, Dict
5
+ from datetime import datetime
6
+
7
+ from .base import MultiplexerBase
8
+ from ..logger import get_logger
9
+
10
+
11
+ class TmuxManager(MultiplexerBase):
12
+ """tmux implementation for SSHplex multiplexer."""
13
+
14
+ def __init__(self, session_name: Optional[str] = None):
15
+ """Initialize tmux manager with session name."""
16
+ if session_name is None:
17
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
18
+ session_name = f"sshplex-{timestamp}"
19
+
20
+ super().__init__(session_name)
21
+ self.logger = get_logger()
22
+ self.server = libtmux.Server()
23
+ self.session: Optional[libtmux.Session] = None
24
+ self.window: Optional[libtmux.Window] = None
25
+ self.panes: Dict[str, libtmux.Pane] = {}
26
+
27
+ def create_session(self) -> bool:
28
+ """Create a new tmux session with SSHplex branding."""
29
+ try:
30
+ self.logger.info(f"SSHplex: Creating tmux session '{self.session_name}'")
31
+
32
+ # Check if session already exists
33
+ if self.server.has_session(self.session_name):
34
+ self.logger.warning(f"SSHplex: Session '{self.session_name}' already exists")
35
+ self.session = self.server.sessions.get(session_name=self.session_name)
36
+ else:
37
+ # Create new session
38
+ self.session = self.server.new_session(
39
+ session_name=self.session_name,
40
+ window_name="sshplex",
41
+ start_directory="~"
42
+ )
43
+
44
+ # Get the main window
45
+ if self.session:
46
+ self.window = self.session.attached_window
47
+ self.logger.info(f"SSHplex: tmux session '{self.session_name}' created successfully")
48
+ return True
49
+ else:
50
+ return False
51
+
52
+ except Exception as e:
53
+ self.logger.error(f"SSHplex: Failed to create tmux session: {e}")
54
+ return False
55
+
56
+ def create_pane(self, hostname: str, command: Optional[str] = None) -> bool:
57
+ """Create a new pane for the given hostname."""
58
+ try:
59
+ if self.session is None or self.window is None:
60
+ if not self.create_session():
61
+ return False
62
+
63
+ self.logger.info(f"SSHplex: Creating pane for host '{hostname}'")
64
+
65
+ # Split window to create new pane (except for the first pane)
66
+ if self.window is None:
67
+ self.logger.error("SSHplex: No window available for pane creation")
68
+ return False
69
+
70
+ if len(self.panes) == 0:
71
+ # First pane - use the existing window pane
72
+ pane = self.window.attached_pane
73
+ if pane is None:
74
+ raise RuntimeError(f"No attached pane available for {hostname}")
75
+ else:
76
+ # Additional panes - split the window
77
+ pane = self.window.split_window()
78
+ if pane is None:
79
+ raise RuntimeError(f"Failed to create tmux pane for {hostname}")
80
+
81
+ # Store pane reference
82
+ self.panes[hostname] = pane
83
+
84
+ # Set pane title
85
+ self.set_pane_title(hostname, hostname)
86
+
87
+ # Execute the provided command (should be SSH command)
88
+ if command:
89
+ self.send_command(hostname, command)
90
+
91
+ self.logger.info(f"SSHplex: Pane created for '{hostname}' successfully")
92
+ return True
93
+
94
+ except Exception as e:
95
+ self.logger.error(f"SSHplex: Failed to create pane for '{hostname}': {e}")
96
+ return False
97
+
98
+ def create_window(self, hostname: str, command: Optional[str] = None) -> bool:
99
+ """Create a new window (tab) in the tmux session and execute a command."""
100
+ try:
101
+ if not self.session:
102
+ self.logger.error("SSHplex: No active tmux session for window creation")
103
+ return False
104
+
105
+ # Create new window with hostname as the window name
106
+ window = self.session.new_window(window_name=hostname)
107
+
108
+ if not window:
109
+ self.logger.error(f"SSHplex: Failed to create window for '{hostname}'")
110
+ return False
111
+
112
+ # Get the main pane of the new window
113
+ pane = window.panes[0] if window.panes else None
114
+ if not pane:
115
+ self.logger.error(f"SSHplex: No pane found in new window for '{hostname}'")
116
+ return False
117
+
118
+ # Store the pane reference
119
+ self.panes[hostname] = pane
120
+
121
+ # Execute the provided command (should be SSH command)
122
+ if command:
123
+ pane.send_keys(command, enter=True)
124
+
125
+ self.logger.info(f"SSHplex: Window created for '{hostname}' successfully")
126
+ return True
127
+
128
+ except Exception as e:
129
+ self.logger.error(f"SSHplex: Failed to create window for '{hostname}': {e}")
130
+ return False
131
+
132
+ def set_pane_title(self, hostname: str, title: str) -> bool:
133
+ """Set the title of a specific pane."""
134
+ try:
135
+ if hostname not in self.panes:
136
+ self.logger.error(f"SSHplex: Pane for '{hostname}' not found")
137
+ return False
138
+
139
+ pane = self.panes[hostname]
140
+ # Set pane title using printf escape sequence
141
+ pane.send_keys(f'printf "\\033]2;{title}\\033\\\\"', enter=True)
142
+ return True
143
+
144
+ except Exception as e:
145
+ self.logger.error(f"SSHplex: Failed to set pane title for '{hostname}': {e}")
146
+ return False
147
+
148
+ def send_command(self, hostname: str, command: str) -> bool:
149
+ """Send a command to a specific pane."""
150
+ try:
151
+ if hostname not in self.panes:
152
+ self.logger.error(f"SSHplex: Pane for '{hostname}' not found")
153
+ return False
154
+
155
+ pane = self.panes[hostname]
156
+ pane.send_keys(command, enter=True)
157
+ self.logger.debug(f"SSHplex: Command sent to '{hostname}': {command}")
158
+ return True
159
+
160
+ except Exception as e:
161
+ self.logger.error(f"SSHplex: Failed to send command to '{hostname}': {e}")
162
+ return False
163
+
164
+ def broadcast_command(self, command: str) -> bool:
165
+ """Send a command to all panes."""
166
+ try:
167
+ success_count = 0
168
+ for hostname in self.panes:
169
+ if self.send_command(hostname, command):
170
+ success_count += 1
171
+
172
+ self.logger.info(f"SSHplex: Broadcast command sent to {success_count}/{len(self.panes)} panes")
173
+ return success_count == len(self.panes)
174
+
175
+ except Exception as e:
176
+ self.logger.error(f"SSHplex: Failed to broadcast command: {e}")
177
+ return False
178
+
179
+ def close_session(self) -> None:
180
+ """Close the tmux session."""
181
+ try:
182
+ if self.session:
183
+ self.logger.info(f"SSHplex: Closing tmux session '{self.session_name}'")
184
+ self.session.kill_session()
185
+ self.session = None
186
+ self.window = None
187
+ self.panes.clear()
188
+
189
+ except Exception as e:
190
+ self.logger.error(f"SSHplex: Error closing session: {e}")
191
+
192
+ def attach_to_session(self, auto_attach: bool = True) -> None:
193
+ """Attach to the tmux session."""
194
+ try:
195
+ if self.session:
196
+ if auto_attach:
197
+ self.logger.info(f"SSHplex: Auto-attaching to tmux session '{self.session_name}'")
198
+ # Auto-attach to the session by replacing current shell
199
+ import os
200
+ import sys
201
+ # Use exec to replace the current Python process with tmux attach
202
+ os.execlp("tmux", "tmux", "attach-session", "-t", self.session_name)
203
+ else:
204
+ self.logger.info(f"SSHplex: Tmux session '{self.session_name}' is ready for attachment")
205
+ print(f"\nTo attach to the session, run: tmux attach-session -t {self.session_name}")
206
+ else:
207
+ self.logger.error("SSHplex: No session to attach to")
208
+
209
+ except Exception as e:
210
+ self.logger.error(f"SSHplex: Error attaching to session: {e}")
211
+
212
+ def get_session_name(self) -> str:
213
+ """Get the tmux session name for external attachment."""
214
+ return self.session_name
215
+
216
+ def setup_tiled_layout(self) -> bool:
217
+ """Set up tiled layout for multiple panes."""
218
+ try:
219
+ if self.window and len(self.panes) > 1:
220
+ self.window.select_layout('tiled')
221
+ self.logger.info("SSHplex: Applied tiled layout to tmux window")
222
+ return True
223
+ return False
224
+
225
+ except Exception as e:
226
+ self.logger.error(f"SSHplex: Failed to set tiled layout: {e}")
227
+ return False
@@ -0,0 +1 @@
1
+ # The logs directory is intentionally left blank.