splunkctl 0.2.0__tar.gz → 0.3.0__tar.gz

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 (53) hide show
  1. {splunkctl-0.2.0 → splunkctl-0.3.0}/PKG-INFO +12 -2
  2. {splunkctl-0.2.0 → splunkctl-0.3.0}/pyproject.toml +14 -1
  3. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/__init__.py +1 -1
  4. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/client.py +104 -105
  5. splunkctl-0.3.0/splunkctl/commands/alerts.py +131 -0
  6. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/apps.py +4 -0
  7. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/commands_meta.py +8 -0
  8. splunkctl-0.3.0/splunkctl/commands/common.py +161 -0
  9. splunkctl-0.3.0/splunkctl/commands/dashboards.py +467 -0
  10. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/doctor.py +69 -7
  11. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/hec.py +113 -10
  12. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/indexes.py +29 -4
  13. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/inputs.py +5 -0
  14. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/lookups.py +19 -4
  15. splunkctl-0.3.0/splunkctl/commands/parsers.py +380 -0
  16. splunkctl-0.3.0/splunkctl/commands/parsers_io.py +228 -0
  17. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/rules.py +140 -11
  18. splunkctl-0.3.0/splunkctl/commands/rules_io.py +302 -0
  19. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/search.py +75 -14
  20. splunkctl-0.3.0/splunkctl/commands/server.py +107 -0
  21. splunkctl-0.3.0/splunkctl/commands/users.py +430 -0
  22. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/guard.py +14 -0
  23. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/main.py +32 -2
  24. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/output.py +47 -4
  25. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/skill/SKILL.md +57 -12
  26. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/PKG-INFO +12 -2
  27. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/SOURCES.txt +4 -0
  28. splunkctl-0.3.0/splunkctl.egg-info/requires.txt +15 -0
  29. splunkctl-0.3.0/tests/test_client.py +199 -0
  30. splunkctl-0.3.0/tests/test_main_hoisting.py +60 -0
  31. {splunkctl-0.2.0 → splunkctl-0.3.0}/tests/test_output.py +70 -0
  32. {splunkctl-0.2.0 → splunkctl-0.3.0}/tests/test_version.py +1 -1
  33. splunkctl-0.2.0/splunkctl/commands/alerts.py +0 -94
  34. splunkctl-0.2.0/splunkctl/commands/dashboards.py +0 -203
  35. splunkctl-0.2.0/splunkctl/commands/parsers.py +0 -177
  36. splunkctl-0.2.0/splunkctl/commands/rules_io.py +0 -222
  37. splunkctl-0.2.0/splunkctl/commands/users.py +0 -245
  38. splunkctl-0.2.0/splunkctl.egg-info/requires.txt +0 -4
  39. splunkctl-0.2.0/tests/test_client.py +0 -56
  40. {splunkctl-0.2.0 → splunkctl-0.3.0}/LICENSE +0 -0
  41. {splunkctl-0.2.0 → splunkctl-0.3.0}/README.md +0 -0
  42. {splunkctl-0.2.0 → splunkctl-0.3.0}/setup.cfg +0 -0
  43. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/__main__.py +0 -0
  44. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/__init__.py +0 -0
  45. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/config_cmd.py +0 -0
  46. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/info.py +0 -0
  47. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/commands/skill_cmd.py +0 -0
  48. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl/config.py +0 -0
  49. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  50. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/entry_points.txt +0 -0
  51. {splunkctl-0.2.0 → splunkctl-0.3.0}/splunkctl.egg-info/top_level.txt +0 -0
  52. {splunkctl-0.2.0 → splunkctl-0.3.0}/tests/test_config.py +0 -0
  53. {splunkctl-0.2.0 → splunkctl-0.3.0}/tests/test_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
@@ -20,9 +20,19 @@ Requires-Python: >=3.13
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Requires-Dist: splunk-sdk>=2.0
23
- Requires-Dist: click>=8.0
23
+ Requires-Dist: click>=8.2
24
24
  Requires-Dist: pyyaml>=6.0
25
25
  Requires-Dist: tabulate>=0.9
