splunkctl 0.8.0__tar.gz → 0.10.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 (71) hide show
  1. {splunkctl-0.8.0 → splunkctl-0.10.0}/PKG-INFO +45 -22
  2. {splunkctl-0.8.0 → splunkctl-0.10.0}/README.md +44 -21
  3. splunkctl-0.10.0/splunkctl/__init__.py +3 -0
  4. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/client.py +5 -0
  5. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/es.py +1 -1
  6. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/mcp/server.py +124 -17
  7. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/mcp/tools.py +107 -35
  8. splunkctl-0.10.0/splunkctl/soar/cimcef.py +254 -0
  9. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/soar/client.py +32 -12
  10. splunkctl-0.10.0/splunkctl/soar/web.py +137 -0
  11. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl.egg-info/PKG-INFO +45 -22
  12. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl.egg-info/SOURCES.txt +2 -0
  13. {splunkctl-0.8.0 → splunkctl-0.10.0}/tests/test_guard.py +33 -0
  14. {splunkctl-0.8.0 → splunkctl-0.10.0}/tests/test_version.py +1 -1
  15. splunkctl-0.8.0/splunkctl/__init__.py +0 -3
  16. {splunkctl-0.8.0 → splunkctl-0.10.0}/LICENSE +0 -0
  17. {splunkctl-0.8.0 → splunkctl-0.10.0}/pyproject.toml +0 -0
  18. {splunkctl-0.8.0 → splunkctl-0.10.0}/setup.cfg +0 -0
  19. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/__main__.py +0 -0
  20. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/__init__.py +0 -0
  21. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/alerts.py +0 -0
  22. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/apps.py +0 -0
  23. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/audit.py +0 -0
  24. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/audit_parse.py +0 -0
  25. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/commands_meta.py +0 -0
  26. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/common.py +0 -0
  27. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/conf.py +0 -0
  28. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/conf_ops.py +0 -0
  29. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/config_cmd.py +0 -0
  30. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/dashboards.py +0 -0
  31. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/datamodels.py +0 -0
  32. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/doctor.py +0 -0
  33. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/hec.py +0 -0
  34. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/indexes.py +0 -0
  35. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/info.py +0 -0
  36. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/inputs.py +0 -0
  37. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/knowledge.py +0 -0
  38. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/kvstore.py +0 -0
  39. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/lookups.py +0 -0
  40. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/lookups_wiring.py +0 -0
  41. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/mcp_cmd.py +0 -0
  42. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/parsers.py +0 -0
  43. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/parsers_io.py +0 -0
  44. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/rules.py +0 -0
  45. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/rules_io.py +0 -0
  46. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/search.py +0 -0
  47. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/server.py +0 -0
  48. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/server_topology.py +0 -0
  49. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/state.py +0 -0
  50. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/state_io.py +0 -0
  51. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/state_io_blobs.py +0 -0
  52. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/state_io_confs.py +0 -0
  53. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/state_types.py +0 -0
  54. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/commands/users.py +0 -0
  55. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/config.py +0 -0
  56. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/errors.py +0 -0
  57. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/guard.py +0 -0
  58. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/main.py +0 -0
  59. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/mcp/__init__.py +0 -0
  60. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/mcp/resources.py +0 -0
  61. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/output.py +0 -0
  62. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl/soar/__init__.py +0 -0
  63. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl.egg-info/dependency_links.txt +0 -0
  64. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl.egg-info/entry_points.txt +0 -0
  65. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl.egg-info/requires.txt +0 -0
  66. {splunkctl-0.8.0 → splunkctl-0.10.0}/splunkctl.egg-info/top_level.txt +0 -0
  67. {splunkctl-0.8.0 → splunkctl-0.10.0}/tests/test_client.py +0 -0
  68. {splunkctl-0.8.0 → splunkctl-0.10.0}/tests/test_config.py +0 -0
  69. {splunkctl-0.8.0 → splunkctl-0.10.0}/tests/test_main_errors.py +0 -0
  70. {splunkctl-0.8.0 → splunkctl-0.10.0}/tests/test_main_hoisting.py +0 -0
  71. {splunkctl-0.8.0 → splunkctl-0.10.0}/tests/test_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: splunkctl
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: CLI tool for Splunk Enterprise SIEM operations.
5
5
  Author: dannyota
6
6
  License-Expression: Apache-2.0
