Skip to content

Commit 3db1db1

Browse files
magifd2claude
andcommitted
feat: add 3-mode SPL prepend behavior (closes #4)
Issue #4 reports that splunk-cli prepends `search ` to user-supplied SPL even when the input already begins with the `search` command, so copy-pasting `search index=foo` from Splunk Web yields the submitted SPL `search search index=foo`. Splunk then parses the second `search` as a literal token to match — returning silent no-results. Adds a `--prepend <mode>` global flag and a `[splunk] prepend = "..."` config field. Three modes: pipe-only (default, historical behavior) — prepend `search ` unless the SPL starts with `|`. auto — also skip when the SPL already starts with the `search` command (followed by whitespace or end-of-string). Fixes the doubled-search artifact. Does not detect macros that expand to a leading command — use `off` for that. off — never prepend; caller supplies a complete SPL. Default mode is unchanged so existing workflows are unaffected. The auto-prepend logic moves into a new `internal/spl` package as a pure `Wrap(spl, mode)` helper with table-driven tests covering each mode against bare terms, leading-`search` commands, pipe leaders, quoted phrases, backtick macros, and tokens that merely start with "search" (e.g. `searchengine=foo`). The Splunk client now delegates to `spl.Wrap` instead of inlining the rule. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9935379 commit 3db1db1

9 files changed

Lines changed: 302 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- **`--prepend` flag and `prepend` config field** for choosing how SPL
13+
is wrapped before submission. Three modes:
14+
- `pipe-only` (default, historical behavior): prepend `search ` unless
15+
the SPL starts with `|`.
16+
- `auto`: also skip the prefix when the SPL already starts with the
17+
`search` command (followed by whitespace or end-of-string). Avoids
18+
the doubled-`search` artifact when a user pastes `search index=foo`
19+
from Splunk Web. Does not detect macros that expand to a leading
20+
command — use `off` for that.
21+
- `off`: never prepend; the caller supplies a complete SPL.
22+
23+
Precedence: `--prepend` CLI flag > `[splunk] prepend` in config file
24+
> built-in default (`pipe-only`). Default is unchanged from prior
25+
versions, so existing workflows are unaffected. Addresses
26+
https://github.com/nlink-jp/splunk-cli/issues/4.
27+
28+
### Changed
29+
30+
- The auto-prepend logic moves into the new `internal/spl` package as
31+
a pure `Wrap(spl, mode)` helper, and the Splunk client now delegates
32+
to it instead of inlining the rule. No observable change at the
33+
default mode.
34+
835
## [2.0.4] - 2026-05-22
936

1037
### Added

README.ja.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,35 @@ splunk-cli [command]
111111
--insecure TLS 証明書検証をスキップ
112112
--http-timeout duration リクエストごとの HTTP タイムアウト(例: 30s, 2m)
113113
--debug デバッグログを有効化
114+
--prepend string SPL 自動補完モード: auto | pipe-only(デフォルト)| off
114115
-v, --version バージョン情報を表示
115116
```
116117

118+
### SPL 自動補完モード
119+
120+
splunk-cli はデフォルトでユーザの SPL の先頭に `search ` を自動付与します(`|` 始まりは除く)。このため **自分で `search index=foo` と書くと `search search index=foo` になり**、Splunk は 2 番目の `search` をリテラルトークン検索として解釈する結果、ほぼ常に空ヒットになります。3 つのモードを選べます。
121+
122+
| モード | `search ` を付与する条件 | 備考 |
123+
|---|---|---|
124+
| `pipe-only`(デフォルト) | 入力が `\|` で始まらない場合 | 従来動作。後方互換維持。 |
125+
| `auto` | 入力が `\|` で始まらず、かつ `search` コマンド(後ろが空白 / 末尾)で始まらない場合 | Splunk Web からのコピペに親切。マクロが先頭コマンドに展開されるケースは検出できない。 |
126+
| `off` | 付与しない | 完全な SPL を自分で書く必要があります(先頭 `search` / 生成コマンド / `\|` を含む)。 |
127+
128+
実行ごとに指定:
129+
130+
```bash
131+
splunk-cli run --prepend auto --spl 'search index=foo | stats count'
132+
```
133+
134+
または `~/.config/splunk-cli/config.toml` でデフォルト指定:
135+
136+
```toml
137+
[splunk]
138+
prepend = "auto"
139+
```
140+
141+
優先順位: CLI フラグ > 設定ファイル > 組み込みデフォルト(`pipe-only`
142+
117143
### `run` — 同期検索
118144

119145
```bash

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,39 @@ Global flags:
111111
--insecure Skip TLS certificate verification
112112
--http-timeout duration Per-request HTTP timeout (e.g. 30s, 2m)
113113
--debug Enable verbose debug logging
114+
--prepend string SPL prepend mode: auto | pipe-only (default) | off
114115
-v, --version Print version information
115116
```
116117

118+
### SPL prepend mode
119+
120+
By default, splunk-cli wraps your SPL with a leading `search ` command
121+
unless it starts with `|`. This means **typing `search index=foo`
122+
yourself produces a doubled `search search index=foo`**, which Splunk
123+
parses as "search command + literal token search + index=foo" and
124+
typically returns no results. Three modes are available:
125+
126+
| Mode | When `search ` is added | Notes |
127+
|---|---|---|
128+
| `pipe-only` (default) | unless input starts with `\|` | Historical behavior; backward compatible. |
129+
| `auto` | unless input starts with `\|` **or** with the `search` command (followed by whitespace / EOF) | Convenient when pasting from Splunk Web. Does **not** detect macros that expand to a leading command. |
130+
| `off` | never | You supply a complete SPL, including any leading command. |
131+
132+
Set per-invocation:
133+
134+
```bash
135+
splunk-cli run --prepend auto --spl 'search index=foo | stats count'
136+
```
137+
138+
Or set the default in `~/.config/splunk-cli/config.toml`:
139+
140+
```toml
141+
[splunk]
142+
prepend = "auto"
143+
```
144+
145+
Priority: CLI flag > config file > built-in default (`pipe-only`).
146+
117147
### `run` — Synchronous search
118148

119149
```bash

cmd/root.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/nlink-jp/splunk-cli/internal/client"
1313
"github.com/nlink-jp/splunk-cli/internal/config"
14+
"github.com/nlink-jp/splunk-cli/internal/spl"
1415
)
1516