26
+ Requires-Dist: requests>=2.32
27
+ Requires-Dist: defusedxml>=0.7
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.6; extra == "dev"
31
+ Requires-Dist: mypy>=1.11; extra == "dev"
32
+ Requires-Dist: types-requests; extra == "dev"
33
+ Requires-Dist: types-PyYAML; extra == "dev"
34
+ Requires-Dist: types-tabulate; extra == "dev"
35
+ Requires-Dist: types-defusedxml; extra == "dev"
26
36
  Dynamic: license-file
27
37
 
28
38
  # splunkctl
@@ -24,9 +24,22 @@ classifiers = [
24
24
  ]
25
25
  dependencies = [
26
26
  "splunk-sdk>=2.0",
27
- "click>=8.0",
27
+ "click>=8.2",
28
28
  "pyyaml>=6.0",
29
29
  "tabulate>=0.9",
30
+ "requests>=2.32",
31
+ "defusedxml>=0.7",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8.0",
37
+ "ruff>=0.6",
38
+ "mypy>=1.11",
39
+ "types-requests",
40
+ "types-PyYAML",
41
+ "types-tabulate",
42
+ "types-defusedxml",
30
43
  ]
31
44
 
32
45
  [project.scripts]
@@ -1,3 +1,3 @@
1
1
  """CLI tool for Splunk Enterprise SIEM operations."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -6,17 +6,13 @@ handle remotely (e.g. lookup file upload requires server-side staging).
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import http.cookiejar
10
- import json
11
9
  import re
12
- import ssl
13
10
  import urllib.parse
14
- import urllib.request
15
- import uuid
16
11
  from pathlib import Path
17
12
  from typing import Any
18
13
 
19
14
  import click
15
+ import requests
20
16
  import splunklib.client as splunk_client
21
17
 
22
18
  from splunkctl import config as cfg_mod
@@ -111,7 +107,10 @@ class SplunkClient:
111
107
  cfg = cfg_mod.load(self._config_path)
112
108
  cfg.update(self._overrides)
113
109
  self._web_session = _WebSession(
114
- self.service, verify=bool(cfg.get("verify", False))
110
+ self.service,
111
+ verify=bool(cfg.get("verify", False)),
112
+ debug=self._debug,
113
+ timeout=self._timeout or 30,
115
114
  )
116
115
  return self._web_session
117
116
 
@@ -137,11 +136,36 @@ class SplunkClient:
137
136
  """Install a .spl/.tar.gz app package via the Splunk Web UI."""
138
137
  self._ensure_web_session().install_app(file_path, force=force)
139
138
 
139
+ def set_acl(self, entity: Any, *, sharing: str, owner: str | None = None) -> None:
140
+ """Change an entity's sharing level via its ACL endpoint.
141
+
142
+ Args:
143
+ entity: Any SDK entity (saved search, dashboard, conf stanza...).
144
+ sharing: One of user, app, global.
145
+ owner: Defaults to the entity's current owner, else "nobody".
146
+ """
147
+ acl: dict[str, Any] = dict(entity.access)
148
+ entity.acl_update(sharing=sharing, owner=owner or acl.get("owner", "nobody"))
149
+
140
150
 
141
151
  class _WebSession:
142
- """Manages authentication and uploads via Splunk Web UI."""
152
+ """Authenticated Splunk Web session for form-handler operations.
153
+
154
+ Uses ``requests`` deliberately: the manager form handlers answer a
155
+ plain urllib POST with a 303 that dead-ends in a 404, while a
156
+ keep-alive session receives the JSON result directly (verified
157
+ against Splunk 10.4). TLS verification is on unless the user's
158
+ config explicitly disables it for self-signed dev instances.
159
+ """
143
160
 
144
- def __init__(self, service: Any, *, verify: bool = True) -> None:
161
+ def __init__(
162
+ self,
163
+ service: Any,
164
+ *,
165
+ verify: bool = True,
166
+ debug: bool = False,
167
+ timeout: int = 30,
168
+ ) -> None:
145
169
  self._host: str = service.host
146
170
  self._username: str = service.username
147
171
  self._password: str = service.password
@@ -155,15 +179,10 @@ class _WebSession:
155
179
  self._web_port = int(web_conf["httpport"])
156
180
  self._web_ssl = str(web_conf.content.get("enableSplunkWebSSL", "0")) == "1"
157
181
 
158
- self._cookies = http.cookiejar.CookieJar()
159
- ctx = ssl.create_default_context()
160
- if not verify:
161
- ctx.check_hostname = False
162
- ctx.verify_mode = ssl.CERT_NONE # noqa: S501
163
- self._opener = urllib.request.build_opener(
164
- urllib.request.HTTPCookieProcessor(self._cookies),
165
- urllib.request.HTTPSHandler(context=ctx),
166
- )
182
+ self._debug = debug
183
+ self._timeout = timeout
184
+ self._session = requests.Session()
185
+ self._session.verify = verify
167
186
  self._csrf_token: str | None = None
168
187
  self._logged_in = False
169
188
 
@@ -172,80 +191,47 @@ class _WebSession:
172
191
  scheme = "https" if self._web_ssl else "http"
173
192
  return f"{scheme}://{self._host}:{self._web_port}"
174
193
 
194
+ def _request(self, method: str, url: str, **kwargs: Any) -> requests.Response:
195
+ resp = self._session.request(method, url, timeout=self._timeout, **kwargs)
196
+ if self._debug:
197
+ click.echo(f"web {method} {url} -> {resp.status_code}", err=True)
198
+ return resp
199
+
175
200
  def _login(self) -> None:
176
201
  """Authenticate to Splunk Web and obtain session cookies."""
177
202
  login_url = f"{self._base_url}/en-US/account/login"
178
- resp = self._opener.open(login_url) # noqa: S310
179
- page = resp.read().decode("utf-8")
203
+ page = self._request("GET", login_url).text
180
204
 
181
205
  m = re.search(r'"cval"\s*:\s*(\d+)', page)
182
206
  cval = m.group(1) if m else "0"
183
207
 
184
- data = urllib.parse.urlencode(
185
- {
208
+ resp = self._request(
209
+ "POST",
210
+ login_url,
211
+ data={
186
212
  "username": self._username,
187
213
  "password": self._password,
188
214
  "cval": cval,
189
- }
190
- ).encode()
191
- resp = self._opener.open(login_url, data) # noqa: S310
192
- body = json.loads(resp.read().decode("utf-8"))
215
+ },
216
+ )
217
+ try:
218
+ body: dict[str, Any] = resp.json()
219
+ except ValueError:
220
+ raise RuntimeError(
221
+ f"Splunk Web login failed: HTTP {resp.status_code}"
222
+ ) from None
193
223
  if body.get("status") == "fail":
194
224
  msg = body.get("msg", "unknown error")
195
225
  raise RuntimeError(f"Splunk Web login failed: {msg}")
196
226
 
197
- for cookie in self._cookies:
198
- if cookie.name.startswith("splunkweb_csrf_token"):
227
+ for cookie in self._session.cookies:
228
+ if cookie.name and cookie.name.startswith("splunkweb_csrf_token"):
199
229
  self._csrf_token = cookie.value
200
230
  break
201
231
  if not self._csrf_token:
202
232
  raise RuntimeError("Could not obtain CSRF token from Splunk Web")
203
233
  self._logged_in = True
204
234
 
205
- def _multipart_post(
206
- self,
207
- url: str,
208
- fields: list[tuple[str, str]],
209
- file_field: str,
210
- file_name: str,
211
- file_data: bytes,
212
- content_type: str = "application/octet-stream",
213
- ) -> bytes:
214
- """Build and send a multipart/form-data POST."""
215
- if not self._logged_in:
216
- self._login()
217
-
218
- boundary = uuid.uuid4().hex
219
- body = b""
220
- for field_name, value in fields:
221
- body += f"--{boundary}\r\n".encode()
222
- body += (
223
- f'Content-Disposition: form-data; name="{field_name}"\r\n'
224
- f"\r\n"
225
- f"{value}\r\n"
226
- ).encode()
227
-
228
- body += f"--{boundary}\r\n".encode()
229
- body += (
230
- f'Content-Disposition: form-data; name="{file_field}";'
231
- f' filename="{file_name}"\r\n'
232
- f"Content-Type: {content_type}\r\n"
233
- f"\r\n"
234
- ).encode()
235
- body += file_data
236
- body += f"\r\n--{boundary}--\r\n".encode()
237
-
238
- req = urllib.request.Request( # noqa: S310
239
- url,
240
- data=body,
241
- headers={
242
- "Content-Type": f"multipart/form-data; boundary={boundary}",
243
- },
244
- method="POST",
245
- )
246
- resp = self._opener.open(req) # noqa: S310
247
- return bytes(resp.read())
248
-
249
235
  def upload_lookup(
250
236
  self,
251
237
  name: str,
@@ -255,6 +241,9 @@ class _WebSession:
255
241
  update: bool = False,
256
242
  ) -> None:
257
243
  """Upload a lookup file via multipart form POST."""
244
+ if not self._logged_in:
245
+ self._login()
246
+
258
247
  if update:
259
248
  url = (
260
249
  f"{self._base_url}/en-US/manager/{app}"
@@ -265,27 +254,22 @@ class _WebSession:
265
254
  url = f"{self._base_url}/en-US/manager/{app}/data/lookup-table-files/_new"
266
255
  action = "new"
267
256
 
268
- fields: list[tuple[str, str]] = [
269
- ("__action", action),
270
- ("__redirect", ""),
271
- ("__ns", app),
272
- ("splunk_form_key", self._csrf_token or ""),
273
- ]
257
+ data: dict[str, str] = {
258
+ "__action": action,
259
+ "__redirect": "",
260
+ "__ns": app,
261
+ "splunk_form_key": self._csrf_token or "",
262
+ }
274
263
  if not update:
275
- fields.append(("name", name))
264
+ data["name"] = name
276
265
 
277
- resp_body = self._multipart_post(
266
+ resp = self._request(
267
+ "POST",
278
268
  url,
279
- fields,
280
- file_field="spl-ctrl_lookupfile",
281
- file_name=name,
282
- file_data=file_path.read_bytes(),
283
- content_type="text/csv",
269
+ data=data,
270
+ files={"spl-ctrl_lookupfile": (name, file_path.read_bytes(), "text/csv")},
284
271
  )
285
- result = json.loads(resp_body.decode("utf-8"))
286
- if result.get("status") != "OK":
287
- msg = result.get("msg", "unknown error")
288
- raise RuntimeError(f"Lookup upload failed: {msg}")
272
+ _expect_ok(resp, "Lookup upload")
289
273
 
290
274
  def install_app(
291
275
  self,
@@ -298,30 +282,45 @@ class _WebSession:
298
282
  self._login()
299
283
 
300
284
  upload_url = f"{self._base_url}/en-US/manager/appinstall/_upload"
301
- resp = self._opener.open(upload_url) # noqa: S310
302
- page = resp.read().decode("utf-8")
285
+ page = self._request("GET", upload_url).text
303
286
 
304
- m = re.search(
305
- r'name="state"\s+[^>]*value="([^"]*)"',
306
- page,
307
- )
287
+ m = re.search(r'name="state"\s+[^>]*value="([^"]*)"', page)
308
288
  state = m.group(1) if m else ""
309
289
 
310
- fields: list[tuple[str, str]] = [
311
- ("state", state),
312
- ("splunk_form_key", self._csrf_token or ""),
313
- ]
290
+ data: dict[str, str] = {
291
+ "state": state,
292
+ "splunk_form_key": self._csrf_token or "",
293
+ }
314
294
  if force:
315
- fields.append(("force", "1"))
295
+ data["force"] = "1"
316
296
 
317
- self._multipart_post(
297
+ resp = self._request(
298
+ "POST",
318
299
  upload_url,
319
- fields,
320
- file_field="appfile",
321
- file_name=file_path.name,
322
- file_data=file_path.read_bytes(),
323
- content_type="application/gzip",
300
+ data=data,
301
+ files={
302
+ "appfile": (
303
+ file_path.name,
304
+ file_path.read_bytes(),
305
+ "application/gzip",
306
+ )
307
+ },
324
308
  )
309
+ if resp.status_code >= 400:
310
+ raise RuntimeError(f"App install failed: HTTP {resp.status_code}")
311
+
312
+
313
+ def _expect_ok(resp: requests.Response, what: str) -> None:
314
+ """Raise unless the form handler answered with JSON status OK."""
315
+ try:
316
+ result: dict[str, Any] = resp.json()
317
+ except ValueError:
318
+ raise RuntimeError(
319
+ f"{what} failed: HTTP {resp.status_code} — "
320
+ f"unexpected response: {resp.text[:200]!r}"
321
+ ) from None
322
+ if result.get("status") != "OK":
323
+ raise RuntimeError(f"{what} failed: {result.get('msg', 'unknown error')}")
325
324
 
326
325
 
327
326
  def get_client(ctx: click.Context) -> SplunkClient:
@@ -0,0 +1,131 @@
1
+ """Alerts commands — fired alerts, alert actions, suppression."""
2
+
3
+ from typing import Any
4
+
5
+ import click
6
+
7
+ from splunkctl import guard, output
8
+ from splunkctl.client import get_client
9
+
10
+
11
+ def _firing_rows(group: Any) -> list[dict[str, Any]]:
12
+ """One row per triggered alert in a fired-alert group."""
13
+ rows: list[dict[str, Any]] = []
14
+ for firing in group.alerts:
15
+ c: dict[str, Any] = dict(firing.content)
16
+ rows.append(
17
+ {
18
+ "rule": group.name,
19
+ "triggered": c.get("trigger_time_rendered", c.get("trigger_time", "")),
20
+ "severity": c.get("severity", ""),
21
+ "sid": c.get("sid", ""),
22
+ "actions": c.get("actions", ""),
23
+ }
24
+ )
25
+ return rows
26
+
27
+
28
+ @click.group("alerts")
29
+ def alerts_group() -> None:
30
+ """Manage fired alerts and alert actions."""
31
+
32
+
33
+ @alerts_group.command("list")
34
+ @click.pass_context
35
+ def list_alerts(ctx: click.Context) -> None:
36
+ """List fired alerts (one row per firing, with sid for drill-down)."""
37
+ client = get_client(ctx)
38
+ rows: list[dict[str, Any]] = []
39
+ for group in client.service.fired_alerts:
40
+ if group.name == "-":
41
+ continue
42
+ rows.extend(_firing_rows(group))
43
+ output.render(ctx, rows, empty="No fired alerts.")
44
+
45
+
46
+ @alerts_group.command("get")
47
+ @click.argument("name")
48
+ @click.pass_context
49
+ def get_alert(ctx: click.Context, name: str) -> None:
50
+ """Get every firing of a fired-alert group."""
51
+ client = get_client(ctx)
52
+ for group in client.service.fired_alerts:
53
+ if group.name == name:
54
+ rows = _firing_rows(group)
55
+ output.info(f"{name}: {len(rows)} firing(s)")
56
+ output.render(ctx, rows)
57
+ return
58
+ output.error(f"Fired alert not found: {name}")
59
+ ctx.exit(1)
60
+
61
+
62
+ @alerts_group.command("actions")
63
+ @click.pass_context
64
+ def list_actions(ctx: click.Context) -> None:
65
+ """List available alert action types."""
66
+ client = get_client(ctx)
67
+ rows: list[dict[str, Any]] = []
68
+ for stanza in client.service.confs["alert_actions"]:
69
+ content: dict[str, Any] = dict(stanza.content)
70
+ rows.append(
71
+ {
72
+ "name": stanza.name,
73
+ "label": content.get("label", ""),
74
+ "description": content.get("description", ""),
75
+ }
76
+ )
77
+ output.render(ctx, rows)
78
+
79
+
80
+ @alerts_group.command("suppress")
81
+ @guard.guarded
82
+ @click.argument("name")
83
+ @click.option(
84
+ "--duration",
85
+ type=int,
86
+ default=3600,
87
+ help="Throttle window in seconds.",
88
+ )
89
+ @click.pass_context
90
+ def suppress_alert(ctx: click.Context, name: str, duration: int) -> None:
91
+ """Throttle a rule's alerts by setting alert.suppress on its saved search.
92
+
93
+ Splunk's fired-alerts endpoint cannot be edited; throttling is a
94
+ property of the underlying saved search.
95
+ """
96
+ details = (
97
+ f"Set alert.suppress=1, alert.suppress.period={duration}s "
98
+ f"on saved search '{name}'"
99
+ )
100
+ if not guard.check(ctx, "Throttle alerts for rule", details=details):
101
+ return
102
+ client = get_client(ctx)
103
+ try:
104
+ ss = client.service.saved_searches[name]
105
+ except KeyError:
106
+ output.error(f"Saved search not found: {name}")
107
+ ctx.exit(1)
108
+ return
109
+ ss.update(
110
+ **{"alert.suppress": "1", "alert.suppress.period": f"{duration}s"}
111
+ ).refresh()
112
+ output.info(f"Throttled '{name}' for {duration}s.")
113
+
114
+
115
+ @alerts_group.command("unsuppress")
116
+ @guard.guarded
117
+ @click.argument("name")
118
+ @click.pass_context
119
+ def unsuppress_alert(ctx: click.Context, name: str) -> None:
120
+ """Remove alert throttling from a rule's saved search."""
121
+ if not guard.check(ctx, f"Remove alert throttling from '{name}'"):
122
+ return
123
+ client = get_client(ctx)
124
+ try:
125
+ ss = client.service.saved_searches[name]
126
+ except KeyError:
127
+ output.error(f"Saved search not found: {name}")
128
+ ctx.exit(1)
129
+ return
130
+ ss.update(**{"alert.suppress": "0"}).refresh()
131
+ output.info(f"Removed throttling from '{name}'.")
@@ -67,6 +67,7 @@ def get_app(ctx: click.Context, name: str) -> None:
67
67
 
