ntermqt 0.1.1__py3-none-any.whl → 0.1.4__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.
nterm/scripting/cli.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ nterm/scripting/cli.py
3
+
4
+ Command-line interface for nterm scripting API.
5
+
6
+ Usage:
7
+ nterm-cli devices
8
+ nterm-cli search leaf
9
+ nterm-cli device eng-leaf-1
10
+ nterm-cli credentials --unlock
11
+ nterm-cli status
12
+ """
13
+
14
+ import sys
15
+ import json
16
+ import getpass
17
+ from typing import Optional
18
+
19
+ import click
20
+
21
+ from .api import NTermAPI, DeviceInfo, CredentialInfo
22
+
23
+ # Shared API instance
24
+ _api: Optional[NTermAPI] = None
25
+
26
+
27
+ def get_api() -> NTermAPI:
28
+ global _api
29
+ if _api is None:
30
+ _api = NTermAPI()
31
+ return _api
32
+
33
+
34
+ def format_table(items: list, columns: list[tuple[str, str, int]]) -> str:
35
+ """
36
+ Format items as a simple table.
37
+
38
+ Args:
39
+ items: List of objects with attributes
40
+ columns: List of (attr_name, header, width) tuples
41
+ """
42
+ if not items:
43
+ return "No results."
44
+
45
+ # Build header
46
+ header = ""
47
+ separator = ""
48
+ for attr, name, width in columns:
49
+ header += f"{name:<{width}} "
50
+ separator += "-" * width + " "
51
+
52
+ lines = [header.rstrip(), separator.rstrip()]
53
+
54
+ # Build rows
55
+ for item in items:
56
+ row = ""
57
+ for attr, name, width in columns:
58
+ val = getattr(item, attr, "")
59
+ if val is None:
60
+ val = ""
61
+ val_str = str(val)[:width - 1] # Truncate if needed
62
+ row += f"{val_str:<{width}} "
63
+ lines.append(row.rstrip())
64
+
65
+ return "\n".join(lines)
66
+
67
+
68
+ @click.group()
69
+ @click.option("--json", "output_json", is_flag=True, help="Output as JSON")
70
+ @click.pass_context
71
+ def cli(ctx, output_json):
72
+ """nterm command-line interface for device and credential management."""
73
+ ctx.ensure_object(dict)
74
+ ctx.obj["json"] = output_json
75
+
76
+
77
+ @cli.command("devices")
78
+ @click.option("-p", "--pattern", default=None, help="Filter by glob pattern (e.g., 'eng-*')")
79
+ @click.option("-f", "--folder", default=None, help="Filter by folder name")
80
+ @click.pass_context
81
+ def list_devices(ctx, pattern, folder):
82
+ """List saved devices/sessions."""
83
+ api = get_api()
84
+ devices = api.devices(pattern=pattern, folder=folder)
85
+
86
+ if ctx.obj["json"]:
87
+ click.echo(json.dumps([d.to_dict() for d in devices], indent=2))
88
+ else:
89
+ columns = [
90
+ ("name", "NAME", 25),
91
+ ("hostname", "HOSTNAME", 20),
92
+ ("port", "PORT", 6),
93
+ ("folder", "FOLDER", 15),
94
+ ("credential", "CREDENTIAL", 20),
95
+ ]
96
+ click.echo(format_table(devices, columns))
97
+ click.echo(f"\n{len(devices)} device(s)")
98
+
99
+
100
+ @cli.command("search")
101
+ @click.argument("query")
102
+ @click.pass_context
103
+ def search_devices(ctx, query):
104
+ """Search devices by name, hostname, or description."""
105
+ api = get_api()
106
+ devices = api.search(query)
107
+
108
+ if ctx.obj["json"]:
109
+ click.echo(json.dumps([d.to_dict() for d in devices], indent=2))
110
+ else:
111
+ columns = [
112
+ ("name", "NAME", 25),
113
+ ("hostname", "HOSTNAME", 20),
114
+ ("port", "PORT", 6),
115
+ ("folder", "FOLDER", 15),
116
+ ("credential", "CREDENTIAL", 20),
117
+ ]
118
+ click.echo(format_table(devices, columns))
119
+ click.echo(f"\n{len(devices)} result(s)")
120
+
121
+
122
+ @cli.command("device")
123
+ @click.argument("name")
124
+ @click.pass_context
125
+ def get_device(ctx, name):
126
+ """Get details for a specific device."""
127
+ api = get_api()
128
+ device = api.device(name)
129
+
130
+ if not device:
131
+ click.echo(f"Device '{name}' not found.", err=True)
132
+ sys.exit(1)
133
+
134
+ if ctx.obj["json"]:
135
+ click.echo(json.dumps(device.to_dict(), indent=2))
136
+ else:
137
+ click.echo(f"Name: {device.name}")
138
+ click.echo(f"Hostname: {device.hostname}")
139
+ click.echo(f"Port: {device.port}")
140
+ click.echo(f"Folder: {device.folder or '(root)'}")
141
+ click.echo(f"Credential: {device.credential or '(none)'}")
142
+ click.echo(f"Last Connected: {device.last_connected or 'never'}")
143
+ click.echo(f"Connect Count: {device.connect_count}")
144
+
145
+
146
+ @cli.command("folders")
147
+ @click.pass_context
148
+ def list_folders(ctx):
149
+ """List all folders."""
150
+ api = get_api()
151
+ folders = api.folders()
152
+
153
+ if ctx.obj["json"]:
154
+ click.echo(json.dumps(folders, indent=2))
155
+ else:
156
+ if folders:
157
+ for f in folders:
158
+ click.echo(f" {f}")
159
+ click.echo(f"\n{len(folders)} folder(s)")
160
+ else:
161
+ click.echo("No folders.")
162
+
163
+
164
+ @cli.command("credentials")
165
+ @click.option("-p", "--pattern", default=None, help="Filter by glob pattern")
166
+ @click.option("-u", "--unlock", is_flag=True, help="Prompt for vault password")
167
+ @click.option("--password", default=None, help="Vault password (use --unlock for interactive)")
168
+ @click.pass_context
169
+ def list_credentials(ctx, pattern, unlock, password):
170
+ """List credentials (requires unlocked vault)."""
171
+ api = get_api()
172
+
173
+ # Handle vault unlock
174
+ if not api.vault_initialized:
175
+ click.echo("Vault not initialized. Run nterm to create a vault first.", err=True)
176
+ sys.exit(1)
177
+
178
+ if not api.vault_unlocked:
179
+ if unlock:
180
+ password = getpass.getpass("Vault password: ")
181
+
182
+ if password:
183
+ if not api.unlock(password):
184
+ click.echo("Failed to unlock vault. Wrong password?", err=True)
185
+ sys.exit(1)
186
+ else:
187
+ click.echo("Vault is locked. Use --unlock or --password to unlock.", err=True)
188
+ sys.exit(1)
189
+
190
+ credentials = api.credentials(pattern=pattern)
191
+
192
+ if ctx.obj["json"]:
193
+ click.echo(json.dumps([c.to_dict() for c in credentials], indent=2))
194
+ else:
195
+ columns = [
196
+ ("name", "NAME", 20),
197
+ ("username", "USERNAME", 15),
198
+ ("has_password", "PASS", 5),
199
+ ("has_key", "KEY", 5),
200
+ ("is_default", "DEFAULT", 8),
201
+ ("jump_host", "JUMP HOST", 20),
202
+ ]
203
+ click.echo(format_table(credentials, columns))
204
+ click.echo(f"\n{len(credentials)} credential(s)")
205
+
206
+
207
+ @cli.command("credential")
208
+ @click.argument("name")
209
+ @click.option("-u", "--unlock", is_flag=True, help="Prompt for vault password")
210
+ @click.option("--password", default=None, help="Vault password")
211
+ @click.pass_context
212
+ def get_credential(ctx, name, unlock, password):
213
+ """Get details for a specific credential."""
214
+ api = get_api()
215
+
216
+ # Handle vault unlock
217
+ if not api.vault_unlocked:
218
+ if unlock:
219
+ password = getpass.getpass("Vault password: ")
220
+
221
+ if password:
222
+ if not api.unlock(password):
223
+ click.echo("Failed to unlock vault.", err=True)
224
+ sys.exit(1)
225
+ else:
226
+ click.echo("Vault is locked. Use --unlock or --password.", err=True)
227
+ sys.exit(1)
228
+
229
+ cred = api.credential(name)
230
+
231
+ if not cred:
232
+ click.echo(f"Credential '{name}' not found.", err=True)
233
+ sys.exit(1)
234
+
235
+ if ctx.obj["json"]:
236
+ click.echo(json.dumps(cred.to_dict(), indent=2))
237
+ else:
238
+ click.echo(f"Name: {cred.name}")
239
+ click.echo(f"Username: {cred.username}")
240
+ click.echo(f"Has Password: {cred.has_password}")
241
+ click.echo(f"Has Key: {cred.has_key}")
242
+ click.echo(f"Is Default: {cred.is_default}")
243
+ click.echo(f"Jump Host: {cred.jump_host or '(none)'}")
244
+ click.echo(f"Match Hosts: {', '.join(cred.match_hosts) or '(any)'}")
245
+ click.echo(f"Match Tags: {', '.join(cred.match_tags) or '(any)'}")
246
+
247
+
248
+ @cli.command("resolve")
249
+ @click.argument("hostname")
250
+ @click.option("-u", "--unlock", is_flag=True, help="Prompt for vault password")
251
+ @click.option("--password", default=None, help="Vault password")
252
+ @click.pass_context
253
+ def resolve_credential(ctx, hostname, unlock, password):
254
+ """Find which credential would be used for a hostname."""
255
+ api = get_api()
256
+
257
+ # Handle vault unlock
258
+ if not api.vault_unlocked:
259
+ if unlock:
260
+ password = getpass.getpass("Vault password: ")
261
+
262
+ if password:
263
+ if not api.unlock(password):
264
+ click.echo("Failed to unlock vault.", err=True)
265
+ sys.exit(1)
266
+ else:
267
+ click.echo("Vault is locked. Use --unlock or --password.", err=True)
268
+ sys.exit(1)
269
+
270
+ cred_name = api.resolve_credential(hostname)
271
+
272
+ if ctx.obj["json"]:
273
+ click.echo(json.dumps({"hostname": hostname, "credential": cred_name}))
274
+ else:
275
+ if cred_name:
276
+ click.echo(f"{hostname} -> {cred_name}")
277
+ else:
278
+ click.echo(f"No matching credential for {hostname}")
279
+
280
+
281
+ @cli.command("status")
282
+ @click.pass_context
283
+ def show_status(ctx):
284
+ """Show API status summary."""
285
+ api = get_api()
286
+ status = api.status()
287
+
288
+ if ctx.obj["json"]:
289
+ click.echo(json.dumps(status, indent=2))
290
+ else:
291
+ click.echo(f"Devices: {status['devices']}")
292
+ click.echo(f"Folders: {status['folders']}")
293
+ click.echo(f"Vault Initialized: {status['vault_initialized']}")
294
+ click.echo(f"Vault Unlocked: {status['vault_unlocked']}")
295
+ if status['vault_unlocked']:
296
+ click.echo(f"Credentials: {status['credentials']}")
297
+
298
+
299
+ def main():
300
+ """Entry point for CLI."""
301
+ cli(obj={})
302
+
303
+
304
+ if __name__ == "__main__":
305
+ main()
@@ -0,0 +1,225 @@
1
+ """
2
+ Standalone local terminal session.
3
+ Minimal Session-compatible wrapper for local PTY processes.
4
+ """
5
+
6
+ import sys
7
+ import os
8
+ import threading
9
+ import time
10
+ import logging
11
+ from typing import Optional, Callable, List
12
+
13
+ from .pty_transport import create_pty, is_pty_available, PTYTransport
14
+ from .base import SessionState, SessionEvent, DataReceived, StateChanged
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # IPython startup code to inject the nterm API
20
+ IPYTHON_STARTUP = '''
21
+ from nterm.scripting import api
22
+ print("\\n\\033[1;36mnterm API loaded.\\033[0m")
23
+ print(" api.devices() - List saved devices")
24
+ print(" api.search(query) - Search devices")
25
+ print(" api.credentials() - List credentials (after api.unlock())")
26
+ print(" api.help() - Show all commands")
27
+ print()
28
+ '''
29
+
30
+
31
+ class LocalTerminal:
32
+ """
33
+ Lightweight local PTY session.
34
+
35
+ Implements just enough of Session interface for TerminalWidget.
36
+ Completely separate from SSH session management.
37
+
38
+ Usage:
39
+ # Default shell
40
+ session = LocalTerminal()
41
+
42
+ # IPython with nterm API pre-loaded
43
+ session = LocalTerminal.ipython()
44
+
45
+ # Python REPL
46
+ session = LocalTerminal.python()
47
+
48
+ # Arbitrary command
49
+ session = LocalTerminal(['htop'])
50
+ """
51
+
52
+ def __init__(self, command: Optional[List[str]] = None):
53
+ """
54
+ Initialize local terminal session.
55
+
56
+ Args:
57
+ command: Command to run. Defaults to user's shell.
58
+ """
59
+ self._command = command or self._default_shell()
60
+ self._pty: Optional[PTYTransport] = None
61
+ self._state = SessionState.DISCONNECTED
62
+ self._stop = threading.Event()
63
+ self._handler: Optional[Callable[[SessionEvent], None]] = None
64
+ self._cols, self._rows = 120, 40
65
+
66
+ @staticmethod
67
+ def _default_shell() -> List[str]:
68
+ """Get user's default shell."""
69
+ if sys.platform == 'win32':
70
+ return [os.environ.get('COMSPEC', 'cmd.exe')]
71
+ return [os.environ.get('SHELL', '/bin/bash')]
72
+
73
+ @classmethod
74
+ def ipython(cls, with_api: bool = True) -> 'LocalTerminal':
75
+ """
76
+ Create IPython session in current venv.
77
+
78
+ Args:
79
+ with_api: If True, pre-load nterm scripting API into namespace
80
+
81
+ Returns:
82
+ LocalTerminal configured to run IPython
83
+
84
+ Raises:
85
+ RuntimeError: If IPython is not installed
86
+ """
87
+ try:
88
+ import IPython # noqa: F401
89
+ except ImportError:
90
+ raise RuntimeError(
91
+ "IPython not installed. Install with: pip install ntermqt[scripting]"
92
+ )
93
+
94
+ if with_api:
95
+ # Use -i with -c to run startup code then go interactive
96
+ cmd = [
97
+ sys.executable, '-m', 'IPython',
98
+ '-i', '-c', IPYTHON_STARTUP
99
+ ]
100
+ else:
101
+ cmd = [sys.executable, '-m', 'IPython']
102
+ return cls(cmd)
103
+
104
+ @classmethod
105
+ def python(cls) -> 'LocalTerminal':
106
+ """
107
+ Create Python REPL session in current venv.
108
+
109
+ Returns:
110
+ LocalTerminal configured to run Python
111
+ """
112
+ return cls([sys.executable])
113
+
114
+ @classmethod
115
+ def shell(cls, shell: str = None) -> 'LocalTerminal':
116
+ """
117
+ Create shell session.
118
+
119
+ Args:
120
+ shell: Optional shell path. Defaults to user's default shell.
121
+
122
+ Returns:
123
+ LocalTerminal configured to run shell
124
+ """
125
+ if shell:
126
+ return cls([shell])
127
+ return cls()
128
+
129
+ # -------------------------------------------------------------------------
130
+ # Session interface (minimal subset for TerminalWidget compatibility)
131
+ # -------------------------------------------------------------------------
132
+
133
+ @property
134
+ def state(self) -> SessionState:
135
+ """Current session state."""
136
+ return self._state
137
+
138
+ @property
139
+ def is_connected(self) -> bool:
140
+ """Check if process is running."""
141
+ return self._state == SessionState.CONNECTED
142
+
143
+ def set_event_handler(self, handler: Callable[[SessionEvent], None]) -> None:
144
+ """Set callback for session events."""
145
+ self._handler = handler
146
+
147
+ def set_auto_reconnect(self, enabled: bool) -> None:
148
+ """No-op for local sessions."""
149
+ pass
150
+
151
+ def connect(self) -> None:
152
+ """Start the local process."""
153
+ if not is_pty_available():
154
+ self._set_state(SessionState.FAILED, "PTY unavailable. On Windows, install pywinpty.")
155
+ return
156
+ self._stop.clear()
157
+ threading.Thread(target=self._run, daemon=True).start()
158
+
159
+ def write(self, data: bytes) -> None:
160
+ """Send input to process."""
161
+ if self._pty:
162
+ self._pty.write(data)
163
+
164
+ def resize(self, cols: int, rows: int) -> None:
165
+ """Resize terminal."""
166
+ self._cols, self._rows = cols, rows
167
+ if self._pty:
168
+ self._pty.resize(cols, rows)
169
+
170
+ def disconnect(self) -> None:
171
+ """Terminate the process."""
172
+ self._stop.set()
173
+ if self._pty:
174
+ self._pty.close()
175
+ self._pty = None
176
+ self._set_state(SessionState.DISCONNECTED, "Closed")
177
+
178
+ # -------------------------------------------------------------------------
179
+ # Internal
180
+ # -------------------------------------------------------------------------
181
+
182
+ def _set_state(self, state: SessionState, msg: str = ""):
183
+ """Update state and notify handler."""
184
+ old, self._state = self._state, state
185
+ logger.debug(f"LocalTerminal: {old.name} -> {state.name} {msg}")
186
+ if self._handler:
187
+ try:
188
+ self._handler(StateChanged(old, state, msg))
189
+ except Exception as e:
190
+ logger.exception(f"Event handler error: {e}")
191
+
192
+ def _run(self):
193
+ """Main PTY read loop (runs in thread)."""
194
+ try:
195
+ self._set_state(SessionState.CONNECTING)
196
+
197
+ logger.info(f"Spawning: {' '.join(self._command)}")
198
+
199
+ self._pty = create_pty()
200
+ self._pty.spawn(self._command, echo=True) # Local apps need echo
201
+ self._pty.resize(self._cols, self._rows)
202
+
203
+ self._set_state(SessionState.CONNECTED)
204
+
205
+ # Read loop
206
+ while not self._stop.is_set() and self._pty.is_alive:
207
+ data = self._pty.read(8192)
208
+ if data and self._handler:
209
+ self._handler(DataReceived(data))
210
+ else:
211
+ time.sleep(0.01)
212
+
213
+ # Process exited
214
+ if not self._stop.is_set():
215
+ exit_code = self._pty.exit_code if self._pty else None
216
+ msg = f"Process exited (code {exit_code})" if exit_code is not None else "Process exited"
217
+ self._set_state(SessionState.DISCONNECTED, msg)
218
+
219
+ except Exception as e:
220
+ logger.exception("LocalTerminal failed")
221
+ self._set_state(SessionState.FAILED, str(e))
222
+ finally:
223
+ if self._pty:
224
+ self._pty.close()
225
+ self._pty = None