splunkctl 0.10.0__tar.gz → 0.11.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 (111) hide show
  1. {splunkctl-0.10.0 → splunkctl-0.11.0}/PKG-INFO +5 -5
  2. {splunkctl-0.10.0 → splunkctl-0.11.0}/README.md +2 -2
  3. {splunkctl-0.10.0 → splunkctl-0.11.0}/pyproject.toml +6 -4
  4. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/__init__.py +1 -1
  5. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/client.py +34 -10
  6. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/commands_meta.py +2 -0
  7. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/common.py +74 -0
  8. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/config_cmd.py +2 -1
  9. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/dashboards.py +6 -6
  10. splunkctl-0.11.0/splunkctl/commands/docs_cmd.py +324 -0
  11. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/doctor.py +114 -1
  12. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/es.py +184 -1
  13. splunkctl-0.11.0/splunkctl/commands/es_threat_intel.py +165 -0
  14. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/hec.py +12 -3
  15. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/lookups.py +12 -6
  16. splunkctl-0.11.0/splunkctl/commands/mcp_cmd.py +99 -0
  17. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/rules.py +11 -0
  18. splunkctl-0.11.0/splunkctl/commands/rules_schedule.py +97 -0
  19. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/search.py +68 -16
  20. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/server.py +8 -0
  21. splunkctl-0.11.0/splunkctl/commands/server_auth.py +210 -0
  22. splunkctl-0.11.0/splunkctl/commands/server_deployment.py +176 -0
  23. splunkctl-0.11.0/splunkctl/commands/server_tokens.py +139 -0
  24. splunkctl-0.11.0/splunkctl/commands/server_workloads.py +174 -0
  25. splunkctl-0.11.0/splunkctl/commands/soar/__init__.py +68 -0
  26. splunkctl-0.11.0/splunkctl/commands/soar/_client.py +44 -0
  27. splunkctl-0.11.0/splunkctl/commands/soar/_ingest_helpers.py +250 -0
  28. splunkctl-0.11.0/splunkctl/commands/soar/actions.py +416 -0
  29. splunkctl-0.11.0/splunkctl/commands/soar/admin.py +342 -0
  30. splunkctl-0.11.0/splunkctl/commands/soar/admin_roles_audit.py +161 -0
  31. splunkctl-0.11.0/splunkctl/commands/soar/admin_views.py +195 -0
  32. splunkctl-0.11.0/splunkctl/commands/soar/approvals.py +152 -0
  33. splunkctl-0.11.0/splunkctl/commands/soar/apps.py +211 -0
  34. splunkctl-0.11.0/splunkctl/commands/soar/artifacts.py +419 -0
  35. splunkctl-0.11.0/splunkctl/commands/soar/assets.py +469 -0
  36. splunkctl-0.11.0/splunkctl/commands/soar/cases.py +395 -0
  37. splunkctl-0.11.0/splunkctl/commands/soar/containers.py +275 -0
  38. splunkctl-0.11.0/splunkctl/commands/soar/containers_assign.py +166 -0
  39. splunkctl-0.11.0/splunkctl/commands/soar/containers_write.py +394 -0
  40. splunkctl-0.11.0/splunkctl/commands/soar/functions.py +291 -0
  41. splunkctl-0.11.0/splunkctl/commands/soar/indicators.py +303 -0
  42. splunkctl-0.11.0/splunkctl/commands/soar/ingest.py +396 -0
  43. splunkctl-0.11.0/splunkctl/commands/soar/lists.py +478 -0
  44. splunkctl-0.11.0/splunkctl/commands/soar/notes.py +229 -0
  45. splunkctl-0.11.0/splunkctl/commands/soar/playbook_runs.py +354 -0
  46. splunkctl-0.11.0/splunkctl/commands/soar/playbooks.py +478 -0
  47. splunkctl-0.11.0/splunkctl/commands/soar/playbooks_delete.py +79 -0
  48. splunkctl-0.11.0/splunkctl/commands/soar/search.py +71 -0
  49. splunkctl-0.11.0/splunkctl/commands/soar/system.py +192 -0
  50. splunkctl-0.11.0/splunkctl/commands/soar/vault.py +206 -0
  51. splunkctl-0.11.0/splunkctl/commands/soar/workbook_templates.py +232 -0
  52. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state.py +43 -11
  53. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state_io.py +44 -2
  54. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state_io_blobs.py +3 -1
  55. splunkctl-0.11.0/splunkctl/commands/state_io_soar.py +304 -0
  56. splunkctl-0.11.0/splunkctl/commands/state_io_soar_assets.py +335 -0
  57. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/users.py +43 -15
  58. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/config.py +2 -2
  59. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/errors.py +18 -0
  60. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/main.py +82 -3
  61. splunkctl-0.11.0/splunkctl/mcp/output_cap.py +84 -0
  62. splunkctl-0.11.0/splunkctl/mcp/prompts.py +160 -0
  63. splunkctl-0.11.0/splunkctl/mcp/runner.py +181 -0
  64. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/mcp/server.py +44 -162
  65. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/mcp/tools.py +9 -7
  66. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/soar/client.py +45 -0
  67. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/PKG-INFO +5 -5
  68. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/SOURCES.txt +40 -0
  69. {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_client.py +2 -1
  70. splunkctl-0.11.0/tests/test_client_errors.py +249 -0
  71. {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_config.py +25 -3
  72. {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_version.py +1 -1
  73. splunkctl-0.10.0/splunkctl/commands/mcp_cmd.py +0 -47
  74. {splunkctl-0.10.0 → splunkctl-0.11.0}/LICENSE +0 -0
  75. {splunkctl-0.10.0 → splunkctl-0.11.0}/setup.cfg +0 -0
  76. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/__main__.py +0 -0
  77. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/__init__.py +0 -0
  78. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/alerts.py +0 -0
  79. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/apps.py +0 -0
  80. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/audit.py +0 -0
  81. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/audit_parse.py +0 -0
  82. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/conf.py +0 -0
  83. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/conf_ops.py +0 -0
  84. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/datamodels.py +0 -0
  85. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/indexes.py +0 -0
  86. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/info.py +0 -0
  87. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/inputs.py +0 -0
  88. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/knowledge.py +0 -0
  89. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/kvstore.py +0 -0
  90. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/lookups_wiring.py +0 -0
  91. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/parsers.py +0 -0
  92. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/parsers_io.py +0 -0
  93. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/rules_io.py +0 -0
  94. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/server_topology.py +0 -0
  95. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state_io_confs.py +0 -0
  96. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/commands/state_types.py +0 -0
  97. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/guard.py +0 -0
  98. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/mcp/__init__.py +0 -0
  99. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/mcp/resources.py +0 -0
  100. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/output.py +0 -0
  101. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/soar/__init__.py +0 -0
  102. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/soar/cimcef.py +0 -0
  103. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl/soar/web.py +0 -0
  104. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  105. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/entry_points.txt +0 -0
  106. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/requires.txt +0 -0
  107. {splunkctl-0.10.0 → splunkctl-0.11.0}/splunkctl.egg-info/top_level.txt +0 -0
  108. {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_guard.py +0 -0
  109. {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_main_errors.py +0 -0
  110. {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_main_hoisting.py +0 -0
  111. {splunkctl-0.10.0 → splunkctl-0.11.0}/tests/test_output.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.10.0
4
- Summary: CLI tool for Splunk Enterprise SIEM operations.
3
+ Version: 0.11.0
4
+ Summary: Open-source MCP server and CLI for Splunk Enterprise SIEM and Splunk SOAR.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/dannyota/splunkctl
8
8
  Project-URL: Repository, https://github.com/dannyota/splunkctl
9
9
  Project-URL: Issues, https://github.com/dannyota/splunkctl/issues
10
- Keywords: splunk,siem,security,cli,detection,spl
10
+ Keywords: splunk,mcp,model-context-protocol,siem,soar,security,cli,detection,spl
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Environment :: Console
13
13
  Classifier: Intended Audience :: System Administrators
@@ -109,7 +109,7 @@ ingest. Built for humans and LLM agents alike: deterministic flags,
109
109
  creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
110
110
  field map, with SDI dedup, severity mapping, and last-artifact automation
111
111
  batching.
112
- - **Built for agents** — built-in MCP server with 237 auto-generated tools,
112
+ - **Built for agents** — built-in MCP server with 259 auto-generated tools,
113
113
  progressive discovery (5 meta-tools + focus/unfocus), 33 guide resources,
114
114
  guard markers on every mutation, dual output (TTY = table, pipe = JSON).
115
115
 
@@ -267,7 +267,7 @@ splunkctl mcp install # register in .mcp.json
267
267
  splunkctl mcp serve # start stdio MCP server
268
268
  ```
269
269
 
270
- The MCP server auto-generates 229 typed tools from the Click command tree
270
+ The MCP server auto-generates 259 typed tools from the Click command tree
271
271
  (SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
272
272
  (`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
273
273
  schemas per command group. Subgroup-granular focus works for nested groups
@@ -71,7 +71,7 @@ ingest. Built for humans and LLM agents alike: deterministic flags,
71
71
  creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
72
72
  field map, with SDI dedup, severity mapping, and last-artifact automation
73
73
  batching.
74
- - **Built for agents** — built-in MCP server with 237 auto-generated tools,
74
+ - **Built for agents** — built-in MCP server with 259 auto-generated tools,
75
75
  progressive discovery (5 meta-tools + focus/unfocus), 33 guide resources,
76
76
  guard markers on every mutation, dual output (TTY = table, pipe = JSON).
77
77
 
@@ -229,7 +229,7 @@ splunkctl mcp install # register in .mcp.json
229
229
  splunkctl mcp serve # start stdio MCP server
230
230
  ```
231
231
 
232
- The MCP server auto-generates 229 typed tools from the Click command tree
232
+ The MCP server auto-generates 259 typed tools from the Click command tree
233
233
  (SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
234
234
  (`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
235
235
  schemas per command group. Subgroup-granular focus works for nested groups
@@ -5,13 +5,13 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "splunkctl"
7
7
  dynamic = ["version"]
8
- description = "CLI tool for Splunk Enterprise SIEM operations."
8
+ description = "Open-source MCP server and CLI for Splunk Enterprise SIEM and Splunk SOAR."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
11
11
  license = "Apache-2.0"
12
12
  license-files = ["LICENSE"]
13
13
  authors = [{ name = "dannyota" }]
14
- keywords = ["splunk", "siem", "security", "cli", "detection", "spl"]
14
+ keywords = ["splunk", "mcp", "model-context-protocol", "siem", "soar", "security", "cli", "detection", "spl"]
15
15
  classifiers = [
16
16
  "Development Status :: 3 - Alpha",
17
17
  "Environment :: Console",
@@ -51,8 +51,10 @@ Homepage = "https://github.com/dannyota/splunkctl"
51
51
  Repository = "https://github.com/dannyota/splunkctl"
52
52
  Issues = "https://github.com/dannyota/splunkctl/issues"
53
53
 
54
- [tool.setuptools]
55
- packages = ["splunkctl", "splunkctl.commands", "splunkctl.mcp", "splunkctl.soar"]
54
+ # Auto-discover subpackages — a hardcoded list silently dropped
55
+ # splunkctl.commands.soar from every wheel (v0.7.0–v0.10.0).
56
+ [tool.setuptools.packages.find]
57
+ include = ["splunkctl*"]
56
58
 
57
59
  [tool.setuptools.dynamic]
58
60
  version = { attr = "splunkctl.__version__" }
@@ -1,3 +1,3 @@
1
1
  """CLI tool for Splunk Enterprise and Splunk SOAR operations."""
2
2
 
3
- __version__ = "0.10.0"
3
+ __version__ = "0.11.0"
@@ -18,6 +18,20 @@ import splunklib.client as splunk_client
18
18
  import urllib3
19
19
 
20
20
  from splunkctl import config as cfg_mod
21
+ from splunkctl.errors import WebSessionError
22
+
23
+ _tls_warned: bool = False
24
+
25
+
26
+ def _warn_tls_off() -> None:
27
+ """Print one warning per process when TLS verification is disabled."""
28
+ global _tls_warned # noqa: PLW0603
29
+ if not _tls_warned:
30
+ _tls_warned = True
31
+ click.echo(
32
+ "Warning: TLS certificate verification is disabled for this connection.",
33
+ err=True,
34
+ )
21
35
 
22
36
 
23
37
  class SplunkClient:
@@ -98,6 +112,7 @@ class SplunkClient:
98
112
 
99
113
  if not cfg.get("verify", False):
100
114
  connect_args["verify"] = False
115
+ _warn_tls_off()
101
116
 
102
117
  if self._timeout:
103
118
  connect_args["timeout"] = self._timeout
@@ -174,9 +189,10 @@ class _WebSession:
174
189
  self._username: str = service.username
175
190
  self._password: str = service.password
176
191
  if not self._username or not self._password:
177
- raise RuntimeError(
192
+ raise WebSessionError(
178
193
  "Lookup upload requires username/password authentication. "
179
- "Token-only auth cannot authenticate to Splunk Web UI."
194
+ "Token-only auth cannot authenticate to Splunk Web UI.",
195
+ kind="auth",
180
196
  )
181
197
 
182
198
  web_conf = service.confs["web"]["settings"]
@@ -225,19 +241,22 @@ class _WebSession:
225
241
  try:
226
242
  body: dict[str, Any] = resp.json()
227
243
  except ValueError:
228
- raise RuntimeError(
229
- f"Splunk Web login failed: HTTP {resp.status_code}"
244
+ raise WebSessionError(
245
+ f"Splunk Web login failed: HTTP {resp.status_code}",
246
+ kind="auth",
230
247
  ) from None
231
248
  if body.get("status") == "fail":
232
249
  msg = body.get("msg", "unknown error")
233
- raise RuntimeError(f"Splunk Web login failed: {msg}")
250
+ raise WebSessionError(f"Splunk Web login failed: {msg}", kind="auth")
234
251
 
235
252
  for cookie in self._session.cookies:
236
253
  if cookie.name and cookie.name.startswith("splunkweb_csrf_token"):
237
254
  self._csrf_token = cookie.value
238
255
  break
239
256
  if not self._csrf_token:
240
- raise RuntimeError("Could not obtain CSRF token from Splunk Web")
257
+ raise WebSessionError(
258
+ "Could not obtain CSRF token from Splunk Web", kind="web"
259
+ )
241
260
  self._logged_in = True
242
261
 
243
262
  def upload_lookup(
@@ -315,7 +334,9 @@ class _WebSession:
315
334
  },
316
335
  )
317
336
  if resp.status_code >= 400:
318
- raise RuntimeError(f"App install failed: HTTP {resp.status_code}")
337
+ raise WebSessionError(
338
+ f"App install failed: HTTP {resp.status_code}", kind="web"
339
+ )
319
340
 
320
341
 
321
342
  def _expect_ok(resp: requests.Response, what: str) -> None:
@@ -323,12 +344,15 @@ def _expect_ok(resp: requests.Response, what: str) -> None:
323
344
  try:
324
345
  result: dict[str, Any] = resp.json()
325
346
  except ValueError:
326
- raise RuntimeError(
347
+ raise WebSessionError(
327
348
  f"{what} failed: HTTP {resp.status_code} — "
328
- f"unexpected response: {resp.text[:200]!r}"
349
+ f"unexpected response: {resp.text[:200]!r}",
350
+ kind="web",
329
351
  ) from None
330
352
  if result.get("status") != "OK":
331
- raise RuntimeError(f"{what} failed: {result.get('msg', 'unknown error')}")
353
+ raise WebSessionError(
354
+ f"{what} failed: {result.get('msg', 'unknown error')}", kind="web"
355
+ )
332
356
 
333
357
 
334
358
  def rest_get_json(
@@ -39,6 +39,8 @@ def _walk(group: click.Group) -> list[dict[str, Any]]:
39
39
  """Recursively build the command tree."""
40
40
  nodes: list[dict[str, Any]] = []
41
41
  for name, cmd in sorted(group.commands.items()):
42
+ if cmd.hidden:
43
+ continue
42
44
  node: dict[str, Any] = {
43
45
  "name": name,
44
46
  "help": (cmd.help or "").split("\n")[0],
@@ -1,5 +1,8 @@
1
1
  """Shared helpers for command groups."""
2
2
 
3
+ import subprocess
4
+ import sys
5
+ import time
3
6
  from collections.abc import Callable, Iterable
4
7
  from typing import Any
5
8
 
@@ -18,6 +21,29 @@ def spl_quote(value: str) -> str:
18
21
  return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
19
22
 
20
23
 
24
+ class UnsafeLookupNameError(click.ClickException):
25
+ """Lookup name contains characters unsafe for SPL interpolation."""
26
+
27
+
28
+ def spl_quote_lookup_name(name: str) -> str:
29
+ """Quote a lookup name for safe interpolation into SPL commands.
30
+
31
+ Used in ``| inputlookup <name>`` to prevent SPL injection. Rejects
32
+ names containing newlines or NUL (which could break out of quoting
33
+ regardless of escaping), escapes backslashes and double quotes, and
34
+ wraps the result in double quotes.
35
+
36
+ Raises:
37
+ UnsafeLookupNameError: If the name contains newlines or NUL.
38
+ """
39
+ if "\n" in name or "\r" in name or "\x00" in name:
40
+ raise UnsafeLookupNameError(
41
+ f"Lookup name {name!r} contains newline or NUL characters "
42
+ "and cannot be safely used in SPL."
43
+ )
44
+ return '"' + name.replace("\\", "\\\\").replace('"', '\\"') + '"'
45
+
46
+
21
47
  _ALERT_TYPES = (
22
48
  "custom",
23
49
  "number of events",
@@ -354,6 +380,54 @@ _REQUIRED_ACTION_FIELDS: dict[str, str] = {
354
380
  }
355
381
 
356
382
 
383
+ def resolve_leaf_command(
384
+ group: click.Group,
385
+ args: list[str],
386
+ ) -> click.Command | None:
387
+ """Walk the Click tree to find the leaf command for the given args."""
388
+ cmd: click.Command = group
389
+ for tok in args:
390
+ if tok.startswith("-"):
391
+ continue
392
+ if isinstance(cmd, click.Group) and tok in cmd.commands:
393
+ cmd = cmd.commands[tok]
394
+ else:
395
+ break
396
+ return cmd if cmd is not group else None
397
+
398
+
399
+ def watch_loop(
400
+ ctx: click.Context,
401
+ interval: int,
402
+ invoke_fn: Callable[[click.Context], Any],
403
+ ) -> None:
404
+ """Re-invoke a read-only command every *interval* seconds.
405
+
406
+ Clears the terminal between runs (like ``watch(1)``). Errors are
407
+ printed in-place and the loop continues. Ctrl-C (KeyboardInterrupt)
408
+ exits cleanly with code 0.
409
+
410
+ Args:
411
+ ctx: The top-level Click context.
412
+ interval: Seconds between runs.
413
+ invoke_fn: Callable that re-invokes the resolved command tree.
414
+ """
415
+ while True:
416
+ if sys.stdout.isatty():
417
+ # Hardcoded command, no user input — safe to invoke directly.
418
+ subprocess.run( # noqa: S603, S607
419
+ ["cls" if sys.platform == "win32" else "clear"],
420
+ check=False,
421
+ )
422
+ try:
423
+ invoke_fn(ctx)
424
+ except SystemExit:
425
+ pass
426
+ except Exception as exc: # noqa: BLE001
427
+ output.error(str(exc))
428
+ time.sleep(interval)
429
+
430
+
357
431
  def app_scope(app: str | None) -> dict[str, str]:
358
432
  """Build the ``app``/``owner`` kwargs for an app-scoped list call.
359
433
 
@@ -93,7 +93,8 @@ def init(
93
93
  else click.prompt("Password", default="", hide_input=True)
94
94
  )
95
95
  scheme = scheme or "https"
96
- verify = verify if verify is not None else False
96
+ if verify is None:
97
+ verify = click.confirm("Verify TLS certificates", default=True)
97
98
 
98
99
  cfg: dict[str, Any] = {
99
100
  "host": host,
@@ -176,7 +176,7 @@ def get_dashboard(
176
176
  client = get_client(ctx)
177
177
  try:
178
178
  d = _resolve(client.service, name, app)
179
- except (KeyError, Exception):
179
+ except KeyError:
180
180
  output.error(f"Dashboard '{name}' not found.", kind="not_found")
181
181
  ctx.exit(1)
182
182
  return
@@ -303,7 +303,7 @@ def update_dashboard(
303
303
  client = get_client(ctx)
304
304
  try:
305
305
  d = _resolve(client.service, name, app)
306
- except (KeyError, Exception):
306
+ except KeyError:
307
307
  output.error(f"Dashboard '{name}' not found.", kind="not_found")
308
308
  ctx.exit(1)
309
309
  return
@@ -332,8 +332,8 @@ def delete_dashboard(ctx: click.Context, name: str, *, app: str) -> None:
332
332
  client = get_client(ctx)
333
333
  try:
334
334
  d = _resolve(client.service, name, app)
335
- except (KeyError, Exception) as exc:
336
- output.error(f"Delete failed: {exc}")
335
+ except KeyError:
336
+ output.error(f"Dashboard '{name}' not found.", kind="not_found")
337
337
  ctx.exit(1)
338
338
  return
339
339
  d.delete()
@@ -390,7 +390,7 @@ def export_dashboard(
390
390
  client = get_client(ctx)
391
391
  try:
392
392
  d = _resolve(client.service, name, app)
393
- except (KeyError, Exception):
393
+ except KeyError:
394
394
  output.error(f"Dashboard '{name}' not found.", kind="not_found")
395
395
  ctx.exit(1)
396
396
  return
@@ -470,7 +470,7 @@ def share_dashboard(
470
470
  client = get_client(ctx)
471
471
  try:
472
472
  d = _resolve(client.service, name, app)
473
- except (KeyError, Exception):
473
+ except KeyError:
474
474
  output.error(f"Dashboard '{name}' not found.", kind="not_found")
475
475
  ctx.exit(1)
476
476
  return
@@ -0,0 +1,324 @@
1
+ """`splunkctl docs generate` — the generated command reference.
2
+
3
+ Walks the same runnable-command tree as `commands` and writes one markdown
4
+ page per top-level group into docs/commands/ plus an index, and syncs the
5
+ marker-delimited block in docs/_sidebar.md — so the docs-site reference can
6
+ never drift from the CLI. Hidden: a maintainer/CI tool, not an operator
7
+ surface. `--check` regenerates in memory and fails when the committed files
8
+ are stale (the CI freshness gate).
9
+ """
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ import click
15
+
16
+ from splunkctl.guard import is_guarded
17
+
18
+ _START = "<!-- commands:start -->"
19
+ _END = "<!-- commands:end -->"
20
+ _NOTICE = "<!-- Generated by `splunkctl docs generate` — do not edit by hand. -->"
21
+
22
+
23
+ @click.group("docs", hidden=True)
24
+ def docs_group() -> None:
25
+ """Generate documentation and command-reference pages (maintainers)."""
26
+
27
+
28
+ @dataclass
29
+ class _Entry:
30
+ """One runnable command with its full path (e.g. ``soar playbooks list``)."""
31
+
32
+ path: str
33
+ cmd: click.Command
34
+
35
+
36
+ @dataclass
37
+ class _Group:
38
+ """One reference page: a top-level command group and its runnable commands.
39
+
40
+ Single leaf top-level commands (doctor, info, ...) are folded into the
41
+ synthetic "global" group.
42
+ """
43
+
44
+ name: str
45
+ short: str
46
+ entries: list[_Entry]
47
+
48
+
49
+ def _first_line(text: str | None) -> str:
50
+ return (text or "").split("\n")[0].strip()
51
+
52
+
53
+ def _walk(group: click.Group, prefix: str, out: list[_Entry]) -> None:
54
+ """Collect every visible runnable command under ``group``."""
55
+ for name, cmd in sorted(group.commands.items()):
56
+ if cmd.hidden:
57
+ continue
58
+ path = f"{prefix} {name}".strip()
59
+ if isinstance(cmd, click.Group):
60
+ _walk(cmd, path, out)
61
+ else:
62
+ out.append(_Entry(path, cmd))
63
+
64
+
65
+ def _collect_groups(root: click.Group) -> list[_Group]:
66
+ """Bucket every runnable command by its top-level segment.
67
+
68
+ A top-level command with no subcommands lands in "global"; groups are
69
+ sorted by name with "global" last.
70
+ """
71
+ entries: list[_Entry] = []
72
+ _walk(root, "", entries)
73
+
74
+ shorts = {name: _first_line(cmd.help) for name, cmd in root.commands.items()}
75
+
76
+ by_top: dict[str, list[_Entry]] = {}
77
+ for e in entries:
78
+ top = e.path.split(" ")[0]
79
+ by_top.setdefault(top, []).append(e)
80
+
81
+ groups: list[_Group] = []
82
+ global_entries: list[_Entry] = []
83
+ for top, es in sorted(by_top.items()):
84
+ # A single entry whose path IS the top-level name is a plain leaf
85
+ # command (doctor, info, commands, ...) — folded into "global".
86
+ if len(es) == 1 and es[0].path == top:
87
+ global_entries.append(es[0])
88
+ continue
89
+ groups.append(_Group(top, shorts.get(top, ""), es))
90
+ if global_entries:
91
+ groups.append(_Group("global", "Top-level commands", global_entries))
92
+ return groups
93
+
94
+
95
+ def _cell(s: str) -> str:
96
+ """Make a string safe inside a one-line markdown table cell."""
97
+ return " ".join(s.replace("|", "\\|").split())
98
+
99
+
100
+ def _usage(e: _Entry) -> str:
101
+ ctx = click.Context(e.cmd, info_name=f"splunkctl {e.path}")
102
+ pieces = e.cmd.collect_usage_pieces(ctx)
103
+ return " ".join([f"splunkctl {e.path}", *pieces])
104
+
105
+
106
+ def _long_help(e: _Entry) -> str:
107
+ r"""Body of the command help below the summary line, ``\b`` markers dropped."""
108
+ lines = (e.cmd.help or "").split("\n")[1:]
109
+ return "\n".join(ln for ln in lines if ln.strip() != "\b").strip()
110
+
111
+
112
+ def _flag_default(p: click.Option) -> str:
113
+ d = p.default
114
+ if isinstance(d, bool):
115
+ return "true" if d else "false"
116
+ # Only scalar defaults are meaningful in docs — sentinels, tuples, and
117
+ # callables render as "-".
118
+ if isinstance(d, (str, int, float)) and d != "":
119
+ return str(d)
120
+ return "-"
121
+
122
+
123
+ def _flag_type(p: click.Option) -> str:
124
+ if isinstance(p.type, click.Choice):
125
+ return "choice: " + ", ".join(str(c) for c in p.type.choices)
126
+ if p.is_flag:
127
+ return "flag"
128
+ return p.type.name
129
+
130
+
131
+ def _flag_rows(cmd: click.Command) -> list[str]:
132
+ """Render the command's options as markdown table rows."""
133
+ rows: list[str] = []
134
+ for p in cmd.params:
135
+ if not isinstance(p, click.Option) or p.hidden or p.name == "help":
136
+ continue
137
+ shorts = [o for o in p.opts if not o.startswith("--")]
138
+ longs = [o for o in p.opts if o.startswith("--")]
139
+ name = ", ".join(shorts + longs)
140
+ if p.secondary_opts:
141
+ name += " / " + ", ".join(p.secondary_opts)
142
+ desc = p.help or ""
143
+ if p.required:
144
+ desc = f"{desc} [required]".strip()
145
+ rows.append(
146
+ f"| `{_cell(name)}` | {_cell(_flag_type(p))}"
147
+ f" | {_cell(_flag_default(p))} | {_cell(desc)} |"
148
+ )
149
+ return rows
150
+
151
+
152
+ def _render_entry(e: _Entry) -> list[str]:
153
+ out = [f"## {e.path}", ""]
154
+ if short := _first_line(e.cmd.help):
155
+ out += [short, ""]
156
+ if is_guarded(e.cmd):
157
+ out += ["> Guarded mutation — dry-run by default; apply with `--yes`.", ""]
158
+ out += ["```text", _usage(e), "```"]
159
+
160
+ # The long help is CLI help text, not authored markdown — fence it
161
+ # verbatim so stray tables/lists/pipes inside it can never break the page.
162
+ if long := _long_help(e):
163
+ fence = "~~~~" if "```" in long else "```"
164
+ out += ["", f"{fence}text", long, fence]
165
+
166
+ if rows := _flag_rows(e.cmd):
167
+ out += [
168
+ "",
169
+ "**Flags**",
170
+ "",
171
+ "| Flag | Type | Default | Description |",
172
+ "|---|---|---|---|",
173
+ *rows,
174
+ ]
175
+ return out
176
+
177
+
178
+ def _render_group(g: _Group) -> str:
179
+ title = "Global commands" if g.name == "global" else f"splunkctl {g.name}"
180
+ lines = [_NOTICE, "", f"# {title}", ""]
181
+ if g.short:
182
+ lines.append(g.short)
183
+ for e in g.entries:
184
+ lines += ["", *_render_entry(e)]
185
+ return "\n".join(lines) + "\n"
186
+
187
+
188
+ def _render_index(groups: list[_Group]) -> str:
189
+ lines = [
190
+ _NOTICE,
191
+ "",
192
+ "# Command reference",
193
+ "",
194
+ "Generated from the Click command tree — run `splunkctl docs generate`",
195
+ 'after changing a command. "Guarded" commands are live mutations behind',
196
+ "the standard dry-run/`--yes` gate; everything else is a read.",
197
+ "",
198
+ "| Group | Commands | Description |",
199
+ "|---|---|---|",
200
+ ]
201
+ for g in groups:
202
+ lines.append(
203
+ f"| [{g.name}]({g.name}.md) | {len(g.entries)} | {_cell(g.short)} |"
204
+ )
205
+ return "\n".join(lines) + "\n"
206
+
207
+
208
+ def _render_pages(groups: list[_Group]) -> dict[str, str]:
209
+ """Render every group page plus the index, keyed by filename."""
210
+ pages = {f"{g.name}.md": _render_group(g) for g in groups}
211
+ pages["README.md"] = _render_index(groups)
212
+ return pages
213
+
214
+
215
+ def _sidebar_block(pages: dict[str, str]) -> str:
216
+ names = sorted(n.removesuffix(".md") for n in pages if n != "README.md")
217
+ lines = [_START, "- **Command reference**", " - [Overview](commands/README.md)"]
218
+ lines += [f" - [{n}](commands/{n}.md)" for n in names]
219
+ lines.append(_END)
220
+ return "\n".join(lines)
221
+
222
+
223
+ def _patch_sidebar(content: str, block: str) -> str:
224
+ """Replace the marker-delimited sidebar block, or insert before Design."""
225
+ if (start := content.find(_START)) >= 0:
226
+ end = content.find(_END)
227
+ if end < start:
228
+ raise click.ClickException(
229
+ f"sidebar: {_START} found without a following {_END}"
230
+ )
231
+ return content[:start] + block + content[end + len(_END) :]
232
+ anchor = "- **Design**"
233
+ if (i := content.find(anchor)) >= 0:
234
+ return content[:i] + block + "\n\n" + content[i:]
235
+ return content.rstrip("\n") + "\n\n" + block + "\n"
236
+
237
+
238
+ def _stale_pages(pages: dict[str, str], out_dir: Path) -> list[str]:
239
+ """Committed *.md files the current command tree no longer generates."""
240
+ if not out_dir.is_dir():
241
+ return []
242
+ return sorted(
243
+ f.name
244
+ for f in out_dir.iterdir()
245
+ if f.is_file() and f.name.endswith(".md") and f.name not in pages
246
+ )
247
+
248
+
249
+ def _write_docs(pages: dict[str, str], out_dir: Path, sidebar: str) -> None:
250
+ out_dir.mkdir(parents=True, exist_ok=True)
251
+ for name, content in pages.items():
252
+ (out_dir / name).write_text(content, encoding="utf-8")
253
+ stale = _stale_pages(pages, out_dir)
254
+ for name in stale:
255
+ (out_dir / name).unlink()
256
+ if sidebar:
257
+ path = Path(sidebar)
258
+ raw = path.read_text(encoding="utf-8")
259
+ path.write_text(_patch_sidebar(raw, _sidebar_block(pages)), encoding="utf-8")
260
+ msg = f"generated {len(pages)} page(s) in {out_dir}"
261
+ if stale:
262
+ msg += f", removed {len(stale)} stale"
263
+ click.echo(msg)
264
+
265
+
266
+ def _check_docs(pages: dict[str, str], out_dir: Path, sidebar: str) -> list[str]:
267
+ problems: list[str] = []
268
+ for name, content in pages.items():
269
+ f = out_dir / name
270
+ if not f.is_file():
271
+ problems.append(f"missing: {name}")
272
+ elif f.read_text(encoding="utf-8") != content:
273
+ problems.append(f"stale: {name}")
274
+ problems += [f"orphaned: {name}" for name in _stale_pages(pages, out_dir)]
275
+ if sidebar:
276
+ raw = Path(sidebar).read_text(encoding="utf-8")
277
+ if _patch_sidebar(raw, _sidebar_block(pages)) != raw:
278
+ problems.append(f"stale: {sidebar} command-reference block")
279
+ return sorted(problems)
280
+
281
+
282
+ @docs_group.command("generate")
283
+ @click.option(
284
+ "--out",
285
+ "out_dir",
286
+ default="docs/commands",
287
+ help="Output directory for the generated pages.",
288
+ )
289
+ @click.option(
290
+ "--sidebar",
291
+ default="docs/_sidebar.md",
292
+ help="Sidebar file to sync (empty to skip).",
293
+ )
294
+ @click.option(
295
+ "--check",
296
+ is_flag=True,
297
+ help="Verify the committed pages are current; write nothing.",
298
+ )
299
+ @click.pass_context
300
+ def docs_generate(ctx: click.Context, out_dir: str, sidebar: str, check: bool) -> None:
301
+ """Generate the per-group command-reference pages under docs/commands/.
302
+
303
+ Walk the command tree and write one markdown page per top-level command
304
+ group into docs/commands/ (plus an index README), and sync the
305
+ commands:start/end block in docs/_sidebar.md. With --check, nothing is
306
+ written: the run fails when any committed page, the index, or the
307
+ sidebar block is stale — the CI freshness gate.
308
+ """
309
+ root = ctx.find_root().command
310
+ if not isinstance(root, click.Group):
311
+ raise click.ClickException("Cannot resolve CLI root.")
312
+ pages = _render_pages(_collect_groups(root))
313
+ if check:
314
+ if problems := _check_docs(pages, Path(out_dir), sidebar):
315
+ click.echo(
316
+ "command reference is out of date — run `splunkctl docs generate`:",
317
+ err=True,
318
+ )
319
+ for p in problems:
320
+ click.echo(f" {p}", err=True)
321
+ ctx.exit(1)
322
+ click.echo("ok: command reference is current")
323
+ return
324
+ _write_docs(pages, Path(out_dir), sidebar)