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.
- praetorian_cli/handlers/add.py +20 -27
- praetorian_cli/handlers/agent.py +29 -0
- praetorian_cli/handlers/get.py +4 -2
- praetorian_cli/handlers/utils.py +59 -0
- praetorian_cli/sdk/entities/configurations.py +1 -1
- praetorian_cli/sdk/entities/definitions.py +11 -2
- praetorian_cli/sdk/entities/files.py +10 -5
- praetorian_cli/sdk/entities/search.py +6 -4
- praetorian_cli/sdk/model/globals.py +1 -0
- praetorian_cli/sdk/test/test_conversation.py +195 -0
- praetorian_cli/sdk/test/test_z_cli.py +24 -0
- praetorian_cli/ui/conversation/__init__.py +3 -0
- praetorian_cli/ui/conversation/textual_chat.py +622 -0
- {praetorian_cli-2.2.4.dist-info → praetorian_cli-2.2.5.dist-info}/METADATA +1 -1
- {praetorian_cli-2.2.4.dist-info → praetorian_cli-2.2.5.dist-info}/RECORD +19 -16
- {praetorian_cli-2.2.4.dist-info → praetorian_cli-2.2.5.dist-info}/WHEEL +0 -0
- {praetorian_cli-2.2.4.dist-info → praetorian_cli-2.2.5.dist-info}/entry_points.txt +0 -0
- {praetorian_cli-2.2.4.dist-info → praetorian_cli-2.2.5.dist-info}/licenses/LICENSE +0 -0
- {praetorian_cli-2.2.4.dist-info → praetorian_cli-2.2.5.dist-info}/top_level.txt +0 -0
praetorian_cli/handlers/add.py
CHANGED
|
@@ -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=
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
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()
|
praetorian_cli/handlers/agent.py
CHANGED
|
@@ -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)
|
praetorian_cli/handlers/get.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
praetorian_cli/handlers/utils.py
CHANGED
|
@@ -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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
|
@@ -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,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,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=
|
|
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=
|
|
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
|
|
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=
|
|
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=
|
|
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=
|
|
41
|
-
praetorian_cli/sdk/entities/files.py,sha256=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
97
|
-
praetorian_cli
|
|
98
|
-
praetorian_cli-2.2.
|
|
99
|
-
praetorian_cli-2.2.
|
|
100
|
-
praetorian_cli-2.2.
|
|
101
|
-
praetorian_cli-2.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|