prelude-cli-beta 1396__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.

Potentially problematic release.


This version of prelude-cli-beta might be problematic. Click here for more details.

@@ -0,0 +1,471 @@
1
+ import click
2
+ import requests
3
+ from time import sleep
4
+
5
+ from prelude_cli.views.shared import Spinner, pretty_print
6
+ from prelude_sdk.controllers.export_controller import ExportController
7
+ from prelude_sdk.controllers.jobs_controller import JobsController
8
+ from prelude_sdk.controllers.scm_controller import ScmController
9
+ from prelude_sdk.models.codes import (
10
+ Control,
11
+ ControlCategory,
12
+ PartnerEvents,
13
+ RunCode,
14
+ SCMCategory,
15
+ )
16
+
17
+
18
+ @click.group()
19
+ @click.pass_context
20
+ def scm(ctx):
21
+ """SCM system commands"""
22
+ ctx.obj = ScmController(account=ctx.obj)
23
+
24
+
25
+ @scm.command("endpoints")
26
+ @click.option(
27
+ "--limit", default=100, help="maximum number of results to return", type=int
28
+ )
29
+ @click.option("--odata_filter", help="OData filter string", default=None)
30
+ @click.option("--odata_orderby", help="OData orderby string", default=None)
31
+ @click.pass_obj
32
+ @pretty_print
33
+ def endpoints(controller, limit, odata_filter, odata_orderby):
34
+ """List endpoints with SCM data"""
35
+ with Spinner(description="Fetching endpoints from partner"):
36
+ return controller.endpoints(
37
+ filter=odata_filter, orderby=odata_orderby, top=limit
38
+ )
39
+
40
+
41
+ @scm.command("inboxes")
42
+ @click.option(
43
+ "--limit", default=100, help="maximum number of results to return", type=int
44
+ )
45
+ @click.option("--odata_filter", help="OData filter string", default=None)
46
+ @click.option("--odata_orderby", help="OData orderby string", default=None)
47
+ @click.pass_obj
48
+ @pretty_print
49
+ def endpoints(controller, limit, odata_filter, odata_orderby):
50
+ """List inboxes with SCM data"""
51
+ with Spinner(description="Fetching inboxes from partner"):
52
+ return controller.inboxes(filter=odata_filter, orderby=odata_orderby, top=limit)
53
+
54
+
55
+ @scm.command("users")
56
+ @click.option(
57
+ "--limit", default=100, help="maximum number of results to return", type=int
58
+ )
59
+ @click.option("--odata_filter", help="OData filter string", default=None)
60
+ @click.option("--odata_orderby", help="OData orderby string", default=None)
61
+ @click.pass_obj
62
+ @pretty_print
63
+ def endpoints(controller, limit, odata_filter, odata_orderby):
64
+ """List users with SCM data"""
65
+ with Spinner(description="Fetching users from partner"):
66
+ return controller.users(filter=odata_filter, orderby=odata_orderby, top=limit)
67
+
68
+
69
+ @scm.command("technique-summary")
70
+ @click.option(
71
+ "-q",
72
+ "--techniques",
73
+ help="comma-separated list of techniques to filter by",
74
+ type=str,
75
+ required=True,
76
+ )
77
+ @click.pass_obj
78
+ @pretty_print
79
+ def technique_summary(controller, techniques):
80
+ """Get policy summary per technique"""
81
+ with Spinner(description="Getting policy summary by technique"):
82
+ return controller.technique_summary(techniques=techniques)
83
+
84
+
85
+ @scm.command("evaluation-summary")
86
+ @click.option(
87
+ "--endpoint_odata_filter", help="OData filter string for endpoints", default=None
88
+ )
89
+ @click.option(
90
+ "--inbox_odata_filter", help="OData filter string for inboxes", default=None
91
+ )
92
+ @click.option("--user_odata_filter", help="OData filter string for users", default=None)
93
+ @click.option(
94
+ "-q",
95
+ "--techniques",
96
+ help="comma-separated list of techniques to filter by",
97
+ type=str,
98
+ default=None,
99
+ )
100
+ @click.pass_obj
101
+ @pretty_print
102
+ def evaluation_summary(
103
+ controller, endpoint_odata_filter, inbox_odata_filter, user_odata_filter, techniques
104
+ ):
105
+ """Get policy evaluation summary for all partners"""
106
+ with Spinner(description="Getting policy evaluation summary"):
107
+ return controller.evaluation_summary(
108
+ endpoint_filter=endpoint_odata_filter,
109
+ inbox_filter=inbox_odata_filter,
110
+ user_filter=user_odata_filter,
111
+ techniques=techniques,
112
+ )
113
+
114
+
115
+ @scm.command("evaluation")
116
+ @click.argument(
117
+ "partner",
118
+ type=click.Choice(
119
+ [c.name for c in Control if c != Control.INVALID], case_sensitive=False
120
+ ),
121
+ )
122
+ @click.option("--instance_id", required=True, help="instance ID of the partner")
123
+ @click.option("--odata_filter", help="OData filter string", default=None)
124
+ @click.option(
125
+ "-q",
126
+ "--techniques",
127
+ help="comma-separated list of techniques to filter by",
128
+ type=str,
129
+ default=None,
130
+ )
131
+ @click.pass_obj
132
+ @pretty_print
133
+ def evaluation(controller, partner, instance_id, odata_filter, techniques):
134
+ """Get policy evaluation for given partner"""
135
+ with Spinner(description="Getting policy evaluation"):
136
+ return controller.evaluation(
137
+ partner=Control[partner],
138
+ instance_id=instance_id,
139
+ filter=odata_filter,
140
+ techniques=techniques,
141
+ )
142
+
143
+
144
+ @scm.command("sync")
145
+ @click.argument(
146
+ "partner",
147
+ type=click.Choice(
148
+ [c.name for c in Control if c != Control.INVALID], case_sensitive=False
149
+ ),
150
+ required=True,
151
+ )
152
+ @click.option("--instance_id", required=True, help="instance ID of the partner")
153
+ @click.pass_obj
154
+ @pretty_print
155
+ def sync(controller, partner, instance_id):
156
+ """Update policy evaluation for given partner"""
157
+ with Spinner(description="Updating policy evaluation"):
158
+ job_id = controller.update_evaluation(
159
+ partner=Control[partner], instance_id=instance_id
160
+ )["job_id"]
161
+ jobs = JobsController(account=controller.account)
162
+ while (result := jobs.job_status(job_id))["end_time"] is None:
163
+ sleep(3)
164
+ return result
165
+
166
+
167
+ @scm.command("export")
168
+ @click.argument(
169
+ "type",
170
+ type=click.Choice(
171
+ [c.name for c in SCMCategory if c.value > 0], case_sensitive=False
172
+ ),
173
+ )
174
+ @click.option(
175
+ "-o",
176
+ "--output_file",
177
+ help="csv filename to export to",
178
+ type=click.Path(writable=True),
179
+ required=True,
180
+ )
181
+ @click.option(
182
+ "--limit", default=None, help="maximum number of results to return", type=int
183
+ )
184
+ @click.option("--odata_filter", help="OData filter string", default=None)
185
+ @click.option("--odata_orderby", help="OData orderby string", default=None)
186
+ @click.pass_obj
187
+ @pretty_print
188
+ def export(controller, type, output_file, limit, odata_filter, odata_orderby):
189
+ """Export SCM data"""
190
+ with Spinner(description="Exporting SCM data"):
191
+ export = ExportController(account=controller.account)
192
+ jobs = JobsController(account=controller.account)
193
+ job_id = export.export_scm(
194
+ export_type=SCMCategory[type],
195
+ filter=odata_filter,
196
+ orderby=odata_orderby,
197
+ top=limit,
198
+ )["job_id"]
199
+ while (result := jobs.job_status(job_id))["end_time"] is None:
200
+ sleep(3)
201
+ if result["successful"]:
202
+ data = requests.get(result["results"]["url"], timeout=10).content
203
+ with open(output_file, "wb") as f:
204
+ f.write(data)
205
+ return result, f"Exported data to {output_file}"
206
+
207
+
208
+ @scm.command("groups")
209
+ @click.option("--odata_filter", help="OData filter string", default=None)
210
+ @click.option("--odata_orderby", help="OData orderby string", default=None)
211
+ @click.pass_obj
212
+ @pretty_print
213
+ def list_partner_groups(controller, odata_filter, odata_orderby):
214
+ """List all partner groups"""
215
+ with Spinner(description="Fetching partner groups"):
216
+ return controller.list_partner_groups(filter=odata_filter, orderby=odata_orderby)
217
+
218
+
219
+ @scm.command("sync-groups")
220
+ @click.argument(
221
+ "partner",
222
+ type=click.Choice(
223
+ [c.name for c in Control if c != Control.INVALID], case_sensitive=False
224
+ ),
225
+ required=True,
226
+ )
227
+ @click.option("--instance_id", required=True, help="instance ID of the partner")
228
+ @click.option("--group_ids", required=True, help="comma-separated list of group IDs")
229
+ @click.pass_obj
230
+ @pretty_print
231
+ def sync_groups(controller, partner, instance_id, group_ids):
232
+ """Update groups for a partner"""
233
+ with Spinner(description="Updating groups"):
234
+ job_id = controller.update_partner_groups(
235
+ partner=Control[partner],
236
+ instance_id=instance_id,
237
+ group_ids=group_ids.split(","),
238
+ )["job_id"]
239
+ jobs = JobsController(account=controller.account)
240
+ while (result := jobs.job_status(job_id))["end_time"] is None:
241
+ sleep(3)
242
+ return result
243
+
244
+
245
+ @scm.command("create-threat")
246
+ @click.argument("name")
247
+ @click.option(
248
+ "-d", "--description", help="description of the threat", default=None, type=str
249
+ )
250
+ @click.option("--id", help="uuid for threat", default=None, type=str)
251
+ @click.option(
252
+ "-g", "--generated", help="was the threat AI generated", default=False, type=bool
253
+ )
254
+ @click.option(
255
+ "-p", "--published", help="date the threat was published", default=None, type=str
256
+ )
257
+ @click.option(
258
+ "-s", "--source", help="source of threat (ex. www.cisa.gov)", default=None, type=str
259
+ )
260
+ @click.option(
261
+ "-i",
262
+ "--source_id",
263
+ help="ID of the threat, per the source (ex. aa23-075a)",
264
+ default=None,
265
+ type=str,
266
+ )
267
+ @click.option(
268
+ "-q",
269
+ "--techniques",
270
+ help="comma-separated list of techniques (MITRE ATT&CK IDs)",
271
+ default=None,
272
+ type=str,
273
+ )
274
+ @click.pass_obj
275
+ @pretty_print
276
+ def create_threat(
277
+ controller,
278
+ name,
279
+ description,
280
+ id,
281
+ generated,
282
+ published,
283
+ source,
284
+ source_id,
285
+ techniques,
286
+ ):
287
+ """Create an scm threat"""
288
+ with Spinner(description="Creating scm threat"):
289
+ return controller.create_threat(
290
+ name=name,
291
+ description=description,
292
+ id=id,
293
+ generated=generated,
294
+ published=published,
295
+ source=source,
296
+ source_id=source_id,
297
+ techniques=techniques,
298
+ )
299
+
300
+
301
+ @scm.command("delete-threat")
302
+ @click.argument("threat_id")
303
+ @click.confirmation_option(prompt="Are you sure?")
304
+ @click.pass_obj
305
+ @pretty_print
306
+ def delete_threat(controller, threat_id):
307
+ """Delete an scm threat"""
308
+ with Spinner(description="Removing scm threat"):
309
+ return controller.delete_threat(id=threat_id)
310
+
311
+
312
+ @scm.command("threats")
313
+ @click.pass_obj
314
+ @pretty_print
315
+ def list_threats(controller):
316
+ """List all scm threats"""
317
+ with Spinner(description="Fetching scm threats"):
318
+ return controller.list_threats()
319
+
320
+
321
+ @scm.command("threat")
322
+ @click.argument("threat_id")
323
+ @click.pass_obj
324
+ @pretty_print
325
+ def get_threat(controller, threat_id):
326
+ """Get specific scm threat"""
327
+ with Spinner(description="Fetching scm threat"):
328
+ return controller.get_threat(id=threat_id)
329
+
330
+
331
+ @scm.command("threat-intel")
332
+ @click.argument(
333
+ "threat_pdf",
334
+ type=click.Path(exists=True, file_okay=True, dir_okay=False, readable=True),
335
+ )
336
+ @click.pass_obj
337
+ @pretty_print
338
+ def parse_threat_intel(controller, threat_pdf):
339
+ with Spinner("Parsing PDF"):
340
+ return controller.parse_threat_intel(threat_pdf)
341
+
342
+
343
+ @scm.command("from-advisory")
344
+ @click.argument(
345
+ "partner", type=click.Choice([Control.CROWDSTRIKE.name], case_sensitive=False)
346
+ )
347
+ @click.option(
348
+ "-a", "--advisory_id", required=True, type=str, help="Partner advisory ID"
349
+ )
350
+ @click.pass_obj
351
+ @pretty_print
352
+ def parse_from_partner_advisory(controller, partner, advisory_id):
353
+ with Spinner("Uploading"):
354
+ return controller.parse_from_partner_advisory(
355
+ partner=Control[partner], advisory_id=advisory_id
356
+ )
357
+
358
+
359
+ @scm.command("list-notifications")
360
+ @click.pass_obj
361
+ @pretty_print
362
+ def list_notifications(controller):
363
+ with Spinner("Fetching notifications"):
364
+ return controller.list_notifications()
365
+
366
+
367
+ @scm.command("delete-notification")
368
+ @click.argument("notification_id", type=str)
369
+ @click.confirmation_option(prompt="Are you sure?")
370
+ @click.pass_obj
371
+ @pretty_print
372
+ def delete_notification(controller, notification_id):
373
+ with Spinner("Deleting notification"):
374
+ return controller.delete_notification(notification_id)
375
+
376
+
377
+ @scm.command("upsert-notification")
378
+ @click.argument(
379
+ "control_category",
380
+ type=click.Choice([c.name for c in ControlCategory], case_sensitive=False),
381
+ )
382
+ @click.option(
383
+ "-e",
384
+ "--emails",
385
+ help="comma-separated list of emails to notify",
386
+ default=None,
387
+ type=str,
388
+ )
389
+ @click.option(
390
+ "-v",
391
+ "--event",
392
+ help="event to trigger notification for",
393
+ type=click.Choice([e.name for e in PartnerEvents], case_sensitive=False),
394
+ required=True,
395
+ )
396
+ @click.option("-f", "--filter", help="OData filter string", default=None, type=str)
397
+ @click.option(
398
+ "-i", "--id", help="ID of the notification to update", default=None, type=str
399
+ )
400
+ @click.option("-m", "--message", help="notification message", default="", type=str)
401
+ @click.option(
402
+ "-r",
403
+ "--run_code",
404
+ help="notification frequency",
405
+ type=click.Choice([r.name for r in RunCode], case_sensitive=False),
406
+ required=True,
407
+ )
408
+ @click.option(
409
+ "-s",
410
+ "--scheduled_hour",
411
+ help="scheduled UTC hour to receive notifications",
412
+ type=int,
413
+ required=True,
414
+ )
415
+ @click.option(
416
+ "-u",
417
+ "--slack_urls",
418
+ help="comma-separated list of Slack Webhook URLs to notify",
419
+ default=None,
420
+ type=str,
421
+ )
422
+ @click.option(
423
+ "-p",
424
+ "--suppress_empty",
425
+ help="suppress notifications with no results",
426
+ default=True,
427
+ type=bool,
428
+ )
429
+ @click.option(
430
+ "-u",
431
+ "--teams_urls",
432
+ help="comma-separated list of Teams Webhook URLs to notify",
433
+ default=None,
434
+ type=str,
435
+ )
436
+ @click.option(
437
+ "-t", "--title", help="notification title", default="SCM Notification", type=str
438
+ )
439
+ @click.pass_obj
440
+ @pretty_print
441
+ def upsert_notification(
442
+ controller,
443
+ control_category,
444
+ emails,
445
+ event,
446
+ filter,
447
+ id,
448
+ message,
449
+ run_code,
450
+ scheduled_hour,
451
+ slack_urls,
452
+ suppress_empty,
453
+ teams_urls,
454
+ title,
455
+ ):
456
+ """Upsert an SCM notification"""
457
+ with Spinner("Upserting notification"):
458
+ return controller.upsert_notification(
459
+ control_category=ControlCategory[control_category],
460
+ emails=emails.split(",") if emails else None,
461
+ event=PartnerEvents[event],
462
+ filter=filter,
463
+ id=id,
464
+ message=message,
465
+ run_code=RunCode[run_code],
466
+ scheduled_hour=scheduled_hour,
467
+ slack_urls=slack_urls.split(",") if slack_urls else None,
468
+ suppress_empty=suppress_empty,
469
+ teams_urls=teams_urls.split(",") if teams_urls else None,
470
+ title=title,
471
+ )
@@ -0,0 +1,37 @@
1
+ from functools import wraps
2
+ from rich import print_json
3
+ from rich.progress import Progress, TextColumn, SpinnerColumn
4
+
5
+
6
+ def pretty_print(func):
7
+ @wraps(func)
8
+ def handler(*args, **kwargs):
9
+ try:
10
+ res = func(*args, **kwargs)
11
+ msg = None
12
+ if isinstance(res, tuple):
13
+ res, msg = res
14
+ if not isinstance(res, list):
15
+ res = [res]
16
+ return print_json(data=dict(status="complete", results=res, message=msg))
17
+ except Exception as e:
18
+ return print_json(
19
+ data=dict(
20
+ status="error",
21
+ results=None,
22
+ message=" ".join(str(arg) for arg in e.args),
23
+ )
24
+ )
25
+
26
+ return handler
27
+
28
+
29
+ class Spinner(Progress):
30
+ def __init__(self, description="Loading"):
31
+ super().__init__(
32
+ SpinnerColumn(style="green", spinner_name="line"),
33
+ TextColumn("[green]{task.description}..."),
34
+ transient=True,
35
+ refresh_per_second=10,
36
+ )
37
+ self.add_task(description)
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: prelude-cli-beta
3
+ Version: 1396
4
+ Summary: For interacting with the Prelude SDK
5
+ Home-page: https://github.com/preludeorg
6
+ Author: Prelude Research
7
+ Author-email: support@preludesecurity.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: prelude-sdk==2.6.13a1396
15
+ Requires-Dist: click>8
16
+ Requires-Dist: rich
17
+ Requires-Dist: python-dateutil
18
+ Requires-Dist: pyyaml
19
+ Dynamic: license-file
20
+
21
+ # Prelude CLI
22
+
23
+ Interact with the full range of features in Prelude Detect, organized by:
24
+
25
+ - IAM: manage your account
26
+ - Build: write and maintain your collection of security tests
27
+ - Detect: schedule security tests for your endpoints
28
+
29
+ ## Quick start
30
+ ```bash
31
+ pip install prelude-cli
32
+ prelude --help
33
+ prelude --interactive
34
+ ```
35
+
36
+ ## Documentation
37
+
38
+ https://docs.preludesecurity.com/docs/prelude-cli
@@ -0,0 +1,22 @@
1
+ prelude_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ prelude_cli/cli.py,sha256=uxX3WkgjEu7gSSFVtcPA-sCPoaS-2L81OfKveM3ia2I,1239
3
+ prelude_cli/templates/README.md,sha256=uSrjMIA4dmO5gNu_KvZHVOL0gdm39nL6OJQQT8L-zhg,1120
4
+ prelude_cli/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ prelude_cli/templates/template.go,sha256=1HAYJmM842MjiH_hnXtYMHYAuqZES4njWa4dtbZ9A_0,319
6
+ prelude_cli/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ prelude_cli/views/auth.py,sha256=frbmcuQhVFRLibQBJEDppiRPdDBjGHCycUEaqIIkIhE,1859
8
+ prelude_cli/views/build.py,sha256=casUGNyvBvE8uM9Cw1PjGQUFqLb1Rg29d_ZbZOPmmhw,16488
9
+ prelude_cli/views/configure.py,sha256=saj0kR9mQqBp7cCmq-yfEr3UwZCZIV1vL8WDQLDAO7o,991
10
+ prelude_cli/views/detect.py,sha256=sHc2P4kqZwrnlfxCP6JI1PjWQ3Hzky4xeNWFlZDZNWs,12992
11
+ prelude_cli/views/generate.py,sha256=xSy0TN6wtUV5_rDolPdPPI0dp5wZC9nxhmLG0YVLc70,4664
12
+ prelude_cli/views/iam.py,sha256=Q8Q4vWxpTRWtbXBUMeIDWT69UklihrFdtVpHaJTwGPU,9998
13
+ prelude_cli/views/jobs.py,sha256=8F6BiPoYj3LADHa8gpbbrd2eVLetsI1g-N98i8AyPq8,1440
14
+ prelude_cli/views/partner.py,sha256=MlcqOVDANtwBHUqGD-nROEGmptb6741HPUcFU6RF_as,6364
15
+ prelude_cli/views/scm.py,sha256=4x5EwP0HyuiOe_Lops8qCTX52pj1y7mTGbc11FbWZHM,14000
16
+ prelude_cli/views/shared.py,sha256=ZKvY8N1Vi6RtEbJli5PDzJ9R6L_bX2F27n1tm6Knvgs,1101
17
+ prelude_cli_beta-1396.dist-info/licenses/LICENSE,sha256=ttdT5omfN6LNmtQoIjUhkkFhz6i44SDMRNwKrbfyTf8,1069
18
+ prelude_cli_beta-1396.dist-info/METADATA,sha256=cLFF1BmGd_ItOlnO-9kp_e0q5weQVMjfHohXJFNYAUo,995
19
+ prelude_cli_beta-1396.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
+ prelude_cli_beta-1396.dist-info/entry_points.txt,sha256=EcHxlP45Wb60ThfVcsNSEAmWdy-chL_1crJKR_9a3uU,48
21
+ prelude_cli_beta-1396.dist-info/top_level.txt,sha256=tPjI9IbMelcZ6RUw_gqloIqK_C2yr7NJ6v6mI88xwHM,12
22
+ prelude_cli_beta-1396.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ prelude = prelude_cli.cli:cli
@@ -0,0 +1,9 @@
1
+ MIT LICENSE
2
+
3
+ Copyright 2022, Prelude Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ prelude_cli