praetorian-cli 2.2.4__py3-none-any.whl → 2.2.5__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.
@@ -5,7 +5,7 @@ import click
5
5
 
6
6
  from praetorian_cli.handlers.chariot import chariot
7
7
  from praetorian_cli.handlers.cli_decorators import cli_handler, praetorian_only
8
- from praetorian_cli.handlers.utils import error
8
+ from praetorian_cli.handlers.utils import error, parse_configuration_value
9
9
  from praetorian_cli.sdk.model.globals import AddRisk, Asset, Seed, Kind
10
10
 
11
11
 
@@ -274,40 +274,33 @@ def setting(sdk, name, value):
274
274
  @add.command()
275
275
  @cli_handler
276
276
  @click.option('-n', '--name', required=True, help='Name of the configuration')
277
- @click.option('-e', '--entry', required=True, multiple=True, help='Key-value pair in format key=value. Can be specified multiple times to set multiple values.')
277
+ @click.option('-e', '--entry', required=False, multiple=True,
278
+ help='Key-value pair in format key=value. Can be specified multiple times to set multiple values.')
279
+ @click.option('--string', 'string_value', required=False,
280
+ help='Set the configuration value to a string')
281
+ @click.option('--integer', 'integer_value', required=False,
282
+ help='Set the configuration value to an integer')
283
+ @click.option('--float', 'float_value', required=False,
284
+ help='Set the configuration value to a floating point number')
278
285
  @praetorian_only
279
- def configuration(sdk, name, entry):
286
+ def configuration(sdk, name, entry, string_value, integer_value, float_value):
280
287
  """ Add a configuration
281
288
 
282
- This command adds, or overwrites if exists, a name-value configuration.
289
+ This command adds, or overwrites if exists, a configuration value.
290
+
291
+ Configuration values can be provided as a mapping of key-value pairs using
292
+ ``--entry`` (the previous behavior), or as primitive values using
293
+ ``--string``, ``--integer``, or ``--float``.
283
294
 
284
295
  \b
285
296
  Example usages:
286
297
  - praetorian chariot add configuration --name "nuclei" --entry extra-tags=http,sql --entry something=else
298
+ - praetorian chariot add configuration --name "billing-status" --string PAID_MS
299
+ - praetorian chariot add configuration --name "request-timeout" --integer 60
300
+ - praetorian chariot add configuration --name "scoring-threshold" --float 0.85
287
301
  """
288
- config_dict = {}
289
- for item in entry:
290
- if '=' not in item:
291
- click.echo(f"Error: Entry '{item}' is not in the format key=value")
292
- return
293
-
294
- if item.count('=') > 1:
295
- click.echo(f"Error: Entry '{item}' contains multiple '=' characters. Format should be key=value")
296
- return
297
-
298
- key, value = item.split('=', 1)
299
-
300
- if not key:
301
- click.echo("Error: Key cannot be empty")
302
- return
303
-
304
- if not value:
305
- click.echo("Error: Value cannot be empty")
306
- return
307
-
308
- config_dict[key] = value
309
-
310
- sdk.configurations.add(name, config_dict)
302
+ config_value = parse_configuration_value(entry, string_value, integer_value, float_value)
303
+ sdk.configurations.add(name, config_value)
311
304
 
312
305
 
313
306
  @add.command()
@@ -69,3 +69,32 @@ def tools(sdk, allowed):
69
69
  """
70
70
  for tool in dict.keys(sdk.agents.list_mcp_tools(allowed)):
71
71
  click.echo(tool)
72
+
73
+ @agent.command()
74
+ @cli_handler
75
+ def conversation(sdk):
76
+ """ Interactive conversation with Chariot AI assistant
77
+
78
+ Start an interactive chat session with the Chariot AI assistant.
79
+ The AI can help you query security data, understand findings,
80
+ and provide insights about your attack surface.
81
+
82
+ \b
83
+ Commands within conversation:
84
+ - help Show available commands and query examples
85
+ - clear Clear the screen
86
+ - new Start a new conversation
87
+ - quit Exit the conversation
88
+
89
+ \b
90
+ Example queries:
91
+ - "Find all active assets"
92
+ - "Show me critical risks"
93
+ - "What assets do we have for example.com?"
94
+
95
+ \b
96
+ Usage:
97
+ praetorian chariot agent conversation
98
+ """
99
+ from praetorian_cli.ui.conversation import run_textual_conversation
100
+ run_textual_conversation(sdk)
@@ -147,7 +147,8 @@ def file(chariot, name, path):
147
147
  @cli_handler
148
148
  @click.argument('name')
149
149
  @click.option('-path', '--path', default=os.getcwd(), help='Download path. Default: save to current directory')
150
- def definition(chariot, name, path):
150
+ @click.option('--global', 'global_', is_flag=True, help='Fetch from global definitions instead of user-specific')
151
+ def definition(chariot, name, path, global_):
151
152
  """ Download a definition using the risk name
152
153
 
153
154
  \b
@@ -158,8 +159,9 @@ def definition(chariot, name, path):
158
159
  Example usage:
