diff --git a/README.md b/README.md index 46483a8c..bd5bf257 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/root.go b/cmd/root.go index 5b34a49c..3dd54b48 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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 diff --git a/cmd/search.go b/cmd/search.go new file mode 100644 index 00000000..654a2673 --- /dev/null +++ b/cmd/search.go @@ -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 ", 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 +} diff --git a/cmd/search_test.go b/cmd/search_test.go new file mode 100644 index 00000000..47486c2a --- /dev/null +++ b/cmd/search_test.go @@ -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)) + } +}