diff --git a/cmd/docs/search.go b/cmd/docs/search.go index 6fb17b8a..ac5b69a7 100644 --- a/cmd/docs/search.go +++ b/cmd/docs/search.go @@ -25,6 +25,7 @@ import ( "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/internal/style" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/spf13/cobra" ) @@ -50,6 +51,24 @@ func makeAbsoluteURL(relativeURL string) string { return docsURL + relativeURL } +// makeMarkdownURL returns the markdown version of a docs page URL, which a +// coding agent can read without parsing HTML. Pages are served as markdown at +// their path plus ".md", and keep their heading anchors, so any "#section" is +// kept. Generated reference pages ending in ".html" have no markdown version, +// so those URLs are returned unchanged. +func makeMarkdownURL(absoluteURL string) string { + u, err := url.Parse(absoluteURL) + if err != nil || strings.HasSuffix(u.Path, ".html") || strings.HasSuffix(u.Path, ".md") { + return absoluteURL + } + path := strings.TrimSuffix(u.Path, "/") + if path == "" { + return absoluteURL + } + u.Path = path + ".md" + return u.String() +} + func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { cfg := &searchConfig{} @@ -62,6 +81,9 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { "", "Results can be filtered to a single category with the --category flag. Available", "categories: " + strings.Join(api.DocsSearchCategories, ", ") + ".", + "", + "When a coding agent runs this command, text results link to the markdown", + "version of each page.", }, "\n"), Example: style.ExampleCommandsf([]style.ExampleCommand{ { @@ -150,8 +172,13 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg }, })) + // Coding agents read pages as markdown, so link them to the markdown version + isAgent := useragent.GetAIAgent() != nil for _, result := range searchResponse.Results { absoluteURL := makeAbsoluteURL(result.URL) + if isAgent { + absoluteURL = makeMarkdownURL(absoluteURL) + } clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{ Emoji: "book", Text: result.Title, diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go index d2c916da..c661595b 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -24,14 +24,25 @@ import ( "github.com/slackapi/slack-cli/internal/slacktrace" "github.com/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) +// clearAgentEnvVars unsets the environment variables that identify a coding +// agent, so tests behave the same when run from one +func clearAgentEnvVars(t *testing.T) { + t.Helper() + for _, env := range []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CODEX_CI", "GEMINI_CLI", "CLINE_ACTIVE", "CURSOR_AGENT", "OPENCODE", "AGENT"} { + t.Setenv(env, "") + } +} + func Test_Docs_SearchCommand(t *testing.T) { testutil.TableTestCommand(t, testutil.CommandTests{ "returns text results": { 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{ TotalResults: 2, Limit: 20, @@ -49,6 +60,42 @@ func Test_Docs_SearchCommand(t *testing.T) { cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything) }, }, + "returns text results without markdown links for people": { + 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{ + TotalResults: 1, + Limit: 20, + Results: []api.DocsSearchItem{ + {Title: "Block Kit", URL: "/block-kit/"}, + }, + }, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + assert.Contains(t, cm.GetStdoutOutput(), "https://docs.slack.dev/block-kit/") + assert.NotContains(t, cm.GetStdoutOutput(), ".md") + }, + }, + "returns text results with markdown links for coding agents": { + CmdArgs: []string{"search", "Block Kit"}, + 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{ + TotalResults: 2, + Limit: 20, + Results: []api.DocsSearchItem{ + {Title: "Block Kit", URL: "/block-kit/"}, + {Title: "Base client", URL: "/tools/python-slack-sdk/reference/web/base_client.html"}, + }, + }, nil) + }, + ExpectedStdoutOutputs: []string{ + "https://docs.slack.dev/block-kit.md", + "https://docs.slack.dev/tools/python-slack-sdk/reference/web/base_client.html", + }, + }, "returns JSON results": { CmdArgs: []string{"search", "Block Kit", "--output=json"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { @@ -262,3 +309,44 @@ func Test_Docs_SearchCommand(t *testing.T) { return NewCommand(cf) }) } + +func Test_Docs_makeMarkdownURL(t *testing.T) { + tests := map[string]struct { + url string + expected string + }{ + "appends .md to a page path": { + url: "https://docs.slack.dev/block-kit", + expected: "https://docs.slack.dev/block-kit.md", + }, + "drops a trailing slash before appending .md": { + url: "https://docs.slack.dev/tools/bolt-js/concepts/socket-mode/", + expected: "https://docs.slack.dev/tools/bolt-js/concepts/socket-mode.md", + }, + "keeps an anchor": { + url: "https://docs.slack.dev/tools/developer-sandboxes#basic", + expected: "https://docs.slack.dev/tools/developer-sandboxes.md#basic", + }, + "keeps an anchor after a trailing slash": { + url: "https://docs.slack.dev/tools/bolt-js/concepts/socket-mode/#custom-socketmode-receiver", + expected: "https://docs.slack.dev/tools/bolt-js/concepts/socket-mode.md#custom-socketmode-receiver", + }, + "keeps generated .html reference pages": { + url: "https://docs.slack.dev/tools/python-slack-sdk/reference/web/base_client.html", + expected: "https://docs.slack.dev/tools/python-slack-sdk/reference/web/base_client.html", + }, + "keeps a URL that is already markdown": { + url: "https://docs.slack.dev/quickstart.md", + expected: "https://docs.slack.dev/quickstart.md", + }, + "keeps the homepage": { + url: "https://docs.slack.dev/", + expected: "https://docs.slack.dev/", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, makeMarkdownURL(tc.url)) + }) + } +} diff --git a/docs/reference/commands/slack_docs_search.md b/docs/reference/commands/slack_docs_search.md index 37c6a7dc..848ce102 100644 --- a/docs/reference/commands/slack_docs_search.md +++ b/docs/reference/commands/slack_docs_search.md @@ -10,6 +10,9 @@ format. Results can be filtered to a single category with the --category flag. Available categories: guides, reference, changelog, python, javascript, java, slack_cli, slack_github_action, deno_slack_sdk, legacy. +When a coding agent runs this command, text results link to the markdown +version of each page. + ``` slack docs search [query] [flags] ```