@@ -38,11 +38,11 @@ Dynamic: license-file
38
38
 
39
39
  <div align="center">
40
40
 
41
- <a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise as code" width="600"></a>
41
+ <a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise and Splunk SOAR as code" width="600"></a>
42
42
 
43
43
  # splunkctl
44
44
 
45
- **Operate Splunk Enterprise as code — for SOC teams, detection engineers, and AI agents.**
45
+ **Operate Splunk Enterprise and Splunk SOAR as code — for SOC teams, detection engineers, and AI agents.**
46
46
 
47
47
  [Docs](https://splunk.danny.vn) · [Catalog](docs/design/catalog.md) · [Releases](https://github.com/dannyota/splunkctl/releases)
48
48
 
@@ -50,15 +50,19 @@ Dynamic: license-file
50
50
 
51
51
  ---
52
52
 
53
- A Python CLI that queries, inspects, and manages a **remote** Splunk
54
- Enterprise instance over the REST API. Built on the
53
+ A Python CLI that queries, inspects, and manages **remote** Splunk
54
+ Enterprise and Splunk SOAR instances over their REST APIs. SIEM commands
55
+ are built on the
55
56
  [splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
56
- fork with [Click](https://click.palletsprojects.com/). The core loop is
57
- **pull live state → review the diff → push it back** — one `state` engine
58
- covering rules, parsers, macros, lookups, and dashboards, with a
59
- change-evidence report artifact for bank change tickets. It's built for
60
- humans and LLM agents alike: deterministic flags, `--json` everywhere,
61
- structured error envelopes, and a built-in MCP server
57
+ fork with [Click](https://click.palletsprojects.com/); SOAR commands use
58
+ `SOARClient`, a requests-based Django REST client with dual auth (token +
59
+ Basic fallback). The SIEM core loop is **pull live state, review the diff,
60
+ push it back** -- one `state` engine covering rules, parsers, macros,
61
+ lookups, and dashboards, with a change-evidence report artifact for change
62
+ tickets. The SOAR surface covers ops visibility, container/artifact
63
+ lifecycle, the first playbooks-as-code loop anywhere, and SIEM-to-SOAR
64
+ ingest. Built for humans and LLM agents alike: deterministic flags,
65
+ `--json` everywhere, structured error envelopes, and a built-in MCP server
62
66
  (`splunkctl mcp serve`) with progressive tool discovery.
63
67
 
64
68
  > **Every mutation is dry-run by default.** Nothing changes until you pass
@@ -90,8 +94,23 @@ structured error envelopes, and a built-in MCP server
90
94
  `--limit`/`--offset`/`--filter` on every list surface, multi-instance
91
95
  profiles with a bank-safety guard banner
92
96
  (`(profile: uat @ host:port)`).
93
- - **Built for agents** — built-in MCP server with 129 auto-generated tools,
94
- progressive discovery (5 meta-tools + focus/unfocus), 22 guide resources,
97
+ - **SOAR ops** — `soar` command tree: containers, artifacts, vault, notes,
98
+ cases/workbooks, approvals, custom lists, indicators, evidence, users,
99
+ roles, audit, and cross-object search. Full lifecycle: create, update,
100
+ close, assign, delete -- with the same dry-run guard and typed error
101
+ envelopes.
102
+ - **Playbooks as code** — `soar playbooks export --unpack` / `import`
103
+ round-trips playbook tgz bundles (the first such tool anywhere);
104
+ `soar playbooks delete` removes them through the Web UI route (no
105
+ REST deletion exists); `soar playbooks run --wait` drives and polls
106
+ runs to completion; `soar actions run --wait` does the same for
107
+ connector actions.
108
+ - **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
109
+ creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
110
+ field map, with SDI dedup, severity mapping, and last-artifact automation
111
+ batching.
112
+ - **Built for agents** — built-in MCP server with 237 auto-generated tools,
113
+ progressive discovery (5 meta-tools + focus/unfocus), 33 guide resources,
95
114
  guard markers on every mutation, dual output (TTY = table, pipe = JSON).
96
115
 
97
116
  ## Install
@@ -119,10 +138,12 @@ splunkctl --version
119
138
  ## Quickstart
120
139
 
121
140
  ```bash
122
- splunkctl config init # interactive setup
123
- splunkctl doctor # check connection, auth, permissions
124
- splunkctl search run 'index=main | head 10' # run a search
125
- splunkctl rules list # list detection rules
141
+ splunkctl config init # interactive SIEM setup
142
+ splunkctl config init --soar # add SOAR credentials
143
+ splunkctl doctor # check SIEM connection
144
+ splunkctl soar test # check SOAR connection
145
+ splunkctl search run 'index=main | head 10' # run a SIEM search
146
+ splunkctl soar containers list # list SOAR containers
126
147
  splunkctl commands --json # discover every verb
127
148
  ```
128
149
 
@@ -174,6 +195,7 @@ splunkctl state push --dir config/ --report r.json --yes # deploy + evidence
174
195
  | `tags` | Tags — list, get |
175
196
  | `datamodels` | Data model definitions + acceleration health |
176
197
  | `state` | Config-as-code pull/diff/push with change-evidence reports |
198
+ | `soar` | SOAR: containers, artifacts, vault, playbooks, actions, cases, ingest |
177
199
  | `commands` | Machine-readable command tree (JSON) |
178
200
  | `mcp` | Built-in MCP server for AI agent integration |
179
201
 
@@ -245,11 +267,12 @@ splunkctl mcp install # register in .mcp.json
245
267
  splunkctl mcp serve # start stdio MCP server
246
268
  ```
247
269
 
248
- The MCP server auto-generates 129 typed tools from the Click command tree
249
- with progressive discovery — agents start with 5 meta-tools (`help`,
250
- `usage`, `focus`, `unfocus`, `run`) and dynamically load typed schemas per
251
- command group. 22 guide resources are served as `guide://` URIs. Mutations
252
- are guarded: `yes=true` to apply (dry-run by default).
270
+ The MCP server auto-generates 229 typed tools from the Click command tree
271
+ (SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
272
+ (`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
273
+ schemas per command group. Subgroup-granular focus works for nested groups
274
+ like `soar containers`. 33 guide resources are served as `guide://` URIs.
275
+ Mutations are guarded: `yes=true` to apply (dry-run by default).
253
276
 
254
277
  ## License
255
278
 
@@ -1,10 +1,10 @@
1
1
  <div align="center">
2
2
 
3
- <a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise as code" width="600"></a>
3
+ <a href="https://splunk.danny.vn"><img src="docs/assets/banner.svg" alt="splunkctl — operate Splunk Enterprise and Splunk SOAR as code" width="600"></a>
4
4
 
5
5
  # splunkctl
6
6
 
7
- **Operate Splunk Enterprise as code — for SOC teams, detection engineers, and AI agents.**
7
+ **Operate Splunk Enterprise and Splunk SOAR as code — for SOC teams, detection engineers, and AI agents.**
8
8
 
9
9
  [Docs](https://splunk.danny.vn) · [Catalog](docs/design/catalog.md) · [Releases](https://github.com/dannyota/splunkctl/releases)
10
10
 
@@ -12,15 +12,19 @@
12
12
 
13
13
  ---
14
14
 
15
- A Python CLI that queries, inspects, and manages a **remote** Splunk
16
- Enterprise instance over the REST API. Built on the
15
+ A Python CLI that queries, inspects, and manages **remote** Splunk
16
+ Enterprise and Splunk SOAR instances over their REST APIs. SIEM commands
17
+ are built on the
17
18
  [splunk-sdk-python](https://github.com/dannyota/splunk-sdk-python/tree/splunkctl)
18
- fork with [Click](https://click.palletsprojects.com/). The core loop is
19
- **pull live state → review the diff → push it back** — one `state` engine
20
- covering rules, parsers, macros, lookups, and dashboards, with a
21
- change-evidence report artifact for bank change tickets. It's built for
22
- humans and LLM agents alike: deterministic flags, `--json` everywhere,
23
- structured error envelopes, and a built-in MCP server
19
+ fork with [Click](https://click.palletsprojects.com/); SOAR commands use
20
+ `SOARClient`, a requests-based Django REST client with dual auth (token +
21
+ Basic fallback). The SIEM core loop is **pull live state, review the diff,
22
+ push it back** -- one `state` engine covering rules, parsers, macros,
23
+ lookups, and dashboards, with a change-evidence report artifact for change
24
+ tickets. The SOAR surface covers ops visibility, container/artifact
25
+ lifecycle, the first playbooks-as-code loop anywhere, and SIEM-to-SOAR
26
+ ingest. Built for humans and LLM agents alike: deterministic flags,
27
+ `--json` everywhere, structured error envelopes, and a built-in MCP server
24
28
  (`splunkctl mcp serve`) with progressive tool discovery.
25
29
 
26
30
  > **Every mutation is dry-run by default.** Nothing changes until you pass
@@ -52,8 +56,23 @@ structured error envelopes, and a built-in MCP server
52
56
  `--limit`/`--offset`/`--filter` on every list surface, multi-instance
53
57
  profiles with a bank-safety guard banner
54
58
  (`(profile: uat @ host:port)`).
55
- - **Built for agents** — built-in MCP server with 129 auto-generated tools,
56
- progressive discovery (5 meta-tools + focus/unfocus), 22 guide resources,
59
+ - **SOAR ops** — `soar` command tree: containers, artifacts, vault, notes,
60
+ cases/workbooks, approvals, custom lists, indicators, evidence, users,
61
+ roles, audit, and cross-object search. Full lifecycle: create, update,
62
+ close, assign, delete -- with the same dry-run guard and typed error
63
+ envelopes.
64
+ - **Playbooks as code** — `soar playbooks export --unpack` / `import`
65
+ round-trips playbook tgz bundles (the first such tool anywhere);
66
+ `soar playbooks delete` removes them through the Web UI route (no
67
+ REST deletion exists); `soar playbooks run --wait` drives and polls
68
+ runs to completion; `soar actions run --wait` does the same for
69
+ connector actions.
70
+ - **SIEM-to-SOAR ingest** — `soar ingest --spl` runs a SIEM search and
71
+ creates SOAR containers + typed CEF artifacts using the official CIM-to-CEF
72
+ field map, with SDI dedup, severity mapping, and last-artifact automation
73
+ batching.
74
+ - **Built for agents** — built-in MCP server with 237 auto-generated tools,
75
+ progressive discovery (5 meta-tools + focus/unfocus), 33 guide resources,
57
76
  guard markers on every mutation, dual output (TTY = table, pipe = JSON).
58
77
 
59
78
  ## Install
@@ -81,10 +100,12 @@ splunkctl --version
81
100
  ## Quickstart
82
101
 
83
102
  ```bash
84
- splunkctl config init # interactive setup
85
- splunkctl doctor # check connection, auth, permissions
86
- splunkctl search run 'index=main | head 10' # run a search
87
- splunkctl rules list # list detection rules
103
+ splunkctl config init # interactive SIEM setup
104
+ splunkctl config init --soar # add SOAR credentials
105
+ splunkctl doctor # check SIEM connection
106
+ splunkctl soar test # check SOAR connection
107
+ splunkctl search run 'index=main | head 10' # run a SIEM search
108
+ splunkctl soar containers list # list SOAR containers
88
109
  splunkctl commands --json # discover every verb
89
110
  ```
90
111
 
@@ -136,6 +157,7 @@ splunkctl state push --dir config/ --report r.json --yes # deploy + evidence
136
157
  | `tags` | Tags — list, get |
137
158
  | `datamodels` | Data model definitions + acceleration health |
138
159
  | `state` | Config-as-code pull/diff/push with change-evidence reports |
160
+ | `soar` | SOAR: containers, artifacts, vault, playbooks, actions, cases, ingest |
139
161
  | `commands` | Machine-readable command tree (JSON) |
140
162
  | `mcp` | Built-in MCP server for AI agent integration |
141
163
 
@@ -207,11 +229,12 @@ splunkctl mcp install # register in .mcp.json
207
229
  splunkctl mcp serve # start stdio MCP server
208
230
  ```
209
231
 
210
- The MCP server auto-generates 129 typed tools from the Click command tree
211
- with progressive discovery — agents start with 5 meta-tools (`help`,
212
- `usage`, `focus`, `unfocus`, `run`) and dynamically load typed schemas per
213
- command group. 22 guide resources are served as `guide://` URIs. Mutations
214
- are guarded: `yes=true` to apply (dry-run by default).
232
+ The MCP server auto-generates 229 typed tools from the Click command tree
233
+ (SIEM + SOAR) with progressive discovery -- agents start with 5 meta-tools
234
+ (`help`, `usage`, `focus`, `unfocus`, `run`) and dynamically load typed
235
+ schemas per command group. Subgroup-granular focus works for nested groups
236
+ like `soar containers`. 33 guide resources are served as `guide://` URIs.
237
+ Mutations are guarded: `yes=true` to apply (dry-run by default).
215
238
 
216
239
  ## License
217
240
 
@@ -0,0 +1,3 @@
1
+ """CLI tool for Splunk Enterprise and Splunk SOAR operations."""
2
+
3
+ __version__ = "0.10.0"
@@ -15,6 +15,7 @@ from typing import Any
15
15
  import click
16
16
  import requests
17
17
  import splunklib.client as splunk_client
18
+ import urllib3
18
19
 
19
20
  from splunkctl import config as cfg_mod
20
21
 
@@ -186,6 +187,10 @@ class _WebSession:
186
187
  self._timeout = timeout
187
188
  self._session = requests.Session()
188
189
  self._session.verify = verify
190
+ if not verify:
191
+ # verify=false is an explicit config choice (lab/self-signed);
192
+ # without this every request spams InsecureRequestWarning.
193
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
189
194
  self._csrf_token: str | None = None
190
195
  self._logged_in = False
191
196
 
@@ -57,7 +57,7 @@ def _status_to_int(value: str) -> str:
57
57
  key = value.strip().lower()
58
58
  if key in _STATUS_MAP:
59
59
  return _STATUS_MAP[key]
60
- if value.isdigit():
60
+ if value.isascii() and value.isdigit():
61
61
  return value
62
62
  known = ", ".join(_STATUS_MAP)
63
63
  raise click.BadParameter(f"'{value}' is not a known status ({known}, or 0-5)")
@@ -8,6 +8,7 @@ import subprocess
8
8
  import sys
9
9
  from typing import Any
10
10
 
11
+ import click
11
12
  from mcp.server.fastmcp import Context, FastMCP
12
13
  from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
13
14
  from mcp.types import ToolAnnotations
@@ -20,9 +21,13 @@ from splunkctl.mcp.tools import (
20
21
  ToolEntry,
21
22
  ToolIndex,
22
23
  build_tool_index,
24
+ direct_commands,
23
25
  group_names,
24
26
  group_summary,
25
27
  group_tools,
28
+ has_subgroups,
29
+ leaf_count,
30
+ subgroup_names,
26
31
  )
27
32
 
28
33
  _INSTRUCTIONS = """\
@@ -32,28 +37,45 @@ the preferred way to run commands (validated arguments, full schemas). \
32
37
  `usage <command>` previews one command's schema and auto-loads it as a \
33
38
  callable tool. `run` is an escape hatch for raw command strings (no \
34
39
  validation). `unfocus` when done to free context. Mutations are guarded: \
35
- pass yes=true to apply (dry-run by default)."""
40
+ pass yes=true to apply (dry-run by default). \
41
+ The soar tree has nested subgroups (containers, playbooks, actions, ...); \
42
+ focus at subgroup granularity: `focus "soar containers"`, not `focus soar`. \
43
+ `help soar` lists available subgroups."""
36
44
 
37
45
  _FORCE_FLAGS = ["--json"]
38
46
 
39
47
  _STRIP_PARAMS = SKIP_PARAMS
40
48
 
41
49
 
50
+ def _decode_stream(data: bytes) -> str:
51
+ """Decode subprocess output, tolerating binary payloads.
52
+
53
+ Commands like ``soar playbooks export`` emit raw bytes (a tgz) on
54
+ stdout when ``--out`` is omitted — surface a hint instead of dying
55
+ on a UTF-8 decode error.
56
+ """
57
+ try:
58
+ return data.decode()
59
+ except UnicodeDecodeError:
60
+ return f"(binary output: {len(data)} bytes — pass --out FILE to save it)"
61
+
62
+
42
63
  def _exec_cli(args: list[str]) -> str:
43
64
  """Run ``splunkctl <args>`` as a subprocess and return output."""
44
65
  cmd = [sys.executable, "-m", "splunkctl", *args, *_FORCE_FLAGS]
45
66
  result = subprocess.run( # noqa: S603
46
67
  cmd,
47
68
  capture_output=True,
48
- text=True,
49
69
  timeout=120,
50
70
  )
51
- out = result.stdout.strip()
52
- err = result.stderr.strip()
71
+ out = _decode_stream(result.stdout).strip()
72
+ err = _decode_stream(result.stderr).strip()
53
73
  if result.returncode != 0:
54
74
  return err or out or f"Command failed with exit code {result.returncode}"
55
75
  if err and out:
56
- return f"{out}\n\n{err}"
76
+ # stderr carries the guard banner / info lines — they precede
77
+ # the payload semantically, so keep them first.
78
+ return f"{err}\n\n{out}"
57
79
  return out or err or "(no output)"
58
80
 
59
81
 
@@ -96,10 +118,15 @@ def _build_cli_args(entry: ToolEntry, params: dict[str, Any]) -> list[str]:
96
118
  items = value if isinstance(value, list) else [value]
97
119
  positional[pname] = [str(v) for v in items]
98
120
  continue
99
- flag = f"--{pname.replace('_', '-')}"
121
+ # Renamed Click options (--severity → severity_override) make the
122
+ # schema name diverge from the CLI flag; the entry's flag map is
123
+ # authoritative.
124
+ flag = entry.flags.get(pname, f"--{pname.replace('_', '-')}")
100
125
  if isinstance(value, bool):
101
126
  if value:
102
127
  args.append(flag)
128
+ elif pname in entry.neg_flags:
129
+ args.append(entry.neg_flags[pname])
103
130
  elif isinstance(value, list):
104
131
  for item in value:
105
132
  args.extend([flag, str(item)])
@@ -149,11 +176,40 @@ def create_server() -> FastMCP:
149
176
  """List command groups, or subcommands within a group."""
150
177
  if group is None:
151
178
  rows = group_summary(cli)
152
- lines = [
153
- f"{r['group']:16s} {r['description']} ({r['count']} cmds)" for r in rows
154
- ]
179
+ lines: list[str] = []
180
+ for r in rows:
181
+ line = f"{r['group']:16s} {r['description']} ({r['count']} cmds)"
182
+ lines.append(line)
183
+ if "subgroups" in r:
184
+ lines.append(f"{'':16s} subgroups: {r['subgroups']}")
185
+ lines.append(
186
+ f"{'':16s} focus at subgroup level: "
187
+ f'focus "{r["group"]} <subgroup>"'
188
+ )
155
189
  return "\n".join(lines)
156
- return _exec_cli([group, "--help"])
190
+ # For groups with subgroups, show the two-level layout
191
+ parts = _split_command(group)
192
+ if len(parts) == 1 and has_subgroups(cli, parts[0]):
193
+ grp = parts[0]
194
+ top_cmd = cli.commands.get(grp)
195
+ if not isinstance(top_cmd, click.Group): # guaranteed by has_subgroups
196
+ raise TypeError(f"expected group for {grp!r}")
197
+ sg = subgroup_names(cli, grp)
198
+ dc = direct_commands(cli, grp)
199
+ lines = [f'{grp} subgroups (focus "{grp} <subgroup>"):']
200
+ for s in sg:
201
+ sub_cmd = top_cmd.commands[s]
202
+ desc = (sub_cmd.help or "").split("\n")[0]
203
+ n = leaf_count(sub_cmd) if isinstance(sub_cmd, click.Group) else 0
204
+ lines.append(f" {grp} {s:16s} {desc} ({n} cmds)")
205
+ if dc:
206
+ lines.append(f"\n{grp} direct commands:")
207
+ for c in dc:
208
+ sub_cmd = top_cmd.commands[c]
209
+ desc = (sub_cmd.help or "").split("\n")[0]
210
+ lines.append(f" {grp} {c:16s} {desc}")
211
+ return "\n".join(lines)
212
+ return _exec_cli([*parts, "--help"])
157
213
 
158
214
  # --- Meta-tool: usage ---
159
215
 
@@ -161,7 +217,8 @@ def create_server() -> FastMCP:
161
217
  name="usage",
162
218
  description=(
163
219
  "Show flags, args, and description for one command. "
164
- "Auto-loads it as a callable tool."
220
+ "Auto-loads it as a callable tool. "
221
+ 'Accepts nested paths: usage "soar playbooks run".'
165
222
  ),
166
223
  annotations=ToolAnnotations(readOnlyHint=True),
167
224
  )
@@ -193,7 +250,8 @@ def create_server() -> FastMCP:
193
250
  name="focus",
194
251
  description=(
195
252
  "Load typed tools for a command group (enables full schemas). "
196
- "Preferred over run for validated arguments."
253
+ "Preferred over run for validated arguments. "
254
+ 'Nested groups like soar require subgroup: focus "soar containers".'
197
255
  ),
198
256
  annotations=ToolAnnotations(readOnlyHint=True),
199
257
  )
@@ -203,6 +261,26 @@ def create_server() -> FastMCP:
203
261
  count = len(focused[group])
204
262
  return f"Group '{group}' already focused ({count} tools)."
205
263
 
264
+ # Reject bare groups that have nested subgroups (too many tools).
265
+ parts = _split_command(group)
266
+ if len(parts) == 1 and has_subgroups(cli, parts[0]):
267
+ sg = subgroup_names(cli, parts[0])
268
+ dc = direct_commands(cli, parts[0])
269
+ lines = [
270
+ f"'{parts[0]}' has nested subgroups — focus at "
271
+ f"subgroup level to avoid flooding context.",
272
+ "",
273
+ "Subgroups:",
274
+ ]
275
+ for s in sg:
276
+ lines.append(f' focus "{parts[0]} {s}"')
277
+ if dc:
278
+ lines.append(
279
+ f"\nDirect commands ({', '.join(dc)}) can be "
280
+ f"loaded individually via usage."
281
+ )
282
+ return "\n".join(lines)
283
+
206
284
  entries = group_tools(all_tools, group)
207
285
  if not entries:
208
286
  available = group_names(cli)
@@ -249,14 +327,22 @@ def create_server() -> FastMCP:
249
327
  await ctx.session.send_tool_list_changed()
250
328
  return f"Unloaded all focused tools ({removed} total)."
251
329
 
252
- if group not in focused:
330
+ # Try exact key first, then normalized form for subgroup paths
331
+ key = group if group in focused else None
332
+ if key is None:
333
+ norm = group.replace(" ", "_").replace("-", "_")
334
+ for k in focused:
335
+ if k.replace(" ", "_").replace("-", "_") == norm:
336
+ key = k
337
+ break
338
+ if key is None:
253
339
  return f"Group '{group}' is not focused."
254
- for n in focused[group]:
340
+ for n in focused[key]:
255
341
  try:
256
342
  mcp.remove_tool(n)
257
343
  except Exception: # noqa: BLE001, S110
258
344
  pass
259
- count = len(focused.pop(group))
345
+ count = len(focused.pop(key))
260
346
  if ctx is not None:
261
347
  await ctx.session.send_tool_list_changed()
262
348
  return f"Unloaded {count} tools for '{group}'."
@@ -276,6 +362,19 @@ def create_server() -> FastMCP:
276
362
  async def run_tool(command: str, yes: bool = False) -> str:
277
363
  """Execute a raw splunkctl command string."""
278
364
  tokens = _split_command(command)
365
+ # The tool's yes parameter is the only mutation switch — a --yes
366
+ # smuggled inside the command string must not bypass the guard.
367
+ # Reject rather than silently strip: after shlex a quoted option
368
+ # VALUE of '-y' is indistinguishable from the flag, and eating
369
+ # it would shift arguments or corrupt the value.
370
+ if any(t in ("--yes", "-y") for t in tokens):
371
+ return (
372
+ "Error: '--yes'/'-y' is not accepted inside the command "
373
+ "string — the tool's yes parameter is the only mutation "
374
+ "switch. Remove it and pass yes=true to apply. If you "
375
+ "need the literal value '-y', use a focused typed tool "
376
+ "instead."
377
+ )
279
378
  if yes:
280
379
  tokens.append("--yes")
281
380
  result = _exec_cli(tokens)
@@ -286,9 +385,17 @@ def create_server() -> FastMCP:
286
385
  if tool_name in all_tools and not any(
287
386
  tool_name in names for names in focused.values()
288
387
  ):
388
+ if len(tokens) >= 2 and tokens[1] in subgroup_names(cli, tokens[0]):
389
+ suggestion = f'focus "{tokens[0]} {tokens[1]}"'
390
+ elif has_subgroups(cli, tokens[0]):
391
+ # Bare focus on a nested group is refused; point at usage
392
+ # for direct commands instead.
393
+ suggestion = f'usage "{" ".join(tokens[:2])}"'
394
+ else:
395
+ suggestion = f"focus {tokens[0]}"
289
396
  result += (
290
- f"\n\nTip: run `focus {tokens[0]}` to load typed tools "
291
- f"with validated schemas for this group."
397
+ f"\n\nTip: run `{suggestion}` to load a typed tool "
398
+ f"with a validated schema for this command."
292
399
  )
293
400
  return result
294
401