Skip to content
Merged
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
16 changes: 11 additions & 5 deletions cmd/docs/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
51 changes: 32 additions & 19 deletions cmd/docs/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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{
Expand All @@ -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{
Expand All @@ -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{
Expand Down Expand Up @@ -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{
Expand All @@ -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,
Expand All @@ -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{
Expand All @@ -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,
Expand All @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/commands/slack_docs_search.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions internal/api/api_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
14 changes: 9 additions & 5 deletions internal/api/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
27 changes: 25 additions & 2 deletions internal/api/docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func Test_buildDocsSearchURL(t *testing.T) {
query string
limit int
category string
hidePinned bool
expectedURL string
expectedErrorContains string
}{
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Loading