praetorian-cli 2.2.4__py3-none-any.whl → 2.2.6__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.
@@ -0,0 +1,622 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+ from datetime import datetime
7
+ from typing import Optional, List, Dict, Any
8
+
9
+ from textual.app import App, ComposeResult
10
+ from textual.containers import Container, Vertical, Horizontal, VerticalScroll
11
+ from textual.widgets import Header, Footer, Input, Static, Markdown
12
+ from textual.message import Message
13
+ from textual.reactive import reactive
14
+ from textual import on
15
+
16
+ from praetorian_cli.sdk.chariot import Chariot
17
+
18
+
19
+ class ChatMessage(Static):
20
+ """A single chat message widget"""
21
+
22
+ def __init__(self, role: str, content: str, **kwargs):
23
+ self.role = role
24
+ self.content = content
25
+ super().__init__(**kwargs)
26
+
27
+ def compose(self) -> ComposeResult:
28
+ if self.role == "user":
29
+ yield Static(f"👤 You: {self.content}", classes="user-message")
30
+ elif self.role == "chariot":
31
+ yield Markdown(self.content, classes="ai-message")
32
+ elif self.role == "tool call":
33
+ yield Static("🔧 Executing tool...", classes="tool-message")
34
+ elif self.role == "tool response":
35
+ yield Static("✅ Tool execution completed", classes="tool-message")
36
+ elif self.role == "planner-output":
37
+ yield Static("🎯 Processing job completion...", classes="system-message")
38
+
39
+
40
+ class ConversationApp(App):
41
+ """Textual-based conversation interface with separate chat log and input"""
42
+
43
+ CSS = """
44
+ Screen {
45
+ layout: vertical;
46
+ background: #0d0d28;
47
+ }
48
+
49
+ #chat-container {
50
+ height: 1fr;
51
+ border: solid #323452;
52
+ margin: 1;
53
+ background: #0d0d28;
54
+ }
55
+
56
+ #input-container {
57
+ height: 7;
58
+ border: solid #5f47b7;
59
+ margin: 0 1 0 1;
60
+ background: #28205a;
61
+ }
62
+
63
+ .user-message {
64
+ background: #28205a;
65
+ color: #afa3db;
66
+ padding: 1;
67
+ margin: 0 0 1 0;
68
+ border-left: thick #5f47b7;
69
+ }
70
+
71
+ .ai-message {
72
+ background: #3d3d53;
73
+ color: #ece6fc;
74
+ padding: 1;
75
+ margin: 0 0 1 0;
76
+ border-left: thick #5f47b7;
77
+ }
78
+
79
+ .tool-message {
80
+ color: #afa3db;
81
+ padding: 0 1;
82
+ text-style: italic;
83
+ background: #323452;
84
+ }
85
+
86
+ .system-message {
87
+ color: #ece6fc;
88
+ padding: 0 1;
89
+ text-style: italic;
90
+ background: #25253e;
91
+ }
92
+
93
+ Input {
94
+ height: 3;
95
+ margin: 1 1;
96
+ background: #0d0d28;
97
+ color: #ece6fc;
98
+ border: solid #323452;
99
+ }
100
+
101
+ #status-bar {
102
+ height: 1;
103
+ background: #323452;
104
+ color: #afa3db;
105
+ padding: 0 1;
106
+ }
107
+
108
+ Header {
109
+ background: #0d0d28;
110
+ color: #ece6fc;
111
+ }
112
+
113
+ Footer {
114
+ background: #323452;
115
+ color: #afa3db;
116
+ }
117
+ """
118
+
119
+ TITLE = "Chariot AI Assistant"
120
+
121
+ # Reactive attributes
122
+ conversation_id: reactive[Optional[str]] = reactive(None)
123
+ last_message_key: reactive[str] = reactive("")
124
+ mode: reactive[str] = reactive("query")
125
+
126
+ def __init__(self, sdk: Chariot):
127
+ super().__init__()
128
+ self.sdk = sdk
129
+ self.user_email, self.username = self.sdk.get_current_user()
130
+ self.polling_task: Optional[asyncio.Task] = None
131
+ self._selecting_conversation = False
132
+ self._available_conversations = []
133
+
134
+ def compose(self) -> ComposeResult:
135
+ """Compose the UI layout"""
136
+ yield Header()
137
+
138
+ # Main chat area with scrolling
139
+ with Container(id="chat-container"):
140
+ yield VerticalScroll(id="chat-log")
141
+
142
+ # Status bar showing conversation info
143
+ yield Static(f"User: {self.username} | Mode: {self.mode} | Ready", id="status-bar")
144
+
145
+ # Input area at bottom
146
+ with Container(id="input-container"):
147
+ yield Input(placeholder="Type your message here... (type 'help' for commands)", id="message-input")
148
+
149
+ yield Footer()
150
+
151
+ def on_mount(self) -> None:
152
+ """Called when app starts"""
153
+ # Start background polling for job completion events
154
+ self.polling_task = asyncio.create_task(self.background_poll())
155
+
156
+ # Focus the input
157
+ self.query_one("#message-input").focus()
158
+
159
+ # Show welcome message
160
+ self.add_system_message("Welcome to Chariot AI Assistant! Type 'help' for commands or ask about your security data.")
161
+
162
+ @on(Input.Submitted, "#message-input")
163
+ async def on_input_submitted(self, event: Input.Submitted) -> None:
164
+ """Handle user input submission"""
165
+ message = event.value.strip()
166
+ if not message:
167
+ return
168
+
169
+ # Clear the input
170
+ input_widget = self.query_one("#message-input")
171
+ input_widget.clear()
172
+
173
+ # Handle special commands
174
+ if message.lower() in ['quit', 'exit', 'q']:
175
+ self.exit()
176
+ return
177
+ elif message.lower() in ['clear', 'cls']:
178
+ await self.clear_chat()
179
+ return
180
+ elif message.lower() == 'help':
181
+ self.show_help()
182
+ return
183
+ elif message.lower() in ['new']:
184
+ await self.start_new_conversation()
185
+ return
186
+ elif message.lower() in ['resume']:
187
+ await self.resume_conversation()
188
+ return
189
+ elif message.lower() in ['mode query', 'query']:
190
+ self.set_mode('query')
191
+ return
192
+ elif message.lower() in ['mode agent', 'agent']:
193
+ self.set_mode('agent')
194
+ return
195
+ elif message.lower() == 'jobs':
196
+ await self.show_job_status()
197
+ return
198
+
199
+ # Handle conversation selection
200
+ if self._selecting_conversation:
201
+ await self.handle_conversation_selection(message)
202
+ return
203
+
204
+ # Send user message
205
+ await self.send_message(message)
206
+
207
+ async def send_message(self, message: str) -> None:
208
+ """Send user message and wait for AI response"""
209
+ try:
210
+ # Display user message immediately for instant feedback
211
+ self.add_user_message(message)
212
+ self.update_status("Sending message...")
213
+
214
+ # Create async task for API call to avoid blocking UI
215
+ async def send_api_request():
216
+ try:
217
+ # Call API in background
218
+ response = self.call_conversation_api(message)
219
+
220
+ if response.get('error'):
221
+ self.add_system_message(f"Error: {response.get('error')}")
222
+ self.update_status("Error - Ready for next message")
223
+ return
224
+
225
+ # Update status and wait for AI response
226
+ self.update_status("Waiting for AI response...")
227
+
228
+ # Poll for AI response
229
+ await self.wait_for_ai_response()
230
+
231
+ except Exception as e:
232
+ self.add_system_message(f"Failed to send message: {e}")
233
+ self.update_status("Error - Ready for next message")
234
+
235
+ # Start the API request as a background task
236
+ asyncio.create_task(send_api_request())
237
+
238
+ except Exception as e:
239
+ self.add_system_message(f"Failed to send message: {e}")
240
+ self.update_status("Error - Ready for next message")
241
+
242
+ async def wait_for_ai_response(self) -> None:
243
+ """Wait for AI response and display it"""
244
+ while True:
245
+ # Check for new messages
246
+ await self.check_for_new_messages()
247
+
248
+ # Check if we got an AI response
249
+ chat_log = self.query_one("#chat-log")
250
+ if chat_log.children and hasattr(chat_log.children[-1], 'role'):
251
+ last_widget = chat_log.children[-1]
252
+ if hasattr(last_widget, 'role') and last_widget.role == "chariot":
253
+ self.update_status("Ready")
254
+ break
255
+
256
+ await asyncio.sleep(1)
257
+
258
+ async def background_poll(self) -> None:
259
+ """Background polling for job completion events"""
260
+ while True:
261
+ try:
262
+ if self.conversation_id:
263
+ await self.check_for_new_messages()
264
+ await asyncio.sleep(3) # Poll every 3 seconds
265
+ except Exception:
266
+ pass
267
+
268
+ async def check_for_new_messages(self) -> None:
269
+ """Check for new messages and display them"""
270
+ if not self.conversation_id:
271
+ return
272
+
273
+ try:
274
+ # Load all messages for this conversation
275
+ all_messages, _ = self.sdk.search.by_key_prefix(f"#message#{self.conversation_id}#", user=True)
276
+
277
+ # Filter to only new messages
278
+ if self.last_message_key:
279
+ messages = [msg for msg in all_messages if msg.get('key', '') > self.last_message_key]
280
+ else:
281
+ messages = all_messages
282
+
283
+ if messages:
284
+ messages = sorted(messages, key=lambda x: x.get('key', ''))
285
+
286
+ for msg in messages:
287
+ role = msg.get('role')
288
+ content = msg.get('content', '')
289
+
290
+ if role == 'chariot':
291
+ self.add_ai_message(content)
292
+ elif role == 'tool call':
293
+ self.add_tool_message("🔧 Executing tool...")
294
+ self.update_status("Executing tool...")
295
+ elif role == 'tool response':
296
+ self.add_tool_message("✅ Tool execution completed")
297
+ self.update_status("Tool completed, thinking...")
298
+ elif role == 'planner-output':
299
+ self.add_system_message("🎯 Processing job completion...")
300
+
301
+ # Update last message key
302
+ self.last_message_key = messages[-1].get('key', '')
303
+
304
+ except Exception as e:
305
+ pass
306
+
307
+ def add_user_message(self, content: str) -> None:
308
+ """Add user message to chat log"""
309
+ chat_log = self.query_one("#chat-log")
310
+ message_widget = Static(f"👤 You: {content}", classes="user-message")
311
+ chat_log.mount(message_widget)
312
+ chat_log.scroll_end()
313
+
314
+ def add_ai_message(self, content: str) -> None:
315
+ """Add AI message to chat log"""
316
+ chat_log = self.query_one("#chat-log")
317
+ message_widget = Markdown(content, classes="ai-message")
318
+ message_widget.role = "chariot" # Add role attribute for tracking
319
+ chat_log.mount(message_widget)
320
+ chat_log.scroll_end()
321
+
322
+ def add_tool_message(self, content: str) -> None:
323
+ """Add tool execution message to chat log"""
324
+ chat_log = self.query_one("#chat-log")
325
+ message_widget = Static(content, classes="tool-message")
326
+ chat_log.mount(message_widget)
327
+ chat_log.scroll_end()
328
+
329
+ def add_system_message(self, content: str) -> None:
330
+ """Add system message to chat log"""
331
+ chat_log = self.query_one("#chat-log")
332
+ message_widget = Static(content, classes="system-message")
333
+ chat_log.mount(message_widget)
334
+ chat_log.scroll_end()
335
+
336
+ def update_status(self, status: str) -> None:
337
+ """Update status bar"""
338
+ status_bar = self.query_one("#status-bar")
339
+ conv_info = f"Conversation: {self.conversation_id[:8]}..." if self.conversation_id else "No conversation"
340
+ status_bar.update(f"User: {self.username} | Mode: {self.mode} | {conv_info} | {status}")
341
+
342
+ def call_conversation_api(self, message: str) -> Dict:
343
+ """Call the Chariot conversation API"""
344
+ url = self.sdk.url("/planner")
345
+ payload = {"message": message, "mode": self.mode}
346
+
347
+ if self.conversation_id:
348
+ payload["conversationId"] = self.conversation_id
349
+
350
+ response = self.sdk._make_request("POST", url, json=payload)
351
+
352
+ if response.status_code == 200:
353
+ result = response.json()
354
+
355
+ if not self.conversation_id and 'conversation' in result:
356
+ self.conversation_id = result['conversation'].get('uuid')
357
+ self.update_status("Ready")
358
+
359
+ return {'success': True}
360
+ else:
361
+ return {
362
+ 'success': False,
363
+ 'error': f"API error: {response.status_code} - {response.text}"
364
+ }
365
+
366
+ def show_help(self) -> None:
367
+ """Show help information"""
368
+ help_text = """
369
+ # Available Commands:
370
+ - `help` - Show this help
371
+ - `clear` - Clear chat log
372
+ - `new` - Start new conversation
373
+ - `resume` - Resume existing conversation
374
+ - `query` - Switch to Query Mode (data discovery only)
375
+ - `agent` - Switch to Agent Mode (full security operations)
376
+ - `jobs` - Show running jobs
377
+ - `quit` - Exit
378
+
379
+ # Query Mode:
380
+ - Search and analyze existing security data
381
+ - List available capabilities
382
+ - Data discovery focus
383
+
384
+ # Agent Mode:
385
+ - Full security operations
386
+ - Execute scans and manage assets
387
+ - Comprehensive attack surface management
388
+
389
+ # Examples:
390
+ - "Find all active assets"
391
+ - "Show me high-priority risks"
392
+ - "Run a port scan on 10.0.1.5" (agent mode only)
393
+ """
394
+ self.add_system_message(help_text)
395
+
396
+ def set_mode(self, mode: str) -> None:
397
+ """Switch conversation mode"""
398
+ if mode in ["query", "agent"]:
399
+ self.mode = mode
400
+ self.update_status("Ready")
401
+ if mode == "query":
402
+ self.add_system_message("Switched to Query Mode - Data discovery and analysis focus")
403
+ elif mode == "agent":
404
+ self.add_system_message("Switched to Agent Mode - Full security operations")
405
+ else:
406
+ self.add_system_message(f"Invalid mode: {mode}. Available modes: query, agent")
407
+
408
+ async def start_new_conversation(self) -> None:
409
+ """Start a new conversation"""
410
+ self.conversation_id = None
411
+ self.last_message_key = ""
412
+ await self.clear_chat()
413
+ self.add_system_message("Started new conversation")
414
+ self.update_status("Ready")
415
+
416
+ async def resume_conversation(self) -> None:
417
+ """Resume an existing conversation"""
418
+ try:
419
+ # Get recent conversations
420
+ conversations, _ = self.sdk.search.by_key_prefix("#conversation#", user=True)
421
+ conversations = sorted(conversations, key=lambda x: x.get('created', ''), reverse=True)
422
+
423
+ if not conversations:
424
+ self.add_system_message("No recent conversations found. Starting new conversation.")
425
+ await self.start_new_conversation()
426
+ return
427
+
428
+ # Show beautiful conversations table
429
+ conv_list = f"""
430
+ # 💬 Resume Conversation
431
+
432
+ **Found {len(conversations)} recent conversations**
433
+
434
+ ```
435
+ ┌─────────────────────────────────────────────────────────────────┐
436
+ │ RECENT CONVERSATIONS │
437
+ ├─────────────────────────────────────────────────────────────────┤
438
+ """
439
+
440
+ for i, conv in enumerate(conversations[:10]):
441
+ topic = conv.get('topic', 'No topic')
442
+ # Truncate topic but show more characters
443
+ if len(topic) > 45:
444
+ topic = topic[:42] + "..."
445
+
446
+ created = conv.get('created', 'Unknown')
447
+ # Format date more nicely
448
+ if created != 'Unknown':
449
+ try:
450
+ # Parse and format the date
451
+ if 'T' in created:
452
+ dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
453
+ created = dt.strftime('%m/%d %H:%M')
454
+ else:
455
+ created = created[:10] # Just date part
456
+ except:
457
+ created = created[:16]
458
+
459
+ conv_list += f"│ {i+1:2}. 💭 {topic:<45} │ {created:<10} │\n"
460
+ conv_list += f"│{'':<65}│\n"
461
+
462
+ conv_list += f"""├─────────────────────────────────────────────────────────────────┤
463
+ │ Type a number (1-{len(conversations[:10])}) to resume, or 'new' to start fresh │
464
+ └─────────────────────────────────────────────────────────────────┘
465
+ ```"""
466
+
467
+ self.add_system_message(conv_list)
468
+
469
+ # Store conversations for selection
470
+ self._available_conversations = conversations[:10]
471
+ self._selecting_conversation = True
472
+
473
+ except Exception as e:
474
+ self.add_system_message(f"Error loading conversations: {e}")
475
+ await self.start_new_conversation()
476
+
477
+ async def handle_conversation_selection(self, selection: str) -> None:
478
+ """Handle conversation selection by number"""
479
+ # Handle non-numeric inputs first
480
+ if selection.lower() in ['new', 'cancel']:
481
+ self._selecting_conversation = False
482
+ self._available_conversations = []
483
+ await self.start_new_conversation()
484
+ return
485
+
486
+ # Try to parse as number
487
+ try:
488
+ conv_index = int(selection) - 1
489
+ if 0 <= conv_index < len(self._available_conversations):
490
+ selected_conv = self._available_conversations[conv_index]
491
+ self.conversation_id = selected_conv['uuid']
492
+ self.last_message_key = ""
493
+ self._selecting_conversation = False
494
+ self._available_conversations = []
495
+
496
+ await self.clear_chat()
497
+ self.add_system_message(f"Resumed conversation: {selected_conv.get('topic', 'No topic')}")
498
+
499
+ # Load conversation history
500
+ await self.load_conversation_history()
501
+ self.update_status("Ready")
502
+ else:
503
+ self.add_system_message(f"Invalid selection. Please choose 1-{len(self._available_conversations)} or type 'new' to cancel.")
504
+
505
+ except ValueError:
506
+ self.add_system_message("Invalid input. Please enter a number or type 'new' to cancel.")
507
+
508
+ async def load_conversation_history(self) -> None:
509
+ """Load and display conversation history"""
510
+ if not self.conversation_id:
511
+ return
512
+
513
+ try:
514
+ # Load all messages for this conversation
515
+ messages, _ = self.sdk.search.by_key_prefix(f"#message#{self.conversation_id}#", user=True)
516
+ messages = sorted(messages, key=lambda x: x.get('key', ''))
517
+
518
+ self.add_system_message(f"Loading {len(messages)} messages from conversation history...")
519
+
520
+ for msg in messages:
521
+ role = msg.get('role')
522
+ content = msg.get('content', '')
523
+
524
+ if role == 'user':
525
+ self.add_user_message(content)
526
+ elif role == 'chariot':
527
+ self.add_ai_message(content)
528
+ elif role == 'tool call':
529
+ self.add_tool_message("🔧 Executing tool...")
530
+ elif role == 'tool response':
531
+ self.add_tool_message("✅ Tool execution completed")
532
+ elif role == 'planner-output':
533
+ self.add_system_message("🎯 Job completion processed")
534
+
535
+ # Set last message key for future polling
536
+ if messages:
537
+ self.last_message_key = messages[-1].get('key', '')
538
+
539
+ self.add_system_message("Conversation history loaded. You can continue the conversation.")
540
+
541
+ except Exception as e:
542
+ self.add_system_message(f"Error loading conversation history: {e}")
543
+
544
+ async def clear_chat(self) -> None:
545
+ """Clear the chat log"""
546
+ chat_log = self.query_one("#chat-log")
547
+ await chat_log.remove_children()
548
+
549
+ async def show_job_status(self) -> None:
550
+ """Show active jobs for the current conversation"""
551
+ if not self.conversation_id:
552
+ self.add_system_message("No active conversation")
553
+ return
554
+
555
+ try:
556
+ jobs, _ = self.sdk.search.by_term(f"conversation:{self.conversation_id}")
557
+ jobs = jobs if jobs else []
558
+
559
+ if not jobs:
560
+ self.add_system_message("No jobs found for this conversation")
561
+ return
562
+
563
+ # Create beautiful jobs table
564
+ job_summary = f"""
565
+ # 🚀 Security Jobs Status
566
+
567
+ **Conversation Jobs: {len(jobs)}**
568
+
569
+ ```
570
+ ┌─────────────────────────────────────────────────────────────────┐
571
+ │ ACTIVE SECURITY JOBS │
572
+ ├─────────────────────────────────────────────────────────────────┤
573
+ """
574
+
575
+ for i, job in enumerate(jobs, 1):
576
+ status = job.get('status', '')
577
+ capability = job.get('source', 'unknown')
578
+
579
+ # Extract target from job key
580
+ job_key = job.get('key', '')
581
+ if job_key.startswith('#job#'):
582
+ parts = job_key.split('#')
583
+ if len(parts) >= 3:
584
+ target_part = parts[2]
585
+ if target_part.startswith('#asset#'):
586
+ asset_parts = target_part.split('#')
587
+ target_display = asset_parts[3] if len(asset_parts) >= 4 else target_part
588
+ else:
589
+ target_display = target_part
590
+ else:
591
+ target_display = job_key
592
+ else:
593
+ target_display = job.get('dns', 'unknown')
594
+
595
+ # Map status to readable format with better emojis
596
+ status_info = {
597
+ 'JQ': ('🔵', 'QUEUED', 'Waiting to start'),
598
+ 'JR': ('🟡', 'RUNNING', 'Currently executing'),
599
+ 'JP': ('🟢', 'COMPLETED', 'Successfully finished'),
600
+ 'JF': ('🔴', 'FAILED', 'Execution failed')
601
+ }
602
+
603
+ emoji, status_name, description = status_info.get(status[:2], ('⚪', 'UNKNOWN', 'Status unknown'))
604
+
605
+ # Format each job entry nicely
606
+ job_summary += f"│ {i:2}. {emoji} {status_name:<9} │ {capability:<15} │ {target_display:<25} │\n"
607
+ if len(jobs) <= 5: # Show descriptions for small lists
608
+ job_summary += f"│ {description:<60} │\n"
609
+ job_summary += f"│{'':<65}│\n"
610
+
611
+ job_summary += "└─────────────────────────────────────────────────────────────────┘\n```"
612
+
613
+ self.add_system_message(job_summary)
614
+
615
+ except Exception as e:
616
+ self.add_system_message(f"Failed to get job status: {e}")
617
+
618
+
619
+ def run_textual_conversation(sdk: Chariot) -> None:
620
+ """Run the Textual-based conversation interface"""
621
+ app = ConversationApp(sdk)
622
+ app.run()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: praetorian-cli
3
- Version: 2.2.4
3
+ Version: 2.2.6
4
4
  Summary: For interacting with the Chariot API
5
5
  Home-page: https://github.com/praetorian-inc/praetorian-cli
6
6
  Author: Praetorian