Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,45 @@ Commands with JSON output support:
- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files`, `webmcp list` (`webmcp invoke` always prints JSON output)
- **Browser NDJSON streaming**: `telemetry stream`

### Search

Search commands always return the full API response as JSON, including results,
warnings, provider attempts, usage, and expiry. They use the normal API key or
OAuth authentication and global `--project` scope. Your organization must have
Search API access enabled.

```bash
# Discover currently configured providers and capabilities
kernel search providers

# Automatic routing
kernel search "Playwright browser automation" --max-results 5

# Retrieve a retained result without another provider call or search charge
kernel search get srch_01jsearchresult

# Use portable filters or other advanced request fields
kernel search --request '{"query":"browser automation","include_domains":["example.com"],"strict_params":true}'

# Load a complete request from a file or stdin
kernel search --request-file request.json
cat request.json | kernel search --request-file -
```

- `--max-results` accepts 1–100; the API may clamp it to the provider cap.
- `--request` and `--request-file` accept the complete Search API JSON object,
including `content`, `include_raw`, date/locale filters, and typed strategies.
They cannot be combined with a positional query, `--provider`, or `--max-results`.
Provider-specific and advanced request validation is performed by the API.
- Create requests are not automatically retried, to avoid duplicate billable
searches after an ambiguous failure. If a request fails ambiguously, use
`search get` only when the API returned a retained search ID.
- Retained searches return 404 when missing, expired, or inaccessible. Deferred
content retrieval is not exposed because it is reserved but unavailable in the
current API contract.
- To search for a literal query equal to a subcommand name (`get` or `providers`),
use `--request '{"query":"providers"}'`.

### Authentication

- `kernel login [--force]` - Login via OAuth 2.0
Expand Down
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ func init() {
rootCmd.AddCommand(mcp.MCPCmd)
rootCmd.AddCommand(upgradeCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(newSearchCommand())

rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error {
// running synchronously so we never slow the command
Expand Down
150 changes: 150 additions & 0 deletions cmd/search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package cmd

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"unicode/utf8"

"github.com/kernel/cli/pkg/util"
"github.com/kernel/kernel-go-sdk/option"
"github.com/spf13/cobra"
)

func newSearchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "search [query]",
Short: "Search the web and return the full result as JSON",
Long: "Search the web using automatic routing or a pinned provider. Returns the full JSON resource, including warnings, attempts, usage, and expiry. Use --request or --request-file for the complete Search API request, including fallback strategies, portable filters, content, and provider-native options. Requires Search API access for your organization.",
Example: " kernel search 'browser automation' --provider exa --max-results 5\n kernel search --request-file request.json\n kernel search get srch_123\n kernel search providers --slug exa",
Args: cobra.MaximumNArgs(1),
RunE: runSearch,
}
cmd.Flags().String("provider", "", "Pin a provider slug (default: automatic routing)")
cmd.Flags().Int("max-results", 10, "Requested result count (1–100; subject to provider cap)")
cmd.Flags().String("request", "", "Complete Search API request as a JSON object; cannot be combined with query flags")
cmd.Flags().String("request-file", "", "Complete Search API request file (use '-' for stdin)")
cmd.MarkFlagsMutuallyExclusive("request", "request-file")
for _, input := range []string{"request", "request-file"} {
cmd.MarkFlagsMutuallyExclusive(input, "provider")
cmd.MarkFlagsMutuallyExclusive(input, "max-results")
}
get := &cobra.Command{Use: "get <id>", Short: "Retrieve a retained search as JSON without running it again", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(args[0]) == "" {
return fmt.Errorf("search ID must not be empty")
}
return executeSearchRequest(cmd, http.MethodGet, "search/"+url.PathEscape(args[0]), nil)
}}
providers := &cobra.Command{Use: "providers", Short: "List configured providers and their capabilities as JSON", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error {
path := "search/providers"
if cmd.Flags().Changed("slug") {
slug, _ := cmd.Flags().GetString("slug")
path += "?" + url.Values{"slug": {slug}}.Encode()
}
return executeSearchRequest(cmd, http.MethodGet, path, nil)
}}
providers.Flags().String("slug", "", "Filter by provider slug")
cmd.AddCommand(get, providers)
return cmd
}

func runSearch(cmd *cobra.Command, args []string) error {
var body json.RawMessage
if cmd.Flags().Changed("request") || cmd.Flags().Changed("request-file") {
if len(args) != 0 {
return fmt.Errorf("query cannot be combined with --request or --request-file")
}
input, _ := cmd.Flags().GetString("request")
if cmd.Flags().Changed("request-file") {
path, _ := cmd.Flags().GetString("request-file")
var data []byte
var err error
if path == "-" {
data, err = io.ReadAll(cmd.InOrStdin())
} else {
data, err = os.ReadFile(path)
}
if err != nil {
return fmt.Errorf("read search request: %w", err)
}
input = string(data)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal([]byte(input), &fields); err != nil {
return fmt.Errorf("search request must be a JSON object: %w", err)
}
if fields == nil {
return fmt.Errorf("search request must be a JSON object")
}
var query string
if err := json.Unmarshal(fields["query"], &query); err != nil {
return fmt.Errorf("search request must contain a string query")
}
if err := validateSearchQuery(query); err != nil {
return err
}
body = json.RawMessage(input)
} else {
if len(args) != 1 {
return fmt.Errorf("provide a query or --request/--request-file")
}
if err := validateSearchQuery(args[0]); err != nil {
return err
}
request := map[string]any{"query": args[0]}
if cmd.Flags().Changed("max-results") {
count, _ := cmd.Flags().GetInt("max-results")
if count < 1 || count > 100 {
return fmt.Errorf("--max-results must be between 1 and 100")
}
request["max_results"] = count
}
if cmd.Flags().Changed("provider") {
provider, _ := cmd.Flags().GetString("provider")
if strings.TrimSpace(provider) == "" {
return fmt.Errorf("--provider must not be empty")
}
request["strategy"] = map[string]any{"type": "pinned", "provider": map[string]string{"provider": provider}}
}
var err error
body, err = json.Marshal(request)
if err != nil {
return err
}
}
return executeSearchRequest(cmd, http.MethodPost, "search", body)
}

func validateSearchQuery(query string) error {
if strings.TrimSpace(query) == "" || utf8.RuneCountInString(query) > 2048 {
return fmt.Errorf("query must contain 1–2048 characters and not be blank")
}
return nil
}

func executeSearchRequest(cmd *cobra.Command, method, path string, body json.RawMessage) error {
client := getKernelClient(cmd)
var response json.RawMessage
var opts []option.RequestOption
if method == http.MethodPost {
// Avoid duplicate billable searches after an ambiguous failure.
opts = append(opts, option.WithMaxRetries(0))
}
var requestBody any
if method == http.MethodPost {
requestBody = body
}
if err := client.Execute(cmd.Context(), method, path, requestBody, &response, opts...); err != nil {
return util.CleanedUpSdkError{Err: err}
}
data, err := json.MarshalIndent(response, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
}
142 changes: 142 additions & 0 deletions cmd/search_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package cmd

import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/kernel/cli/pkg/util"
kernel "github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func executeSearchCommand(t *testing.T, handler http.HandlerFunc, stdin string, args ...string) (string, error) {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test"), option.WithProject("project-test"))
root := &cobra.Command{Use: "kernel", SilenceErrors: true, SilenceUsage: true}
root.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client))
root.SetIn(strings.NewReader(stdin))
root.AddCommand(newSearchCommand())
root.SetArgs(append([]string{"search"}, args...))
var err error
stdout := captureStdout(t, func() { err = root.Execute() })
return stdout, err
}

func TestSearchCreate(t *testing.T) {
const advanced = `{"query":"test","strategy":{"type":"fallback","providers":[{"provider":"exa","options":{"type":"auto"}},{"provider":"brave"}],"fallback_on":["error","timeout","empty"]},"content":true,"strict_params":true,"include_raw":true,"include_domains":["example.com"]}`
path := filepath.Join(t.TempDir(), "request.json")
require.NoError(t, os.WriteFile(path, []byte(advanced), 0600))
for _, tc := range []struct {
name string
args []string
stdin, want string
}{
{"auto", []string{"test"}, "", `{"query":"test"}`},
{"pinned", []string{"test", "--provider", "exa", "--max-results", "5"}, "", `{"query":"test","max_results":5,"strategy":{"type":"pinned","provider":{"provider":"exa"}}}`},
{"inline", []string{"--request", advanced}, "", advanced},
{"stdin", []string{"--request-file", "-"}, advanced, advanced},
{"file", []string{"--request-file", path}, "", advanced},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
const response = `{"id":"srch_test","results":[],"warnings":[{"code":"billing_unavailable"}],"attempts":[],"future_field":9007199254740993}`
stdout, err := executeSearchCommand(t, func(w http.ResponseWriter, r *http.Request) {
calls++
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/search", r.URL.Path)
assert.Equal(t, "Bearer test", r.Header.Get("Authorization"))
assert.Equal(t, "project-test", r.Header.Get("X-Kernel-Project"))
data, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.JSONEq(t, tc.want, string(data))
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, response)
}, tc.stdin, tc.args...)
require.NoError(t, err)
assert.Equal(t, 1, calls)
assert.JSONEq(t, response, stdout)
assert.Contains(t, stdout, "9007199254740993")
})
}
}

func TestSearchReadCommands(t *testing.T) {
for _, tc := range []struct {
args []string
path, query, response string
}{
{[]string{"get", "srch_test"}, "/search/srch_test", "", `{"id":"srch_test","results":[]}`},
{[]string{"providers"}, "/search/providers", "", `[]`},
{[]string{"providers", "--slug", "exa&other=1"}, "/search/providers", "slug=exa%26other%3D1", `[]`},
} {
t.Run(strings.Join(tc.args, " "), func(t *testing.T) {
stdout, err := executeSearchCommand(t, func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, tc.path, r.URL.Path)
assert.Equal(t, tc.query, r.URL.RawQuery)
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
assert.Empty(t, body)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, tc.response)
}, "", tc.args...)
require.NoError(t, err)
assert.JSONEq(t, tc.response, stdout)
})
}
}

func TestSearchInvalidInput(t *testing.T) {
for _, args := range [][]string{
{}, {" "}, {strings.Repeat("x", 2049)}, {"a", "b"}, {"test", "--provider", ""},
{"test", "--max-results", "0"}, {"test", "--max-results", "101"},
{"--request", "null"}, {"--request", "[]"}, {"--request", "{"}, {"--request", `{}`}, {"--request", `{"query":1}`},
{"test", "--request", `{"query":"test"}`}, {"--request", `{}`, "--provider", "exa"},
{"--request", `{}`, "--max-results", "5"}, {"--request", `{}`, "--request-file", "-"},
{"--request-file", "/nonexistent/search-request.json"}, {"get"}, {"providers", "extra"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
_, err := executeSearchCommand(t, func(w http.ResponseWriter, r *http.Request) { t.Error("unexpected API call") }, "", args...)
require.Error(t, err)
})
}
}

func TestSearchErrorsDoNotRetryCreate(t *testing.T) {
for _, status := range []int{400, 401, 403, 429, 500} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
calls := 0
stdout, err := executeSearchCommand(t, func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprint(w, `{"message":"search failed"}`)
}, "", "test")
require.Error(t, err)
assert.Empty(t, stdout)
assert.Equal(t, 1, calls)
})
}
}

func TestSearchWiring(t *testing.T) {
for _, path := range [][]string{{"search"}, {"search", "get"}, {"search", "providers"}} {
cmd, remaining, err := rootCmd.Find(path)
require.NoError(t, err)
require.Empty(t, remaining)
assert.NotNil(t, cmd.RunE)
assert.False(t, isAuthExempt(cmd))
}
}
Loading