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.
Files changed (43) hide show
  1. praetorian_cli/handlers/add.py +25 -7
  2. praetorian_cli/handlers/aegis.py +107 -0
  3. praetorian_cli/handlers/delete.py +3 -2
  4. praetorian_cli/handlers/get.py +48 -2
  5. praetorian_cli/handlers/list.py +41 -9
  6. praetorian_cli/handlers/ssh_utils.py +154 -0
  7. praetorian_cli/handlers/test.py +7 -2
  8. praetorian_cli/handlers/update.py +3 -3
  9. praetorian_cli/main.py +1 -0
  10. praetorian_cli/sdk/chariot.py +71 -12
  11. praetorian_cli/sdk/entities/aegis.py +437 -0
  12. praetorian_cli/sdk/entities/assets.py +30 -12
  13. praetorian_cli/sdk/entities/scanners.py +13 -0
  14. praetorian_cli/sdk/entities/schema.py +27 -0
  15. praetorian_cli/sdk/entities/seeds.py +108 -56
  16. praetorian_cli/sdk/mcp_server.py +2 -3
  17. praetorian_cli/sdk/model/aegis.py +156 -0
  18. praetorian_cli/sdk/model/query.py +1 -1
  19. praetorian_cli/sdk/model/utils.py +2 -8
  20. praetorian_cli/sdk/test/pytest.ini +1 -0
  21. praetorian_cli/sdk/test/test_asset.py +2 -2
  22. praetorian_cli/sdk/test/test_seed.py +13 -14
  23. praetorian_cli/sdk/test/test_z_cli.py +22 -24
  24. praetorian_cli/sdk/test/ui_mocks.py +133 -0
  25. praetorian_cli/sdk/test/utils.py +16 -4
  26. praetorian_cli/ui/__init__.py +3 -0
  27. praetorian_cli/ui/aegis/__init__.py +5 -0
  28. praetorian_cli/ui/aegis/commands/__init__.py +2 -0
  29. praetorian_cli/ui/aegis/commands/help.py +81 -0
  30. praetorian_cli/ui/aegis/commands/info.py +136 -0
  31. praetorian_cli/ui/aegis/commands/job.py +381 -0
  32. praetorian_cli/ui/aegis/commands/list.py +14 -0
  33. praetorian_cli/ui/aegis/commands/set.py +32 -0
  34. praetorian_cli/ui/aegis/commands/ssh.py +87 -0
  35. praetorian_cli/ui/aegis/constants.py +20 -0
  36. praetorian_cli/ui/aegis/menu.py +395 -0
  37. praetorian_cli/ui/aegis/utils.py +162 -0
  38. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/METADATA +4 -1
  39. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/RECORD +43 -24
  40. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/WHEEL +0 -0
  41. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/entry_points.txt +0 -0
  42. {praetorian_cli-2.2.1.dist-info → praetorian_cli-2.2.3.dist-info}/licenses/LICENSE +0 -0
  43. {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.1
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