159
160
  - praetorian chariot get definition jira-unauthenticated-user-picker
160
161
  - praetorian chariot get definition CVE-2024-23049
162
+ - praetorian chariot get definition CVE-2024-23049 --global
161
163
  """
162
- downloaded_path = chariot.definitions.get(name, path)
164
+ downloaded_path = chariot.definitions.get(name, path, global_=global_)
163
165
  click.echo(f'Saved definition at {downloaded_path}')
164
166
 
165
167
 
@@ -3,6 +3,65 @@ import json
3
3
  import click
4
4
 
5
5
 
6
+ def parse_configuration_value(
7
+ entries = [], s_val=None, i_val=None, f_val=None):
8
+ """Return a configuration value derived from CLI inputs."""
9
+ has_entries = len(entries) > 0
10
+ typed_values = {
11
+ 'string': s_val,
12
+ 'integer': i_val,
13
+ 'float': f_val,
14
+ }
15
+
16
+ if has_entries and any(value is not None for value in typed_values.values()):
17
+ error('--entry cannot be combined with --string, --integer, or --float')
18
+
19
+ if has_entries:
20
+ return _parse_entry_dict(entries)
21
+
22
+ provided = [(name, value) for name, value in typed_values.items() if value is not None]
23
+
24
+ if not provided:
25
+ error('Provide configuration data via --entry, --string, --integer, or --float')
26
+ if len(provided) > 1:
27
+ error('Specify only one of --string, --integer, or --float')
28
+
29
+ value_type, raw_value = provided[0]
30
+ return _cast_typed_value(value_type, raw_value)
31
+
32
+
33
+ def _parse_entry_dict(entries):
34
+ parsed = {}
35
+
36
+ for item in entries:
37
+ key, value = _split_entry(item)
38
+ if not key:
39
+ error(f'Key cannot be empty: {item}')
40
+ if not value:
41
+ error(f'Value cannot be empty: {item}')
42
+ parsed[key] = value
43
+ return parsed
44
+
45
+
46
+ def _split_entry(item):
47
+ if '=' not in item:
48
+ error(f"Entry '{item}' is not in the format key=value")
49
+ if item.count('=') > 1:
50
+ error(f"Entry '{item}' contains multiple '=' characters. Format should be key=value")
51
+ return item.split('=', 1)
52
+
53
+
54
+ def _cast_typed_value(value_type, raw_value):
55
+ try:
56
+ if value_type == 'integer':
57
+ return int(raw_value)
58
+ if value_type == 'float':
59
+ return float(raw_value)
60
+ except ValueError:
61
+ error(f'{value_type} must be a valid {value_type}')
62
+ return raw_value
63
+
64
+
6
65
  def render_list_results(list_results, details):
7
66
  list_data, offset = list_results
8
67
  if details:
@@ -27,7 +27,7 @@ class Configurations:
27
27
  "Please contact your Praetorian representative for assistance."
28
28
  )
29
29
 
30
- def add(self, name, value: dict):
30
+ def add(self, name, value):
31
31
  """
32
32
  Add or update a configuration.
33
33
 
@@ -29,7 +29,7 @@ class Definitions:
29
29
  definition_name = os.path.basename(local_filepath)
30
30
  return self.api.files.add(local_filepath, f'definitions/{definition_name}')
31
31
 
32
- def get(self, definition_name, download_directory=os.getcwd()):
32
+ def get(self, definition_name, download_directory=os.getcwd(), global_=False):
33
33
  """
34
34
  Download a risk definition file from the definitions folder.
35
35
 
@@ -37,15 +37,24 @@ class Definitions:
37
37
  :type definition_name: str
38
38
  :param download_directory: The directory to save the downloaded file (defaults to current working directory)
39
39
  :type download_directory: str
40
+ :param global_: If True, fetch from global definitions instead of user-specific
41
+ :type global_: bool
40
42
  :return: The local file path where the definition was saved
41
43
  :rtype: str
42
44
  """
43
- content = self.api.files.get_utf8(f'definitions/{definition_name}')
45
+ try:
46
+ content = self.api.files.get_utf8(f'definitions/{definition_name}', _global=global_)
47
+ except Exception as e:
48
+ if global_:
49
+ raise Exception(f'Global definition {definition_name} not found or inaccessible.')
50
+ else:
51
+ raise
44
52
  download_path = os.path.join(download_directory, definition_name)
45
53
  with open(download_path, 'w') as file:
46
54
  file.write(content)
47
55
  return download_path
48
56
 
57
+
49
58
  def list(self, name_filter='', offset=None, pages=100000) -> tuple:
50
59
  """
51
60
  List the definition names, optionally prefix-filtered by a definition name.
@@ -46,30 +46,35 @@ class Files:
46
46
 
47
47
  return download_path
48
48
 
49
- def get(self, chariot_filepath) -> bytes:
49
+ def get(self, chariot_filepath, _global=False) -> bytes:
50
50
  """
51
51
  Download a file from Chariot storage into memory as bytes.
52
52
 
