praetorian-cli 2.2.1__py3-none-any.whl → 2.2.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.
- praetorian_cli/handlers/add.py +25 -7
- praetorian_cli/handlers/aegis.py +107 -0
- praetorian_cli/handlers/delete.py +3 -2
- praetorian_cli/handlers/get.py +48 -2
- praetorian_cli/handlers/list.py +41 -9
- praetorian_cli/handlers/ssh_utils.py +154 -0
- praetorian_cli/handlers/test.py +7 -2
- praetorian_cli/handlers/update.py +3 -3
- praetorian_cli/main.py +1 -0
- praetorian_cli/sdk/chariot.py +71 -12
- praetorian_cli/sdk/entities/aegis.py +437 -0
- praetorian_cli/sdk/entities/assets.py +30 -12
- praetorian_cli/sdk/entities/scanners.py +13 -0
- praetorian_cli/sdk/entities/schema.py +27 -0
- praetorian_cli/sdk/entities/seeds.py +108 -56
- praetorian_cli/sdk/mcp_server.py +2 -3
- praetorian_cli/sdk/model/aegis.py +156 -0
- praetorian_cli/sdk/model/query.py +1 -1
- praetorian_cli/sdk/model/utils.py +2 -8
- praetorian_cli/sdk/test/pytest.ini +1 -0
- praetorian_cli/sdk/test/test_asset.py +2 -2
- praetorian_cli/sdk/test/test_seed.py +13 -14
- praetorian_cli/sdk/test/test_z_cli.py +22 -24
- praetorian_cli/sdk/test/ui_mocks.py +133 -0
- praetorian_cli/sdk/test/utils.py +16 -4
- praetorian_cli/ui/__init__.py +3 -0
- praetorian_cli/ui/aegis/__init__.py +5 -0
- praetorian_cli/ui/aegis/commands/__init__.py +2 -0
- praetorian_cli/ui/aegis/commands/help.py +81 -0
- praetorian_cli/ui/aegis/commands/info.py +136 -0
- praetorian_cli/ui/aegis/commands/job.py +381 -0
- praetorian_cli/ui/aegis/commands/list.py +14 -0
- praetorian_cli/ui/aegis/commands/set.py +32 -0
- praetorian_cli/ui/aegis/commands/ssh.py +87 -0
- praetorian_cli/ui/aegis/constants.py +20 -0
- praetorian_cli/ui/aegis/menu.py +395 -0
- praetorian_cli/ui/aegis/utils.py +162 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/METADATA +4 -1
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/RECORD +43 -24
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/WHEEL +0 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/entry_points.txt +0 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/licenses/LICENSE +0 -0
- {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Aegis Menu Interface - Clean operator interface
|
|
4
|
+
Command-driven approach with intuitive UX
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
|
|
10
|
+
# Verbosity setting for Aegis UI (quiet by default)
|
|
11
|
+
VERBOSE = os.getenv('CHARIOT_AEGIS_VERBOSE') == '1'
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.prompt import Prompt
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
from rich.box import MINIMAL
|
|
20
|
+
|
|
21
|
+
from praetorian_cli.sdk.chariot import Chariot
|
|
22
|
+
from praetorian_cli.sdk.model.aegis import Agent
|
|
23
|
+
|
|
24
|
+
from .utils import (
|
|
25
|
+
relative_time, format_os_display,
|
|
26
|
+
compute_agent_groups, get_agent_display_style
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Command handlers
|
|
30
|
+
from .commands.set import handle_set as cmd_handle_set
|
|
31
|
+
from .commands.help import handle_help as cmd_handle_help
|
|
32
|
+
from .commands.list import handle_list as cmd_handle_list
|
|
33
|
+
from .commands.ssh import handle_ssh as cmd_handle_ssh
|
|
34
|
+
from .commands.info import handle_info as cmd_handle_info
|
|
35
|
+
from .commands.job import handle_job as cmd_handle_job
|
|
36
|
+
|
|
37
|
+
# Command completers
|
|
38
|
+
from .commands.set import complete as comp_set
|
|
39
|
+
from .commands.help import complete as comp_help
|
|
40
|
+
from .commands.list import complete as comp_list
|
|
41
|
+
from .commands.ssh import complete as comp_ssh
|
|
42
|
+
from .commands.job import complete as comp_job
|
|
43
|
+
from .constants import DEFAULT_COLORS
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AegisMenu:
|
|
47
|
+
"""Aegis menu interface with modern command-driven UX"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, sdk: Chariot):
|
|
50
|
+
self.sdk: Chariot = sdk
|
|
51
|
+
self.console = Console()
|
|
52
|
+
self.verbose = VERBOSE
|
|
53
|
+
self.agents: List[Agent] = []
|
|
54
|
+
self.selected_agent: Optional[Agent] = None
|
|
55
|
+
self._first_render = True
|
|
56
|
+
self.agent_computed_data = {}
|
|
57
|
+
self.current_prompt = "> "
|
|
58
|
+
|
|
59
|
+
self.user_email, self.username = self.sdk.get_current_user()
|
|
60
|
+
|
|
61
|
+
self.colors = DEFAULT_COLORS
|
|
62
|
+
|
|
63
|
+
self.commands = [
|
|
64
|
+
'set', 'ssh', 'info', 'list', 'job', 'reload', 'clear', 'help', 'quit', 'exit'
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
self._init_autocomplete()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run(self) -> None:
|
|
71
|
+
"""Main interface loop"""
|
|
72
|
+
self.clear_screen()
|
|
73
|
+
self.reload_agents()
|
|
74
|
+
|
|
75
|
+
if self.agents:
|
|
76
|
+
self.show_agents_list()
|
|
77
|
+
|
|
78
|
+
while True:
|
|
79
|
+
try:
|
|
80
|
+
self.show_main_menu()
|
|
81
|
+
choice = self.get_input()
|
|
82
|
+
|
|
83
|
+
if not self.handle_choice(choice):
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
except KeyboardInterrupt:
|
|
87
|
+
self.console.print("\n[dim]Goodbye![/dim]")
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
def handle_choice(self, choice: str) -> bool:
|
|
91
|
+
"""Dead simple command dispatch"""
|
|
92
|
+
if not choice:
|
|
93
|
+
return True # Just refresh
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
args = shlex.split(choice)
|
|
97
|
+
except ValueError:
|
|
98
|
+
self.console.print(f"[red]Invalid command syntax: {choice}[/red]")
|
|
99
|
+
self.pause()
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
if not args:
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
command = args[0].lower()
|
|
106
|
+
cmd_args = args[1:] if len(args) > 1 else []
|
|
107
|
+
|
|
108
|
+
if command in ['q', 'quit', 'exit']:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
elif command in ['r', 'reload']:
|
|
112
|
+
self.reload_agents()
|
|
113
|
+
|
|
114
|
+
elif command == 'clear':
|
|
115
|
+
self.clear_screen()
|
|
116
|
+
|
|
117
|
+
elif command == 'set':
|
|
118
|
+
cmd_handle_set(self, cmd_args)
|
|
119
|
+
|
|
120
|
+
elif command in ['h', 'help']:
|
|
121
|
+
cmd_handle_help(self, cmd_args)
|
|
122
|
+
|
|
123
|
+
elif command == 'list':
|
|
124
|
+
cmd_handle_list(self, cmd_args)
|
|
125
|
+
|
|
126
|
+
elif command == 'ssh':
|
|
127
|
+
cmd_handle_ssh(self, cmd_args)
|
|
128
|
+
|
|
129
|
+
elif command == 'info':
|
|
130
|
+
cmd_handle_info(self, cmd_args)
|
|
131
|
+
|
|
132
|
+
elif command == 'job':
|
|
133
|
+
cmd_handle_job(self, cmd_args)
|
|
134
|
+
|
|
135
|
+
else:
|
|
136
|
+
self.console.print(f"\n Unknown command: {command}")
|
|
137
|
+
self.console.print(f" [{self.colors['dim']}]Type 'help' for available commands[/{self.colors['dim']}]\n")
|
|
138
|
+
self.pause()
|
|
139
|
+
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
def clear_screen(self) -> None:
|
|
143
|
+
"""Clear the screen"""
|
|
144
|
+
os.system('clear' if os.name == 'posix' else 'cls')
|
|
145
|
+
|
|
146
|
+
def reload_agents(self) -> None:
|
|
147
|
+
"""Load agents with 60-second caching and compute status properties"""
|
|
148
|
+
self.load_agents()
|
|
149
|
+
|
|
150
|
+
if self.verbose and self.agents:
|
|
151
|
+
self.console.print(f"[green]Loaded {len(self.agents)} agents successfully[/green]")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _compute_agent_status(self) -> None:
|
|
155
|
+
"""Compute agent status data and groupings using utility function"""
|
|
156
|
+
current_time = datetime.now().timestamp()
|
|
157
|
+
self.agent_computed_data = compute_agent_groups(self.agents, current_time)
|
|
158
|
+
|
|
159
|
+
def show_agents_list(self, show_offline: bool = False) -> None:
|
|
160
|
+
"""Compose and display the agents table using pre-computed properties"""
|
|
161
|
+
if not self.agents:
|
|
162
|
+
self.console.print(f" [{self.colors['warning']}]No agents available[/{self.colors['warning']}]")
|
|
163
|
+
self.console.print(f" [{self.colors['dim']}]Press 'r <Enter>' to reload[/{self.colors['dim']}]")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
self._compute_agent_status()
|
|
167
|
+
|
|
168
|
+
active_tunnel_agents = self.agent_computed_data.get('active_tunnel')
|
|
169
|
+
online_agents = self.agent_computed_data.get('online')
|
|
170
|
+
offline_agents = self.agent_computed_data.get('offline')
|
|
171
|
+
|
|
172
|
+
display_agents = active_tunnel_agents + online_agents
|
|
173
|
+
if show_offline:
|
|
174
|
+
display_agents = display_agents + offline_agents
|
|
175
|
+
|
|
176
|
+
self.console.print()
|
|
177
|
+
|
|
178
|
+
if not display_agents:
|
|
179
|
+
if offline_agents:
|
|
180
|
+
self.console.print(f" No agents online\n")
|
|
181
|
+
self.console.print(f" [{self.colors['dim']}]• {len(offline_agents)} agents are offline[/{self.colors['dim']}]")
|
|
182
|
+
self.console.print(f" [{self.colors['dim']}]• Use 'list --all' to see them[/{self.colors['dim']}]")
|
|
183
|
+
else:
|
|
184
|
+
self.console.print(f" No agents found\n")
|
|
185
|
+
self.console.print(f" [{self.colors['dim']}]• Check your network connection[/{self.colors['dim']}]")
|
|
186
|
+
self.console.print(f" [{self.colors['dim']}]• Verify agents are running[/{self.colors['dim']}]")
|
|
187
|
+
self.console.print(f" [{self.colors['dim']}]• Use 'reload' to refresh[/{self.colors['dim']}]")
|
|
188
|
+
self.console.print()
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
status_parts = []
|
|
192
|
+
if active_tunnel_agents:
|
|
193
|
+
status_parts.append(f"{len(active_tunnel_agents)} tunneled")
|
|
194
|
+
if online_agents:
|
|
195
|
+
status_parts.append(f"{len(online_agents)} online")
|
|
196
|
+
if offline_agents and not show_offline:
|
|
197
|
+
status_parts.append(f"[{self.colors['dim']}]{len(offline_agents)} hidden[/{self.colors['dim']}]")
|
|
198
|
+
elif offline_agents:
|
|
199
|
+
status_parts.append(f"[{self.colors['dim']}]{len(offline_agents)} offline[/{self.colors['dim']}]")
|
|
200
|
+
|
|
201
|
+
if status_parts:
|
|
202
|
+
self.console.print(" " + " ".join(status_parts))
|
|
203
|
+
|
|
204
|
+
self.console.print()
|
|
205
|
+
table = Table(
|
|
206
|
+
show_header=True,
|
|
207
|
+
header_style=f"{self.colors['dim']}",
|
|
208
|
+
border_style=self.colors['dim'],
|
|
209
|
+
box=MINIMAL,
|
|
210
|
+
show_lines=False,
|
|
211
|
+
padding=(0, 2),
|
|
212
|
+
pad_edge=False
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
table.add_column("", style=f"{self.colors['dim']}", width=4, justify="right", no_wrap=True)
|
|
216
|
+
table.add_column("HOSTNAME", style="white", min_width=25, no_wrap=False)
|
|
217
|
+
table.add_column("OS", style=f"{self.colors['dim']}", width=16, no_wrap=True)
|
|
218
|
+
table.add_column("STATUS", width=8, justify="left", no_wrap=True)
|
|
219
|
+
table.add_column("TUNNEL", width=7, justify="left", no_wrap=True)
|
|
220
|
+
table.add_column("SEEN", style=f"{self.colors['dim']}", width=10, justify="right", no_wrap=True)
|
|
221
|
+
|
|
222
|
+
for i, (agent_idx, agent) in enumerate(display_agents, 1):
|
|
223
|
+
hostname = agent.hostname
|
|
224
|
+
os_info = agent.os
|
|
225
|
+
os_version = agent.os_version
|
|
226
|
+
os_display = format_os_display(os_info, os_version)
|
|
227
|
+
|
|
228
|
+
if agent.is_online and agent.has_tunnel:
|
|
229
|
+
group = 'active_tunnel'
|
|
230
|
+
elif agent.is_online:
|
|
231
|
+
group = 'online'
|
|
232
|
+
else:
|
|
233
|
+
group = 'offline'
|
|
234
|
+
|
|
235
|
+
styles = get_agent_display_style(group, self.colors)
|
|
236
|
+
status = styles['status']
|
|
237
|
+
tunnel = styles['tunnel']
|
|
238
|
+
idx_style = styles['idx_style']
|
|
239
|
+
hostname_style = styles['hostname_style']
|
|
240
|
+
|
|
241
|
+
current_time = datetime.now().timestamp()
|
|
242
|
+
if agent.last_seen_at and agent.is_online:
|
|
243
|
+
last_seen = relative_time(agent.last_seen_at / 1000000 if agent.last_seen_at > 1000000000000 else agent.last_seen_at, current_time)
|
|
244
|
+
else:
|
|
245
|
+
last_seen = "—"
|
|
246
|
+
|
|
247
|
+
table.add_row(
|
|
248
|
+
Text(str(i), style=idx_style),
|
|
249
|
+
Text(hostname, style=hostname_style),
|
|
250
|
+
os_display,
|
|
251
|
+
status,
|
|
252
|
+
tunnel,
|
|
253
|
+
last_seen
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
self.console.print(table)
|
|
257
|
+
self.console.print()
|
|
258
|
+
|
|
259
|
+
def show_main_menu(self) -> None:
|
|
260
|
+
"""Show the main interface with reduced noise"""
|
|
261
|
+
if self._first_render:
|
|
262
|
+
current_account = self.sdk.keychain.account
|
|
263
|
+
|
|
264
|
+
self.console.print(f"\n[bold {self.colors['primary']}]Aegis Agent Interface[/bold {self.colors['primary']}]")
|
|
265
|
+
self.console.print(f" [{self.colors['dim']}]User: {self.username} | Account: {current_account}[/{self.colors['dim']}]")
|
|
266
|
+
self.console.print(f" [{self.colors['dim']}]hint: type 'help' for commands[/{self.colors['dim']}]\n")
|
|
267
|
+
|
|
268
|
+
self._first_render = False
|
|
269
|
+
|
|
270
|
+
def get_input(self) -> str:
|
|
271
|
+
"""Get user input with minimal context-aware prompt"""
|
|
272
|
+
try:
|
|
273
|
+
if self.selected_agent:
|
|
274
|
+
hostname = self.selected_agent.hostname
|
|
275
|
+
self.current_prompt = f"{hostname}> "
|
|
276
|
+
else:
|
|
277
|
+
self.current_prompt = "> "
|
|
278
|
+
|
|
279
|
+
user_input = input(self.current_prompt).strip()
|
|
280
|
+
return user_input
|
|
281
|
+
except (EOFError, KeyboardInterrupt):
|
|
282
|
+
return "quit"
|
|
283
|
+
|
|
284
|
+
def _init_autocomplete(self) -> None:
|
|
285
|
+
"""Attach a minimal Tab-completion using readline when available."""
|
|
286
|
+
try:
|
|
287
|
+
import readline # type: ignore
|
|
288
|
+
except Exception:
|
|
289
|
+
self._readline = None
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
self._readline = readline
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
delims = readline.get_completer_delims()
|
|
296
|
+
for ch in "-/.":
|
|
297
|
+
delims = delims.replace(ch, "")
|
|
298
|
+
readline.set_completer_delims(delims)
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
def completer(text: str, state: int):
|
|
303
|
+
try:
|
|
304
|
+
buf = readline.get_line_buffer()
|
|
305
|
+
beg = getattr(readline, 'get_begidx', lambda: len(buf))()
|
|
306
|
+
before = buf[:beg]
|
|
307
|
+
try:
|
|
308
|
+
tokens = shlex.split(before)
|
|
309
|
+
except Exception:
|
|
310
|
+
tokens = before.split()
|
|
311
|
+
|
|
312
|
+
if not tokens:
|
|
313
|
+
options = [c for c in self.commands if c.startswith(text)]
|
|
314
|
+
else:
|
|
315
|
+
cmd = tokens[0]
|
|
316
|
+
|
|
317
|
+
if len(tokens) == 1 and not before.endswith(' '):
|
|
318
|
+
options = [c for c in self.commands if c.startswith(text)]
|
|
319
|
+
else:
|
|
320
|
+
options = self._autocomplete_options_for(cmd, text, tokens)
|
|
321
|
+
|
|
322
|
+
options = sorted(set(options))
|
|
323
|
+
return options[state] if state < len(options) else None
|
|
324
|
+
except Exception:
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
self._readline.set_completer(completer)
|
|
329
|
+
doc = getattr(self._readline, "__doc__", "") or ""
|
|
330
|
+
if "libedit" in doc.lower():
|
|
331
|
+
# macOS default: libedit compatibility layer
|
|
332
|
+
self._readline.parse_and_bind("bind ^I rl_complete")
|
|
333
|
+
else:
|
|
334
|
+
# GNU readline
|
|
335
|
+
self._readline.parse_and_bind('tab: complete')
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
def _autocomplete_options_for(self, cmd: str, text: str, tokens: list[str]) -> list[str]:
|
|
340
|
+
"""Return simple context-aware options for completion."""
|
|
341
|
+
# Top-level fallbacks
|
|
342
|
+
if cmd in ['quit', 'exit', 'clear', 'reload']:
|
|
343
|
+
return []
|
|
344
|
+
|
|
345
|
+
if cmd == 'help':
|
|
346
|
+
return comp_help(self, text, tokens)
|
|
347
|
+
|
|
348
|
+
if cmd == 'list':
|
|
349
|
+
return comp_list(self, text, tokens)
|
|
350
|
+
|
|
351
|
+
if cmd == 'set':
|
|
352
|
+
return comp_set(self, text, tokens)
|
|
353
|
+
|
|
354
|
+
if cmd == 'job':
|
|
355
|
+
return comp_job(self, text, tokens)
|
|
356
|
+
|
|
357
|
+
if cmd == 'ssh':
|
|
358
|
+
return comp_ssh(self, text, tokens)
|
|
359
|
+
|
|
360
|
+
# Default: no suggestions
|
|
361
|
+
return []
|
|
362
|
+
|
|
363
|
+
def load_agents(self) -> None:
|
|
364
|
+
"""Load agents from SDK"""
|
|
365
|
+
try:
|
|
366
|
+
with self.console.status(
|
|
367
|
+
f"[{self.colors['dim']}]Loading agents...[/{self.colors['dim']}]",
|
|
368
|
+
spinner="dots",
|
|
369
|
+
spinner_style=f"{self.colors['primary']}"
|
|
370
|
+
):
|
|
371
|
+
agents, _ = self.sdk.aegis.list()
|
|
372
|
+
self.agents = agents or []
|
|
373
|
+
|
|
374
|
+
if self.verbose or not self.agents:
|
|
375
|
+
agent_count = len(self.agents)
|
|
376
|
+
if agent_count > 0:
|
|
377
|
+
self.console.print(f"[green]✓ Loaded {agent_count} agents[/green]")
|
|
378
|
+
else:
|
|
379
|
+
self.console.print(f"[yellow]⚠ No agents found[/yellow]")
|
|
380
|
+
|
|
381
|
+
except Exception as e:
|
|
382
|
+
self.console.print(f"[red]✗ Error loading agents: {e}[/red]")
|
|
383
|
+
self.agents = []
|
|
384
|
+
|
|
385
|
+
def pause(self):
|
|
386
|
+
"""Professional pause with styling"""
|
|
387
|
+
if self.verbose:
|
|
388
|
+
Prompt.ask(f"\n[{self.colors['dim']}]Press Enter to continue...[/{self.colors['dim']}]")
|
|
389
|
+
# Quiet mode: do not block
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def run_aegis_menu(sdk: Chariot) -> None:
|
|
393
|
+
"""Run the Aegis menu interface"""
|
|
394
|
+
menu = AegisMenu(sdk)
|
|
395
|
+
menu.run()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Aegis UI Utility Functions
|
|
4
|
+
Helper functions for formatting, time calculations, and other utilities
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional, List
|
|
9
|
+
from praetorian_cli.sdk.model.aegis import Agent
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def relative_time(ts_seconds: float, now_seconds: float) -> str:
|
|
14
|
+
"""Render clean, minimal relative time"""
|
|
15
|
+
delta = max(0, int(now_seconds - ts_seconds))
|
|
16
|
+
if delta < 5:
|
|
17
|
+
return "just now"
|
|
18
|
+
if delta < 60:
|
|
19
|
+
return f"{delta}s"
|
|
20
|
+
minutes = delta // 60
|
|
21
|
+
if minutes < 60:
|
|
22
|
+
return f"{minutes}m"
|
|
23
|
+
hours = minutes // 60
|
|
24
|
+
if hours < 24:
|
|
25
|
+
return f"{hours}h"
|
|
26
|
+
if hours < 48:
|
|
27
|
+
return "yesterday"
|
|
28
|
+
days = hours // 24
|
|
29
|
+
if days < 7:
|
|
30
|
+
return f"{days}d"
|
|
31
|
+
weeks = days // 7
|
|
32
|
+
if weeks < 4:
|
|
33
|
+
return f"{weeks}w"
|
|
34
|
+
return "long ago"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_job_status(status: str, colors: dict) -> Text:
|
|
39
|
+
"""Format job status for display with appropriate color"""
|
|
40
|
+
status_upper = status.upper()
|
|
41
|
+
|
|
42
|
+
if status_upper.startswith('JP'): # Job Passed
|
|
43
|
+
return Text("PASSED", style=f"{colors['success']}")
|
|
44
|
+
elif status_upper.startswith('JF'): # Job Failed
|
|
45
|
+
return Text("FAILED", style=f"{colors['error']}")
|
|
46
|
+
elif status_upper.startswith('JR'): # Job Running
|
|
47
|
+
return Text("RUNNING", style=f"{colors['warning']}")
|
|
48
|
+
elif status_upper.startswith('JQ'): # Job Queued
|
|
49
|
+
return Text("QUEUED", style=f"{colors['info']}")
|
|
50
|
+
else:
|
|
51
|
+
return Text(status[:8].upper(), style=f"{colors['dim']}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_os_display(os_info: str, os_version: str = "", max_length: int = 18) -> str:
|
|
55
|
+
"""Format OS information for table display"""
|
|
56
|
+
if not os_info:
|
|
57
|
+
return "unknown"
|
|
58
|
+
|
|
59
|
+
os_full = os_info.lower()
|
|
60
|
+
version = os_version or ""
|
|
61
|
+
display = f"{os_full} {version}".strip()
|
|
62
|
+
|
|
63
|
+
return display[:max_length] if len(display) > max_length else display
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def format_timestamp(timestamp: float, format_str: str = "%m/%d %H:%M") -> str:
|
|
69
|
+
"""Format timestamp for display"""
|
|
70
|
+
if not timestamp:
|
|
71
|
+
return "—"
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
dt = datetime.fromtimestamp(timestamp)
|
|
75
|
+
return dt.strftime(format_str)
|
|
76
|
+
except (ValueError, OSError):
|
|
77
|
+
return "—"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def compute_agent_groups(agents: List[Agent], current_time: float) -> dict:
|
|
81
|
+
"""Compute agent status groups for display organization"""
|
|
82
|
+
groups = {
|
|
83
|
+
'active_tunnel': [],
|
|
84
|
+
'online': [],
|
|
85
|
+
'offline': []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for i, agent in enumerate(agents):
|
|
89
|
+
# Compute relative time string
|
|
90
|
+
if agent.last_seen_at > 0 and agent.is_online:
|
|
91
|
+
last_seen_str = relative_time(agent.last_seen_at / 1000000 if agent.last_seen_at > 1000000000000 else agent.last_seen_at, current_time)
|
|
92
|
+
else:
|
|
93
|
+
last_seen_str = "—"
|
|
94
|
+
|
|
95
|
+
# Determine group
|
|
96
|
+
if agent.is_online and getattr(agent, 'has_tunnel', False):
|
|
97
|
+
group = 'active_tunnel'
|
|
98
|
+
elif agent.is_online:
|
|
99
|
+
group = 'online'
|
|
100
|
+
else:
|
|
101
|
+
group = 'offline'
|
|
102
|
+
|
|
103
|
+
# Store agent with computed data
|
|
104
|
+
agent_data = (i + 1, agent) # Store 1-based index with agent
|
|
105
|
+
groups[group].append(agent_data)
|
|
106
|
+
|
|
107
|
+
return groups
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_agent_display_style(group: str, colors: dict) -> dict:
|
|
111
|
+
"""Get display styles for agent based on group"""
|
|
112
|
+
if group == 'active_tunnel':
|
|
113
|
+
return {
|
|
114
|
+
'status': Text("online", style=f"{colors['success']}"),
|
|
115
|
+
'tunnel': Text("active", style=f"{colors['warning']}"),
|
|
116
|
+
'idx_style': f"{colors['warning']}",
|
|
117
|
+
'hostname_style': "bold white"
|
|
118
|
+
}
|
|
119
|
+
elif group == 'online':
|
|
120
|
+
return {
|
|
121
|
+
'status': Text("online", style=f"{colors['success']}"),
|
|
122
|
+
'tunnel': Text("—", style=f"{colors['dim']}"),
|
|
123
|
+
'idx_style': f"{colors['success']}",
|
|
124
|
+
'hostname_style': "white"
|
|
125
|
+
}
|
|
126
|
+
else: # offline
|
|
127
|
+
return {
|
|
128
|
+
'status': Text("offline", style=f"{colors['dim']}"),
|
|
129
|
+
'tunnel': Text("—", style=f"{colors['dim']}"),
|
|
130
|
+
'idx_style': f"{colors['dim']}",
|
|
131
|
+
'hostname_style': f"{colors['dim']}"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_agent_identifier(identifier: str, agents: List[Agent]) -> Optional[Agent]:
|
|
136
|
+
"""Parse agent identifier and return matching agent"""
|
|
137
|
+
if not agents:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
# Try numeric index first
|
|
141
|
+
if identifier.isdigit():
|
|
142
|
+
agent_num = int(identifier)
|
|
143
|
+
if 1 <= agent_num <= len(agents):
|
|
144
|
+
return agents[agent_num - 1]
|
|
145
|
+
|
|
146
|
+
# Try client ID match
|
|
147
|
+
for agent in agents:
|
|
148
|
+
try:
|
|
149
|
+
if agent.client_id and agent.client_id.lower() == identifier.lower():
|
|
150
|
+
return agent
|
|
151
|
+
except AttributeError:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
# Try hostname match
|
|
155
|
+
for agent in agents:
|
|
156
|
+
try:
|
|
157
|
+
if agent.hostname and agent.hostname.lower() == identifier.lower():
|
|
158
|
+
return agent
|
|
159
|
+
except AttributeError:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: praetorian-cli
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: For interacting with the Chariot API
|
|
5
5
|
Home-page: https://github.com/praetorian-inc/praetorian-cli
|
|
6
6
|
Author: Praetorian
|
|
@@ -17,6 +17,9 @@ Requires-Dist: requests>=2.31.0
|
|
|
17
17
|
Requires-Dist: pytest>=8.0.2
|
|
18
18
|
Requires-Dist: mcp>=1.12.2
|
|
19
19
|
Requires-Dist: anyio>=3.0.0
|
|
20
|
+
Requires-Dist: textual>=0.47.0
|
|
21
|
+
Requires-Dist: rich>=13.0.0
|
|
22
|
+
Requires-Dist: prompt_toolkit>=3.0.0
|
|
20
23
|
Dynamic: license-file
|
|
21
24
|
|
|
22
25
|
# Praetorian CLI and SDK
|