sshm-terminal 1.0.0__tar.gz

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.
@@ -0,0 +1,23 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+
9
+ # Virtual environment
10
+ .venv/
11
+
12
+ # Environment variables
13
+ .env
14
+
15
+ # macOS
16
+ .DS_Store
17
+
18
+ # IDE
19
+ .vscode/
20
+ .idea/
21
+
22
+ # Claude Code
23
+ .claude/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rajesh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.4
2
+ Name: sshm-terminal
3
+ Version: 1.0.0
4
+ Summary: Terminal SSH connection manager with macOS Keychain integration and Matrix-themed TUI
5
+ Project-URL: Homepage, https://github.com/dailydeploy365/sshm
6
+ Project-URL: Repository, https://github.com/dailydeploy365/sshm
7
+ Project-URL: Issues, https://github.com/dailydeploy365/sshm/issues
8
+ Author: Rajesh
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: connection-manager,keychain,macos,ssh,terminal,tui
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: MacOS
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: System :: Networking
25
+ Classifier: Topic :: System :: Systems Administration
26
+ Classifier: Topic :: Terminals
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: textual>=0.80.0
29
+ Description-Content-Type: text/markdown
30
+
31
+ # SSHM - SSH Connection Manager
32
+
33
+ A terminal-based SSH connection manager for macOS with a Matrix-themed TUI. Store server credentials securely (passwords in macOS Keychain), and connect with a single keypress.
34
+
35
+ ![Python](https://img.shields.io/badge/python-3.10+-00ff41?style=flat-square&logo=python&logoColor=00ff41&labelColor=0d0208)
36
+ ![macOS](https://img.shields.io/badge/macOS-supported-00ff41?style=flat-square&logo=apple&logoColor=00ff41&labelColor=0d0208)
37
+ ![License](https://img.shields.io/badge/license-MIT-00ff41?style=flat-square&labelColor=0d0208)
38
+
39
+ ## Features
40
+
41
+ - **Interactive TUI** - Navigate servers with arrow keys, connect with Enter
42
+ - **Secure password storage** - Passwords saved in macOS Keychain (never plaintext)
43
+ - **Auto sudo** - Optionally elevate to root automatically after SSH login
44
+ - **Search/filter** - Quickly find servers by name, host, user, or group
45
+ - **Copy SSH command** - Copy the raw `ssh` command to clipboard
46
+ - **Matrix theme** - Green-on-black hacker aesthetic
47
+
48
+ ## Install
49
+
50
+ ### Option A: pipx (recommended)
51
+
52
+ One command, no manual venv or alias needed:
53
+
54
+ ```bash
55
+ pipx install sshm-terminal
56
+ ```
57
+
58
+ > Don't have pipx? Install it first: `brew install pipx && pipx ensurepath`
59
+
60
+ ### Option B: Homebrew
61
+
62
+ ```bash
63
+ brew tap dailydeploy365/tap https://github.com/dailydeploy365/homebrew-tap
64
+ brew install sshm
65
+ ```
66
+
67
+ ### Option C: From source (for development)
68
+
69
+ ```bash
70
+ git clone https://github.com/dailydeploy365/sshm.git
71
+ cd sshm
72
+ python3 -m venv .venv
73
+ source .venv/bin/activate
74
+ pip install -e .
75
+ ```
76
+
77
+ Then add an alias so `sshm` works from anywhere:
78
+
79
+ ```bash
80
+ # Add to ~/.zshrc
81
+ alias sshm="/path/to/sshm/.venv/bin/sshm"
82
+ source ~/.zshrc
83
+ ```
84
+
85
+ ### Optional: install sshpass for cleaner password auth
86
+
87
+ ```bash
88
+ brew install esolitos/ipa/sshpass
89
+ ```
90
+
91
+ Without it, the app falls back to macOS built-in `expect` which works fine.
92
+
93
+ ## Requirements
94
+
95
+ - macOS (uses `security` CLI for Keychain and `expect` for password automation)
96
+ - Python 3.10+
97
+
98
+ ## Usage
99
+
100
+ ### Launch the TUI
101
+
102
+ ```bash
103
+ sshm
104
+ ```
105
+
106
+ ### Quick list from the terminal
107
+
108
+ ```bash
109
+ sshm list
110
+ ```
111
+
112
+ ### TUI Keybindings
113
+
114
+ | Key | Action |
115
+ |-----------|---------------------------------|
116
+ | `a` | Add a new server |
117
+ | `e` | Edit selected server |
118
+ | `d` | Delete selected server |
119
+ | `Enter` | SSH into selected server |
120
+ | `c` | Copy SSH command to clipboard |
121
+ | `/` | Search / filter servers |
122
+ | `Esc` | Close search, or quit |
123
+ | `q` | Quit |
124
+
125
+ ### Add/Edit Form
126
+
127
+ | Key | Action |
128
+ |-----------|------------------|
129
+ | `ctrl+s` | Save |
130
+ | `Esc` | Cancel |
131
+ | `Tab` | Next field |
132
+
133
+ ### Delete Confirmation
134
+
135
+ | Key | Action |
136
+ |---------------|-----------|
137
+ | `y` / `Enter` | Confirm |
138
+ | `n` / `Esc` | Cancel |
139
+
140
+ ## How-To: Add and connect to a server
141
+
142
+ **Step 1** - Run `sshm`
143
+
144
+ **Step 2** - Press `a` to open the Add Server form
145
+
146
+ **Step 3** - Fill in the fields:
147
+
148
+ ```
149
+ Name: Production API
150
+ Host / IP: 192.168.1.50
151
+ Port: 2222
152
+ User: deploy
153
+ Group: production
154
+ Password: ••••••••
155
+ SSH Key: (leave empty if using password)
156
+ ```
157
+
158
+ Check **"Auto sudo"** if you want to automatically get a root shell after login.
159
+
160
+ **Step 4** - Press `ctrl+s` to save
161
+
162
+ **Step 5** - Select the server in the list and press `Enter` to connect
163
+
164
+ That's it. Next time, just `sshm` → arrow to your server → `Enter`.
165
+
166
+ ## How-To: Use SSH key instead of password
167
+
168
+ **Step 1** - When adding/editing a server, leave the Password field empty
169
+
170
+ **Step 2** - Fill in the SSH Key Path field:
171
+
172
+ ```
173
+ SSH Key Path: ~/.ssh/id_rsa
174
+ ```
175
+
176
+ **Step 3** - Save with `ctrl+s`
177
+
178
+ The app will use `ssh -i ~/.ssh/id_rsa` when connecting.
179
+
180
+ ## How-To: Copy the SSH command
181
+
182
+ Select a server and press `c`. The raw SSH command gets copied to your clipboard:
183
+
184
+ ```
185
+ ssh -p 2222 deploy@192.168.1.50
186
+ ```
187
+
188
+ Useful for scripts, sharing with teammates, or pasting into another terminal.
189
+
190
+ ## How it works
191
+
192
+ | What | Where |
193
+ |-------------------|--------------------------------------------|
194
+ | Server metadata | `~/.sshm/servers.json` |
195
+ | Passwords | macOS Keychain (via `security` CLI) |
196
+ | SSH connection | `sshpass` if installed, else `expect` |
197
+ | Auto sudo | `expect` script (SSH login + `sudo -i`) |
198
+
199
+ ## CLI Commands
200
+
201
+ ```
202
+ sshm Open the TUI manager
203
+ sshm list List saved servers in the terminal
204
+ sshm help Show usage info
205
+ ```
206
+
207
+ ## Uninstall
208
+
209
+ ```bash
210
+ # Remove the alias from ~/.zshrc
211
+ # Then:
212
+ rm -rf ~/.sshm # Remove server data
213
+ pip uninstall sshm # Remove the package
214
+ ```
215
+
216
+ Passwords stored in macOS Keychain can be removed via Keychain Access.app (search for "sshm").
@@ -0,0 +1,186 @@
1
+ # SSHM - SSH Connection Manager
2
+
3
+ A terminal-based SSH connection manager for macOS with a Matrix-themed TUI. Store server credentials securely (passwords in macOS Keychain), and connect with a single keypress.
4
+
5
+ ![Python](https://img.shields.io/badge/python-3.10+-00ff41?style=flat-square&logo=python&logoColor=00ff41&labelColor=0d0208)
6
+ ![macOS](https://img.shields.io/badge/macOS-supported-00ff41?style=flat-square&logo=apple&logoColor=00ff41&labelColor=0d0208)
7
+ ![License](https://img.shields.io/badge/license-MIT-00ff41?style=flat-square&labelColor=0d0208)
8
+
9
+ ## Features
10
+
11
+ - **Interactive TUI** - Navigate servers with arrow keys, connect with Enter
12
+ - **Secure password storage** - Passwords saved in macOS Keychain (never plaintext)
13
+ - **Auto sudo** - Optionally elevate to root automatically after SSH login
14
+ - **Search/filter** - Quickly find servers by name, host, user, or group
15
+ - **Copy SSH command** - Copy the raw `ssh` command to clipboard
16
+ - **Matrix theme** - Green-on-black hacker aesthetic
17
+
18
+ ## Install
19
+
20
+ ### Option A: pipx (recommended)
21
+
22
+ One command, no manual venv or alias needed:
23
+
24
+ ```bash
25
+ pipx install sshm-terminal
26
+ ```
27
+
28
+ > Don't have pipx? Install it first: `brew install pipx && pipx ensurepath`
29
+
30
+ ### Option B: Homebrew
31
+
32
+ ```bash
33
+ brew tap dailydeploy365/tap https://github.com/dailydeploy365/homebrew-tap
34
+ brew install sshm
35
+ ```
36
+
37
+ ### Option C: From source (for development)
38
+
39
+ ```bash
40
+ git clone https://github.com/dailydeploy365/sshm.git
41
+ cd sshm
42
+ python3 -m venv .venv
43
+ source .venv/bin/activate
44
+ pip install -e .
45
+ ```
46
+
47
+ Then add an alias so `sshm` works from anywhere:
48
+
49
+ ```bash
50
+ # Add to ~/.zshrc
51
+ alias sshm="/path/to/sshm/.venv/bin/sshm"
52
+ source ~/.zshrc
53
+ ```
54
+
55
+ ### Optional: install sshpass for cleaner password auth
56
+
57
+ ```bash
58
+ brew install esolitos/ipa/sshpass
59
+ ```
60
+
61
+ Without it, the app falls back to macOS built-in `expect` which works fine.
62
+
63
+ ## Requirements
64
+
65
+ - macOS (uses `security` CLI for Keychain and `expect` for password automation)
66
+ - Python 3.10+
67
+
68
+ ## Usage
69
+
70
+ ### Launch the TUI
71
+
72
+ ```bash
73
+ sshm
74
+ ```
75
+
76
+ ### Quick list from the terminal
77
+
78
+ ```bash
79
+ sshm list
80
+ ```
81
+
82
+ ### TUI Keybindings
83
+
84
+ | Key | Action |
85
+ |-----------|---------------------------------|
86
+ | `a` | Add a new server |
87
+ | `e` | Edit selected server |
88
+ | `d` | Delete selected server |
89
+ | `Enter` | SSH into selected server |
90
+ | `c` | Copy SSH command to clipboard |
91
+ | `/` | Search / filter servers |
92
+ | `Esc` | Close search, or quit |
93
+ | `q` | Quit |
94
+
95
+ ### Add/Edit Form
96
+
97
+ | Key | Action |
98
+ |-----------|------------------|
99
+ | `ctrl+s` | Save |
100
+ | `Esc` | Cancel |
101
+ | `Tab` | Next field |
102
+
103
+ ### Delete Confirmation
104
+
105
+ | Key | Action |
106
+ |---------------|-----------|
107
+ | `y` / `Enter` | Confirm |
108
+ | `n` / `Esc` | Cancel |
109
+
110
+ ## How-To: Add and connect to a server
111
+
112
+ **Step 1** - Run `sshm`
113
+
114
+ **Step 2** - Press `a` to open the Add Server form
115
+
116
+ **Step 3** - Fill in the fields:
117
+
118
+ ```
119
+ Name: Production API
120
+ Host / IP: 192.168.1.50
121
+ Port: 2222
122
+ User: deploy
123
+ Group: production
124
+ Password: ••••••••
125
+ SSH Key: (leave empty if using password)
126
+ ```
127
+
128
+ Check **"Auto sudo"** if you want to automatically get a root shell after login.
129
+
130
+ **Step 4** - Press `ctrl+s` to save
131
+
132
+ **Step 5** - Select the server in the list and press `Enter` to connect
133
+
134
+ That's it. Next time, just `sshm` → arrow to your server → `Enter`.
135
+
136
+ ## How-To: Use SSH key instead of password
137
+
138
+ **Step 1** - When adding/editing a server, leave the Password field empty
139
+
140
+ **Step 2** - Fill in the SSH Key Path field:
141
+
142
+ ```
143
+ SSH Key Path: ~/.ssh/id_rsa
144
+ ```
145
+
146
+ **Step 3** - Save with `ctrl+s`
147
+
148
+ The app will use `ssh -i ~/.ssh/id_rsa` when connecting.
149
+
150
+ ## How-To: Copy the SSH command
151
+
152
+ Select a server and press `c`. The raw SSH command gets copied to your clipboard:
153
+
154
+ ```
155
+ ssh -p 2222 deploy@192.168.1.50
156
+ ```
157
+
158
+ Useful for scripts, sharing with teammates, or pasting into another terminal.
159
+
160
+ ## How it works
161
+
162
+ | What | Where |
163
+ |-------------------|--------------------------------------------|
164
+ | Server metadata | `~/.sshm/servers.json` |
165
+ | Passwords | macOS Keychain (via `security` CLI) |
166
+ | SSH connection | `sshpass` if installed, else `expect` |
167
+ | Auto sudo | `expect` script (SSH login + `sudo -i`) |
168
+
169
+ ## CLI Commands
170
+
171
+ ```
172
+ sshm Open the TUI manager
173
+ sshm list List saved servers in the terminal
174
+ sshm help Show usage info
175
+ ```
176
+
177
+ ## Uninstall
178
+
179
+ ```bash
180
+ # Remove the alias from ~/.zshrc
181
+ # Then:
182
+ rm -rf ~/.sshm # Remove server data
183
+ pip uninstall sshm # Remove the package
184
+ ```
185
+
186
+ Passwords stored in macOS Keychain can be removed via Keychain Access.app (search for "sshm").
@@ -0,0 +1,24 @@
1
+ class Sshm < Formula
2
+ include Language::Python::Virtualenv
3
+
4
+ desc "Terminal SSH connection manager with Matrix-themed TUI and macOS Keychain integration"
5
+ homepage "https://github.com/rajesh/sshm"
6
+ url "https://pypi.io/packages/source/s/sshm-terminal/sshm_terminal-1.0.0.tar.gz"
7
+ sha256 "2dbe18ce5210eeeb02202b78d3e5bc45c9685787feaeb03717f9a88cef1a9ddb"
8
+ license "MIT"
9
+
10
+ depends_on "python@3.13"
11
+
12
+ resource "textual" do
13
+ url "https://pypi.io/packages/source/t/textual/textual-8.2.4.tar.gz"
14
+ sha256 "PLACEHOLDER"
15
+ end
16
+
17
+ def install
18
+ virtualenv_install_with_resources
19
+ end
20
+
21
+ test do
22
+ assert_match "Usage", shell_output("#{bin}/sshm help")
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # homebrew-tap
2
+
3
+ Homebrew formulae for SSHM - Terminal SSH Connection Manager.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ brew tap rajesh/tap https://github.com/rajesh/homebrew-tap
9
+ brew install sshm
10
+ ```
11
+
12
+ ## Update
13
+
14
+ ```bash
15
+ brew upgrade sshm
16
+ ```
17
+
18
+ ## Uninstall
19
+
20
+ ```bash
21
+ brew uninstall sshm
22
+ brew untap rajesh/tap
23
+ ```
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sshm-terminal"
7
+ version = "1.0.0"
8
+ description = "Terminal SSH connection manager with macOS Keychain integration and Matrix-themed TUI"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Rajesh" },
14
+ ]
15
+ keywords = ["ssh", "terminal", "tui", "connection-manager", "macos", "keychain"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: System Administrators",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: MacOS",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Topic :: System :: Networking",
30
+ "Topic :: System :: Systems Administration",
31
+ "Topic :: Terminals",
32
+ ]
33
+ dependencies = [
34
+ "textual>=0.80.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ sshm = "sshm.app:main"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["sshm"]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/dailydeploy365/sshm"
45
+ Repository = "https://github.com/dailydeploy365/sshm"
46
+ Issues = "https://github.com/dailydeploy365/sshm/issues"
@@ -0,0 +1 @@
1
+ """sshm - Terminal SSH connection manager."""
@@ -0,0 +1,3 @@
1
+ from sshm.app import main
2
+
3
+ main()
@@ -0,0 +1,671 @@
1
+ """SSH Manager - Terminal UI application."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+
8
+ from textual import on
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical, VerticalScroll
12
+ from textual.screen import ModalScreen
13
+ from textual.widgets import Checkbox, DataTable, Footer, Input, Label, Static
14
+
15
+ from .storage import Server, Storage
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # SSH connection
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def ssh_connect(server: Server, password: str):
23
+ """Connect to a server via SSH. Auto-elevates to root when user is not root."""
24
+ base_args = ["-o", "StrictHostKeyChecking=accept-new", "-p", str(server.port)]
25
+
26
+ if server.sudo and password:
27
+ _ssh_with_sudo(server, password, base_args)
28
+ elif server.identity_file:
29
+ path = os.path.expanduser(server.identity_file)
30
+ cmd = ["ssh", "-i", path] + base_args + [f"{server.user}@{server.host}"]
31
+ subprocess.call(cmd)
32
+ elif password:
33
+ if shutil.which("sshpass"):
34
+ env = os.environ.copy()
35
+ env["SSHPASS"] = password
36
+ cmd = ["sshpass", "-e", "ssh"] + base_args + [f"{server.user}@{server.host}"]
37
+ subprocess.call(cmd, env=env)
38
+ else:
39
+ _ssh_via_expect(server, password, base_args)
40
+ else:
41
+ cmd = ["ssh"] + base_args + [f"{server.user}@{server.host}"]
42
+ subprocess.call(cmd)
43
+
44
+
45
+ def _write_password_file(password: str) -> str:
46
+ """Write password to a temp file readable only by owner. Returns path."""
47
+ pw_file = tempfile.NamedTemporaryFile(mode="w", suffix=".pw", delete=False)
48
+ pw_file.write(password)
49
+ pw_file.close()
50
+ os.chmod(pw_file.name, 0o400)
51
+ return pw_file.name
52
+
53
+
54
+ def _run_expect(script: str, pw_path: str):
55
+ """Run an expect script and clean up the password file."""
56
+ try:
57
+ subprocess.call(["expect", "-c", script])
58
+ finally:
59
+ if os.path.exists(pw_path):
60
+ os.unlink(pw_path)
61
+
62
+
63
+ def _ssh_via_expect(server: Server, password: str, base_args: list[str]):
64
+ """Use macOS built-in expect to automate password entry."""
65
+ args_str = " ".join(base_args)
66
+ pw_path = _write_password_file(password)
67
+
68
+ key_arg = ""
69
+ if server.identity_file:
70
+ key_arg = f"-i {os.path.expanduser(server.identity_file)} "
71
+
72
+ script = f'''
73
+ set timeout 30
74
+ set f [open "{pw_path}" r]
75
+ set pw [read $f]
76
+ close $f
77
+ file delete "{pw_path}"
78
+ spawn ssh {key_arg}{args_str} {server.user}@{server.host}
79
+ expect {{
80
+ -re {{[Pp]assword:}} {{ send "$pw\\r"; interact }}
81
+ -re {{passphrase}} {{ send "$pw\\r"; interact }}
82
+ timeout {{ puts "Connection timed out"; exit 1 }}
83
+ }}
84
+ '''
85
+ _run_expect(script, pw_path)
86
+
87
+
88
+ def _ssh_with_sudo(server: Server, password: str, base_args: list[str]):
89
+ """SSH in and auto-elevate to root via sudo."""
90
+ args_str = " ".join(["-t"] + base_args)
91
+ pw_path = _write_password_file(password)
92
+
93
+ key_arg = ""
94
+ if server.identity_file:
95
+ key_arg = f"-i {os.path.expanduser(server.identity_file)} "
96
+
97
+ script = f'''
98
+ set timeout 30
99
+ set f [open "{pw_path}" r]
100
+ set pw [read $f]
101
+ close $f
102
+ file delete "{pw_path}"
103
+ spawn ssh {key_arg}{args_str} {server.user}@{server.host}
104
+
105
+ # Handle SSH password
106
+ expect {{
107
+ -re {{[Pp]assword:}} {{ send "$pw\\r" }}
108
+ -re {{passphrase}} {{ send "$pw\\r" }}
109
+ timeout {{ puts "SSH timed out"; exit 1 }}
110
+ }}
111
+
112
+ # Drain MOTD output until we see the shell prompt (user@host:...$ )
113
+ expect {{
114
+ -re {{@[^:]+:.*\\$ }} {{ }}
115
+ timeout {{ }}
116
+ }}
117
+
118
+ # Elevate to root
119
+ send "sudo -i\\r"
120
+
121
+ # Handle sudo password then hand over to user
122
+ expect {{
123
+ -re {{[Pp]assword}} {{ send "$pw\\r"; interact }}
124
+ -re {{root@}} {{ interact }}
125
+ -re {{# }} {{ interact }}
126
+ timeout {{ interact }}
127
+ }}
128
+ '''
129
+ _run_expect(script, pw_path)
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Matrix color palette
134
+ # ---------------------------------------------------------------------------
135
+
136
+ C_BG = "#0d0208"
137
+ C_SURFACE = "#0a1a0a"
138
+ C_PANEL = "#0f1a0f"
139
+ C_GREEN_BRIGHT = "#00ff41"
140
+ C_GREEN_MID = "#00cc33"
141
+ C_GREEN_DIM = "#008f11"
142
+ C_GREEN_DARK = "#003b00"
143
+ C_GREEN_MUTED = "#337733"
144
+ C_RED = "#ff3333"
145
+ C_RED_DARK = "#661111"
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Screens
150
+ # ---------------------------------------------------------------------------
151
+
152
+ class ServerForm(ModalScreen[Server | None]):
153
+ """Modal form for adding or editing a server."""
154
+
155
+ CSS = f"""
156
+ ServerForm {{
157
+ align: center middle;
158
+ background: {C_BG} 80%;
159
+ }}
160
+ #form-box {{
161
+ width: 65;
162
+ max-height: 90%;
163
+ border: solid {C_GREEN_DIM};
164
+ background: {C_BG};
165
+ padding: 1 2;
166
+ }}
167
+ #form-fields {{
168
+ height: 1fr;
169
+ background: {C_BG};
170
+ }}
171
+ #form-title {{
172
+ text-align: center;
173
+ text-style: bold;
174
+ width: 100%;
175
+ color: {C_GREEN_BRIGHT};
176
+ }}
177
+ .field-label {{
178
+ margin-top: 0;
179
+ color: {C_GREEN_DIM};
180
+ }}
181
+ #form-box Input {{
182
+ width: 100%;
183
+ background: {C_SURFACE};
184
+ color: {C_GREEN_BRIGHT};
185
+ border: tall {C_GREEN_DARK};
186
+ }}
187
+ #form-box Input:focus {{
188
+ border: tall {C_GREEN_BRIGHT};
189
+ }}
190
+ #form-box Checkbox {{
191
+ background: transparent;
192
+ color: {C_GREEN_MID};
193
+ padding: 0 2;
194
+ }}
195
+ #form-box Checkbox:focus {{
196
+ color: {C_GREEN_BRIGHT};
197
+ }}
198
+ #form-hints {{
199
+ width: 100%;
200
+ height: 1;
201
+ content-align: center middle;
202
+ color: {C_GREEN_DIM};
203
+ margin-top: 1;
204
+ }}
205
+ """
206
+
207
+ BINDINGS = [
208
+ Binding("ctrl+s", "save", "Save", show=False),
209
+ Binding("escape", "cancel", "Cancel", show=False),
210
+ ]
211
+
212
+ def __init__(self, server: Server | None = None, password: str = ""):
213
+ super().__init__()
214
+ self.server = server
215
+ self._password = password
216
+
217
+ def compose(self) -> ComposeResult:
218
+ editing = self.server is not None
219
+ s = self.server
220
+
221
+ with Vertical(id="form-box"):
222
+ with VerticalScroll(id="form-fields"):
223
+ yield Static(
224
+ f"[bold {C_GREEN_BRIGHT}]// {'Edit' if editing else 'New'} Server[/]",
225
+ id="form-title",
226
+ )
227
+
228
+ yield Label("Name", classes="field-label")
229
+ yield Input(value=s.name if editing else "", placeholder="My Server", id="inp-name")
230
+
231
+ yield Label("Host / IP", classes="field-label")
232
+ yield Input(value=s.host if editing else "", placeholder="192.168.1.10", id="inp-host")
233
+
234
+ yield Label("Port", classes="field-label")
235
+ yield Input(value=str(s.port) if editing else "22", placeholder="22", id="inp-port")
236
+
237
+ yield Label("User", classes="field-label")
238
+ yield Input(value=s.user if editing else "root", placeholder="root", id="inp-user")
239
+
240
+ yield Label("Group (optional)", classes="field-label")
241
+ yield Input(value=s.group if editing else "", placeholder="production", id="inp-group")
242
+
243
+ yield Label("Password", classes="field-label")
244
+ yield Input(value=self._password, placeholder="Leave empty for key auth", password=True, id="inp-pass")
245
+
246
+ yield Label("SSH Key Path (optional)", classes="field-label")
247
+ yield Input(value=s.identity_file if editing else "", placeholder="~/.ssh/id_rsa", id="inp-key")
248
+
249
+ yield Checkbox("Auto sudo (elevate to root)", s.sudo if editing else False, id="inp-sudo")
250
+
251
+ yield Static(
252
+ f"[bold {C_GREEN_BRIGHT}]ctrl+s[/] save "
253
+ f"[{C_GREEN_DARK}]|[/] "
254
+ f"[bold {C_GREEN_BRIGHT}]esc[/] cancel",
255
+ id="form-hints",
256
+ )
257
+
258
+ def action_save(self) -> None:
259
+ name = self.query_one("#inp-name", Input).value.strip()
260
+ host = self.query_one("#inp-host", Input).value.strip()
261
+ port_s = self.query_one("#inp-port", Input).value.strip()
262
+ user = self.query_one("#inp-user", Input).value.strip() or "root"
263
+ group = self.query_one("#inp-group", Input).value.strip()
264
+ password = self.query_one("#inp-pass", Input).value
265
+ key = self.query_one("#inp-key", Input).value.strip()
266
+ sudo = self.query_one("#inp-sudo", Checkbox).value
267
+
268
+ if not name or not host:
269
+ self.notify("Name and Host are required.", severity="error")
270
+ return
271
+ try:
272
+ port = int(port_s) if port_s else 22
273
+ except ValueError:
274
+ self.notify("Port must be a number.", severity="error")
275
+ return
276
+
277
+ if self.server:
278
+ self.server.name = name
279
+ self.server.host = host
280
+ self.server.port = port
281
+ self.server.user = user
282
+ self.server.group = group
283
+ self.server.identity_file = key
284
+ self.server.sudo = sudo
285
+ server = self.server
286
+ else:
287
+ server = Server(name=name, host=host, port=port, user=user, group=group, identity_file=key, sudo=sudo)
288
+
289
+ self.app.storage.set_password(server.id, password)
290
+ self.dismiss(server)
291
+
292
+ def action_cancel(self) -> None:
293
+ self.dismiss(None)
294
+
295
+
296
+ class ConfirmDelete(ModalScreen[bool]):
297
+ """Confirmation dialog for server deletion."""
298
+
299
+ CSS = f"""
300
+ ConfirmDelete {{
301
+ align: center middle;
302
+ background: {C_BG} 80%;
303
+ }}
304
+ #del-box {{
305
+ width: 50;
306
+ height: auto;
307
+ border: solid {C_RED};
308
+ background: {C_BG};
309
+ padding: 1 2;
310
+ }}
311
+ #del-msg {{
312
+ text-align: center;
313
+ width: 100%;
314
+ margin: 1 0;
315
+ color: {C_RED};
316
+ }}
317
+ #del-hints {{
318
+ text-align: center;
319
+ width: 100%;
320
+ margin-top: 1;
321
+ color: {C_GREEN_DIM};
322
+ }}
323
+ """
324
+
325
+ BINDINGS = [
326
+ Binding("y", "confirm", "Yes", show=False),
327
+ Binding("enter", "confirm", "Confirm", show=False),
328
+ Binding("n", "deny", "No", show=False),
329
+ Binding("escape", "deny", "Cancel", show=False),
330
+ ]
331
+
332
+ def __init__(self, server_name: str):
333
+ super().__init__()
334
+ self.server_name = server_name
335
+
336
+ def compose(self) -> ComposeResult:
337
+ with Vertical(id="del-box"):
338
+ yield Static(
339
+ f"[bold {C_RED}]// WARNING[/]\n"
340
+ f"[{C_RED}]Delete '[bold]{self.server_name}[/bold]' ?[/]",
341
+ id="del-msg",
342
+ )
343
+ yield Static(
344
+ f"[bold {C_GREEN_BRIGHT}]y[/] / [bold {C_GREEN_BRIGHT}]enter[/] confirm "
345
+ f"[{C_GREEN_DARK}]|[/] "
346
+ f"[bold {C_GREEN_BRIGHT}]n[/] / [bold {C_GREEN_BRIGHT}]esc[/] cancel",
347
+ id="del-hints",
348
+ )
349
+
350
+ def action_confirm(self) -> None:
351
+ self.dismiss(True)
352
+
353
+ def action_deny(self) -> None:
354
+ self.dismiss(False)
355
+
356
+
357
+ # ---------------------------------------------------------------------------
358
+ # Main App
359
+ # ---------------------------------------------------------------------------
360
+
361
+ class SSHManagerApp(App):
362
+ """Terminal SSH connection manager."""
363
+
364
+ TITLE = "SSHM"
365
+
366
+ CSS = f"""
367
+ Screen {{
368
+ background: {C_BG};
369
+ }}
370
+
371
+ /* ---- Banner ---- */
372
+ #banner {{
373
+ dock: top;
374
+ width: 100%;
375
+ height: 3;
376
+ background: {C_SURFACE};
377
+ content-align: center middle;
378
+ border-bottom: solid {C_GREEN_DARK};
379
+ }}
380
+
381
+ /* ---- Search ---- */
382
+ #search-bar {{
383
+ dock: top;
384
+ height: 3;
385
+ display: none;
386
+ padding: 0 1;
387
+ background: {C_SURFACE};
388
+ }}
389
+ #search-bar.visible {{
390
+ display: block;
391
+ }}
392
+ #search-input {{
393
+ background: {C_BG};
394
+ color: {C_GREEN_BRIGHT};
395
+ border: tall {C_GREEN_DARK};
396
+ }}
397
+ #search-input:focus {{
398
+ border: tall {C_GREEN_BRIGHT};
399
+ }}
400
+
401
+ /* ---- Server table ---- */
402
+ DataTable {{
403
+ height: 1fr;
404
+ background: {C_BG};
405
+ color: {C_GREEN_MID};
406
+ }}
407
+ DataTable > .datatable--cursor {{
408
+ background: {C_GREEN_DIM};
409
+ color: {C_BG};
410
+ text-style: bold;
411
+ }}
412
+ DataTable > .datatable--header {{
413
+ background: {C_SURFACE};
414
+ color: {C_GREEN_BRIGHT};
415
+ text-style: bold;
416
+ }}
417
+ DataTable > .datatable--even-row {{
418
+ background: {C_BG};
419
+ }}
420
+ DataTable > .datatable--odd-row {{
421
+ background: {C_PANEL};
422
+ }}
423
+
424
+ /* ---- Empty state ---- */
425
+ #empty-hint {{
426
+ width: 100%;
427
+ height: 100%;
428
+ content-align: center middle;
429
+ color: {C_GREEN_MUTED};
430
+ }}
431
+ #empty-hint.hidden {{
432
+ display: none;
433
+ }}
434
+
435
+ /* ---- Footer ---- */
436
+ Footer {{
437
+ background: {C_SURFACE};
438
+ }}
439
+ Footer > .footer--key {{
440
+ background: {C_GREEN_DARK};
441
+ color: {C_GREEN_BRIGHT};
442
+ }}
443
+ Footer > .footer--description {{
444
+ color: {C_GREEN_DIM};
445
+ }}
446
+ FooterKey > .footer--key {{
447
+ background: {C_GREEN_DARK};
448
+ color: {C_GREEN_BRIGHT};
449
+ }}
450
+ FooterKey > .footer--description {{
451
+ color: {C_GREEN_DIM};
452
+ }}
453
+ """
454
+
455
+ BINDINGS = [
456
+ Binding("a", "add_server", "Add"),
457
+ Binding("e", "edit_server", "Edit"),
458
+ Binding("d", "delete_server", "Delete"),
459
+ Binding("c", "copy_cmd", "Copy SSH"),
460
+ Binding("slash", "toggle_search", "Search"),
461
+ Binding("escape", "esc_pressed", "Quit", show=False),
462
+ Binding("q", "quit", "Quit"),
463
+ ]
464
+
465
+ def __init__(self):
466
+ super().__init__()
467
+ self.storage = Storage()
468
+ self._filter = ""
469
+ self._visible: list[Server] = []
470
+
471
+ def compose(self) -> ComposeResult:
472
+ yield Static(
473
+ f"[bold {C_GREEN_BRIGHT}]>_[/] "
474
+ f"[bold {C_GREEN_BRIGHT}]SSHM[/] "
475
+ f"[{C_GREEN_DARK}]::[/] "
476
+ f"[{C_GREEN_DIM}]SSH Connection Manager[/]",
477
+ id="banner",
478
+ )
479
+ with Vertical(id="search-bar"):
480
+ yield Input(placeholder="// filter servers ...", id="search-input")
481
+ yield DataTable(id="server-table")
482
+ yield Static(
483
+ f"[{C_GREEN_MUTED}]> No servers in the matrix. "
484
+ f"Press [{C_GREEN_BRIGHT}]a[/{C_GREEN_BRIGHT}] to add one.[/]",
485
+ id="empty-hint",
486
+ )
487
+ yield Footer()
488
+
489
+ def on_mount(self) -> None:
490
+ table = self.query_one(DataTable)
491
+ table.add_columns("Name", "Host", "User", "Port", "Group")
492
+ table.cursor_type = "row"
493
+ table.zebra_stripes = True
494
+ self._refresh_table()
495
+
496
+ # --- table helpers ---
497
+
498
+ def _refresh_table(self) -> None:
499
+ table = self.query_one(DataTable)
500
+ table.clear()
501
+
502
+ servers = self.storage.servers
503
+ if self._filter:
504
+ ft = self._filter.lower()
505
+ servers = [
506
+ s for s in servers
507
+ if ft in s.name.lower()
508
+ or ft in s.host.lower()
509
+ or ft in s.user.lower()
510
+ or ft in s.group.lower()
511
+ ]
512
+
513
+ self._visible = list(servers)
514
+ for s in servers:
515
+ table.add_row(s.name, s.host, s.user, str(s.port), s.group or "-", key=s.id)
516
+
517
+ hint = self.query_one("#empty-hint")
518
+ if servers:
519
+ hint.add_class("hidden")
520
+ else:
521
+ hint.remove_class("hidden")
522
+
523
+ def _selected_server(self) -> Server | None:
524
+ table = self.query_one(DataTable)
525
+ if table.row_count == 0:
526
+ return None
527
+ idx = table.cursor_coordinate.row
528
+ if 0 <= idx < len(self._visible):
529
+ return self._visible[idx]
530
+ return None
531
+
532
+ # --- actions ---
533
+
534
+ def action_add_server(self) -> None:
535
+ def on_result(server: Server | None) -> None:
536
+ if server:
537
+ self.storage.add_server(server)
538
+ self._refresh_table()
539
+ self.notify(f"Added '{server.name}'")
540
+
541
+ self.push_screen(ServerForm(), callback=on_result)
542
+
543
+ def action_edit_server(self) -> None:
544
+ server = self._selected_server()
545
+ if not server:
546
+ self.notify("No server selected", severity="warning")
547
+ return
548
+ password = self.storage.get_password(server.id)
549
+
550
+ def on_result(updated: Server | None) -> None:
551
+ if updated:
552
+ self.storage.update_server(updated)
553
+ self._refresh_table()
554
+ self.notify(f"Updated '{updated.name}'")
555
+
556
+ self.push_screen(ServerForm(server=server, password=password), callback=on_result)
557
+
558
+ def action_delete_server(self) -> None:
559
+ server = self._selected_server()
560
+ if not server:
561
+ self.notify("No server selected", severity="warning")
562
+ return
563
+
564
+ def on_confirm(confirmed: bool) -> None:
565
+ if confirmed:
566
+ self.storage.delete_server(server.id)
567
+ self._refresh_table()
568
+ self.notify(f"Deleted '{server.name}'")
569
+
570
+ self.push_screen(ConfirmDelete(server.name), callback=on_confirm)
571
+
572
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
573
+ """Connect on Enter / double-click."""
574
+ server_id = str(event.row_key.value)
575
+ server = self.storage.get_server(server_id)
576
+ if not server:
577
+ return
578
+ password = self.storage.get_password(server.id)
579
+ with self.suspend():
580
+ ssh_connect(server, password)
581
+
582
+ def action_copy_cmd(self) -> None:
583
+ server = self._selected_server()
584
+ if not server:
585
+ self.notify("No server selected", severity="warning")
586
+ return
587
+ parts = ["ssh"]
588
+ if server.identity_file:
589
+ parts += ["-i", server.identity_file]
590
+ parts += ["-p", str(server.port), f"{server.user}@{server.host}"]
591
+ cmd = " ".join(parts)
592
+ subprocess.run(["pbcopy"], input=cmd.encode(), check=False)
593
+ self.notify(f"Copied: {cmd}")
594
+
595
+ # --- search ---
596
+
597
+ def action_toggle_search(self) -> None:
598
+ bar = self.query_one("#search-bar")
599
+ if bar.has_class("visible"):
600
+ self._close_search()
601
+ else:
602
+ bar.add_class("visible")
603
+ self.query_one("#search-input", Input).focus()
604
+
605
+ def action_esc_pressed(self) -> None:
606
+ bar = self.query_one("#search-bar")
607
+ if bar.has_class("visible"):
608
+ self._close_search()
609
+ else:
610
+ self.exit()
611
+
612
+ def _close_search(self) -> None:
613
+ self.query_one("#search-bar").remove_class("visible")
614
+ self.query_one("#search-input", Input).value = ""
615
+ self._filter = ""
616
+ self._refresh_table()
617
+ self.query_one(DataTable).focus()
618
+
619
+ @on(Input.Changed, "#search-input")
620
+ def _on_search(self, event: Input.Changed) -> None:
621
+ self._filter = event.value
622
+ self._refresh_table()
623
+
624
+ @on(Input.Submitted, "#search-input")
625
+ def _on_search_submit(self) -> None:
626
+ self.query_one(DataTable).focus()
627
+
628
+
629
+ def cmd_list():
630
+ """Print saved servers to stdout."""
631
+ storage = Storage()
632
+ if not storage.servers:
633
+ print(f"\033[32m> No servers saved. Run `sshm` to add one.\033[0m")
634
+ return
635
+ rows = [(s.name, s.host, s.user, str(s.port), s.group or "-") for s in storage.servers]
636
+ headers = ("NAME", "HOST", "USER", "PORT", "GROUP")
637
+ widths = [max(len(h), *(len(r[i]) for r in rows)) for i, h in enumerate(headers)]
638
+ fmt = " ".join(f"{{:<{w}}}" for w in widths)
639
+ print(f"\033[1;32m{fmt.format(*headers)}\033[0m")
640
+ print(f"\033[32m{fmt.format(*('─' * w for w in widths))}\033[0m")
641
+ for r in rows:
642
+ print(f"\033[32m{fmt.format(*r)}\033[0m")
643
+
644
+
645
+ def main():
646
+ import sys
647
+
648
+ if len(sys.argv) > 1:
649
+ cmd = sys.argv[1]
650
+ if cmd == "list":
651
+ cmd_list()
652
+ elif cmd == "help":
653
+ print("\033[1;32m> SSHM\033[0m \033[32m:: SSH Connection Manager\033[0m")
654
+ print()
655
+ print("\033[32mUsage: sshm [command]\033[0m")
656
+ print()
657
+ print("\033[32m (none) Open the TUI manager\033[0m")
658
+ print("\033[32m list List saved servers\033[0m")
659
+ print("\033[32m help Show this help\033[0m")
660
+ else:
661
+ print(f"\033[31mUnknown command: {cmd}\033[0m")
662
+ print("\033[32mRun `sshm help` for usage.\033[0m")
663
+ sys.exit(1)
664
+ return
665
+
666
+ app = SSHManagerApp()
667
+ app.run()
668
+
669
+
670
+ if __name__ == "__main__":
671
+ main()
@@ -0,0 +1,114 @@
1
+ """Server storage with JSON persistence and macOS Keychain for passwords."""
2
+
3
+ import json
4
+ import subprocess
5
+ import uuid
6
+ from dataclasses import asdict, dataclass, field
7
+ from pathlib import Path
8
+
9
+ DATA_DIR = Path.home() / ".sshm"
10
+ SERVERS_FILE = DATA_DIR / "servers.json"
11
+ KEYCHAIN_SERVICE = "sshm"
12
+
13
+
14
+ @dataclass
15
+ class Server:
16
+ name: str
17
+ host: str
18
+ user: str = "root"
19
+ port: int = 22
20
+ group: str = ""
21
+ identity_file: str = ""
22
+ sudo: bool = False
23
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
24
+
25
+ def to_dict(self) -> dict:
26
+ return asdict(self)
27
+
28
+ @classmethod
29
+ def from_dict(cls, data: dict) -> "Server":
30
+ known = {f for f in cls.__dataclass_fields__}
31
+ return cls(**{k: v for k, v in data.items() if k in known})
32
+
33
+
34
+ class Storage:
35
+ def __init__(self):
36
+ DATA_DIR.mkdir(exist_ok=True)
37
+ self.servers: list[Server] = []
38
+ self.load()
39
+
40
+ def load(self):
41
+ if SERVERS_FILE.exists():
42
+ data = json.loads(SERVERS_FILE.read_text())
43
+ self.servers = [Server.from_dict(s) for s in data]
44
+ else:
45
+ self.servers = []
46
+
47
+ def save(self):
48
+ data = [s.to_dict() for s in self.servers]
49
+ SERVERS_FILE.write_text(json.dumps(data, indent=2))
50
+
51
+ def add_server(self, server: Server):
52
+ self.servers.append(server)
53
+ self.save()
54
+
55
+ def update_server(self, server: Server):
56
+ for i, s in enumerate(self.servers):
57
+ if s.id == server.id:
58
+ self.servers[i] = server
59
+ break
60
+ self.save()
61
+
62
+ def delete_server(self, server_id: str):
63
+ self.servers = [s for s in self.servers if s.id != server_id]
64
+ self.save()
65
+ self.delete_password(server_id)
66
+
67
+ def get_server(self, server_id: str) -> Server | None:
68
+ for s in self.servers:
69
+ if s.id == server_id:
70
+ return s
71
+ return None
72
+
73
+ # --- macOS Keychain via `security` CLI ---
74
+
75
+ def get_password(self, server_id: str) -> str:
76
+ try:
77
+ result = subprocess.run(
78
+ [
79
+ "security", "find-generic-password",
80
+ "-a", KEYCHAIN_SERVICE,
81
+ "-s", server_id,
82
+ "-w",
83
+ ],
84
+ capture_output=True,
85
+ text=True,
86
+ )
87
+ if result.returncode == 0:
88
+ return result.stdout.strip()
89
+ except Exception:
90
+ pass
91
+ return ""
92
+
93
+ def set_password(self, server_id: str, password: str):
94
+ self.delete_password(server_id)
95
+ if password:
96
+ subprocess.run(
97
+ [
98
+ "security", "add-generic-password",
99
+ "-a", KEYCHAIN_SERVICE,
100
+ "-s", server_id,
101
+ "-w", password,
102
+ ],
103
+ capture_output=True,
104
+ )
105
+
106
+ def delete_password(self, server_id: str):
107
+ subprocess.run(
108
+ [
109
+ "security", "delete-generic-password",
110
+ "-a", KEYCHAIN_SERVICE,
111
+ "-s", server_id,
112
+ ],
113
+ capture_output=True,
114
+ )