53
53
  :param chariot_filepath: Path of the file in Chariot storage to download
54
54
  :type chariot_filepath: str
55
+ :param _global: If True, fetch from global storage instead of user-specific
56
+ :type _global: bool
55
57
  :return: The file content as bytes
56
58
  :rtype: bytes
57
59
  :raises Exception: If the file does not exist in Chariot storage
58
60
  """
59
- self.raise_if_missing(chariot_filepath)
60
- return self.api.download(chariot_filepath)
61
+ if not _global:
62
+ self.raise_if_missing(chariot_filepath)
63
+ return self.api.download(chariot_filepath, global_=_global)
61
64
 
62
- def get_utf8(self, chariot_filepath) -> str:
65
+ def get_utf8(self, chariot_filepath, _global=False) -> str:
63
66
  """
64
67
  Download a file from Chariot storage into memory as a UTF-8 string.
65
68
 
66
69
  :param chariot_filepath: Path of the file in Chariot storage to download
67
70
  :type chariot_filepath: str
71
+ :param _global: If True, fetch from global storage instead of user-specific
72
+ :type _global: bool
68
73
  :return: The file content as a UTF-8 decoded string
69
74
  :rtype: str
70
75
  :raises Exception: If the file does not exist in Chariot storage
71
76
  """
72
- return self.get(chariot_filepath).decode('utf-8')
77
+ return self.get(chariot_filepath, _global=_global).decode('utf-8')
73
78
 
74
79
  def list(self, prefix_filter='', offset=None, pages=100000) -> tuple:
75
80
  """
@@ -1,6 +1,6 @@
1
1
  import json
2
2
  from praetorian_cli.sdk.model.query import Query
3
- from praetorian_cli.sdk.model.globals import EXACT_FLAG, DESCENDING_FLAG, GLOBAL_FLAG, Kind
3
+ from praetorian_cli.sdk.model.globals import EXACT_FLAG, DESCENDING_FLAG, GLOBAL_FLAG, USER_FLAG, Kind
4
4
  class Search:
5
5
 
6
6
  def __init__(self, api):
@@ -17,7 +17,7 @@ class Search:
17
17
  """
18
18
  return self.api.count(dict(key=search_term))
19
19
 
20
- def by_key_prefix(self, key_prefix, offset=None, pages=100000) -> tuple:
20
+ def by_key_prefix(self, key_prefix, offset=None, pages=100000, user=False) -> tuple:
21
21
  """
22
22
  Search for entities by key prefix. <mcp>If the response is too large, make your query more specific.<mcp>
23
23
 
@@ -30,7 +30,7 @@ class Search:
30
30
  :return: A tuple containing (list of matching entities, next page offset)
31
31
  :rtype: tuple
32
32
  """
33
- return self.by_term(key_prefix, None, offset, pages)
33
+ return self.by_term(key_prefix, None, offset, pages, user=user)
34
34
 
35
35
  def by_exact_key(self, key, get_attributes=False) -> {}:
36
36
  """
@@ -119,7 +119,7 @@ class Search:
119
119
  return self.by_term(f'dns:{dns_prefix}', kind, offset, pages)
120
120
 