68
68
 
69
69
  @apps_group.command("install")
70
+ @guard.guarded
70
71
  @click.option(
71
72
  "--path",
72
73
  "file_path",
@@ -100,6 +101,7 @@ def install_app(ctx: click.Context, file_path: str, *, force: bool) -> None:
100
101
 
101
102
 
102
103
  @apps_group.command("uninstall")
104
+ @guard.guarded
103
105
  @click.argument("name")
104
106
  @click.pass_context
105
107
  def uninstall_app(ctx: click.Context, name: str) -> None:
@@ -125,6 +127,7 @@ def uninstall_app(ctx: click.Context, name: str) -> None:
125
127
 
126
128
 
127
129
  @apps_group.command("update")
130
+ @guard.guarded
128
131
  @click.argument("name")
129
132
  @click.option("--visible/--hidden", default=None, help="Set app visibility.")
130
133
  @click.option("--enabled/--disabled", default=None, help="Enable or disable the app.")
@@ -173,6 +176,7 @@ def update_app(
173
176
 
174
177
 
175
178
  @apps_group.command("reload")
179
+ @guard.guarded
176
180
  @click.pass_context
177
181
  def reload_apps(ctx: click.Context) -> None:
178
182
  """Reload all apps."""
@@ -6,6 +6,7 @@ from typing import Any
6
6
  import click
7
7
 
8
8
  from splunkctl import __version__, output
9
+ from splunkctl.guard import is_guarded
9
10
 
10
11
 
11
12
  def _param_entry(p: click.Parameter) -> dict[str, Any]:
@@ -45,6 +46,8 @@ def _walk(group: click.Group) -> list[dict[str, Any]]:
45
46
  if isinstance(cmd, click.Group):
46
47
  node["subcommands"] = _walk(cmd)
47
48
  else:
49
+ if is_guarded(cmd):
50
+ node["guarded"] = True
48
51
  params: list[dict[str, Any]] = []
49
52
  for p in cmd.params:
50
53
  if p.name in ("help",):
@@ -65,8 +68,13 @@ def commands_meta(ctx: click.Context) -> None:
65
68
  output.error("Cannot resolve CLI root.")
66
69
  ctx.exit(1)
67
70
  return
71
+ global_opts = [
72
+ _param_entry(p) for p in root.command.params if p.name not in ("help",)
73
+ ]
68
74
  result: dict[str, Any] = {
69
75
  "version": __version__,
76
+ "global_options": global_opts,
77
+ "note": "guarded commands are dry-run by default; pass --yes to apply",
70
78
  "commands": _walk(root.command),
71
79
  }
72
80
  click.echo(json.dumps(result, indent=2))