ntermqt 0.1.1__py3-none-any.whl → 0.1.3__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/__main__.py +132 -9
- nterm/scripting/__init__.py +43 -0
- nterm/scripting/api.py +447 -0
- nterm/scripting/cli.py +305 -0
- nterm/session/local_terminal.py +225 -0
- nterm/session/pty_transport.py +105 -91
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.3.dist-info}/METADATA +118 -11
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.3.dist-info}/RECORD +11 -7
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.3.dist-info}/entry_points.txt +1 -0
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.3.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.3.dist-info}/top_level.txt +0 -0
nterm/scripting/api.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nterm/scripting/api.py
|
|
3
|
+
|
|
4
|
+
Scripting API for nterm - usable from IPython, CLI, or MCP tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import fnmatch
|
|
9
|
+
from typing import Optional, List, Dict, Any
|
|
10
|
+
from dataclasses import dataclass, asdict
|
|
11
|
+
|
|
12
|
+
from ..manager.models import SessionStore, SavedSession, SessionFolder
|
|
13
|
+
from ..vault.resolver import CredentialResolver
|
|
14
|
+
from ..vault.store import StoredCredential
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DeviceInfo:
|
|
19
|
+
"""Simplified device view for scripting."""
|
|
20
|
+
name: str
|
|
21
|
+
hostname: str
|
|
22
|
+
port: int
|
|
23
|
+
folder: Optional[str] = None
|
|
24
|
+
credential: Optional[str] = None
|
|
25
|
+
last_connected: Optional[str] = None
|
|
26
|
+
connect_count: int = 0
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_session(cls, session: SavedSession, folder_name: str = None) -> 'DeviceInfo':
|
|
30
|
+
return cls(
|
|
31
|
+
name=session.name,
|
|
32
|
+
hostname=session.hostname,
|
|
33
|
+
port=session.port,
|
|
34
|
+
folder=folder_name,
|
|
35
|
+
credential=session.credential_name,
|
|
36
|
+
last_connected=str(session.last_connected) if session.last_connected else None,
|
|
37
|
+
connect_count=session.connect_count,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
41
|
+
return asdict(self)
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
cred = f", cred={self.credential}" if self.credential else ""
|
|
45
|
+
folder = f", folder={self.folder}" if self.folder else ""
|
|
46
|
+
return f"Device({self.name}, {self.hostname}:{self.port}{cred}{folder})"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CredentialInfo:
|
|
51
|
+
"""Simplified credential view for scripting (no secrets exposed)."""
|
|
52
|
+
name: str
|
|
53
|
+
username: str
|
|
54
|
+
has_password: bool
|
|
55
|
+
has_key: bool
|
|
56
|
+
match_hosts: List[str]
|
|
57
|
+
match_tags: List[str]
|
|
58
|
+
jump_host: Optional[str] = None
|
|
59
|
+
is_default: bool = False
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
62
|
+
return asdict(self)
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str:
|
|
65
|
+
auth = []
|
|
66
|
+
if self.has_password:
|
|
67
|
+
auth.append("password")
|
|
68
|
+
if self.has_key:
|
|
69
|
+
auth.append("key")
|
|
70
|
+
auth_str = "+".join(auth) if auth else "none"
|
|
71
|
+
default = " [default]" if self.is_default else ""
|
|
72
|
+
return f"Credential({self.name}, user={self.username}, auth={auth_str}{default})"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class NTermAPI:
|
|
76
|
+
"""
|
|
77
|
+
Scripting interface for nterm.
|
|
78
|
+
|
|
79
|
+
Provides read access to saved sessions and credentials,
|
|
80
|
+
and connection/command execution capabilities.
|
|
81
|
+
|
|
82
|
+
Usage:
|
|
83
|
+
api = NTermAPI()
|
|
84
|
+
|
|
85
|
+
# List and search devices
|
|
86
|
+
api.devices()
|
|
87
|
+
api.search("leaf")
|
|
88
|
+
api.devices("Lab-*")
|
|
89
|
+
|
|
90
|
+
# Credentials (requires unlocked vault)
|
|
91
|
+
api.credentials()
|
|
92
|
+
api.credential("lab-admin")
|
|
93
|
+
|
|
94
|
+
# Connect and execute (future)
|
|
95
|
+
session = api.connect("eng-leaf-1")
|
|
96
|
+
output = api.send(session, "show version")
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
session_store: SessionStore = None,
|
|
102
|
+
credential_resolver: CredentialResolver = None,
|
|
103
|
+
):
|
|
104
|
+
self._sessions = session_store or SessionStore()
|
|
105
|
+
self._resolver = credential_resolver or CredentialResolver()
|
|
106
|
+
self._folder_cache: Dict[int, str] = {}
|
|
107
|
+
self._active_sessions: Dict[str, Any] = {} # Future: track open sessions
|
|
108
|
+
|
|
109
|
+
# -------------------------------------------------------------------------
|
|
110
|
+
# Device / Session listing
|
|
111
|
+
# -------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def devices(self, pattern: str = None, folder: str = None) -> List[DeviceInfo]:
|
|
114
|
+
"""
|
|
115
|
+
List saved devices/sessions.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
pattern: Optional glob pattern to filter by name (e.g., "eng-*", "*leaf*")
|
|
119
|
+
folder: Optional folder name to filter by
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of DeviceInfo objects
|
|
123
|
+
|
|
124
|
+
Examples:
|
|
125
|
+
api.devices() # All devices
|
|
126
|
+
api.devices("eng-*") # All devices starting with "eng-"
|
|
127
|
+
api.devices(folder="Lab-ENG") # All devices in Lab-ENG folder
|
|
128
|
+
"""
|
|
129
|
+
self._refresh_folder_cache()
|
|
130
|
+
sessions = self._sessions.list_all_sessions()
|
|
131
|
+
|
|
132
|
+
results = []
|
|
133
|
+
for session in sessions:
|
|
134
|
+
folder_name = self._folder_cache.get(session.folder_id)
|
|
135
|
+
|
|
136
|
+
# Filter by folder if specified
|
|
137
|
+
if folder and folder_name != folder:
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# Filter by pattern if specified
|
|
141
|
+
if pattern and not fnmatch.fnmatch(session.name, pattern):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
results.append(DeviceInfo.from_session(session, folder_name))
|
|
145
|
+
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
def search(self, query: str) -> List[DeviceInfo]:
|
|
149
|
+
"""
|
|
150
|
+
Search devices by name, hostname, or description.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
query: Search string (partial match)
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List of matching DeviceInfo objects
|
|
157
|
+
|
|
158
|
+
Examples:
|
|
159
|
+
api.search("leaf") # Find devices with "leaf" in name/hostname
|
|
160
|
+
api.search("192.168") # Find devices by IP prefix
|
|
161
|
+
"""
|
|
162
|
+
self._refresh_folder_cache()
|
|
163
|
+
sessions = self._sessions.search_sessions(query)
|
|
164
|
+
|
|
165
|
+
return [
|
|
166
|
+
DeviceInfo.from_session(s, self._folder_cache.get(s.folder_id))
|
|
167
|
+
for s in sessions
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
def device(self, name: str) -> Optional[DeviceInfo]:
|
|
171
|
+
"""
|
|
172
|
+
Get a specific device by exact name.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
name: Device/session name
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
DeviceInfo or None if not found
|
|
179
|
+
|
|
180
|
+
Examples:
|
|
181
|
+
api.device("eng-leaf-1")
|
|
182
|
+
"""
|
|
183
|
+
self._refresh_folder_cache()
|
|
184
|
+
sessions = self._sessions.list_all_sessions()
|
|
185
|
+
|
|
186
|
+
for session in sessions:
|
|
187
|
+
if session.name == name:
|
|
188
|
+
return DeviceInfo.from_session(
|
|
189
|
+
session,
|
|
190
|
+
self._folder_cache.get(session.folder_id)
|
|
191
|
+
)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
def folders(self) -> List[str]:
|
|
195
|
+
"""
|
|
196
|
+
List all folder names.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of folder names
|
|
200
|
+
"""
|
|
201
|
+
self._refresh_folder_cache()
|
|
202
|
+
return list(self._folder_cache.values())
|
|
203
|
+
|
|
204
|
+
def _refresh_folder_cache(self):
|
|
205
|
+
"""Refresh folder ID -> name mapping."""
|
|
206
|
+
tree = self._sessions.get_tree()
|
|
207
|
+
self._folder_cache = {f.id: f.name for f in tree["folders"]}
|
|
208
|
+
|
|
209
|
+
# -------------------------------------------------------------------------
|
|
210
|
+
# Credential access
|
|
211
|
+
# -------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def vault_unlocked(self) -> bool:
|
|
215
|
+
"""Check if credential vault is unlocked."""
|
|
216
|
+
return self._resolver.store.is_unlocked if self._resolver.is_initialized() else False
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def vault_initialized(self) -> bool:
|
|
220
|
+
"""Check if vault exists."""
|
|
221
|
+
return self._resolver.is_initialized()
|
|
222
|
+
|
|
223
|
+
def unlock(self, password: str) -> bool:
|
|
224
|
+
"""
|
|
225
|
+
Unlock the credential vault.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
password: Vault master password
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if unlocked successfully
|
|
232
|
+
"""
|
|
233
|
+
return self._resolver.unlock_vault(password)
|
|
234
|
+
|
|
235
|
+
def lock(self) -> None:
|
|
236
|
+
"""Lock the credential vault."""
|
|
237
|
+
self._resolver.lock_vault()
|
|
238
|
+
|
|
239
|
+
def credentials(self, pattern: str = None) -> List[CredentialInfo]:
|
|
240
|
+
"""
|
|
241
|
+
List available credentials (names and metadata only, no secrets).
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
pattern: Optional glob pattern to filter by name
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
List of CredentialInfo objects
|
|
248
|
+
|
|
249
|
+
Raises:
|
|
250
|
+
RuntimeError: If vault is locked
|
|
251
|
+
|
|
252
|
+
Examples:
|
|
253
|
+
api.credentials() # All credentials
|
|
254
|
+
api.credentials("*admin*") # Credentials with "admin" in name
|
|
255
|
+
"""
|
|
256
|
+
if not self.vault_unlocked:
|
|
257
|
+
raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
|
|
258
|
+
|
|
259
|
+
creds = self._resolver.list_credentials()
|
|
260
|
+
results = []
|
|
261
|
+
|
|
262
|
+
for cred in creds:
|
|
263
|
+
if pattern and not fnmatch.fnmatch(cred.name, pattern):
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
results.append(CredentialInfo(
|
|
267
|
+
name=cred.name,
|
|
268
|
+
username=cred.username,
|
|
269
|
+
has_password=bool(cred.password),
|
|
270
|
+
has_key=bool(cred.ssh_key),
|
|
271
|
+
match_hosts=cred.match_hosts or [],
|
|
272
|
+
match_tags=cred.match_tags or [],
|
|
273
|
+
jump_host=cred.jump_host,
|
|
274
|
+
is_default=cred.is_default,
|
|
275
|
+
))
|
|
276
|
+
|
|
277
|
+
return results
|
|
278
|
+
|
|
279
|
+
def credential(self, name: str) -> Optional[CredentialInfo]:
|
|
280
|
+
"""
|
|
281
|
+
Get credential info by name (no secrets exposed).
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
name: Credential name
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
CredentialInfo or None
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
RuntimeError: If vault is locked
|
|
291
|
+
"""
|
|
292
|
+
if not self.vault_unlocked:
|
|
293
|
+
raise RuntimeError("Vault is locked. Call api.unlock(password) first.")
|
|
294
|
+
|
|
295
|
+
cred = self._resolver.get_credential(name)
|
|
296
|
+
if not cred:
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
return CredentialInfo(
|
|
300
|
+
name=cred.name,
|
|
301
|
+
username=cred.username,
|
|
302
|
+
has_password=bool(cred.password),
|
|
303
|
+
has_key=bool(cred.ssh_key),
|
|
304
|
+
match_hosts=cred.match_hosts or [],
|
|
305
|
+
match_tags=cred.match_tags or [],
|
|
306
|
+
jump_host=cred.jump_host,
|
|
307
|
+
is_default=cred.is_default,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def resolve_credential(self, hostname: str, tags: List[str] = None) -> Optional[str]:
|
|
311
|
+
"""
|
|
312
|
+
Find which credential would be used for a hostname.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
hostname: Target hostname
|
|
316
|
+
tags: Optional device tags
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Credential name that would match, or None
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
RuntimeError: If vault is locked
|
|
323
|
+
"""
|
|
324
|
+
if not self.vault_unlocked:
|
|
325
|
+
raise RuntimeError("Vault is locked.")
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
profile = self._resolver.resolve_for_device(hostname, tags)
|
|
329
|
+
# Extract credential name from profile name format "hostname (cred_name)"
|
|
330
|
+
if "(" in profile.name and ")" in profile.name:
|
|
331
|
+
return profile.name.split("(")[1].rstrip(")")
|
|
332
|
+
return None
|
|
333
|
+
except Exception:
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
# -------------------------------------------------------------------------
|
|
337
|
+
# Connection operations (future expansion)
|
|
338
|
+
# -------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def connect(self, device: str, credential: str = None):
|
|
341
|
+
"""
|
|
342
|
+
Connect to a device.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
device: Device name (from saved sessions) or hostname
|
|
346
|
+
credential: Optional credential name (auto-resolved if not specified)
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Session handle for sending commands
|
|
350
|
+
"""
|
|
351
|
+
# TODO: Implement actual connection logic
|
|
352
|
+
raise NotImplementedError("Connection support coming soon")
|
|
353
|
+
|
|
354
|
+
def send(self, session, command: str, timeout: int = 30) -> str:
|
|
355
|
+
"""
|
|
356
|
+
Send command to a connected session.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
session: Session handle from connect()
|
|
360
|
+
command: Command to execute
|
|
361
|
+
timeout: Timeout in seconds
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Command output
|
|
365
|
+
"""
|
|
366
|
+
raise NotImplementedError("Connection support coming soon")
|
|
367
|
+
|
|
368
|
+
def disconnect(self, session) -> None:
|
|
369
|
+
"""Disconnect a session."""
|
|
370
|
+
raise NotImplementedError("Connection support coming soon")
|
|
371
|
+
|
|
372
|
+
# -------------------------------------------------------------------------
|
|
373
|
+
# Convenience / REPL helpers
|
|
374
|
+
# -------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def __repr__(self) -> str:
|
|
377
|
+
device_count = len(self._sessions.list_all_sessions())
|
|
378
|
+
vault_status = "unlocked" if self.vault_unlocked else "locked"
|
|
379
|
+
return f"<NTermAPI: {device_count} devices, vault {vault_status}>"
|
|
380
|
+
|
|
381
|
+
def status(self) -> Dict[str, Any]:
|
|
382
|
+
"""
|
|
383
|
+
Get API status summary.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Dict with device count, folder count, credential count, vault status
|
|
387
|
+
"""
|
|
388
|
+
sessions = self._sessions.list_all_sessions()
|
|
389
|
+
folders = self._sessions.get_tree()["folders"]
|
|
390
|
+
|
|
391
|
+
cred_count = 0
|
|
392
|
+
if self.vault_unlocked:
|
|
393
|
+
cred_count = len(self._resolver.list_credentials())
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
"devices": len(sessions),
|
|
397
|
+
"folders": len(folders),
|
|
398
|
+
"credentials": cred_count,
|
|
399
|
+
"vault_initialized": self.vault_initialized,
|
|
400
|
+
"vault_unlocked": self.vault_unlocked,
|
|
401
|
+
"active_sessions": len(self._active_sessions),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
def help(self) -> None:
|
|
405
|
+
"""Print available commands."""
|
|
406
|
+
print("""
|
|
407
|
+
nterm API Commands
|
|
408
|
+
==================
|
|
409
|
+
|
|
410
|
+
Devices:
|
|
411
|
+
api.devices() List all devices
|
|
412
|
+
api.devices("pattern*") Filter by glob pattern
|
|
413
|
+
api.devices(folder="Lab-ENG") Filter by folder
|
|
414
|
+
api.search("query") Search by name/hostname/description
|
|
415
|
+
api.device("name") Get specific device
|
|
416
|
+
api.folders() List all folders
|
|
417
|
+
|
|
418
|
+
Credentials (requires unlocked vault):
|
|
419
|
+
api.unlock("password") Unlock vault
|
|
420
|
+
api.lock() Lock vault
|
|
421
|
+
api.credentials() List all credentials
|
|
422
|
+
api.credentials("*admin*") Filter by pattern
|
|
423
|
+
api.credential("name") Get specific credential
|
|
424
|
+
api.resolve_credential("host") Find matching credential
|
|
425
|
+
|
|
426
|
+
Status:
|
|
427
|
+
api.status() Get summary
|
|
428
|
+
api.vault_unlocked Check vault status
|
|
429
|
+
""")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# Singleton for convenience in IPython
|
|
433
|
+
_default_api: Optional[NTermAPI] = None
|
|
434
|
+
|
|
435
|
+
def get_api() -> NTermAPI:
|
|
436
|
+
"""Get or create default API instance."""
|
|
437
|
+
global _default_api
|
|
438
|
+
if _default_api is None:
|
|
439
|
+
_default_api = NTermAPI()
|
|
440
|
+
return _default_api
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def reset_api() -> NTermAPI:
|
|
444
|
+
"""Reset and return fresh API instance."""
|
|
445
|
+
global _default_api
|
|
446
|
+
_default_api = NTermAPI()
|
|
447
|
+
return _default_api
|