asp-cli 0.1.0__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.
asp_cli/main.py ADDED
@@ -0,0 +1,1461 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+ from urllib.parse import urlencode
9
+
10
+ if __package__ in {None, ""}:
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
12
+ __package__ = "asp_cli"
13
+
14
+ import httpx
15
+ import jmespath
16
+ import typer
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from . import __version__
21
+ from .config import (
22
+ clear_auth,
23
+ get_config_value,
24
+ read_settings,
25
+ redact_secret,
26
+ resolve_config,
27
+ save_auth,
28
+ set_config_value,
29
+ )
30
+ from .errors import CliError, EXIT_AUTH, EXIT_CONFIG, EXIT_NETWORK, EXIT_USAGE
31
+ from .api_client import AspClient
32
+ from .output import OutputFormat, emit_error, emit_success, key_value_table
33
+
34
+ console = Console()
35
+ err_console = Console(stderr=True)
36
+
37
+
38
+ @dataclass
39
+ class RuntimeOptions:
40
+ api_url: str | None
41
+ api_key: str | None
42
+ output: OutputFormat
43
+ query: str | None
44
+ verbose: bool
45
+
46
+
47
+ app = typer.Typer(no_args_is_help=True, help="ASP command line client.")
48
+ auth_app = typer.Typer(no_args_is_help=True, help="Authenticate and inspect the current ASP session.")
49
+ config_app = typer.Typer(no_args_is_help=True, help="Read and write ASP CLI settings.")
50
+ completion_app = typer.Typer(no_args_is_help=True, help="Show shell completion installation commands.")
51
+ case_app = typer.Typer(no_args_is_help=True, help="List, show, and update ASP cases.")
52
+ alert_app = typer.Typer(no_args_is_help=True, help="List and show ASP alerts.")
53
+ artifact_app = typer.Typer(no_args_is_help=True, help="List and show ASP artifacts.")
54
+ knowledge_app = typer.Typer(no_args_is_help=True, help="Search, show, and update ASP knowledge.")
55
+ comment_app = typer.Typer(no_args_is_help=True, help="List and add ASP comments.")
56
+ file_app = typer.Typer(no_args_is_help=True, help="Upload, inspect, download, and read ASP files.")
57
+ enrichment_app = typer.Typer(no_args_is_help=True, help="Create ASP enrichments.")
58
+ playbook_app = typer.Typer(no_args_is_help=True, help="List and run ASP playbooks.")
59
+ playbook_template_app = typer.Typer(no_args_is_help=True, help="List playbook templates.")
60
+ siem_app = typer.Typer(no_args_is_help=True, help="Search and query ASP SIEM integrations.")
61
+ siem_schema_app = typer.Typer(no_args_is_help=True, help="Explore SIEM schema metadata.")
62
+ siem_search_app = typer.Typer(no_args_is_help=True, help="Run SIEM search workflows.")
63
+ siem_query_app = typer.Typer(no_args_is_help=True, help="Run structured or raw SIEM queries.")
64
+ siem_fields_app = typer.Typer(no_args_is_help=True, help="Discover live SIEM fields.")
65
+ ti_app = typer.Typer(no_args_is_help=True, help="Query threat intelligence providers.")
66
+ cmdb_app = typer.Typer(no_args_is_help=True, help="Look up asset context from CMDB providers.")
67
+ dev_app = typer.Typer(no_args_is_help=True, help="Advanced developer and debugging commands.")
68
+ dev_stream_app = typer.Typer(no_args_is_help=True, help="Inspect Redis streams.")
69
+
70
+ app.add_typer(auth_app, name="auth")
71
+ app.add_typer(config_app, name="config")
72
+ app.add_typer(completion_app, name="completion")
73
+ app.add_typer(case_app, name="case")
74
+ app.add_typer(alert_app, name="alert")
75
+ app.add_typer(artifact_app, name="artifact")
76
+ app.add_typer(knowledge_app, name="knowledge")
77
+ app.add_typer(comment_app, name="comment")
78
+ app.add_typer(file_app, name="file")
79
+ app.add_typer(enrichment_app, name="enrichment")
80
+ playbook_app.add_typer(playbook_template_app, name="template")
81
+ app.add_typer(playbook_app, name="playbook")
82
+ siem_app.add_typer(siem_schema_app, name="schema")
83
+ siem_app.add_typer(siem_search_app, name="search")
84
+ siem_app.add_typer(siem_query_app, name="query")
85
+ siem_app.add_typer(siem_fields_app, name="fields")
86
+ app.add_typer(siem_app, name="siem")
87
+ app.add_typer(ti_app, name="ti")
88
+ app.add_typer(cmdb_app, name="cmdb")
89
+ dev_app.add_typer(dev_stream_app, name="stream")
90
+ app.add_typer(dev_app, name="dev")
91
+
92
+
93
+ @app.callback()
94
+ def main(
95
+ ctx: typer.Context,
96
+ version: Annotated[bool, typer.Option("--version", help="Show CLI version and exit.")] = False,
97
+ api_url: Annotated[str | None, typer.Option("--api-url", help="Temporarily override the ASP base URL.")] = None,
98
+ api_key: Annotated[str | None, typer.Option("--api-key", help="Temporarily override the ASP API key.")] = None,
99
+ output: Annotated[OutputFormat, typer.Option("--output", help="Output format.")] = OutputFormat.human,
100
+ query: Annotated[str | None, typer.Option("--query", help="JMESPath query applied to JSON data.")] = None,
101
+ verbose: Annotated[bool, typer.Option("--verbose", help="Show redacted request diagnostics.")] = False,
102
+ ) -> None:
103
+ if version:
104
+ console.print(__version__)
105
+ raise typer.Exit()
106
+ ctx.obj = RuntimeOptions(api_url=api_url, api_key=api_key, output=output, query=query, verbose=verbose)
107
+
108
+
109
+ @auth_app.command("login")
110
+ def auth_login(
111
+ ctx: typer.Context,
112
+ api_url: Annotated[str, typer.Option("--api-url", help="ASP base URL, for example https://asp.example.com.")],
113
+ api_key: Annotated[str, typer.Option("--api-key", help="ASP user API key.")],
114
+ local: Annotated[bool, typer.Option("--local", help="Write .asp/settings.json in the current directory.")] = False,
115
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
116
+ ) -> None:
117
+ run_command(ctx, "auth.login", output, lambda runtime, out: _auth_login(runtime, out, api_url, api_key, local))
118
+
119
+
120
+ @auth_app.command("status")
121
+ def auth_status(
122
+ ctx: typer.Context,
123
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
124
+ ) -> None:
125
+ run_command(ctx, "auth.status", output, _auth_status)
126
+
127
+
128
+ @auth_app.command("logout")
129
+ def auth_logout(
130
+ ctx: typer.Context,
131
+ local: Annotated[bool, typer.Option("--local", help="Remove auth from local .asp/settings.json instead of global settings.")] = False,
132
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
133
+ ) -> None:
134
+ run_command(ctx, "auth.logout", output, lambda runtime, out: _auth_logout(runtime, out, local))
135
+
136
+
137
+ @config_app.command("list")
138
+ def config_list(
139
+ ctx: typer.Context,
140
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
141
+ ) -> None:
142
+ run_command(ctx, "config.list", output, _config_list)
143
+
144
+
145
+ @config_app.command("get")
146
+ def config_get(
147
+ ctx: typer.Context,
148
+ key: Annotated[str, typer.Argument(help="Config key: api_url or api_key.")],
149
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
150
+ ) -> None:
151
+ run_command(ctx, "config.get", output, lambda runtime, out: _config_get(runtime, out, key))
152
+
153
+
154
+ @config_app.command("set")
155
+ def config_set(
156
+ ctx: typer.Context,
157
+ key: Annotated[str, typer.Argument(help="Config key: api_url or api_key.")],
158
+ value: Annotated[str, typer.Argument(help="Config value.")],
159
+ local: Annotated[bool, typer.Option("--local", help="Write local .asp/settings.json instead of global settings.")] = False,
160
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
161
+ ) -> None:
162
+ run_command(ctx, "config.set", output, lambda runtime, out: _config_set(runtime, out, key, value, local))
163
+
164
+
165
+ @app.command("doctor")
166
+ def doctor(
167
+ ctx: typer.Context,
168
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
169
+ ) -> None:
170
+ run_command(ctx, "doctor", output, _doctor)
171
+
172
+
173
+ @case_app.command("list")
174
+ def case_list(
175
+ ctx: typer.Context,
176
+ status: Annotated[str | None, typer.Option("--status", help="Case status filter. Repeat values with commas.")] = None,
177
+ severity: Annotated[str | None, typer.Option("--severity", help="Case severity filter. Repeat values with commas.")] = None,
178
+ confidence: Annotated[str | None, typer.Option("--confidence", help="Case confidence filter. Repeat values with commas.")] = None,
179
+ verdict: Annotated[str | None, typer.Option("--verdict", help="Case verdict filter. Repeat values with commas.")] = None,
180
+ correlation_uid: Annotated[str | None, typer.Option("--correlation-uid", help="Case correlation UID.")] = None,
181
+ title: Annotated[str | None, typer.Option("--title", help="Title substring filter.")] = None,
182
+ tags: Annotated[str | None, typer.Option("--tags", help="Comma-separated tag filters.")] = None,
183
+ include_related: Annotated[bool, typer.Option("--include-related", help="Include related alerts.")] = False,
184
+ cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None,
185
+ page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None,
186
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
187
+ ) -> None:
188
+ run_command(
189
+ ctx,
190
+ "case.list",
191
+ output,
192
+ lambda runtime, out: _list_cases(
193
+ runtime,
194
+ out,
195
+ status=status,
196
+ severity=severity,
197
+ confidence=confidence,
198
+ verdict=verdict,
199
+ correlation_uid=correlation_uid,
200
+ title=title,
201
+ tags=tags,
202
+ include_related=include_related,
203
+ cursor=cursor,
204
+ page_size=page_size,
205
+ ),
206
+ )
207
+
208
+
209
+ @case_app.command("show")
210
+ def case_show(
211
+ ctx: typer.Context,
212
+ case_id: Annotated[str, typer.Argument(help="Case ID, for example case_000001.")],
213
+ include_related: Annotated[bool, typer.Option("--include-related/--no-include-related", help="Include related alerts.")] = True,
214
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
215
+ ) -> None:
216
+ run_command(ctx, "case.show", output, lambda runtime, out: _show_case(runtime, out, case_id, include_related))
217
+
218
+
219
+ @case_app.command("update-ai")
220
+ def case_update_ai(
221
+ ctx: typer.Context,
222
+ case_id: Annotated[str, typer.Argument(help="Case ID, for example case_000001.")],
223
+ severity_ai: Annotated[str | None, typer.Option("--severity-ai", help="AI-assessed severity.")] = None,
224
+ confidence_ai: Annotated[str | None, typer.Option("--confidence-ai", help="AI-assessed confidence.")] = None,
225
+ impact_ai: Annotated[str | None, typer.Option("--impact-ai", help="AI-assessed impact.")] = None,
226
+ priority_ai: Annotated[str | None, typer.Option("--priority-ai", help="AI-assessed priority.")] = None,
227
+ verdict_ai: Annotated[str | None, typer.Option("--verdict-ai", help="AI-assessed verdict.")] = None,
228
+ summary: Annotated[str | None, typer.Option("--summary", help="Case summary.")] = None,
229
+ summary_file: Annotated[Path | None, typer.Option("--summary-file", help="Read summary from file.")] = None,
230
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
231
+ ) -> None:
232
+ run_command(
233
+ ctx,
234
+ "case.update_ai",
235
+ output,
236
+ lambda runtime, out: _update_case_ai(
237
+ runtime,
238
+ out,
239
+ case_id,
240
+ severity_ai=severity_ai,
241
+ confidence_ai=confidence_ai,
242
+ impact_ai=impact_ai,
243
+ priority_ai=priority_ai,
244
+ verdict_ai=verdict_ai,
245
+ summary=summary,
246
+ summary_file=summary_file,
247
+ ),
248
+ )
249
+
250
+
251
+ @alert_app.command("list")
252
+ def alert_list(
253
+ ctx: typer.Context,
254
+ status: Annotated[str | None, typer.Option("--status", help="Alert status filter. Repeat values with commas.")] = None,
255
+ severity: Annotated[str | None, typer.Option("--severity", help="Alert severity filter. Repeat values with commas.")] = None,
256
+ confidence: Annotated[str | None, typer.Option("--confidence", help="Alert confidence filter. Repeat values with commas.")] = None,
257
+ case_id: Annotated[str | None, typer.Option("--case-id", help="Linked case ID.")] = None,
258
+ correlation_uid: Annotated[str | None, typer.Option("--correlation-uid", help="Correlation UID.")] = None,
259
+ include_related: Annotated[bool, typer.Option("--include-related", help="Include related artifacts.")] = False,
260
+ cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None,
261
+ page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None,
262
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
263
+ ) -> None:
264
+ run_command(
265
+ ctx,
266
+ "alert.list",
267
+ output,
268
+ lambda runtime, out: _list_alerts(
269
+ runtime,
270
+ out,
271
+ status=status,
272
+ severity=severity,
273
+ confidence=confidence,
274
+ case_id=case_id,
275
+ correlation_uid=correlation_uid,
276
+ include_related=include_related,
277
+ cursor=cursor,
278
+ page_size=page_size,
279
+ ),
280
+ )
281
+
282
+
283
+ @alert_app.command("show")
284
+ def alert_show(
285
+ ctx: typer.Context,
286
+ alert_id: Annotated[str, typer.Argument(help="Alert ID, for example alert_000001.")],
287
+ include_related: Annotated[bool, typer.Option("--include-related/--no-include-related", help="Include related artifacts.")] = True,
288
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
289
+ ) -> None:
290
+ run_command(ctx, "alert.show", output, lambda runtime, out: _show_alert(runtime, out, alert_id, include_related))
291
+
292
+
293
+ @artifact_app.command("list")
294
+ def artifact_list(
295
+ ctx: typer.Context,
296
+ type: Annotated[str | None, typer.Option("--type", help="Artifact type filter. Repeat values with commas.")] = None,
297
+ role: Annotated[str | None, typer.Option("--role", help="Artifact role filter. Repeat values with commas.")] = None,
298
+ value: Annotated[str | None, typer.Option("--value", help="Exact artifact value.")] = None,
299
+ include_related: Annotated[bool, typer.Option("--include-related", help="Include related alerts.")] = False,
300
+ cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None,
301
+ page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None,
302
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
303
+ ) -> None:
304
+ run_command(
305
+ ctx,
306
+ "artifact.list",
307
+ output,
308
+ lambda runtime, out: _list_artifacts(
309
+ runtime,
310
+ out,
311
+ type=type,
312
+ role=role,
313
+ value=value,
314
+ include_related=include_related,
315
+ cursor=cursor,
316
+ page_size=page_size,
317
+ ),
318
+ )
319
+
320
+
321
+ @artifact_app.command("show")
322
+ def artifact_show(
323
+ ctx: typer.Context,
324
+ artifact_id: Annotated[str, typer.Argument(help="Artifact ID, for example artifact_000001.")],
325
+ include_related: Annotated[bool, typer.Option("--include-related/--no-include-related", help="Include related alerts.")] = True,
326
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
327
+ ) -> None:
328
+ run_command(ctx, "artifact.show", output, lambda runtime, out: _show_artifact(runtime, out, artifact_id, include_related))
329
+
330
+
331
+ @knowledge_app.command("search")
332
+ def knowledge_search(
333
+ ctx: typer.Context,
334
+ keyword: Annotated[str | None, typer.Argument(help="Keyword to search in title, body, or tags.")] = None,
335
+ source: Annotated[str | None, typer.Option("--source", help="Knowledge source filter.")] = None,
336
+ case_id: Annotated[str | None, typer.Option("--case-id", help="Linked case ID.")] = None,
337
+ tags: Annotated[str | None, typer.Option("--tags", help="Comma-separated tag filters.")] = None,
338
+ cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None,
339
+ page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None,
340
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
341
+ ) -> None:
342
+ run_command(
343
+ ctx,
344
+ "knowledge.search",
345
+ output,
346
+ lambda runtime, out: _search_knowledge(
347
+ runtime,
348
+ out,
349
+ keyword=keyword,
350
+ source=source,
351
+ case_id=case_id,
352
+ tags=tags,
353
+ cursor=cursor,
354
+ page_size=page_size,
355
+ ),
356
+ )
357
+
358
+
359
+ @knowledge_app.command("show")
360
+ def knowledge_show(
361
+ ctx: typer.Context,
362
+ knowledge_id: Annotated[str, typer.Argument(help="Knowledge ID, for example knowledge_000001.")],
363
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
364
+ ) -> None:
365
+ run_command(ctx, "knowledge.show", output, lambda runtime, out: _show_knowledge(runtime, out, knowledge_id))
366
+
367
+
368
+ @knowledge_app.command("update")
369
+ def knowledge_update(
370
+ ctx: typer.Context,
371
+ knowledge_id: Annotated[str, typer.Argument(help="Knowledge ID, for example knowledge_000001.")],
372
+ title: Annotated[str | None, typer.Option("--title", help="Knowledge title.")] = None,
373
+ body: Annotated[str | None, typer.Option("--body", help="Knowledge body.")] = None,
374
+ body_file: Annotated[Path | None, typer.Option("--body-file", help="Read body from file.")] = None,
375
+ expires_at: Annotated[str | None, typer.Option("--expires-at", help="ISO 8601 datetime with timezone.")] = None,
376
+ tags: Annotated[str | None, typer.Option("--tags", help="Comma-separated tags.")] = None,
377
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
378
+ ) -> None:
379
+ run_command(
380
+ ctx,
381
+ "knowledge.update",
382
+ output,
383
+ lambda runtime, out: _update_knowledge(
384
+ runtime,
385
+ out,
386
+ knowledge_id,
387
+ title=title,
388
+ body=body,
389
+ body_file=body_file,
390
+ expires_at=expires_at,
391
+ tags=tags,
392
+ ),
393
+ )
394
+
395
+
396
+ @comment_app.command("list")
397
+ def comment_list(
398
+ ctx: typer.Context,
399
+ target_id: Annotated[str, typer.Argument(help="Target record ID, for example case_000001.")],
400
+ cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None,
401
+ page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None,
402
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
403
+ ) -> None:
404
+ run_command(ctx, "comment.list", output, lambda runtime, out: _list_comments(runtime, out, target_id, cursor, page_size))
405
+
406
+
407
+ @comment_app.command("add")
408
+ def comment_add(
409
+ ctx: typer.Context,
410
+ target_id: Annotated[str, typer.Argument(help="Target record ID, for example case_000001.")],
411
+ body: Annotated[str | None, typer.Option("--body", help="Comment body.")] = None,
412
+ body_file: Annotated[Path | None, typer.Option("--body-file", help="Read comment body from file.")] = None,
413
+ file_key: Annotated[str | None, typer.Option("--file-key", help="Attachment file_key. Use commas for multiple keys.")] = None,
414
+ parent_id: Annotated[int | None, typer.Option("--parent-id", help="Parent comment ID for replies.")] = None,
415
+ mentions: Annotated[str | None, typer.Option("--mentions", help="Comma-separated usernames or user IDs.")] = None,
416
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
417
+ ) -> None:
418
+ run_command(
419
+ ctx,
420
+ "comment.add",
421
+ output,
422
+ lambda runtime, out: _add_comment(
423
+ runtime,
424
+ out,
425
+ target_id,
426
+ body=body,
427
+ body_file=body_file,
428
+ file_key=file_key,
429
+ parent_id=parent_id,
430
+ mentions=mentions,
431
+ ),
432
+ )
433
+
434
+
435
+ @file_app.command("upload")
436
+ def file_upload(
437
+ ctx: typer.Context,
438
+ path: Annotated[Path, typer.Argument(help="Local file path to upload.")],
439
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
440
+ ) -> None:
441
+ run_command(ctx, "file.upload", output, lambda runtime, out: _upload_file(runtime, out, path))
442
+
443
+
444
+ @file_app.command("info")
445
+ def file_info(
446
+ ctx: typer.Context,
447
+ file_key: Annotated[str, typer.Argument(help="Attachment file_key.")],
448
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
449
+ ) -> None:
450
+ run_command(ctx, "file.info", output, lambda runtime, out: _file_info(runtime, out, file_key))
451
+
452
+
453
+ @file_app.command("download")
454
+ def file_download(
455
+ ctx: typer.Context,
456
+ file_key: Annotated[str, typer.Argument(help="Attachment file_key.")],
457
+ output_path: Annotated[Path | None, typer.Option("--output-path", "-o", help="Local output path.")] = None,
458
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
459
+ ) -> None:
460
+ run_command(ctx, "file.download", output, lambda runtime, out: _download_file(runtime, out, file_key, output_path))
461
+
462
+
463
+ @file_app.command("read-text")
464
+ def file_read_text(
465
+ ctx: typer.Context,
466
+ file_key: Annotated[str, typer.Argument(help="Attachment file_key.")],
467
+ max_bytes: Annotated[int, typer.Option("--max-bytes", min=1, max=262144, help="Maximum bytes to read.")] = 65536,
468
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
469
+ ) -> None:
470
+ run_command(ctx, "file.read_text", output, lambda runtime, out: _read_file_text(runtime, out, file_key, max_bytes))
471
+
472
+
473
+ @enrichment_app.command("create")
474
+ def enrichment_create(
475
+ ctx: typer.Context,
476
+ target_id: Annotated[str, typer.Argument(help="Target case_, alert_, or artifact_ ID.")],
477
+ name: Annotated[str, typer.Option("--name", help="Enrichment name.")] = "",
478
+ type: Annotated[str, typer.Option("--type", help="Enrichment type.")] = "Other",
479
+ value: Annotated[str, typer.Option("--value", help="Enrichment value.")] = "",
480
+ uid: Annotated[str, typer.Option("--uid", help="Stable external identifier.")] = "",
481
+ desc: Annotated[str, typer.Option("--desc", help="Enrichment summary.")] = "",
482
+ data_json: Annotated[str | None, typer.Option("--data-json", help="JSON object string.")] = None,
483
+ data_file: Annotated[Path | None, typer.Option("--data-file", help="JSON object file.")] = None,
484
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
485
+ ) -> None:
486
+ run_command(
487
+ ctx,
488
+ "enrichment.create",
489
+ output,
490
+ lambda runtime, out: _create_enrichment(
491
+ runtime,
492
+ out,
493
+ target_id,
494
+ name=name,
495
+ type=type,
496
+ value=value,
497
+ uid=uid,
498
+ desc=desc,
499
+ data_json=data_json,
500
+ data_file=data_file,
501
+ ),
502
+ )
503
+
504
+
505
+ @playbook_template_app.command("list")
506
+ def playbook_template_list(
507
+ ctx: typer.Context,
508
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
509
+ ) -> None:
510
+ run_command(ctx, "playbook.template.list", output, _list_playbook_templates)
511
+
512
+
513
+ @playbook_app.command("list")
514
+ def playbook_list(
515
+ ctx: typer.Context,
516
+ case_id: Annotated[str | None, typer.Option("--case-id", help="Linked case ID.")] = None,
517
+ job_status: Annotated[str | None, typer.Option("--job-status", help="Job status filter. Repeat values with commas.")] = None,
518
+ include_related: Annotated[bool, typer.Option("--include-related", help="Include related case.")] = False,
519
+ cursor: Annotated[str | None, typer.Option("--cursor", help="Pagination cursor.")] = None,
520
+ page_size: Annotated[int | None, typer.Option("--page-size", min=1, max=100, help="Page size.")] = None,
521
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
522
+ ) -> None:
523
+ run_command(
524
+ ctx,
525
+ "playbook.list",
526
+ output,
527
+ lambda runtime, out: _list_playbooks(
528
+ runtime,
529
+ out,
530
+ case_id=case_id,
531
+ job_status=job_status,
532
+ include_related=include_related,
533
+ cursor=cursor,
534
+ page_size=page_size,
535
+ ),
536
+ )
537
+
538
+
539
+ @playbook_app.command("show")
540
+ def playbook_show(
541
+ ctx: typer.Context,
542
+ playbook_id: Annotated[str, typer.Argument(help="Playbook run ID, for example playbook_000001.")],
543
+ include_related: Annotated[bool, typer.Option("--include-related/--no-include-related", help="Include related case.")] = True,
544
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
545
+ ) -> None:
546
+ run_command(ctx, "playbook.show", output, lambda runtime, out: _show_playbook(runtime, out, playbook_id, include_related))
547
+
548
+
549
+ @playbook_app.command("run")
550
+ def playbook_run(
551
+ ctx: typer.Context,
552
+ name: Annotated[str, typer.Argument(help="Playbook template name.")],
553
+ case_id: Annotated[str, typer.Argument(help="Case ID, for example case_000001.")],
554
+ user_input: Annotated[str | None, typer.Option("--user-input", help="Playbook user input.")] = None,
555
+ user_input_file: Annotated[Path | None, typer.Option("--user-input-file", help="Read user input from file.")] = None,
556
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
557
+ ) -> None:
558
+ run_command(
559
+ ctx,
560
+ "playbook.run",
561
+ output,
562
+ lambda runtime, out: _run_playbook(
563
+ runtime,
564
+ out,
565
+ name,
566
+ case_id,
567
+ user_input=user_input,
568
+ user_input_file=user_input_file,
569
+ ),
570
+ )
571
+
572
+
573
+ @siem_schema_app.command("list")
574
+ def siem_schema_list(
575
+ ctx: typer.Context,
576
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
577
+ ) -> None:
578
+ run_command(ctx, "siem.schema", output, lambda runtime, out: _siem_schema(runtime, out, None))
579
+
580
+
581
+ @siem_schema_app.command("show")
582
+ def siem_schema_show(
583
+ ctx: typer.Context,
584
+ target_index: Annotated[str, typer.Argument(help="Registered SIEM index/source name.")],
585
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
586
+ ) -> None:
587
+ run_command(ctx, "siem.schema", output, lambda runtime, out: _siem_schema(runtime, out, target_index))
588
+
589
+
590
+ @siem_search_app.command("keyword")
591
+ def siem_search_keyword(
592
+ ctx: typer.Context,
593
+ keyword: Annotated[str, typer.Argument(help="Keyword or comma-separated AND keyword list.")],
594
+ time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")],
595
+ time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")],
596
+ time_field: Annotated[str, typer.Option("--time-field", help="Time field.")] = "@timestamp",
597
+ index_name: Annotated[str | None, typer.Option("--index-name", help="Optional target index/source.")] = None,
598
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
599
+ ) -> None:
600
+ run_command(
601
+ ctx,
602
+ "siem.search.keyword",
603
+ output,
604
+ lambda runtime, out: _siem_keyword(runtime, out, keyword, time_range_start, time_range_end, time_field, index_name),
605
+ )
606
+
607
+
608
+ @siem_query_app.command("adaptive")
609
+ def siem_query_adaptive(
610
+ ctx: typer.Context,
611
+ index_name: Annotated[str, typer.Argument(help="Target SIEM index/source name.")],
612
+ time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")],
613
+ time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")],
614
+ time_field: Annotated[str, typer.Option("--time-field", help="Time field.")] = "@timestamp",
615
+ filters_json: Annotated[str | None, typer.Option("--filters-json", help="Exact-match filters JSON object.")] = None,
616
+ filters_file: Annotated[Path | None, typer.Option("--filters-file", help="Exact-match filters JSON file.")] = None,
617
+ aggregation_fields: Annotated[str | None, typer.Option("--aggregation-fields", help="Comma-separated aggregation fields.")] = None,
618
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
619
+ ) -> None:
620
+ run_command(
621
+ ctx,
622
+ "siem.query.adaptive",
623
+ output,
624
+ lambda runtime, out: _siem_adaptive(runtime, out, index_name, time_range_start, time_range_end, time_field, filters_json, filters_file, aggregation_fields),
625
+ )
626
+
627
+
628
+ @siem_query_app.command("spl")
629
+ def siem_query_spl(
630
+ ctx: typer.Context,
631
+ query: Annotated[str, typer.Argument(help="Raw SPL query.")],
632
+ time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")],
633
+ time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")],
634
+ limit: Annotated[int, typer.Option("--limit", min=1, max=10000, help="Maximum records.")] = 100,
635
+ time_field: Annotated[str, typer.Option("--time-field", help="Time field.")] = "@timestamp",
636
+ index_name: Annotated[str | None, typer.Option("--index-name", help="Optional index/source label.")] = None,
637
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
638
+ ) -> None:
639
+ run_command(ctx, "siem.query.spl", output, lambda runtime, out: _siem_raw_query(runtime, out, "spl", query, time_range_start, time_range_end, limit, time_field, index_name))
640
+
641
+
642
+ @siem_query_app.command("esql")
643
+ def siem_query_esql(
644
+ ctx: typer.Context,
645
+ query: Annotated[str, typer.Argument(help="Raw ES|QL query.")],
646
+ time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")],
647
+ time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")],
648
+ limit: Annotated[int, typer.Option("--limit", min=1, max=10000, help="Maximum records.")] = 100,
649
+ time_field: Annotated[str, typer.Option("--time-field", help="Time field.")] = "@timestamp",
650
+ index_name: Annotated[str | None, typer.Option("--index-name", help="Optional index/source label.")] = None,
651
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
652
+ ) -> None:
653
+ run_command(ctx, "siem.query.esql", output, lambda runtime, out: _siem_raw_query(runtime, out, "esql", query, time_range_start, time_range_end, limit, time_field, index_name))
654
+
655
+
656
+ @siem_fields_app.command("discover")
657
+ def siem_fields_discover(
658
+ ctx: typer.Context,
659
+ index_name: Annotated[str, typer.Argument(help="Target SIEM index/source name.")],
660
+ backend: Annotated[str, typer.Argument(help="Backend: ELK or Splunk.")],
661
+ time_range_start: Annotated[str, typer.Option("--from", help="ISO 8601 start time with timezone.")],
662
+ time_range_end: Annotated[str, typer.Option("--to", help="ISO 8601 end time with timezone.")],
663
+ doc_limit: Annotated[int, typer.Option("--doc-limit", min=1, max=100000, help="Documents to sample.")] = 10000,
664
+ max_samples_per_field: Annotated[int, typer.Option("--max-samples-per-field", min=1, max=100, help="Samples per field.")] = 20,
665
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
666
+ ) -> None:
667
+ run_command(
668
+ ctx,
669
+ "siem.fields.discover",
670
+ output,
671
+ lambda runtime, out: _siem_fields_discover(runtime, out, index_name, backend, time_range_start, time_range_end, doc_limit, max_samples_per_field),
672
+ )
673
+
674
+
675
+ @ti_app.command("query")
676
+ def ti_query(
677
+ ctx: typer.Context,
678
+ indicator: Annotated[str, typer.Argument(help="Indicator value: IP, domain, URL, hash, etc.")],
679
+ artifact_type: Annotated[str, typer.Option("--artifact-type", help="Artifact type hint.")] = "Unknown",
680
+ provider: Annotated[str | None, typer.Option("--provider", help="Optional provider name.")] = None,
681
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
682
+ ) -> None:
683
+ run_command(ctx, "ti.query", output, lambda runtime, out: _ti_query(runtime, out, indicator, artifact_type, provider))
684
+
685
+
686
+ @cmdb_app.command("lookup")
687
+ def cmdb_lookup(
688
+ ctx: typer.Context,
689
+ artifact_type: Annotated[str, typer.Argument(help="Artifact type.")],
690
+ artifact_value: Annotated[str, typer.Argument(help="Artifact value.")],
691
+ provider: Annotated[str | None, typer.Option("--provider", help="Optional provider name.")] = None,
692
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
693
+ ) -> None:
694
+ run_command(ctx, "cmdb.lookup", output, lambda runtime, out: _cmdb_lookup(runtime, out, artifact_type, artifact_value, provider))
695
+
696
+
697
+ @dev_stream_app.command("head")
698
+ def dev_stream_head(
699
+ ctx: typer.Context,
700
+ stream_name: Annotated[str, typer.Argument(help="Redis stream name.")],
701
+ n: Annotated[int, typer.Option("-n", min=1, max=100, help="Number of messages.")] = 3,
702
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
703
+ ) -> None:
704
+ run_command(ctx, "dev.stream.head", output, lambda runtime, out: _dev_stream_head(runtime, out, stream_name, n))
705
+
706
+
707
+ @dev_stream_app.command("read")
708
+ def dev_stream_read(
709
+ ctx: typer.Context,
710
+ stream_name: Annotated[str, typer.Argument(help="Redis stream name.")],
711
+ message_id: Annotated[str, typer.Argument(help="Redis stream message ID.")],
712
+ output: Annotated[OutputFormat | None, typer.Option("--output", help="Output format.")] = None,
713
+ ) -> None:
714
+ run_command(ctx, "dev.stream.read", output, lambda runtime, out: _dev_stream_read(runtime, out, stream_name, message_id))
715
+
716
+
717
+ @completion_app.command("powershell")
718
+ def completion_powershell() -> None:
719
+ console.print("Run this command to install PowerShell completion:")
720
+ console.print("asp --install-completion powershell", style="cyan")
721
+
722
+
723
+ @completion_app.command("bash")
724
+ def completion_bash() -> None:
725
+ console.print("Run this command to install Bash completion:")
726
+ console.print("asp --install-completion bash", style="cyan")
727
+
728
+
729
+ @completion_app.command("zsh")
730
+ def completion_zsh() -> None:
731
+ console.print("Run this command to install Zsh completion:")
732
+ console.print("asp --install-completion zsh", style="cyan")
733
+
734
+
735
+ def run_command(ctx: typer.Context, operation: str, output: OutputFormat | None, handler) -> None:
736
+ runtime = runtime_options(ctx)
737
+ out = output or runtime.output
738
+ try:
739
+ if runtime.query and out != OutputFormat.json:
740
+ raise CliError("query_requires_json", "--query requires --output json", {}, EXIT_USAGE)
741
+ handler(runtime, out)
742
+ except CliError as exc:
743
+ emit_error(err_console if out == OutputFormat.human else console, output=out, error=exc, operation=operation)
744
+ raise typer.Exit(exc.exit_code) from exc
745
+
746
+
747
+ def runtime_options(ctx: typer.Context) -> RuntimeOptions:
748
+ if isinstance(ctx.obj, RuntimeOptions):
749
+ return ctx.obj
750
+ parent = ctx.parent
751
+ while parent is not None:
752
+ if isinstance(parent.obj, RuntimeOptions):
753
+ return parent.obj
754
+ parent = parent.parent
755
+ return RuntimeOptions(api_url=None, api_key=None, output=OutputFormat.human, query=None, verbose=False)
756
+
757
+
758
+ def _auth_login(runtime: RuntimeOptions, output: OutputFormat, api_url: str, api_key: str, local: bool) -> None:
759
+ path = save_auth(api_url=api_url, api_key=api_key, local=local)
760
+ data = {
761
+ "scope": "local" if local else "global",
762
+ "settings_path": str(path),
763
+ "api_url": api_url.rstrip("/"),
764
+ "api_key": redact_secret(api_key),
765
+ }
766
+ if output == OutputFormat.human:
767
+ console.print(key_value_table("ASP auth saved", list(data.items())))
768
+ console.print("Next: run [cyan]asp doctor[/cyan].")
769
+ return
770
+ emit_runtime_success(runtime, output=output, operation="auth.login", data=data)
771
+
772
+
773
+ def _auth_status(runtime: RuntimeOptions, output: OutputFormat) -> None:
774
+ config = _require_config(runtime)
775
+ client = AspClient(api_url=config.api_url or "", api_key=config.api_key, verbose=runtime.verbose, console=err_console)
776
+ payload = client.version()
777
+ data = {
778
+ "api_url": config.api_url,
779
+ "api_url_source": config.sources.get("api_url"),
780
+ "api_key_source": config.sources.get("api_key"),
781
+ "api_key": redact_secret(config.api_key),
782
+ "server": payload.get("data", {}),
783
+ }
784
+ if output == OutputFormat.human:
785
+ server = data["server"]
786
+ user = server.get("user") or {}
787
+ console.print(
788
+ key_value_table(
789
+ "ASP auth status",
790
+ [
791
+ ("API URL", data["api_url"]),
792
+ ("API URL source", data["api_url_source"]),
793
+ ("API key source", data["api_key_source"]),
794
+ ("API key", data["api_key"]),
795
+ ("User", user.get("username")),
796
+ ("Role", user.get("role")),
797
+ ("API version", server.get("api_version")),
798
+ ],
799
+ )
800
+ )
801
+ return
802
+ emit_runtime_success(runtime, output=output, operation="auth.status", data=data)
803
+
804
+
805
+ def _auth_logout(runtime: RuntimeOptions, output: OutputFormat, local: bool) -> None:
806
+ path = clear_auth(local=local)
807
+ data = {"scope": "local" if local else "global", "settings_path": str(path)}
808
+ if output == OutputFormat.human:
809
+ console.print(key_value_table("ASP auth removed", list(data.items())))
810
+ return
811
+ emit_runtime_success(runtime, output=output, operation="auth.logout", data=data)
812
+
813
+
814
+ def _config_list(runtime: RuntimeOptions, output: OutputFormat) -> None:
815
+ config = resolve_config(api_url=runtime.api_url, api_key=runtime.api_key)
816
+ global_settings = _redacted_settings(read_settings(config.global_path))
817
+ local_settings = _redacted_settings(read_settings(config.local_path) if config.local_path else {})
818
+ data = {
819
+ "global_path": str(config.global_path),
820
+ "local_path": str(config.local_path) if config.local_path else None,
821
+ "global": global_settings,
822
+ "local": local_settings,
823
+ "resolved": {
824
+ "api_url": config.api_url,
825
+ "api_url_source": config.sources.get("api_url"),
826
+ "api_key": redact_secret(config.api_key),
827
+ "api_key_source": config.sources.get("api_key"),
828
+ },
829
+ }
830
+ if output == OutputFormat.human:
831
+ console.print(key_value_table("ASP config", [
832
+ ("Global path", data["global_path"]),
833
+ ("Local path", data["local_path"]),
834
+ ("Resolved API URL", data["resolved"]["api_url"]),
835
+ ("API URL source", data["resolved"]["api_url_source"]),
836
+ ("Resolved API key", data["resolved"]["api_key"]),
837
+ ("API key source", data["resolved"]["api_key_source"]),
838
+ ]))
839
+ return
840
+ emit_runtime_success(runtime, output=output, operation="config.list", data=data)
841
+
842
+
843
+ def _config_get(runtime: RuntimeOptions, output: OutputFormat, key: str) -> None:
844
+ value, source = get_config_value(key, api_url=runtime.api_url, api_key=runtime.api_key)
845
+ display_value = redact_secret(value) if key == "api_key" else value
846
+ data = {"key": key, "value": display_value, "source": source}
847
+ if output == OutputFormat.human:
848
+ console.print(key_value_table("ASP config value", list(data.items())))
849
+ return
850
+ emit_runtime_success(runtime, output=output, operation="config.get", data=data)
851
+
852
+
853
+ def _config_set(runtime: RuntimeOptions, output: OutputFormat, key: str, value: str, local: bool) -> None:
854
+ path = set_config_value(key, value, local=local)
855
+ data = {"key": key, "scope": "local" if local else "global", "settings_path": str(path)}
856
+ if output == OutputFormat.human:
857
+ console.print(key_value_table("ASP config saved", list(data.items())))
858
+ return
859
+ emit_runtime_success(runtime, output=output, operation="config.set", data=data)
860
+
861
+
862
+ def _doctor(runtime: RuntimeOptions, output: OutputFormat) -> None:
863
+ config = _require_config(runtime)
864
+ client = AspClient(api_url=config.api_url or "", api_key=config.api_key, verbose=runtime.verbose, console=err_console)
865
+ checks = []
866
+ ok = True
867
+
868
+ try:
869
+ health = client.health()
870
+ checks.append({"name": "health", "ok": True, "detail": health.get("status", "ok")})
871
+ except CliError as exc:
872
+ ok = False
873
+ checks.append({"name": "health", "ok": False, "detail": exc.message})
874
+
875
+ version_payload = None
876
+ try:
877
+ version_payload = client.version()
878
+ checks.append({"name": "auth", "ok": True, "detail": "authenticated"})
879
+ checks.append({"name": "version", "ok": True, "detail": version_payload.get("data", {}).get("api_version")})
880
+ except CliError as exc:
881
+ ok = False
882
+ checks.append({"name": "auth", "ok": False, "detail": exc.message})
883
+
884
+ data = {
885
+ "ok": ok,
886
+ "api_url": config.api_url,
887
+ "api_url_source": config.sources.get("api_url"),
888
+ "api_key_source": config.sources.get("api_key"),
889
+ "checks": checks,
890
+ "server": version_payload.get("data") if version_payload else None,
891
+ }
892
+
893
+ if output == OutputFormat.human:
894
+ console.print(key_value_table("ASP doctor", [
895
+ ("Overall", "ok" if ok else "failed"),
896
+ ("API URL", data["api_url"]),
897
+ ("API URL source", data["api_url_source"]),
898
+ ("API key source", data["api_key_source"]),
899
+ ]))
900
+ for check in checks:
901
+ style = "green" if check["ok"] else "red"
902
+ console.print(f"{check['name']}: {check['detail']}", style=style)
903
+ else:
904
+ emit_runtime_success(runtime, output=output, operation="doctor", data=data)
905
+
906
+ if not ok:
907
+ raise typer.Exit(1)
908
+
909
+
910
+ def _list_cases(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None:
911
+ payload = _agent_get(runtime, "/api/agent/v1/cases/", _clean_params(filters))
912
+ _emit_agent_payload(runtime, output, "case.list", payload, lambda data, meta: _list_table(
913
+ "ASP cases",
914
+ ["case_id", "title", "severity", "status", "verdict", "priority", "created_at"],
915
+ data,
916
+ meta,
917
+ ))
918
+
919
+
920
+ def _show_case(runtime: RuntimeOptions, output: OutputFormat, case_id: str, include_related: bool) -> None:
921
+ payload = _agent_get(runtime, f"/api/agent/v1/cases/{case_id}/", {"include_related": include_related})
922
+ _emit_agent_payload(runtime, output, "case.show", payload, lambda data, _meta: _detail_table(
923
+ "ASP case",
924
+ data,
925
+ ["case_id", "title", "severity", "confidence", "impact", "priority", "status", "verdict", "severity_ai", "confidence_ai", "impact_ai", "priority_ai", "verdict_ai", "summary", "correlation_uid", "tags", "created_at"],
926
+ ))
927
+
928
+
929
+ def _update_case_ai(runtime: RuntimeOptions, output: OutputFormat, case_id: str, **fields) -> None:
930
+ summary_file = fields.pop("summary_file")
931
+ if summary_file is not None:
932
+ fields["summary"] = _read_text_file(summary_file)
933
+ body = {key: value for key, value in fields.items() if value is not None}
934
+ if not body:
935
+ raise CliError("missing_update_fields", "At least one AI analysis field is required", {}, EXIT_USAGE)
936
+ payload = _agent_request(runtime, "PATCH", f"/api/agent/v1/cases/{case_id}/ai-analysis/", json=body)
937
+ _emit_agent_payload(runtime, output, "case.update_ai", payload, lambda data, _meta: _detail_table(
938
+ "Updated ASP case AI analysis",
939
+ data,
940
+ ["case_id", "severity_ai", "confidence_ai", "impact_ai", "priority_ai", "verdict_ai", "summary"],
941
+ ))
942
+
943
+
944
+ def _list_alerts(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None:
945
+ payload = _agent_get(runtime, "/api/agent/v1/alerts/", _clean_params(filters))
946
+ _emit_agent_payload(runtime, output, "alert.list", payload, lambda data, meta: _list_table(
947
+ "ASP alerts",
948
+ ["alert_id", "case_id", "title", "severity", "status", "confidence", "created_at"],
949
+ data,
950
+ meta,
951
+ ))
952
+
953
+
954
+ def _show_alert(runtime: RuntimeOptions, output: OutputFormat, alert_id: str, include_related: bool) -> None:
955
+ payload = _agent_get(runtime, f"/api/agent/v1/alerts/{alert_id}/", {"include_related": include_related})
956
+ _emit_agent_payload(runtime, output, "alert.show", payload, lambda data, _meta: _detail_table(
957
+ "ASP alert",
958
+ data,
959
+ ["alert_id", "case_id", "title", "severity", "confidence", "impact", "status", "correlation_uid", "source_uid", "rule_id", "rule_name", "created_at"],
960
+ ))
961
+
962
+
963
+ def _list_artifacts(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None:
964
+ payload = _agent_get(runtime, "/api/agent/v1/artifacts/", _clean_params(filters))
965
+ _emit_agent_payload(runtime, output, "artifact.list", payload, lambda data, meta: _list_table(
966
+ "ASP artifacts",
967
+ ["artifact_id", "type", "role", "name", "value", "created_at"],
968
+ data,
969
+ meta,
970
+ ))
971
+
972
+
973
+ def _show_artifact(runtime: RuntimeOptions, output: OutputFormat, artifact_id: str, include_related: bool) -> None:
974
+ payload = _agent_get(runtime, f"/api/agent/v1/artifacts/{artifact_id}/", {"include_related": include_related})
975
+ _emit_agent_payload(runtime, output, "artifact.show", payload, lambda data, _meta: _detail_table(
976
+ "ASP artifact",
977
+ data,
978
+ ["artifact_id", "type", "role", "name", "value", "created_at"],
979
+ ))
980
+
981
+
982
+ def _search_knowledge(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None:
983
+ payload = _agent_get(runtime, "/api/agent/v1/knowledge/", _clean_params(filters))
984
+ _emit_agent_payload(runtime, output, "knowledge.search", payload, lambda data, meta: _list_table(
985
+ "ASP knowledge",
986
+ ["knowledge_id", "title", "source", "case_id", "tags", "created_at"],
987
+ data,
988
+ meta,
989
+ ))
990
+
991
+
992
+ def _show_knowledge(runtime: RuntimeOptions, output: OutputFormat, knowledge_id: str) -> None:
993
+ payload = _agent_get(runtime, f"/api/agent/v1/knowledge/{knowledge_id}/", {})
994
+ _emit_agent_payload(runtime, output, "knowledge.show", payload, lambda data, _meta: _detail_table(
995
+ "ASP knowledge",
996
+ data,
997
+ ["knowledge_id", "title", "source", "case_id", "tags", "expires_at", "body", "created_at"],
998
+ ))
999
+
1000
+
1001
+ def _update_knowledge(runtime: RuntimeOptions, output: OutputFormat, knowledge_id: str, **fields) -> None:
1002
+ body_file = fields.pop("body_file")
1003
+ if body_file is not None:
1004
+ fields["body"] = _read_text_file(body_file)
1005
+ if fields.get("tags") is not None:
1006
+ fields["tags"] = _split_csv(fields["tags"])
1007
+ body = {key: value for key, value in fields.items() if value is not None}
1008
+ if not body:
1009
+ raise CliError("missing_update_fields", "At least one knowledge field is required", {}, EXIT_USAGE)
1010
+ payload = _agent_request(runtime, "PATCH", f"/api/agent/v1/knowledge/{knowledge_id}/", json=body)
1011
+ _emit_agent_payload(runtime, output, "knowledge.update", payload, lambda data, _meta: _detail_table(
1012
+ "Updated ASP knowledge",
1013
+ data,
1014
+ ["knowledge_id", "title", "source", "case_id", "tags", "expires_at", "body"],
1015
+ ))
1016
+
1017
+
1018
+ def _list_comments(runtime: RuntimeOptions, output: OutputFormat, target_id: str, cursor: str | None, page_size: int | None) -> None:
1019
+ payload = _agent_get(runtime, "/api/agent/v1/comments/", _clean_params({
1020
+ "target_id": target_id,
1021
+ "cursor": cursor,
1022
+ "page_size": page_size,
1023
+ }))
1024
+ _emit_agent_payload(runtime, output, "comment.list", payload, lambda data, meta: _list_table(
1025
+ "ASP comments",
1026
+ ["id", "author", "body", "parent_id", "created_at"],
1027
+ data,
1028
+ meta,
1029
+ ))
1030
+
1031
+
1032
+ def _add_comment(runtime: RuntimeOptions, output: OutputFormat, target_id: str, **fields) -> None:
1033
+ body_file = fields.pop("body_file")
1034
+ if body_file is not None:
1035
+ fields["body"] = _read_text_file(body_file)
1036
+ body = {
1037
+ "target_id": target_id,
1038
+ "body": fields.get("body") or "",
1039
+ "file_keys": _split_csv(fields.get("file_key")),
1040
+ "parent_id": fields.get("parent_id"),
1041
+ "mentions": _split_csv(fields.get("mentions")),
1042
+ }
1043
+ if not body["body"].strip() and not body["file_keys"]:
1044
+ raise CliError("missing_comment_content", "Comment body or file_key is required", {}, EXIT_USAGE)
1045
+ payload = _agent_request(runtime, "POST", "/api/agent/v1/comments/", json=body)
1046
+ _emit_agent_payload(runtime, output, "comment.add", payload, lambda data, _meta: _detail_table(
1047
+ "ASP comment added",
1048
+ data,
1049
+ ["id", "author", "body", "parent_id", "created_at"],
1050
+ ))
1051
+
1052
+
1053
+ def _upload_file(runtime: RuntimeOptions, output: OutputFormat, path: Path) -> None:
1054
+ if not path.exists() or not path.is_file():
1055
+ raise CliError("file_not_found", f"File not found: {path}", {"path": str(path)}, EXIT_USAGE)
1056
+ with path.open("rb") as handle:
1057
+ payload = _agent_request(runtime, "POST", "/api/agent/v1/files/", files={"file": (path.name, handle)})
1058
+ _emit_agent_payload(runtime, output, "file.upload", payload, lambda data, _meta: _detail_table(
1059
+ "ASP file uploaded",
1060
+ data,
1061
+ ["file_key", "filename", "size", "content_type", "download_url"],
1062
+ ))
1063
+
1064
+
1065
+ def _file_info(runtime: RuntimeOptions, output: OutputFormat, file_key: str) -> None:
1066
+ payload = _agent_get(runtime, f"/api/agent/v1/files/{file_key}/", {})
1067
+ _emit_agent_payload(runtime, output, "file.info", payload, lambda data, _meta: _detail_table(
1068
+ "ASP file",
1069
+ data,
1070
+ ["file_key", "filename", "size", "content_type", "download_url", "uploaded_at"],
1071
+ ))
1072
+
1073
+
1074
+ def _download_file(runtime: RuntimeOptions, output: OutputFormat, file_key: str, output_path: Path | None) -> None:
1075
+ info = _agent_get(runtime, f"/api/agent/v1/files/{file_key}/", {})
1076
+ data = info.get("data") or {}
1077
+ target_path = output_path or Path(data.get("filename") or file_key)
1078
+ try:
1079
+ response = httpx.get(data["download_url"], timeout=60.0)
1080
+ response.raise_for_status()
1081
+ target_path.parent.mkdir(parents=True, exist_ok=True)
1082
+ target_path.write_bytes(response.content)
1083
+ except (KeyError, httpx.HTTPError, OSError) as exc:
1084
+ raise CliError("file_download_failed", f"Unable to download file: {file_key}", {"file_key": file_key}, EXIT_NETWORK) from exc
1085
+ result = {**data, "output_path": str(target_path)}
1086
+ if output == OutputFormat.human:
1087
+ console.print(key_value_table("ASP file downloaded", [
1088
+ ("file_key", result.get("file_key")),
1089
+ ("filename", result.get("filename")),
1090
+ ("output_path", result.get("output_path")),
1091
+ ("size", result.get("size")),
1092
+ ]))
1093
+ return
1094
+ emit_runtime_success(runtime, output=output, operation="file.download", data=result, meta=info.get("meta"))
1095
+
1096
+
1097
+ def _read_file_text(runtime: RuntimeOptions, output: OutputFormat, file_key: str, max_bytes: int) -> None:
1098
+ payload = _agent_get(runtime, f"/api/agent/v1/files/{file_key}/read-text/", {"max_bytes": max_bytes})
1099
+ _emit_agent_payload(runtime, output, "file.read_text", payload, lambda data, _meta: data.get("text", ""))
1100
+
1101
+
1102
+ def _create_enrichment(runtime: RuntimeOptions, output: OutputFormat, target_id: str, **fields) -> None:
1103
+ data_file = fields.pop("data_file")
1104
+ data_json = fields.pop("data_json")
1105
+ body = {"target_id": target_id, **fields}
1106
+ if data_file is not None:
1107
+ body["data"] = _read_json_object_file(data_file)
1108
+ elif data_json is not None:
1109
+ body["data"] = _read_json_object_text(data_json)
1110
+ else:
1111
+ body["data"] = {}
1112
+ payload = _agent_request(runtime, "POST", "/api/agent/v1/enrichments/", json=body)
1113
+ _emit_agent_payload(runtime, output, "enrichment.create", payload, lambda data, _meta: _detail_table(
1114
+ "ASP enrichment created",
1115
+ data,
1116
+ ["enrichment_id", "target_id", "name", "type", "provider", "uid", "value", "desc", "created_at"],
1117
+ ))
1118
+
1119
+
1120
+ def _list_playbook_templates(runtime: RuntimeOptions, output: OutputFormat) -> None:
1121
+ payload = _agent_get(runtime, "/api/agent/v1/playbooks/templates/", {})
1122
+ _emit_agent_payload(runtime, output, "playbook.template.list", payload, lambda data, _meta: _list_table(
1123
+ "ASP playbook templates",
1124
+ ["name", "description", "tags"],
1125
+ data,
1126
+ ))
1127
+
1128
+
1129
+ def _list_playbooks(runtime: RuntimeOptions, output: OutputFormat, **filters) -> None:
1130
+ payload = _agent_get(runtime, "/api/agent/v1/playbooks/", _clean_params(filters))
1131
+ _emit_agent_payload(runtime, output, "playbook.list", payload, lambda data, meta: _list_table(
1132
+ "ASP playbooks",
1133
+ ["playbook_id", "case_id", "name", "job_status", "job_id", "created_at"],
1134
+ data,
1135
+ meta,
1136
+ ))
1137
+
1138
+
1139
+ def _show_playbook(runtime: RuntimeOptions, output: OutputFormat, playbook_id: str, include_related: bool) -> None:
1140
+ payload = _agent_get(runtime, f"/api/agent/v1/playbooks/{playbook_id}/", {"include_related": include_related})
1141
+ _emit_agent_payload(runtime, output, "playbook.show", payload, lambda data, _meta: _detail_table(
1142
+ "ASP playbook",
1143
+ data,
1144
+ ["playbook_id", "case_id", "name", "user_input", "job_status", "job_id", "remark", "created_at"],
1145
+ ))
1146
+
1147
+
1148
+ def _run_playbook(runtime: RuntimeOptions, output: OutputFormat, name: str, case_id: str, **fields) -> None:
1149
+ user_input_file = fields.get("user_input_file")
1150
+ user_input = _read_text_file(user_input_file) if user_input_file is not None else (fields.get("user_input") or "")
1151
+ payload = _agent_request(runtime, "POST", "/api/agent/v1/playbooks/run/", json={
1152
+ "name": name,
1153
+ "case_id": case_id,
1154
+ "user_input": user_input,
1155
+ })
1156
+ _emit_agent_payload(runtime, output, "playbook.run", payload, lambda data, _meta: _detail_table(
1157
+ "ASP playbook queued",
1158
+ data,
1159
+ ["playbook_id", "case_id", "name", "job_status", "created_at"],
1160
+ ))
1161
+
1162
+
1163
+ def _siem_schema(runtime: RuntimeOptions, output: OutputFormat, target_index: str | None) -> None:
1164
+ payload = _agent_get(runtime, "/api/agent/v1/siem/schema/", _clean_params({"target_index": target_index}))
1165
+ if target_index:
1166
+ renderer = lambda data, _meta: _siem_schema_detail(data)
1167
+ else:
1168
+ renderer = lambda data, _meta: _list_table(
1169
+ "ASP SIEM schema",
1170
+ ["name", "backend", "description", "default_aggregation_fields"],
1171
+ data,
1172
+ )
1173
+ _emit_agent_payload(runtime, output, "siem.schema", payload, renderer)
1174
+
1175
+
1176
+ def _siem_keyword(runtime: RuntimeOptions, output: OutputFormat, keyword: str, time_range_start: str, time_range_end: str, time_field: str, index_name: str | None) -> None:
1177
+ body = {
1178
+ "keyword": _split_csv(keyword) if "," in keyword else keyword,
1179
+ "time_range_start": time_range_start,
1180
+ "time_range_end": time_range_end,
1181
+ "time_field": time_field,
1182
+ "index_name": index_name,
1183
+ }
1184
+ payload = _agent_request(runtime, "POST", "/api/agent/v1/siem/search/keyword/", json=_clean_params(body))
1185
+ _emit_agent_payload(runtime, output, "siem.search.keyword", payload, _siem_query_table)
1186
+
1187
+
1188
+ def _siem_adaptive(
1189
+ runtime: RuntimeOptions,
1190
+ output: OutputFormat,
1191
+ index_name: str,
1192
+ time_range_start: str,
1193
+ time_range_end: str,
1194
+ time_field: str,
1195
+ filters_json: str | None,
1196
+ filters_file: Path | None,
1197
+ aggregation_fields: str | None,
1198
+ ) -> None:
1199
+ filters = {}
1200
+ if filters_file is not None:
1201
+ filters = _read_json_object_file(filters_file)
1202
+ elif filters_json is not None:
1203
+ filters = _read_json_object_text(filters_json)
1204
+ payload = _agent_request(runtime, "POST", "/api/agent/v1/siem/query/adaptive/", json={
1205
+ "index_name": index_name,
1206
+ "time_range_start": time_range_start,
1207
+ "time_range_end": time_range_end,
1208
+ "time_field": time_field,
1209
+ "filters": filters,
1210
+ "aggregation_fields": _split_csv(aggregation_fields),
1211
+ })
1212
+ _emit_agent_payload(runtime, output, "siem.query.adaptive", payload, _siem_query_table)
1213
+
1214
+
1215
+ def _siem_raw_query(
1216
+ runtime: RuntimeOptions,
1217
+ output: OutputFormat,
1218
+ kind: str,
1219
+ query: str,
1220
+ time_range_start: str,
1221
+ time_range_end: str,
1222
+ limit: int,
1223
+ time_field: str,
1224
+ index_name: str | None,
1225
+ ) -> None:
1226
+ payload = _agent_request(runtime, "POST", f"/api/agent/v1/siem/query/{kind}/", json=_clean_params({
1227
+ "query": query,
1228
+ "time_range_start": time_range_start,
1229
+ "time_range_end": time_range_end,
1230
+ "limit": limit,
1231
+ "time_field": time_field,
1232
+ "index_name": index_name,
1233
+ }))
1234
+ _emit_agent_payload(runtime, output, f"siem.query.{kind}", payload, _siem_query_table)
1235
+
1236
+
1237
+ def _siem_fields_discover(runtime: RuntimeOptions, output: OutputFormat, index_name: str, backend: str, time_range_start: str, time_range_end: str, doc_limit: int, max_samples_per_field: int) -> None:
1238
+ payload = _agent_request(runtime, "POST", "/api/agent/v1/siem/fields/discover/", json={
1239
+ "index_name": index_name,
1240
+ "backend": backend,
1241
+ "time_range_start": time_range_start,
1242
+ "time_range_end": time_range_end,
1243
+ "doc_limit": doc_limit,
1244
+ "max_samples_per_field": max_samples_per_field,
1245
+ })
1246
+ _emit_agent_payload(runtime, output, "siem.fields.discover", payload, lambda data, _meta: _detail_table(
1247
+ "ASP SIEM fields",
1248
+ data,
1249
+ ["backend", "index_name", "total_fields"],
1250
+ ))
1251
+
1252
+
1253
+ def _ti_query(runtime: RuntimeOptions, output: OutputFormat, indicator: str, artifact_type: str, provider: str | None) -> None:
1254
+ payload = _agent_request(runtime, "POST", "/api/agent/v1/threat-intel/query/", json=_clean_params({
1255
+ "indicator": indicator,
1256
+ "artifact_type": artifact_type,
1257
+ "provider": provider,
1258
+ }))
1259
+ _emit_agent_payload(runtime, output, "ti.query", payload, lambda data, _meta: _detail_table(
1260
+ "ASP threat intelligence",
1261
+ data,
1262
+ ["indicator", "indicator_type", "aggregated_risk_level", "errors"],
1263
+ ))
1264
+
1265
+
1266
+ def _cmdb_lookup(runtime: RuntimeOptions, output: OutputFormat, artifact_type: str, artifact_value: str, provider: str | None) -> None:
1267
+ payload = _agent_request(runtime, "POST", "/api/agent/v1/cmdb/lookup/", json=_clean_params({
1268
+ "artifact_type": artifact_type,
1269
+ "artifact_value": artifact_value,
1270
+ "provider": provider,
1271
+ }))
1272
+ _emit_agent_payload(runtime, output, "cmdb.lookup", payload, lambda data, _meta: _detail_table(
1273
+ "ASP CMDB lookup",
1274
+ data,
1275
+ ["artifact_type", "artifact_value", "errors"],
1276
+ ))
1277
+
1278
+
1279
+ def _dev_stream_head(runtime: RuntimeOptions, output: OutputFormat, stream_name: str, n: int) -> None:
1280
+ payload = _agent_get(runtime, "/api/agent/v1/dev/streams/head/", {"stream_name": stream_name, "n": n})
1281
+ _emit_agent_payload(runtime, output, "dev.stream.head", payload, lambda data, _meta: _list_table(
1282
+ "ASP stream head",
1283
+ ["message_id", "data"],
1284
+ data,
1285
+ ))
1286
+
1287
+
1288
+ def _dev_stream_read(runtime: RuntimeOptions, output: OutputFormat, stream_name: str, message_id: str) -> None:
1289
+ payload = _agent_get(runtime, "/api/agent/v1/dev/streams/message/", {"stream_name": stream_name, "message_id": message_id})
1290
+ _emit_agent_payload(runtime, output, "dev.stream.read", payload, lambda data, _meta: _detail_table(
1291
+ "ASP stream message",
1292
+ data,
1293
+ ["message_id", "data"],
1294
+ ))
1295
+
1296
+
1297
+ def _agent_get(runtime: RuntimeOptions, path: str, params: dict) -> dict:
1298
+ return _agent_request(runtime, "GET", _path_with_query(path, params))
1299
+
1300
+
1301
+ def _agent_request(runtime: RuntimeOptions, method: str, path: str, *, json=None, files=None) -> dict:
1302
+ config = _require_config(runtime)
1303
+ client = AspClient(api_url=config.api_url or "", api_key=config.api_key, verbose=runtime.verbose, console=err_console)
1304
+ return client.request(method, path, json=json, files=files)
1305
+
1306
+
1307
+ def _path_with_query(path: str, params: dict) -> str:
1308
+ cleaned = _clean_params(params)
1309
+ if not cleaned:
1310
+ return path
1311
+ return f"{path}?{urlencode(cleaned, doseq=True)}"
1312
+
1313
+
1314
+ def _clean_params(params: dict) -> dict:
1315
+ cleaned = {}
1316
+ for key, value in params.items():
1317
+ if value is None or value is False or value == "":
1318
+ continue
1319
+ if key == "tags":
1320
+ cleaned["tag"] = _split_csv(value)
1321
+ elif isinstance(value, str) and "," in value and key in {"status", "severity", "confidence", "verdict", "type", "role"}:
1322
+ cleaned[key] = _split_csv(value)
1323
+ else:
1324
+ cleaned[key] = value
1325
+ return cleaned
1326
+
1327
+
1328
+ def _emit_agent_payload(runtime: RuntimeOptions, output: OutputFormat, operation: str, payload: dict, human_renderer) -> None:
1329
+ data = payload.get("data")
1330
+ meta = payload.get("meta") or {}
1331
+ if output == OutputFormat.human:
1332
+ console.print(human_renderer(data, meta))
1333
+ return
1334
+ emit_runtime_success(runtime, output=output, operation=operation, data=data, meta=meta)
1335
+
1336
+
1337
+ def _list_table(title: str, columns: list[str], rows: list[dict], meta: dict | None = None) -> Table:
1338
+ table = Table(title=title)
1339
+ for column in columns:
1340
+ table.add_column(column)
1341
+ for row in rows or []:
1342
+ table.add_row(*[_format_cell(row.get(column)) for column in columns])
1343
+ pagination = (meta or {}).get("pagination") or {}
1344
+ if pagination.get("has_more"):
1345
+ table.caption = f"More results available. Continue with --cursor {pagination.get('next_cursor')}"
1346
+ return table
1347
+
1348
+
1349
+ def _detail_table(title: str, data: dict, fields: list[str]) -> Table:
1350
+ return key_value_table(title, [(field, _format_cell(data.get(field))) for field in fields if field in data])
1351
+
1352
+
1353
+ def _siem_schema_detail(data: dict) -> Table:
1354
+ table = _detail_table("ASP SIEM schema", data, ["name", "backend", "description"])
1355
+ fields = data.get("fields") or []
1356
+ table.caption = f"{len(fields)} fields. Use --output json for full field metadata."
1357
+ return table
1358
+
1359
+
1360
+ def _siem_query_table(data, _meta) -> Table:
1361
+ rows = data if isinstance(data, list) else [data]
1362
+ return _list_table(
1363
+ "ASP SIEM query",
1364
+ ["backend", "index_name", "status", "total_hits", "returned_records", "truncated", "message"],
1365
+ rows,
1366
+ )
1367
+
1368
+
1369
+ def _format_cell(value) -> str:
1370
+ if value is None:
1371
+ return ""
1372
+ if isinstance(value, list):
1373
+ return ", ".join(str(item) for item in value)
1374
+ if isinstance(value, dict):
1375
+ return ", ".join(f"{key}={val}" for key, val in value.items())
1376
+ text = str(value)
1377
+ return text if len(text) <= 160 else f"{text[:157]}..."
1378
+
1379
+
1380
+ def _split_csv(value: str | None) -> list[str]:
1381
+ if not value:
1382
+ return []
1383
+ return [item.strip() for item in value.split(",") if item.strip()]
1384
+
1385
+
1386
+ def _read_text_file(path: Path) -> str:
1387
+ try:
1388
+ return path.read_text(encoding="utf-8")
1389
+ except OSError as exc:
1390
+ raise CliError("file_read_failed", f"Unable to read file: {path}", {"path": str(path)}, EXIT_USAGE) from exc
1391
+
1392
+
1393
+ def _read_json_object_file(path: Path) -> dict:
1394
+ return _read_json_object_text(_read_text_file(path))
1395
+
1396
+
1397
+ def _read_json_object_text(text: str) -> dict:
1398
+ try:
1399
+ payload = json.loads(text)
1400
+ except json.JSONDecodeError as exc:
1401
+ raise CliError("invalid_json", "Expected a valid JSON object", {}, EXIT_USAGE) from exc
1402
+ if not isinstance(payload, dict):
1403
+ raise CliError("invalid_json", "Expected a valid JSON object", {}, EXIT_USAGE)
1404
+ return payload
1405
+
1406
+
1407
+ def _require_config(runtime: RuntimeOptions):
1408
+ config = resolve_config(api_url=runtime.api_url, api_key=runtime.api_key)
1409
+ missing = []
1410
+ if not config.api_url:
1411
+ missing.append("api_url")
1412
+ if not config.api_key:
1413
+ missing.append("api_key")
1414
+ if missing:
1415
+ raise CliError(
1416
+ "missing_config",
1417
+ "ASP API URL and API key are required. Run: asp auth login --api-url <url> --api-key <key>",
1418
+ {"missing": missing},
1419
+ EXIT_AUTH if "api_key" in missing else EXIT_CONFIG,
1420
+ )
1421
+ return config
1422
+
1423
+
1424
+ def _redacted_settings(settings: dict) -> dict:
1425
+ redacted = dict(settings)
1426
+ if "api_key" in redacted:
1427
+ redacted["api_key"] = redact_secret(str(redacted["api_key"]))
1428
+ return redacted
1429
+
1430
+
1431
+ def apply_query(data, query: str | None):
1432
+ if not query:
1433
+ return data
1434
+ return jmespath.search(query, data)
1435
+
1436
+
1437
+ def emit_runtime_success(
1438
+ runtime: RuntimeOptions,
1439
+ *,
1440
+ output: OutputFormat,
1441
+ operation: str,
1442
+ data,
1443
+ meta: dict | None = None,
1444
+ human: str | None = None,
1445
+ ) -> None:
1446
+ emit_success(
1447
+ console,
1448
+ output=output,
1449
+ operation=operation,
1450
+ data=apply_query(data, runtime.query) if output == OutputFormat.json else data,
1451
+ meta=meta,
1452
+ human=human,
1453
+ )
1454
+
1455
+
1456
+ def run() -> None:
1457
+ app()
1458
+
1459
+
1460
+ if __name__ == "__main__":
1461
+ run()