diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 9b47c3e8..3947b8b9 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -15,10 +15,13 @@ package docs import ( + "context" + "encoding/json" "fmt" "net/url" "strings" + "github.com/slackapi/slack-cli/internal/search" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" @@ -26,25 +29,27 @@ import ( "github.com/spf13/cobra" ) -var searchMode bool +var outputFormat string +var searchLimit int +var searchFilter string func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "docs", Short: "Open Slack developer docs", - Long: "Open the Slack developer docs in your browser, with optional search functionality", + Long: "Open the Slack developer docs in your browser, or search docs with subcommands", Example: style.ExampleCommandsf([]style.ExampleCommand{ { Meaning: "Open Slack developer docs homepage", Command: "docs", }, { - Meaning: "Search Slack developer docs for Block Kit", - Command: "docs --search \"Block Kit\"", + Meaning: "Search and return JSON (default)", + Command: "docs search \"Block Kit\"", }, { - Meaning: "Open Slack docs search page", - Command: "docs --search", + Meaning: "Search and open in browser", + Command: "docs search \"webhooks\" --output=browser", }, }), RunE: func(cmd *cobra.Command, args []string) error { @@ -52,64 +57,136 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { }, } - cmd.Flags().BoolVar(&searchMode, "search", false, "open Slack docs search page or search with query") + // Add search subcommand + cmd.AddCommand(newSearchCommand(clients)) return cmd } +// newSearchCommand creates the search subcommand +func newSearchCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "search [query]", + Short: "Search Slack developer documentation", + Long: "Search the Slack developer documentation and return results in JSON format (default) or open in browser. If no query provided, opens search page in browser.", + Args: cobra.MaximumNArgs(1), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Open docs search page in browser", + Command: "docs search", + }, + { + Meaning: "Search for Block Kit (returns JSON by default)", + Command: "docs search \"Block Kit\"", + }, + { + Meaning: "Search and open in browser", + Command: "docs search \"Block Kit\" --output=browser", + }, + { + Meaning: "Search with custom limit", + Command: "docs search \"webhooks\" --limit=50", + }, + { + Meaning: "Search with filter", + Command: "docs search \"webhooks\" --filter=guides", + }, + { + Meaning: "Search Python documentation and open in browser", + Command: "docs search \"bolt\" --filter=python --output=browser", + }, + }), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return runSearchBrowserCommand(clients, cmd) + } + return runSearchCommand(clients, cmd, args[0]) + }, + } + + cmd.Flags().StringVar(&outputFormat, "output", "json", "output format: json, browser") + cmd.Flags().IntVar(&searchLimit, "limit", 20, "maximum number of results to return") + cmd.Flags().StringVar(&searchFilter, "filter", "", "filter results by content type: guides, reference, changelog, python, javascript, java, slack_cli, slack_github_action, deno_slack_sdk") + + return cmd +} + +// openSearchInBrowser opens the docs search page in browser +func openSearchInBrowser(clients *shared.ClientFactory, ctx context.Context, searchURL string) error { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "Docs Search", + Secondary: []string{ + searchURL, + }, + })) + + clients.Browser().OpenURL(searchURL) + clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, "") + return nil +} + +// runSearchBrowserCommand opens the docs search page in browser +func runSearchBrowserCommand(clients *shared.ClientFactory, cmd *cobra.Command) error { + ctx := cmd.Context() + searchURL := "https://docs.slack.dev/search" + return openSearchInBrowser(clients, ctx, searchURL) +} + +// runSearchCommand handles the search subcommand +func runSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, query string) error { + ctx := cmd.Context() + + results, err := search.SearchDocs(query, searchFilter, searchLimit) + if err != nil { + return fmt.Errorf("search failed: %w", err) + } + + // Output results + if outputFormat == "json" { + jsonBytes, err := json.MarshalIndent(results, "", " ") + if err != nil { + return fmt.Errorf("failed to encode JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + } else { + // Browser output - open search page with query + searchURL := fmt.Sprintf("https://docs.slack.dev/search/?q=%s", url.QueryEscape(query)) + if searchFilter != "" { + searchURL += fmt.Sprintf("&filter=%s", url.QueryEscape(searchFilter)) + } + return openSearchInBrowser(clients, ctx, searchURL) + } + + return nil +} + // runDocsCommand opens Slack developer docs in the browser func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error { ctx := cmd.Context() - var docsURL string - var sectionText string - - // Validate: if there are arguments, --search flag must be used - if len(args) > 0 && !cmd.Flags().Changed("search") { + // If any arguments provided, suggest using search subcommand + if len(args) > 0 { query := strings.Join(args, " ") return slackerror.New(slackerror.ErrDocsSearchFlagRequired).WithRemediation( - "Use --search flag: %s", - style.Commandf(fmt.Sprintf("docs --search \"%s\"", query), false), + "Use search subcommand: %s", + style.Commandf(fmt.Sprintf("docs search \"%s\"", query), false), ) } - if cmd.Flags().Changed("search") { - if len(args) > 0 { - // --search "query" (space-separated) - join all args as the query - query := strings.Join(args, " ") - encodedQuery := url.QueryEscape(query) - docsURL = fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery) - sectionText = "Docs Search" - } else { - // --search (no argument) - open search page - docsURL = "https://docs.slack.dev/search/" - sectionText = "Docs Search" - } - } else { - // No search flag: default homepage - docsURL = "https://docs.slack.dev" - sectionText = "Docs Open" - } + // Open docs homepage + docsURL := "https://docs.slack.dev" clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "books", - Text: sectionText, + Text: "Docs Open", Secondary: []string{ docsURL, }, })) clients.Browser().OpenURL(docsURL) - - if cmd.Flags().Changed("search") { - traceValue := "" - if len(args) > 0 { - traceValue = strings.Join(args, " ") - } - clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, traceValue) - } else { - clients.IO.PrintTrace(ctx, slacktrace.DocsSuccess) - } + clients.IO.PrintTrace(ctx, slacktrace.DocsSuccess) return nil } diff --git a/internal/search/search.go b/internal/search/search.go new file mode 100644 index 00000000..b2e217cc --- /dev/null +++ b/internal/search/search.go @@ -0,0 +1,99 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package search + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const SearchIndexURL = "https://docs-slack-d-search-api-duu9zr.herokuapp.com/api/search" + +// SearchResult represents a single search result +type SearchResult struct { + Title string `json:"title"` + URL string `json:"url"` + Excerpt string `json:"excerpt"` + Breadcrumb string `json:"breadcrumb"` + ContentType string `json:"content_type"` + Score float64 `json:"score"` +} + +// SearchResponse represents the complete search response +type SearchResponse struct { + Query string `json:"query"` + Filter string `json:"filter"` + Results []SearchResult `json:"results"` + TotalResults int `json:"total_results"` + Showing int `json:"showing"` + Pagination interface{} `json:"pagination,omitempty"` +} + +// SearchDocs performs a search using the hosted search API +func SearchDocs(query, filter string, limit int) (*SearchResponse, error) { + // Build query parameters + params := url.Values{} + params.Set("q", query) + if filter != "" { + params.Set("filter", filter) + } + if limit > 0 { + params.Set("limit", fmt.Sprintf("%d", limit)) + } + + // Make HTTP request + searchURL := fmt.Sprintf("%s?%s", SearchIndexURL, params.Encode()) + resp, err := http.Get(searchURL) + if err != nil { + return nil, fmt.Errorf("failed to query search API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("search API returned status %d", resp.StatusCode) + } + + // Parse response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse response directly into our response format + var apiResponse struct { + TotalResults int `json:"total_results"` + Results []SearchResult `json:"results"` + Pagination interface{} `json:"pagination,omitempty"` + } + + if err := json.Unmarshal(body, &apiResponse); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Build response + response := &SearchResponse{ + Query: query, + Filter: filter, + TotalResults: apiResponse.TotalResults, + Results: apiResponse.Results, + Showing: len(apiResponse.Results), + Pagination: apiResponse.Pagination, + } + + return response, nil +} diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index a9d89ea3..c289ed9c 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -96,6 +96,7 @@ const ( ErrDenoNotFound = "deno_not_found" ErrDeployedAppNotSupported = "deployed_app_not_supported" ErrDocumentationGenerationFailed = "documentation_generation_failed" + ErrDocsJSONEncodeFailed = "docs_json_encode_failed" ErrDocsSearchFlagRequired = "docs_search_flag_required" ErrEnterpriseNotFound = "enterprise_not_found" ErrFailedAddingCollaborator = "failed_adding_collaborator" @@ -681,6 +682,11 @@ Otherwise start your app for local development with: %s`, Message: "Failed to generate documentation", }, + ErrDocsJSONEncodeFailed: { + Code: ErrDocsJSONEncodeFailed, + Message: "Failed to encode docs output as JSON", + }, + ErrDocsSearchFlagRequired: { Code: ErrDocsSearchFlagRequired, Message: "Invalid docs command. Did you mean to search?",