praetorian-cli 2.2.3__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.
Files changed (30) hide show
  1. praetorian_cli/handlers/add.py +42 -27
  2. praetorian_cli/handlers/agent.py +29 -0
  3. praetorian_cli/handlers/cli_decorators.py +1 -1
  4. praetorian_cli/handlers/delete.py +12 -0
  5. praetorian_cli/handlers/get.py +21 -2
  6. praetorian_cli/handlers/link.py +29 -1
  7. praetorian_cli/handlers/list.py +22 -0
  8. praetorian_cli/handlers/unlink.py +29 -1
  9. praetorian_cli/handlers/utils.py +59 -0
  10. praetorian_cli/sdk/chariot.py +2 -0
  11. praetorian_cli/sdk/entities/configurations.py +1 -1
  12. praetorian_cli/sdk/entities/definitions.py +11 -2
  13. praetorian_cli/sdk/entities/files.py +10 -5
  14. praetorian_cli/sdk/entities/search.py +6 -4
  15. praetorian_cli/sdk/entities/webpage.py +180 -0
  16. praetorian_cli/sdk/model/globals.py +3 -0
  17. praetorian_cli/sdk/model/query.py +7 -0
  18. praetorian_cli/sdk/test/test_asset.py +36 -0
  19. praetorian_cli/sdk/test/test_conversation.py +195 -0
  20. praetorian_cli/sdk/test/test_webpage.py +46 -0
  21. praetorian_cli/sdk/test/test_z_cli.py +55 -0
  22. praetorian_cli/sdk/test/utils.py +5 -0
  23. praetorian_cli/ui/conversation/__init__.py +3 -0
  24. praetorian_cli/ui/conversation/textual_chat.py +622 -0
  25. {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/METADATA +1 -1
  26. {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/RECORD +30 -25
  27. {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/WHEEL +0 -0
  28. {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/entry_points.txt +0 -0
  29. {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/licenses/LICENSE +0 -0
  30. {praetorian_cli-2.2.3.dist-info → praetorian_cli-2.2.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,180 @@
1
+ from praetorian_cli.sdk.model.query import Query, Node, Filter, Relationship
2
+
3
+
4
+ class Webpage:
5
+ """The methods in this class are to be accessed from sdk.webpage, where sdk
6
+ is an instance of Chariot."""
7
+
8
+ def __init__(self, api):
9
+ self.api = api
10
+
11
+ def add(self, url, parent_key=None):
12
+ """
13
+ Add a Webpage to the Chariot database.
14
+
15
+ WebPages represent individual pages or endpoints that can be optionally
16
+ associated with a parent WebApplication. The backend uses a WebpageRequest
17
+ structure with embedded webpage data and optional parent key.
18
+
19
+ :param url: The full URL of the page
20
+ :type url: str
21
+ :param parent_key: Optional key of the parent WebApplication
22
+ :type webapp_key: str or None
23
+ :return: The created WebPage object
24
+ :rtype: dict
25
+ :raises Exception: If the URL is invalid or the request fails
26
+
27
+ **Example Usage:**
28
+ >>> # Add a simple page without parent
29
+ >>> page = sdk.webpage.add("https://app.example.com/login")
30
+
31
+ >>> # Add a page with parent WebApplication
32
+ >>> page = sdk.webpage.add(
33
+ ... url="https://app.example.com/admin",
34
+ ... parent_key="#webapplication#https://app.example.com")
35
+
36
+ **WebPage Object Structure:**
37
+ The returned Webpage object contains:
38
+ - key: Webpage identifier in format #webpage#{url}
39
+ - url: Full URL of the page
40
+ - status: Current status
41
+ - parent: Parent WebApplication relationship (if applicable)
42
+ - created: Creation timestamp
43
+ """
44
+ if not url:
45
+ raise Exception("URL is required for Webpage")
46
+
47
+ if parent_key and not parent_key.startswith('#webapplication#'):
48
+ raise Exception("Invalid WebApplication key format")
49
+
50
+ payload = {
51
+ 'webpage': {
52
+ 'url': url,
53
+ 'status': 'A' # Active status
54
+ }
55
+
56
+ }
57
+
58
+ if parent_key:
59
+ payload['parent_key'] = parent_key
60
+
61
+ return self.api.post('webpage', payload)
62
+
63
+ def get(self, key):
64
+ """
65
+ Get details of a specific Webpage by its key.
66
+
67
+ :param key: The WebPage key identifier
68
+ :type key: str
69
+ :return: Webpage object with detailed information, or None if not found
70
+ :rtype: dict or None
71
+
72
+ **Example Usage:**
73
+ >>> # Get a specific Webpage
74
+ >>> page = sdk.webpage.get("webpage_key_123")
75
+
76
+ **Webpage Object Structure:**
77
+ The returned Webpage object contains:
78
+ - key: Webpage identifier
79
+ - url: Full URL of the page
80
+ - created: Creation timestamp
81
+ - updated: Last update timestamp
82
+ """
83
+ query = Query(node=Node(labels=[Node.Label.WEBPAGE], filters=[Filter(field=Filter.Field.KEY, operator=Filter.Operator.EQUAL, value=key)]))
84
+ return self.api.search.by_query(query)[0][0]
85
+
86
+ def list(self, parent_key=None, filter=None, offset=0, pages=100000) -> tuple:
87
+ """
88
+ List Webpages, optionally filtered by parent WebApplication.
89
+
90
+ Retrieve Webpage entities with optional filtering capabilities. Can filter by
91
+ parent WebApplication.
92
+
93
+ :param parent_key: Filter pages by specific WebApplication (optional)
94
+ :type parent_key: str or None
95
+ :param filter: Filter pages by specific URL (optional)
96
+ :type filter: str or None
97
+ :param offset: The offset for pagination to retrieve a specific page of results
98
+ :type offset: str or None
99
+ :param pages: Maximum number of pages to retrieve (default: 100000 for all results)
100
+ :type pages: int
101
+ :return: A tuple containing (list of matching Webpages, next page offset)
102
+ :rtype: tuple
103
+
104
+ **Example Usage:**
105
+ >>> # List all WebPages
106
+ >>> pages, offset = sdk.webpage.list()
107
+
108
+ >>> # List pages for specific WebApplication
109
+ >>> pages, offset = sdk.webpage.list(
110
+ ... webapp_key="#asset#webapp#https://app.example.com#https://app.example.com")
111
+
112
+ **WebPage Filtering:**
113
+ - parent_key: Filters by parent WebApplication
114
+ - filter: Filters by specific URL
115
+ """
116
+ if parent_key and not parent_key.startswith('#webapplication#'):
117
+ raise Exception("Invalid WebApplication key format")
118
+
119
+ relationships = []
120
+ filters = []
121
+ if parent_key:
122
+ parentFilter = Filter(field=Filter.Field.KEY, operator=Filter.Operator.EQUAL, value=parent_key)
123
+ relationship = Relationship(label=Relationship.Label.HAS_WEBPAGE, target=Node(labels=[Node.Label.WEBAPPLICATION], filters=[parentFilter]))
124
+ relationships.append(relationship)
125
+ if filter:
126
+ urlFilter = Filter(field=Filter.Field.KEY, operator=Filter.Operator.CONTAINS, value=filter)
127
+ filters.append(urlFilter)
128
+ node = Node(labels=[Node.Label.WEBPAGE], filters=filters, relationships=relationships)
129
+ query = Query(node=node, page=offset, limit=pages)
130
+ return self.api.search.by_query(query, pages)
131
+
132
+ def delete(self, key):
133
+ """
134
+ Delete a webpage by its key.
135
+
136
+ :param key: The WebPage key identifier
137
+ :type key: str
138
+ """
139
+ body = {
140
+ 'webpage': {
141
+ 'key': key
142
+ }
143
+ }
144
+ self.api.delete('webpage', params={}, body=body)
145
+
146
+ def link_source(self, webpage_key, entity_key):
147
+ """
148
+ Link a file or repository to a webpage as source code.
149
+
150
+ :param webpage_key: The webpage key in format #webpage#{url}
151
+ :type webpage_key: str
152
+ :param entity_key: The entity key (file or repository) to link. Format: #file#{path} or #repository#{url}#{name}
153
+ :type entity_key: str
154
+ :return: The updated webpage with linked artifacts
155
+ :rtype: dict
156
+ """
157
+ data = {
158
+ 'webpageKey': webpage_key,
159
+ 'entityKey': entity_key
160
+ }
161
+
162
+ return self.api.put('webpage/link', data, {})
163
+
164
+ def unlink_source(self, webpage_key, entity_key):
165
+ """
166
+ Unlink a file or repository from a webpage's source code.
167
+
168
+ :param webpage_key: The webpage key in format #webpage#{url}
169
+ :type webpage_key: str
170
+ :param entity_key: The entity key (file or repository) to unlink. Format: #file#{path} or #repository#{url}#{name}
171
+ :type entity_key: str
172
+ :return: The updated webpage with artifacts removed
173
+ :rtype: dict
174
+ """
175
+ data = {
176
+ 'webpageKey': webpage_key,
177
+ 'entityKey': entity_key
178
+ }
179
+
180
+ return self.api.delete('webpage/link', data, {})
@@ -108,7 +108,10 @@ class Kind(Enum):
108
108
  SEED = 'seed'
109
109
  PRESEED = 'preseed'
110
110
  OTHERS = 'others'
111
+ WEBAPPLICATION = 'webapplication'
112
+ WEBPAGE = 'webpage'
111
113
 
112
114
  EXACT_FLAG = {'exact': 'true'}
113
115
  DESCENDING_FLAG = {'desc': 'true'}
114
116
  GLOBAL_FLAG = {'global': 'true'}
117
+ USER_FLAG = {'user': 'true'}
@@ -49,6 +49,8 @@ class Filter:
49
49
  KEV = 'kev'
50
50
  EXPLOIT = 'exploit'
51
51
  PRIVATE = 'private'
52
+ PRIMARY_URL = 'primary_url'
53
+ URL = 'url'
52
54
 
53
55
  def __init__(self, field: Field, operator: Operator, value: str, not_: bool = False):
54
56
  self.field = field
@@ -65,6 +67,7 @@ class Relationship:
65
67
  HAS_VULNERABILITY = 'HAS_VULNERABILITY'
66
68
  DISCOVERED = 'DISCOVERED'
67
69
  HAS_ATTRIBUTE = 'HAS_ATTRIBUTE'
70
+ HAS_WEBPAGE = 'HAS_WEBPAGE'
68
71
 
69
72
  def __init__(self, label: Label, source: 'Node' = None, target: 'Node' = None, optional: bool = False, length: int = 0):
70
73
  self.label = label
@@ -97,6 +100,8 @@ class Node:
97
100
  PRESEED = 'Preseed'
98
101
  SEED = 'Seed'
99
102
  TTL = 'TTL'
103
+ WEBAPPLICATION = 'WebApplication'
104
+ WEBPAGE = 'Webpage'
100
105
 
101
106
  def __init__(self, labels: list[Label] = None, filters: list[Filter] = None,
102
107
  relationships: list[Relationship] = None):
@@ -157,6 +162,8 @@ KIND_TO_LABEL = {
157
162
  Kind.REPOSITORY.value: Node.Label.REPOSITORY,
158
163
  Kind.INTEGRATION.value: Node.Label.INTEGRATION,
159
164
  Kind.ADDOMAIN.value: Node.Label.ADDOMAIN,
165
+ Kind.WEBAPPLICATION.value: Node.Label.WEBAPPLICATION,
166
+ Kind.WEBPAGE.value: Node.Label.WEBPAGE,
160
167
  }
161
168
 
162
169
 
@@ -71,11 +71,47 @@ class TestAsset:
71
71
  deleted_assets, _ = self.sdk.search.by_status(Asset.DELETED.value, Kind.ADDOMAIN.value)
72
72
  assert any([a['key'] == self.ad_domain_key for a in deleted_assets])
73
73
 
74
+ def test_add_webapplication(self):
75
+ asset = self.sdk.assets.add(self.webapp_name, self.webapp_url, status=Asset.ACTIVE.value, surface='test-surface', type=Kind.WEBAPPLICATION.value)
76
+ assert asset['key'] == self.webapp_key
77
+ assert len(asset['attackSurface']) == 1
78
+ assert 'test-surface' in asset['attackSurface']
79
+ assert asset['status'] == Asset.ACTIVE.value
80
+
81
+ def test_get_webapplication(self):
82
+ asset = self.sdk.assets.get(self.webapp_key)
83
+ assert asset['key'] == self.webapp_key
84
+ assert asset['group'] == self.webapp_name
85
+ assert asset['identifier'] == self.webapp_url
86
+ assert asset['status'] == Asset.ACTIVE.value
87
+
88
+ def test_list_webapplication(self):
89
+ results, _ = self.sdk.assets.list(asset_type=Kind.WEBAPPLICATION.value)
90
+ assert len(results) > 0
91
+ assert any([a['key'] == self.webapp_key for a in results])
92
+ assert any([a['group'] == self.webapp_name for a in results])
93
+ assert any([a['identifier'] == self.webapp_url for a in results])
94
+
95
+ def test_update_webapplication(self):
96
+ self.sdk.assets.update(self.webapp_key, status=Asset.FROZEN.value, surface='abc')
97
+ asset = self.get_webapplication()
98
+ assert asset['status'] == Asset.FROZEN.value
99
+ assert 'abc' in asset['attackSurface']
100
+
101
+ def test_delete_webapplication(self):
102
+ self.sdk.assets.delete(self.webapp_key)
103
+ assert self.get_webapplication()['status'] == Asset.DELETED.value
104
+ deleted_assets, _ = self.sdk.search.by_status(Asset.DELETED.value, Kind.WEBAPPLICATION.value)
105
+ assert any([a['key'] == self.webapp_key for a in deleted_assets])
106
+
74
107
  def get_asset(self):
75
108
  return self.sdk.assets.get(self.asset_key)
76
109
 
77
110
  def get_ad_domain(self):
78
111
  return self.sdk.assets.get(self.ad_domain_key)
79
112
 
113
+ def get_webapplication(self):
114
+ return self.sdk.assets.get(self.webapp_key)
115
+
80
116
  def teardown_class(self):
81
117
  clean_test_entities(self.sdk, self)
@@ -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
@@ -0,0 +1,46 @@
1
+ import pytest
2
+
3
+ from praetorian_cli.sdk.test.utils import make_test_values, setup_chariot
4
+
5
+
6
+ @pytest.mark.coherence
7
+ class TestWebpage:
8
+ """Test suite for the Webpage entity class."""
9
+
10
+ def setup_class(self):
11
+ self.sdk = setup_chariot()
12
+ make_test_values(self)
13
+
14
+ def test_add_webpage(self):
15
+ """Test adding a Webpage with URL provided."""
16
+ result = self.sdk.webpage.add(self.webpage_url)
17
+
18
+ assert result is not None
19
+ webpage = result.get('webpages')[0]
20
+ assert webpage.get('key') == self.webpage_key
21
+ assert webpage.get('url') == self.webpage_url
22
+
23
+ def test_get_webpage(self):
24
+ """Test retrieving a Webpage by key."""
25
+ result = self.sdk.webpage.get(self.webpage_key)
26
+ assert result is not None
27
+ assert result.get('key') == self.webpage_key
28
+ assert result.get('url') == self.webpage_url
29
+
30
+ def test_list_webpages(self):
31
+ """Test listing Webpages."""
32
+ results, offset = self.sdk.webpage.list(filter=self.webpage_url[:len(self.webpage_url)//2])
33
+ assert isinstance(results, list)
34
+ assert len(results) > 0
35
+ assert any(r.get('key') == self.webpage_key for r in results)
36
+ assert any(r.get('url') == self.webpage_url for r in results)
37
+
38
+ def test_add_webpage_empty_url_raises_exception(self):
39
+ """Test that adding a Webpage with empty URL raises an exception."""
40
+ with pytest.raises(Exception, match="URL is required for Webpage"):
41
+ self.sdk.webpage.add("")
42
+
43
+ def test_add_webpage_none_url_raises_exception(self):
44
+ """Test that adding a Webpage with None URL raises an exception."""
45
+ with pytest.raises(Exception, match="URL is required for Webpage"):
46
+ self.sdk.webpage.add(None)
@@ -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
 
@@ -210,6 +211,21 @@ class TestZCli:
210
211
  self.verify(f'list accounts -f {o.email}', [o.email])
211
212
  self.verify(f'unlink account {o.email}')
212
213
  self.verify(f'list accounts -f {o.email}')
214
+
215
+ def test_webpage_source_cli(self):
216
+ o = make_test_values(lambda: None)
217
+
218
+ self.verify(f'add webpage --url "{o.webpage_url}"')
219
+
220
+ file_key = f'"#file#test-nonexistent-{epoch_micro()}-2.txt"'
221
+
222
+ self.verify(f'link webpage-source "{o.webpage_key}" {file_key}',
223
+ expected_stderr=['not found'])
224
+ self.verify(f'unlink webpage-source "{o.webpage_key}" {file_key}',
225
+ expected_stdout=[o.webpage_key])
226
+
227
+ # Clean up
228
+ self.verify(f'delete webpage "{o.webpage_key}"', ignore_stdout=True)
213
229
 
214
230
  def test_integration_cli(self):
215
231
  self.verify('list integrations', ignore_stdout=True)
@@ -258,6 +274,38 @@ class TestZCli:
258
274
 
259
275
  self.verify(f'delete configuration "{o.configuration_key}"', ignore_stdout=True)
260
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
+
295
+ def test_webapplication_cli(self):
296
+ o = make_test_values(lambda: None)
297
+ self.verify(f'add asset --dns "{o.webapp_name}" --name "{o.webapp_url}" --type webapplication')
298
+ self.verify(f'get asset "{o.webapp_key}"', expected_stdout=[o.webapp_key, o.webapp_url, o.webapp_name, '"status"', '"A"'])
299
+ self.verify(f'list assets -f "{o.webapp_name}"', expected_stdout=[o.webapp_key])
300
+ self.verify(f'delete asset "{o.webapp_key}"', ignore_stdout=True)
301
+
302
+ def test_webpage_cli(self):
303
+ o = make_test_values(lambda: None)
304
+ self.verify(f'add webpage --url "{o.webpage_url}"')
305
+ self.verify(f'get webpage "{o.webpage_key}"', expected_stdout=[o.webpage_key, o.webpage_url, '"status"', '"A"'])
306
+ self.verify(f'list webpages -p all -f "{o.webpage_url[:len(o.webpage_url)//2]}"', expected_stdout=[o.webpage_key])
307
+ self.verify(f'delete webpage "{o.webpage_key}"', ignore_stdout=True)
308
+
261
309
  def test_help_cli(self):
262
310
  self.verify('--help', ignore_stdout=True)
263
311
  self.verify('list --help', ignore_stdout=True)
@@ -310,9 +358,16 @@ class TestZCli:
310
358
 
311
359
  self.verify('link --help', ignore_stdout=True)
312
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)
366
+ self.verify('link webpage-source --help', ignore_stdout=True)
313
367
 
314
368
  self.verify('unlink --help', ignore_stdout=True)
315
369
  self.verify('unlink account --help', ignore_stdout=True)
370
+ self.verify('unlink webpage-source --help', ignore_stdout=True)
316
371
 
317
372
  self.verify('delete --help', ignore_stdout=True)
318
373
  self.verify('delete asset --help', ignore_stdout=True)
@@ -66,6 +66,11 @@ def make_test_values(o):
66
66
  o.configuration_value = {o.configuration_name: o.configuration_name}
67
67
  o.configuration_key = configuration_key(o.configuration_name)
68
68
  o.key_name = f'test-key-name-{epoch_micro()}'
69
+ o.webapp_name = f'test-webapp-name-{epoch_micro()}'
70
+ o.webapp_url = f'https://test-webapp-{epoch_micro()}.com/'
71
+ o.webapp_key = f'#webapplication#{o.webapp_url}'
72
+ o.webpage_url = f'https://test-webpage-{epoch_micro()}.com/index.html'
73
+ o.webpage_key = f'#webpage#{o.webpage_url}'
69
74
  return o
70
75
 
71
76
 
@@ -0,0 +1,3 @@
1
+ from .textual_chat import run_textual_conversation
2
+
3
+ __all__ = ['run_textual_conversation']