1617
// cfg holds the runtime configuration, built by loadConfig in PersistentPreRunE.
@@ -29,6 +30,7 @@ var (
2930
flagHTTPTimeout time.Duration
3031
flagDebug bool
3132
flagLimit int
33+
flagPrepend string
3234
)
3335

3436
var rootCmd = &cobra.Command{
@@ -52,6 +54,7 @@ func init() {
5254
pf.DurationVar(&flagHTTPTimeout, "http-timeout", 0, "Per-request HTTP timeout (e.g. 30s, 2m)")
5355
pf.BoolVar(&flagDebug, "debug", false, "Enable verbose debug logging")
5456
pf.IntVar(&flagLimit, "limit", 0, "Max results to return (0 = all)")
57+
pf.StringVar(&flagPrepend, "prepend", "", `SPL prepend mode: "auto" | "pipe-only" (default) | "off"`)
5558
}
5659

5760
// Execute runs the root command.
@@ -102,6 +105,13 @@ func loadConfig(_ *cobra.Command, _ []string) error {
102105
if pf.Changed("limit") {
103106
cfg.Limit = flagLimit
104107
}
108+
if pf.Changed("prepend") {
109+
mode, err := spl.ParseMode(flagPrepend)
110+
if err != nil {
111+
return err
112+
}
113+
cfg.Prepend = mode
114+
}
105115
return nil
106116
}
107117

config.example.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,20 @@ token = ""
2626

2727
# Maximum results to return per run/results command (0 = all)
2828
# limit = 0
29+
30+
# How splunk-cli wraps the SPL you submit:
31+
# "pipe-only" (default) — prepend "search " unless the SPL starts with "|".
32+
# Matches the historical behavior; if you write
33+
# `search index=foo`, the submitted SPL becomes
34+
# `search search index=foo` (Splunk treats the
35+
# second `search` as a literal token to match).
36+
# "auto" — also skip the prefix when the SPL already starts
37+
# with the `search` command (followed by a space
38+
# or end of string). Convenient if you paste from
39+
# Splunk Web; does not protect against macros that
40+
# expand to a leading command.
41+
# "off" — never prepend. You must supply a complete SPL,
42+
# including any leading `search` or generating
43+
# command.
44+
# Override per-invocation with `--prepend auto|pipe-only|off`.
45+
# prepend = "pipe-only"

internal/client/client.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"time"
1818

1919
"github.com/nlink-jp/splunk-cli/internal/config"
20+
splpkg "github.com/nlink-jp/splunk-cli/internal/spl"
2021
)
2122

2223
const (
@@ -154,11 +155,7 @@ func (c *Client) StartSearch(ctx context.Context, spl, earliest, latest string)
154155
c.debugf("POST %s\n", endpoint)
155156

156157
form := url.Values{}
157-
if !strings.HasPrefix(strings.TrimSpace(spl), "|") {
158-
form.Set("search", "search "+spl)
159-
} else {
160-
form.Set("search", spl)
161-
}
158+
form.Set("search", splpkg.Wrap(spl, c.cfg.Prepend))
162159
if earliest != "" {
163160
form.Set("earliest_time", earliest)
164161
}

internal/config/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"time"
1111

1212
"github.com/BurntSushi/toml"
13+
14+
"github.com/nlink-jp/splunk-cli/internal/spl"
1315
)
1416

