diff --git a/cmd/docs/search.go b/cmd/docs/search.go index 55bbb34f..c7d60680 100644 --- a/cmd/docs/search.go +++ b/cmd/docs/search.go @@ -39,9 +39,10 @@ func buildDocsSearchURL(query, category string) string { } type searchConfig struct { - output string - limit int - category string + output string + limit int + category string + hidePinned bool } func makeAbsoluteURL(relativeURL string) string { @@ -102,6 +103,10 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { Meaning: "Search only the API reference docs", Command: "docs search \"chat.postMessage\" --category=reference", }, + { + Meaning: "Search docs without the pages pinned to the top", + Command: "docs search \"mcp\" --hide-pinned", + }, }), Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -112,6 +117,7 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { cmd.Flags().StringVar(&cfg.output, "output", "text", "output format: text, json, browser") cmd.Flags().IntVar(&cfg.limit, "limit", 20, "maximum number of text or json search results to return") cmd.Flags().StringVar(&cfg.category, "category", "", fmt.Sprintf("filter results by category: %s", strings.Join(api.DocsSearchCategories, ", "))) + cmd.Flags().BoolVar(&cfg.hidePinned, "hide-pinned", false, "leave out pages pinned to the top of text or json results") return cmd } @@ -123,7 +129,7 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg switch cfg.output { case "json": - searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit, cfg.category) + searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit, cfg.category, cfg.hidePinned) if err != nil { return err } @@ -142,7 +148,7 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg return nil case "text": - searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit, cfg.category) + searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit, cfg.category, cfg.hidePinned) if err != nil { return err } diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go index f3137057..362d51e4 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -43,7 +43,7 @@ func Test_Docs_SearchCommand(t *testing.T) { CmdArgs: []string{"search", "Block Kit"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { clearAgentEnvVars(t) - cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "", false).Return(&api.DocsSearchResponse{ TotalResults: 2, Limit: 20, Results: []api.DocsSearchItem{ @@ -64,7 +64,7 @@ func Test_Docs_SearchCommand(t *testing.T) { CmdArgs: []string{"search", "Block Kit"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { clearAgentEnvVars(t) - cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "", false).Return(&api.DocsSearchResponse{ TotalResults: 1, Limit: 20, Results: []api.DocsSearchItem{ @@ -82,7 +82,7 @@ func Test_Docs_SearchCommand(t *testing.T) { Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { clearAgentEnvVars(t) t.Setenv("CLAUDECODE", "1") - cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "", false).Return(&api.DocsSearchResponse{ TotalResults: 2, Limit: 20, Results: []api.DocsSearchItem{ @@ -99,7 +99,7 @@ func Test_Docs_SearchCommand(t *testing.T) { "returns JSON results": { CmdArgs: []string{"search", "Block Kit", "--output=json"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "", false).Return(&api.DocsSearchResponse{ TotalResults: 2, Limit: 20, Results: []api.DocsSearchItem{ @@ -132,7 +132,7 @@ func Test_Docs_SearchCommand(t *testing.T) { "returns JSON results with absolute URLs": { CmdArgs: []string{"search", "test", "--output=json"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "test", 20, "").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "test", 20, "", false).Return(&api.DocsSearchResponse{ TotalResults: 1, Limit: 20, Results: []api.DocsSearchItem{ @@ -150,7 +150,7 @@ func Test_Docs_SearchCommand(t *testing.T) { "returns empty results": { CmdArgs: []string{"search", "nonexistent"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "nonexistent", 20, "").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "nonexistent", 20, "", false).Return(&api.DocsSearchResponse{ TotalResults: 0, Results: []api.DocsSearchItem{}, Limit: 20, @@ -163,47 +163,60 @@ func Test_Docs_SearchCommand(t *testing.T) { "returns error on API failure": { CmdArgs: []string{"search", "test"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "test", 20, "").Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) + cm.API.On("DocsSearch", mock.Anything, "test", 20, "", false).Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) }, ExpectedErrorStrings: []string{slackerror.ErrHTTPRequestFailed}, }, "returns error on API failure for JSON output": { CmdArgs: []string{"search", "test", "--output=json"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "test", 20, "").Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) + cm.API.On("DocsSearch", mock.Anything, "test", 20, "", false).Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) }, ExpectedErrorStrings: []string{slackerror.ErrHTTPRequestFailed}, }, "passes custom limit": { CmdArgs: []string{"search", "test", "--limit=5"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "test", 5, "").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "test", 5, "", false).Return(&api.DocsSearchResponse{ TotalResults: 0, Results: []api.DocsSearchItem{}, Limit: 5, }, nil) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "test", 5, "") + cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "test", 5, "", false) }, }, "joins multiple arguments into query": { CmdArgs: []string{"search", "Block", "Kit", "Element"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "Block Kit Element", 20, "").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "Block Kit Element", 20, "", false).Return(&api.DocsSearchResponse{ TotalResults: 0, Results: []api.DocsSearchItem{}, Limit: 20, }, nil) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "Block Kit Element", 20, "") + cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "Block Kit Element", 20, "", false) }, }, + "passes hide-pinned to API for text output": { + CmdArgs: []string{"search", "mcp", "--category=guides", "--hide-pinned"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("DocsSearch", mock.Anything, "mcp", 20, "guides", true).Return(&api.DocsSearchResponse{ + TotalResults: 1, + Limit: 20, + Results: []api.DocsSearchItem{ + {Title: "MCP server", URL: "/ai/slack-mcp-server"}, + }, + }, nil) + }, + ExpectedStdoutOutputs: []string{"MCP server"}, + }, "passes category to API for text output": { CmdArgs: []string{"search", "chat.postMessage", "--category=reference"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "chat.postMessage", 20, "reference").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "chat.postMessage", 20, "reference", false).Return(&api.DocsSearchResponse{ TotalResults: 1, Limit: 20, Results: []api.DocsSearchItem{ @@ -215,13 +228,13 @@ func Test_Docs_SearchCommand(t *testing.T) { `Displaying first 1 of 1 results for "chat.postMessage" in category "reference"`, }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "chat.postMessage", 20, "reference") + cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "chat.postMessage", 20, "reference", false) }, }, "includes category in zero results message": { CmdArgs: []string{"search", "nonexistent", "--category=reference"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "nonexistent", 20, "reference").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "nonexistent", 20, "reference", false).Return(&api.DocsSearchResponse{ TotalResults: 0, Results: []api.DocsSearchItem{}, Limit: 20, @@ -234,27 +247,27 @@ func Test_Docs_SearchCommand(t *testing.T) { "passes category to API for json output": { CmdArgs: []string{"search", "events", "--category=reference", "--output=json"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "events", 20, "reference").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "events", 20, "reference", false).Return(&api.DocsSearchResponse{ TotalResults: 0, Results: []api.DocsSearchItem{}, Limit: 20, }, nil) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "events", 20, "reference") + cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "events", 20, "reference", false) }, }, "passes unknown category through to API": { CmdArgs: []string{"search", "test", "--category=bogus"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "test", 20, "bogus").Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "test", 20, "bogus", false).Return(&api.DocsSearchResponse{ TotalResults: 0, Results: []api.DocsSearchItem{}, Limit: 20, }, nil) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "test", 20, "bogus") + cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "test", 20, "bogus", false) }, }, "opens browser with category filter": { diff --git a/docs/reference/commands/slack_docs_search.md b/docs/reference/commands/slack_docs_search.md index 4afa8952..346c1e1f 100644 --- a/docs/reference/commands/slack_docs_search.md +++ b/docs/reference/commands/slack_docs_search.md @@ -22,6 +22,7 @@ slack docs search [query] [flags] ``` --category string filter results by category: guides, reference, changelog, python, javascript, java, slack_cli, slack_github_action, deno_slack_sdk, legacy -h, --help help for search + --hide-pinned leave out pages pinned to the top of text or json results --limit int maximum number of text or json search results to return (default 20) --output string output format: text, json, browser (default "text") ``` @@ -56,6 +57,9 @@ $ slack docs search "api" --output=json --limit=5 # Search only the API reference docs $ slack docs search "chat.postMessage" --category=reference + +# Search docs without the pages pinned to the top +$ slack docs search "mcp" --hide-pinned ``` ## See also diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 10ed3ab9..65beac3e 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -395,8 +395,8 @@ func (m *APIMock) DocsGetPage(ctx context.Context, path string) (string, error) return args.String(0), args.Error(1) } -func (m *APIMock) DocsSearch(ctx context.Context, query string, limit int, category string) (*DocsSearchResponse, error) { - args := m.Called(ctx, query, limit, category) +func (m *APIMock) DocsSearch(ctx context.Context, query string, limit int, category string, hidePinned bool) (*DocsSearchResponse, error) { + args := m.Called(ctx, query, limit, category, hidePinned) if args.Get(0) == nil { return nil, args.Error(1) } diff --git a/internal/api/docs.go b/internal/api/docs.go index 78c6ef06..ca20f24d 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -48,7 +48,7 @@ var DocsSearchCategories = []string{ type DocsClient interface { DocsGetPage(ctx context.Context, path string) (string, error) - DocsSearch(ctx context.Context, query string, limit int, category string) (*DocsSearchResponse, error) + DocsSearch(ctx context.Context, query string, limit int, category string, hidePinned bool) (*DocsSearchResponse, error) } type DocsSearchResponse struct { @@ -62,11 +62,14 @@ type DocsSearchItem struct { Title string `json:"title"` } -func buildDocsSearchURL(baseURL, query string, limit int, category string) (string, error) { +func buildDocsSearchURL(baseURL, query string, limit int, category string, hidePinned bool) (string, error) { endpoint := fmt.Sprintf("%s?query=%s&limit=%d", docsSearchMethod, url.QueryEscape(query), limit) if category != "" { endpoint += fmt.Sprintf("&category=%s", url.QueryEscape(category)) } + if hidePinned { + endpoint += "&pin=false" + } sURL, err := url.Parse(baseURL + "/" + endpoint) if err != nil { return "", err @@ -83,13 +86,14 @@ func buildDocsRequest(ctx context.Context, urlStr, cliVersion string) (*http.Req return req, nil } -// DocsSearch searches the Slack developer docs API -func (c *Client) DocsSearch(ctx context.Context, query string, limit int, category string) (*DocsSearchResponse, error) { +// DocsSearch searches the Slack developer docs API. With hidePinned, the +// results leave out pages the docs site pins to the top for some searches. +func (c *Client) DocsSearch(ctx context.Context, query string, limit int, category string, hidePinned bool) (*DocsSearchResponse, error) { var span opentracing.Span span, _ = opentracing.StartSpanFromContext(ctx, "apiclient.DocsSearch") defer span.Finish() - urlStr, err := buildDocsSearchURL(docsBaseURL, query, limit, category) + urlStr, err := buildDocsSearchURL(docsBaseURL, query, limit, category, hidePinned) if err != nil { return nil, errHTTPRequestFailed.WithRootCause(err) } diff --git a/internal/api/docs_test.go b/internal/api/docs_test.go index 8bf86bcc..78185a7e 100644 --- a/internal/api/docs_test.go +++ b/internal/api/docs_test.go @@ -29,6 +29,7 @@ func Test_buildDocsSearchURL(t *testing.T) { query string limit int category string + hidePinned bool expectedURL string expectedErrorContains string }{ @@ -58,6 +59,14 @@ func Test_buildDocsSearchURL(t *testing.T) { category: "", expectedURL: "https://docs.slack.dev/api/v1/search?query=test&limit=20", }, + "appends pin=false when hiding pinned results": { + baseURL: "https://docs.slack.dev", + query: "mcp", + limit: 5, + category: "guides", + hidePinned: true, + expectedURL: "https://docs.slack.dev/api/v1/search?query=mcp&limit=5&category=guides&pin=false", + }, "returns error for invalid base URL": { baseURL: "ht!tp://invalid", query: "test", @@ -67,7 +76,7 @@ func Test_buildDocsSearchURL(t *testing.T) { } for name, tc := range tests { t.Run(name, func(t *testing.T) { - url, err := buildDocsSearchURL(tc.baseURL, tc.query, tc.limit, tc.category) + url, err := buildDocsSearchURL(tc.baseURL, tc.query, tc.limit, tc.category, tc.hidePinned) if tc.expectedErrorContains != "" { require.Error(t, err) @@ -119,6 +128,7 @@ func Test_Client_DocsSearch(t *testing.T) { query string limit int category string + hidePinned bool response string statusCode int expectedQuerystring string @@ -162,6 +172,19 @@ func Test_Client_DocsSearch(t *testing.T) { }, }, }, + "sends pin=false when hiding pinned results": { + query: "mcp", + limit: 5, + category: "guides", + hidePinned: true, + response: `{"total_results":0,"limit":5,"results":[]}`, + expectedQuerystring: "query=mcp&limit=5&category=guides&pin=false", + expectedResponse: &DocsSearchResponse{ + TotalResults: 0, + Limit: 5, + Results: []DocsSearchItem{}, + }, + }, "returns empty results": { query: "nonexistent", limit: 20, @@ -211,7 +234,7 @@ func Test_Client_DocsSearch(t *testing.T) { docsBaseURL = c.Host() defer func() { docsBaseURL = originalURL }() - result, err := c.DocsSearch(ctx, tc.query, tc.limit, tc.category) + result, err := c.DocsSearch(ctx, tc.query, tc.limit, tc.category, tc.hidePinned) if tc.expectedErrorContains != "" { require.Error(t, err)