121
121
  def by_term(self, search_term, kind=None, offset=None, pages=100000, exact=False, descending=False,
122
- global_=False) -> tuple:
122
+ global_=False, user=False) -> tuple:
123
123
  """
124
124
  Search for a given kind by term.
125
125
 
@@ -151,6 +151,8 @@ class Search:
151
151
  params |= DESCENDING_FLAG
152
152
  if global_:
153
153
  params |= GLOBAL_FLAG
154
+ if user:
155
+ params |= USER_FLAG
154
156
 
155
157
  results = self.api.my(params, pages)
156
158
 
@@ -114,3 +114,4 @@ class Kind(Enum):
114
114
  EXACT_FLAG = {'exact': 'true'}
115
115
  DESCENDING_FLAG = {'desc': 'true'}
116
116
  GLOBAL_FLAG = {'global': 'true'}
117
+ USER_FLAG = {'user': 'true'}
@@ -0,0 +1,195 @@
1
+ import pytest
2
+ import json
3
+ import time
4
+
5
+ from praetorian_cli.sdk.test.utils import make_test_values, clean_test_entities, setup_chariot
6
+
7
+
8
+ @pytest.mark.coherence
9
+ class TestConversation:
10
+ """Test conversation functionality with the Chariot agent"""
11
+
12
+ def setup_class(self):
13
+ self.sdk = setup_chariot()
14
+ make_test_values(self)
15
+ self.conversation_id = None
16
+ self.message_keys = []
17
+
18
+ def test_start_conversation(self):
19
+ """Test starting a new conversation with the agent"""
20
+ url = self.sdk.url("/planner")
21
+ payload = {
22
+ "message": f"Test conversation started at {int(time.time())}",
23
+ "mode": "query"
24
+ }
25
+
26
+ response = self.sdk._make_request("POST", url, json=payload)
27
+
28
+ # Should get successful response
29
+ assert response.status_code == 200
30
+
31
+ result = response.json()
32
+ assert "conversation" in result
33
+ assert "uuid" in result["conversation"]
34
+
35
+ self.conversation_id = result["conversation"]["uuid"]
36
+ assert self.conversation_id is not None
37
+ assert len(self.conversation_id) > 0
38
+
39
+ def test_send_message_with_existing_conversation(self):
40
+ """Test sending a message to an existing conversation"""
41
+ # First, start a new conversation
42
+ url = self.sdk.url("/planner")
43
+ initial_payload = {
44
+ "message": f"Initial message for follow-up test at {int(time.time())}",
45
+ "mode": "query"
46
+ }
47
+
48
+ response = self.sdk._make_request("POST", url, json=initial_payload)
49
+ assert response.status_code == 200
50
+
51
+ result = response.json()
52
+ conversation_id = result["conversation"]["uuid"]
53
+
54
+ # Now send a follow-up message to the existing conversation
55
+ follow_up_payload = {
56
+ "message": f"Follow-up message in existing conversation at {int(time.time())}",
57
+ "mode": "query",
58
+ "conversationId": conversation_id
59
+ }
60
+
61
+ response = self.sdk._make_request("POST", url, json=follow_up_payload)
62
+
63
+ # Should get successful response
64
+ assert response.status_code == 200
65
+
66
+ def test_read_conversation_messages(self):
67
+ """Test reading messages from a conversation"""
68
+ # First, create a conversation and send a message
69
+ url = self.sdk.url("/planner")
70
+ payload = {
71
+ "message": f"Test message for reading at {int(time.time())}",
72
+ "mode": "query"
73
+ }
74
+
75
+ response = self.sdk._make_request("POST", url, json=payload)
76
+ assert response.status_code == 200
77
+
78
+ result = response.json()
79
+ conversation_id = result["conversation"]["uuid"]
80
+
81
+ # Wait a moment for message to be stored
82
+ time.sleep(2)
83
+
84
+ # Now try to read messages from this conversation
85
+ messages, offset = self.sdk.search.by_key_prefix(
86
+ f"#message#{conversation_id}#", user=True
87
+ )
88
+
89
+ # Should have at least the user message
90
+ assert isinstance(messages, list)
91
+ # Note: messages might be empty immediately after creation due to async processing
92
+ # This is still a valid test as it verifies the search functionality works
93
+
94
+ def test_read_conversations_list(self):
95
+ """Test reading list of conversations"""
96
+ # First create a test conversation
97
+ url = self.sdk.url("/planner")
98
+ payload = {
99
+ "message": f"Test conversation for listing at {int(time.time())}",
100
+ "mode": "query"
101
+ }
102
+
103
+ response = self.sdk._make_request("POST", url, json=payload)
104
+ assert response.status_code == 200
105
+
106
+ # Wait a moment for conversation to be stored
107
+ time.sleep(1)
108
+
109
+ # Now search for conversations
110
+ conversations, offset = self.sdk.search.by_key_prefix(
111
+ "#conversation#", user=True
112
+ )
113
+
114
+ # Should be able to search for conversations (list might be empty or contain conversations)
115
+ assert isinstance(conversations, list)
116
+
117
+ def test_conversation_api_error_handling(self):
118
+ """Test handling of malformed requests"""
119
+ url = self.sdk.url("/planner")
120
+ # Send malformed payload (missing required fields)
121
+ malformed_payload = {
122
+ "invalid_field": "should cause error"
123
+ # Missing required "message" field
124
+ }
125
+
126
+ response = self.sdk._make_request("POST", url, json=malformed_payload)
127
+
128
+ # Should get a 4xx error for bad request
129
+ assert response.status_code >= 400
130
+
131
+ def test_conversation_modes(self):
132
+ """Test different conversation modes (query vs agent)"""
133
+ url = self.sdk.url("/planner")
134
+
135
+ # Test query mode
136
+ query_payload = {
137
+ "message": f"Test query mode at {int(time.time())}",
138
+ "mode": "query"
139
+ }
140
+
141
+ response = self.sdk._make_request("POST", url, json=query_payload)
142
+ assert response.status_code == 200
143
+
144
+ result = response.json()
145
+ assert "conversation" in result
146
+
147
+ # Test agent mode
148
+ agent_payload = {
149
+ "message": f"Test agent mode at {int(time.time())}",
150
+ "mode": "agent"
151
+ }
152
+
153
+ response = self.sdk._make_request("POST", url, json=agent_payload)
154
+ assert response.status_code == 200
155
+
156
+ result = response.json()
157
+ assert "conversation" in result
158
+
159
+ def test_message_polling_integration(self):
160
+ """Test integration of message polling to check for new messages"""
161
+ # Create a conversation first
162
+ url = self.sdk.url("/planner")
163
+ payload = {
164
+ "message": f"Test message for polling at {int(time.time())}",
165
+ "mode": "query"
166
+ }
167
+
168
+ response = self.sdk._make_request("POST", url, json=payload)
169
+ assert response.status_code == 200
170
+
171
+ result = response.json()
172
+ conversation_id = result["conversation"]["uuid"]
173
+
174
+ # Test polling for messages (first call)
175
+ messages, offset = self.sdk.search.by_key_prefix(
176
+ f"#message#{conversation_id}#", user=True
177
+ )
178
+
179
+ initial_count = len(messages) if messages else 0
180
+
181
+ # Wait a moment and check again (simulating polling)
182
+ time.sleep(1)
183
+
184
+ messages, offset = self.sdk.search.by_key_prefix(
185
+ f"#message#{conversation_id}#", user=True
186
+ )
187
+
188
+ # Should still return valid result (messages may or may not have changed)
189
+ assert isinstance(messages, list)
190
+
191
+ def teardown_class(self):
192
+ """Clean up test data"""
193
+ # Note: Conversations and messages are typically read-only in tests
194
+ # Real cleanup would depend on having delete methods for conversations
195
+ pass
@@ -5,6 +5,7 @@ from subprocess import run
5
5
  import pytest
6
6
 
7
7
  from praetorian_cli.sdk.model.globals import AddRisk, Asset, Risk, Seed, Preseed
8
+ from praetorian_cli.sdk.model.utils import configuration_key
8
9
  from praetorian_cli.sdk.test.utils import epoch_micro, random_ip, make_test_values, clean_test_entities, setup_chariot
9
10
 
10
11
 
@@ -273,6 +274,24 @@ class TestZCli:
273
274
 
274
275
  self.verify(f'delete configuration "{o.configuration_key}"', ignore_stdout=True)
275
276
 
277
+ string_name = f'{o.configuration_name}-string'
278
+ string_key = configuration_key(string_name)
279
+ self.verify(f'add configuration --name "{string_name}" --string PAID_MS')
280
+ self.verify(f'get configuration "{string_key}"', expected_stdout=[string_key, string_name, '"value": "PAID_MS"'])
281
+ self.verify(f'delete configuration "{string_key}"', ignore_stdout=True)
282
+
283
+ integer_name = f'{o.configuration_name}-integer'
284
+ integer_key = configuration_key(integer_name)
285
+ self.verify(f'add configuration --name "{integer_name}" --integer 123')
286
+ self.verify(f'get configuration "{integer_key}"', expected_stdout=[integer_key, integer_name, '"value": 123'])
287
+ self.verify(f'delete configuration "{integer_key}"', ignore_stdout=True)
288
+
289
+ float_name = f'{o.configuration_name}-float'
290
+ float_key = configuration_key(float_name)
291
+ self.verify(f'add configuration --name "{float_name}" --float 1.5')
292
+ self.verify(f'get configuration "{float_key}"', expected_stdout=[float_key, float_name, '"value": 1.5'])
293
+ self.verify(f'delete configuration "{float_key}"', ignore_stdout=True)
294
+
276
295
  def test_webapplication_cli(self):
277
296
  o = make_test_values(lambda: None)
278
297
  self.verify(f'add asset --dns "{o.webapp_name}" --name "{o.webapp_url}" --type webapplication')
@@ -339,6 +358,11 @@ class TestZCli:
339
358
 
340
359
  self.verify('link --help', ignore_stdout=True)
341
360
  self.verify('link account --help', ignore_stdout=True)
361
+
362
+ # Agent commands
363
+ self.verify('agent --help', ignore_stdout=True)
364
+ self.verify('agent conversation --help', ignore_stdout=True)
365
+ self.verify('agent mcp --help', ignore_stdout=True)
342
366
  self.verify('link webpage-source --help', ignore_stdout=True)
343
367
 
344
368
  self.verify('unlink --help', ignore_stdout=True)
@@ -0,0 +1,3 @@
1
+ from .textual_chat import run_textual_conversation
2
+
3
+ __all__ = ['run_textual_conversation']
@@ -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.5
4
4
  Summary: For interacting with the Chariot API
5
5
  Home-page: https://github.com/praetorian-inc/praetorian-cli
6
6
  Author: Praetorian
@@ -1,15 +1,15 @@
1
1
  praetorian_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  praetorian_cli/main.py,sha256=AVrOCQgioLDKm-Y8-b3lLLdLtaO1WwOAzUfs0obe5Nw,1451
3
3
  praetorian_cli/handlers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- praetorian_cli/handlers/add.py,sha256=1n0EZqlKsx2BX99jVs5-gwMKrnfJG4ajQajuWA6qMFI,13443
4
+ praetorian_cli/handlers/add.py,sha256=gJoV4WqQbbWL-O84jXZK7HF0kiFNp9RkpDWPpN6CXAg,13854
5
5
  praetorian_cli/handlers/aegis.py,sha256=1259bNmoUOVhcs7GqI8TyTyCI_ZvKzEPvfUVvAHcTzU,3936
6
- praetorian_cli/handlers/agent.py,sha256=8lmb_R1aWe9bhk-4NPOfXXxaE8E_U97DFNEvJ2mcteg,2291
6
+ praetorian_cli/handlers/agent.py,sha256=52_BcZF10VOuiwkySQpjeh07JdUKaMURo-RLVOINK1w,3165
7
7
  praetorian_cli/handlers/chariot.py,sha256=HClwYdsgFKlLY68RhV65W1Y4g-JgbBDdI4PdP4s8MgI,611
8
8
  praetorian_cli/handlers/cli_decorators.py,sha256=ZLPMZH9i-vNtTT0Y0qnYQsGw8B4lxIdEE1vBX9i75Wc,2884
9
9
  praetorian_cli/handlers/configure.py,sha256=8ABvisb_a4WekVVZ5kEhT1m4Mn2xV5RsO_xO_7f6TkM,1317
10
10
  praetorian_cli/handlers/delete.py,sha256=8K7yEnQQT88vbJG-ouX-aFq6FyjkWNGDMt97VYZGSZg,4433
11
11
  praetorian_cli/handlers/enrich.py,sha256=KRvOAuW7mQdYg_k_XOTwtPmnf8rxOAtoL00Bb0e-N-s,485
12
- praetorian_cli/handlers/get.py,sha256=l9fFo_pSLXIx5Ax6bmSXsVlT7jazzrB235usJJbx82Y,9356
12
+ praetorian_cli/handlers/get.py,sha256=-_gfjPDN_H7tClHzKPN6jo-rtXsUMp7NaQ_YrdEajX4,9564
13
13
  praetorian_cli/handlers/imports.py,sha256=u1TK4w3eCMD5ASPFare2NgTX1_m0_F1ELCmvV7OF-PI,1653
14
14
  praetorian_cli/handlers/link.py,sha256=7JjHoawFZbcfropHVp8IX3Ekci6theAlcYSTPXnrFHQ,1776
15
15
  praetorian_cli/handlers/list.py,sha256=bTPztFlyHHyCO5y5pDOw68CDxGN_Ps8DrhbStgSFgrc,13217
@@ -19,7 +19,7 @@ praetorian_cli/handlers/ssh_utils.py,sha256=53Kke-iFH4sJoCcweiT8q4WVRlaA7SvR5CCq
19
19
  praetorian_cli/handlers/test.py,sha256=uhARoRolaJf6DMRNX-1aj8SDYe1wAvhYDOBYWH39sqo,932
20
20
  praetorian_cli/handlers/unlink.py,sha256=nUTGXZ7JBXwuHy2nzvL79sSO95Vyc0PftM6rm-9YWt8,1725
21
21
  praetorian_cli/handlers/update.py,sha256=rgdOsFTaEivTdTUjUxPNEo13XR7uoIpBFRUqUdFVjaI,2846
22
- praetorian_cli/handlers/utils.py,sha256=2WA_50l6bWo-00cpxsHtqZYF10pSVxwly94qjl_tutY,858
22
+ praetorian_cli/handlers/utils.py,sha256=PaHyDTF2uj6mMMIr1pP1Bfh22H3dMx_UWnJgXK7_yuY,2639
23
23
  praetorian_cli/scripts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
24
  praetorian_cli/scripts/utils.py,sha256=lGCf4trEpsfECa9U42pDJ-f48EimlS-hG6AjnKjNt4I,501
25
25
  praetorian_cli/scripts/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -35,10 +35,10 @@ praetorian_cli/sdk/entities/agents.py,sha256=tJXTBkU7EpLVfhKo7g5pQfcXJeylLEfeLIp
35
35
  praetorian_cli/sdk/entities/assets.py,sha256=z8ErleQQ-BdnYkwPWOp2EQ8eJI4YB8qi45XQMokDFK8,5930
36
36
  praetorian_cli/sdk/entities/attributes.py,sha256=AyWsYyjUFNbHTCN7j-OYA7YD1Y0z_LmnlcME5y74je8,3573
37
37
  praetorian_cli/sdk/entities/capabilities.py,sha256=WeNlPrhVgLQPbpqYvS4nHmIX697ITpoZkJeLYxG5bmY,2808
38
- praetorian_cli/sdk/entities/configurations.py,sha256=rIChKvkaHUv9XRtQJ6OYH3gDDMcN8OPVsM_ylPnPEoI,4255
38
+ praetorian_cli/sdk/entities/configurations.py,sha256=y32_QYSS6MgGDyz0tEuJgG6jWCI4Vyzwyf0m4SVtVNw,4249
39
39
  praetorian_cli/sdk/entities/credentials.py,sha256=BfNBe_0P6-qtwJFicyrAeTWmWttDtaEnTo8nr3pqYoo,5108
40
- praetorian_cli/sdk/entities/definitions.py,sha256=4cTHBoXwUgKVVvh3nOqphtwMlQVPaFM2BFayP1E9Ucg,2765
41
- praetorian_cli/sdk/entities/files.py,sha256=IMPY9KswD5qdhkoC9hBGAoPDEc1XOduIuTYiUg0hPss,5506
40
+ praetorian_cli/sdk/entities/definitions.py,sha256=rpuNLM3DZxMr5YINYDD6ePWG1TEI1biZ4IqnjpDAEh0,3125
41
+ praetorian_cli/sdk/entities/files.py,sha256=Gw9Yt_Cm2xR-Vu8vjaekMHbGldhe930WJjjeaJrBLxg,5822
42
42
  praetorian_cli/sdk/entities/integrations.py,sha256=NVaW_dWbnMkMIs-EYr2W7QAeamPVwLhmM2ppdMJmsK0,3176
43
43
  praetorian_cli/sdk/entities/jobs.py,sha256=JBkNgFzQEtV0XJSuKtAzCHOYa9rcVxgTkp-tAuS6CP0,8181
44
44
  praetorian_cli/sdk/entities/keys.py,sha256=PgoGa3xyLMzWrIIQ8zgi7bfZiUFFumPtMDo64GjhdjE,6089
@@ -46,7 +46,7 @@ praetorian_cli/sdk/entities/preseeds.py,sha256=SeSY4K6diJMQzsjCBxYK3N9Lz0fUz3B_L
46
46
  praetorian_cli/sdk/entities/risks.py,sha256=ZJ9_S8Zf3RwB7wGRjGEpf8j1jTB_leJiyHlFWeXsefc,5953
47
47
  praetorian_cli/sdk/entities/scanners.py,sha256=QCr5QlBy4jfBh8HRvZt9CoZTgNqLNnKNrI4sdfJf0jE,423
48
48
  praetorian_cli/sdk/entities/schema.py,sha256=CPVws1CdRHyOAI7oT9A20WGOCZozTFqZnfo5ox3v0HQ,807
49
- praetorian_cli/sdk/entities/search.py,sha256=hFoANI_arlEmFPQwDiEF7lU-kPgXk0z3VyjubOULhRE,16843
49
+ praetorian_cli/sdk/entities/search.py,sha256=9vTy9HZY2BTlqf5Zwpdl8HCRIfSEvWDRij2_-rAp2Ng,16938
50
50
  praetorian_cli/sdk/entities/seeds.py,sha256=eYDFtfgzIX_o9c5wuPiz9I2xDfTz7k9PZPzL7A7x1CM,5404
51
51
  praetorian_cli/sdk/entities/settings.py,sha256=F-pRCA6UvbdtnjHOLpEG2lN9ws8dcnBNcep-DFlXeTY,2750
52
52
  praetorian_cli/sdk/entities/statistics.py,sha256=gtX-NN7r_RsNjDjlQ-zspmzG_0bzBqBFarCuM4NO-EA,7085
@@ -54,7 +54,7 @@ praetorian_cli/sdk/entities/webhook.py,sha256=7Bqt20GlJFbZTlmQwYTuUadsUQvydym6S4
54
54
  praetorian_cli/sdk/entities/webpage.py,sha256=FBS3HzuUJnQR9Blm_cBCd9efYAdc2wA2tUFY-cpLj4k,6787
55
55
  praetorian_cli/sdk/model/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
56
56
  praetorian_cli/sdk/model/aegis.py,sha256=KNeynkCJtaR5bIhv3-VNirjxq4i-6JhCZGTb5rmxkQ4,5344
57
- praetorian_cli/sdk/model/globals.py,sha256=DxzOyYrVnu9FyvN_L69-1tAmLrFYSQ-nu1xiF8Mmg1I,2961
57
+ praetorian_cli/sdk/model/globals.py,sha256=8nQjs4KD3BR0CWAyE4YAgSCwse4Mtj4wADIGx1avf8M,2990
58
58
  praetorian_cli/sdk/model/query.py,sha256=lqYC_RJRBCSQRPmElmBH6VXcXS8Z8AriLpG7DYUkFhI,6523
59
59
  praetorian_cli/sdk/model/utils.py,sha256=G8cU7K2uG0-J0GanjcCrzDuUtKzDOucFWF6hGtfOsTM,781
60
60
  praetorian_cli/sdk/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -65,6 +65,7 @@ praetorian_cli/sdk/test/test_asset.py,sha256=jcnSJxgnLhRgJ_cjkrlLQTlAYg3wruMsEIn
65
65
  praetorian_cli/sdk/test/test_attribute.py,sha256=AwD_w488rKhc1mxTBwSJcItmhcIYHJHX5DjgQYE3uPo,1169
66
66
  praetorian_cli/sdk/test/test_capabilities.py,sha256=HoAu_WFjqVkwciPgrLp5z0-UbkuqKdqD46jb_8o1PbM,407
67
67
  praetorian_cli/sdk/test/test_configuration.py,sha256=ysyWpt7iq_tNkdvLU8gULCuwbXVqt2HMXgZpWLnWccg,2174
68
+ praetorian_cli/sdk/test/test_conversation.py,sha256=QsLQAdYMcg3xIET8RBgD81D4CqqS0D9cp7-sEuqApR0,6933
68
69
  praetorian_cli/sdk/test/test_definition.py,sha256=8ShZFXJYHJUPH5rfmF3LYk9NE8W4lJBNHE2DhyJgXaY,1016
69
70
  praetorian_cli/sdk/test/test_extend.py,sha256=bHTCwtW0jN1GvFocB_uMJcEj4_IXvCkr35yMWKESbTU,1778
70
71
  praetorian_cli/sdk/test/test_file.py,sha256=rRikM2ceMy5TEX7YOFMPB2dnCRqROE9w8qoqVeCs9HM,2133
@@ -78,7 +79,7 @@ praetorian_cli/sdk/test/test_seed.py,sha256=WfnEPZwMXHFtt4jyVT-1JitIW1zTrl7rwlX8
78
79
  praetorian_cli/sdk/test/test_setting.py,sha256=hdPQj71rjSYxa-PODG2D-kJd8C9gkAg1jQXnqYU4P6A,1326
79
80
  praetorian_cli/sdk/test/test_webhook.py,sha256=FQJY76QQ6Yg2iLCGpxgKiXGI8TtmB4zTpMIM2SpYKCc,2228
80
81
  praetorian_cli/sdk/test/test_webpage.py,sha256=jgKrsobD3ONibDIbbOT-yy7V_NmC5-LwEZmEYdYu0LI,1779
81
- praetorian_cli/sdk/test/test_z_cli.py,sha256=2_cUOExQJOuTVJUdzBmZsQz9tgf6D8M_o2XEIW5-HQY,19234
82
+ praetorian_cli/sdk/test/test_z_cli.py,sha256=z2gcA9w7Qw6assGMLOrFG3jKmbGWSueSQL0g5zYMvQE,20670
82
83
  praetorian_cli/sdk/test/ui_mocks.py,sha256=kiqAPxaM-_T0NQ-HgOZupNiUoJa5mE2CsyK2cXWiPws,4146
83
84
  praetorian_cli/sdk/test/utils.py,sha256=svxMpzlaW4FRCij05cPgJFrTUEELVdt8G7SPKEdsgPo,3526
84
85
  praetorian_cli/ui/__init__.py,sha256=wEgkrgIaoOguH1VVp2FndaGIxWmZ5CfAynXtNtZ6iTo,81
@@ -93,9 +94,11 @@ praetorian_cli/ui/aegis/commands/job.py,sha256=BUcp47K-4PBIYq9nyUQTKhOakZwXM4fOf
93
94
  praetorian_cli/ui/aegis/commands/list.py,sha256=puIiy0skYE59Q2hVSMsla1tKiYYAFTOaz8oPM17tCyI,352
94
95
  praetorian_cli/ui/aegis/commands/set.py,sha256=ODa9u_6yW2dbKVQBuV9YeiS_ty_R_Xk_vPz7YrW3OWs,1101
95
96
  praetorian_cli/ui/aegis/commands/ssh.py,sha256=KGsNlN0i-Cwp6gWyr-cjML9_L13oE7xFenysF2pC8Rc,3045
96
- praetorian_cli-2.2.4.dist-info/licenses/LICENSE,sha256=Zv97QripiVALv-WokW_Elsiz9vtOfbtNt1aLZhhk67I,1067
97
- praetorian_cli-2.2.4.dist-info/METADATA,sha256=Zfbe0aPsveflR-8qlRyKbgL6kC0_vx9RjQY1qZQmrtE,7751
98
- praetorian_cli-2.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
99
- praetorian_cli-2.2.4.dist-info/entry_points.txt,sha256=uJbDvZdkYaLiCh2DMvXPUGKFm2p5ZfzJCizUK3-PUEE,56
100
- praetorian_cli-2.2.4.dist-info/top_level.txt,sha256=QbUdRPGEj_TyHO-E7AD5BxFfR8ore37i273jP4Gn43c,15
101
- praetorian_cli-2.2.4.dist-info/RECORD,,
97
+ praetorian_cli/ui/conversation/__init__.py,sha256=sNhNN_ZG1Va_7OLTaoXlIFL6ageKHWdufFVYw6F_aV8,90
98
+ praetorian_cli/ui/conversation/textual_chat.py,sha256=bvTaBm_ZCPI4Z1HDn3e-GfH2z4JVLcuFh3SX0BHtcGE,24403
99
+ praetorian_cli-2.2.5.dist-info/licenses/LICENSE,sha256=Zv97QripiVALv-WokW_Elsiz9vtOfbtNt1aLZhhk67I,1067
100
+ praetorian_cli-2.2.5.dist-info/METADATA,sha256=QmLUy78-ijFSJl2oCskS-ow3qyrBwTSxu1RdlmpE0Mo,7751
101
+ praetorian_cli-2.2.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
102
+ praetorian_cli-2.2.5.dist-info/entry_points.txt,sha256=uJbDvZdkYaLiCh2DMvXPUGKFm2p5ZfzJCizUK3-PUEE,56
103
+ praetorian_cli-2.2.5.dist-info/top_level.txt,sha256=QbUdRPGEj_TyHO-E7AD5BxFfR8ore37i273jP4Gn43c,15
104
+ praetorian_cli-2.2.5.dist-info/RECORD,,