1517
// Stderr is the writer for warnings. Overridable in tests.
@@ -28,6 +30,7 @@ type Config struct {
2830
HTTPTimeout time.Duration
2931
Limit int
3032
Debug bool
33+
Prepend spl.PrependMode
3134
}
3235

3336
// tomlConfig mirrors the TOML file structure.
@@ -45,6 +48,7 @@ type tomlSplunk struct {
4548
Insecure bool `toml:"insecure"`
4649
HTTPTimeout string `toml:"http_timeout"`
4750
Limit int `toml:"limit"`
51+
Prepend string `toml:"prepend"`
4852
}
4953

5054
// DefaultPath returns the default config file path.
@@ -60,6 +64,7 @@ func DefaultPath() string {
6064
// A missing file is not an error — the returned Config will have zero values.
6165
func Load(path string) (Config, error) {
6266
var cfg Config
67+
cfg.Prepend = spl.DefaultMode
6368

6469
if path == "" {
6570
path = DefaultPath()
@@ -98,6 +103,12 @@ func Load(path string) (Config, error) {
98103
cfg.HTTPTimeout = d
99104
}
100105

106+
mode, err := spl.ParseMode(s.Prepend)
107+
if err != nil {
108+
return cfg, fmt.Errorf("config: %w", err)
109+
}
110+
cfg.Prepend = mode
111+
101112
return cfg, nil
102113
}
103114

internal/spl/prepend.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Package spl contains pure helpers for shaping SPL queries before they are
2+
// sent to Splunk's REST API.
3+
package spl
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
)
9+
10+
// PrependMode controls how Wrap decides whether to add a leading "search "
11+
// command to a user-supplied SPL string.
12+
type PrependMode string
13+
14+
const (
15+
// ModeAuto skips the "search " prefix when the input already starts with
16+
// the "search" command (followed by whitespace or end-of-string) or with
17+
// a "|" pipe leader. Everything else (bare terms, quoted phrases,
18+
// backtick macros) gets the prefix.
19+
ModeAuto PrependMode = "auto"
20+
21+
// ModePipeOnly skips the prefix only when the input starts with "|".
22+
// This matches the historical default — passing `search index=foo`
23+
// produces the double-search artifact `search search index=foo`.
24+
ModePipeOnly PrependMode = "pipe-only"
25+
26+
// ModeOff never prepends. The caller must supply a complete SPL,
27+
// including any leading "search" / generating command / "|" leader.
28+
ModeOff PrependMode = "off"
29+
)
30+
31+
// DefaultMode is the prepend mode used when neither config nor flag specifies
32+
// one. ModePipeOnly preserves historical splunk-cli behavior so existing
33+
// workflows keep working without change.
34+
const DefaultMode = ModePipeOnly
35+
36+
// Wrap returns the SPL string ready for submission, applying "search "
37+
// prepend according to mode. The original input is preserved verbatim
38+
// when no prefix is added (whitespace and all).
39+
func Wrap(spl string, mode PrependMode) string {
40+
if mode == "" {
41+
mode = DefaultMode
42+
}
43+
if mode == ModeOff {
44+
return spl
45+
}
46+
trimmed := strings.TrimSpace(spl)
47+
if strings.HasPrefix(trimmed, "|") {
48+
return spl
49+
}
50+
if mode == ModeAuto && hasLeadingSearchCommand(trimmed) {
51+
return spl
52+
}
53+
return "search " + spl
54+
}
55+
56+
// hasLeadingSearchCommand reports whether s begins with the SPL command
57+
// "search" followed by whitespace or end-of-string. Tokens that merely
58+
// start with "search" (for example searchengine=foo) return false.
59+
func hasLeadingSearchCommand(s string) bool {
60+
const kw = "search"
61+
if !strings.HasPrefix(s, kw) {
62+
return false
63+
}
64+
if len(s) == len(kw) {
65+
return true
66+
}
67+
switch s[len(kw)] {
68+
case ' ', '\t', '\n', '\r':
69+
return true
70+
}
71+
return false
72+
}
73+
74+
// ParseMode normalizes user-supplied input (CLI flag value or TOML field)
75+
// into a PrependMode. Empty string maps to DefaultMode. Unknown values
76+
// return an error so misconfiguration fails loudly rather than silently
77+
// falling back.
78+
func ParseMode(s string) (PrependMode, error) {
79+
switch strings.ToLower(strings.TrimSpace(s)) {
80+
case "":
81+
return DefaultMode, nil
82+
case string(ModeAuto):
83+
return ModeAuto, nil
84+
case string(ModePipeOnly):
85+
return ModePipeOnly, nil
86+
case string(ModeOff):
87+
return ModeOff, nil
88+
}
89+
return "", fmt.Errorf("invalid prepend mode %q (want auto | pipe-only | off)", s)
90+
}

0 commit comments

Comments
 (0)