From dcdc86f5749d2c90f490fd7c428de3a947bfc04c Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Thu, 5 Nov 2026 14:13:55 -0700 Subject: [PATCH 1/3] feat: link docs search results to markdown pages for coding agents --- cmd/docs/search.go | 27 +++++++ cmd/docs/search_test.go | 84 ++++++++++++++++++++ docs/reference/commands/slack_docs_search.md | 3 + 3 files changed, 114 insertions(+) diff --git a/cmd/docs/search.go b/cmd/docs/search.go index 6fb17b8a..63b28b7b 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". 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" + u.Fragment = "" + 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..d9ae1552 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -24,14 +24,62 @@ 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_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", + }, + "drops an anchor": { + url: "https://docs.slack.dev/tools/developer-sandboxes#basic", + expected: "https://docs.slack.dev/tools/developer-sandboxes.md", + }, + "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)) + }) + } +} + 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 +97,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) { diff --git a/docs/reference/commands/slack_docs_search.md b/docs/reference/commands/slack_docs_search.md index b4e62c0d..a010769a 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] ``` From b9d940efeb282a51e0abc3f5a7bc11680e066277 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Thu, 5 Nov 2026 21:43:29 -0700 Subject: [PATCH 2/3] fix: keep the section anchor on markdown docs links --- cmd/docs/search.go | 6 +++--- cmd/docs/search_test.go | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/cmd/docs/search.go b/cmd/docs/search.go index 63b28b7b..ac5b69a7 100644 --- a/cmd/docs/search.go +++ b/cmd/docs/search.go @@ -53,8 +53,9 @@ func makeAbsoluteURL(relativeURL string) string { // 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". Generated reference pages ending in ".html" have no -// markdown version, so those URLs are returned unchanged. +// 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") { @@ -65,7 +66,6 @@ func makeMarkdownURL(absoluteURL string) string { return absoluteURL } u.Path = path + ".md" - u.Fragment = "" return u.String() } diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go index d9ae1552..86f015f6 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -50,9 +50,13 @@ func Test_Docs_makeMarkdownURL(t *testing.T) { url: "https://docs.slack.dev/tools/bolt-js/concepts/socket-mode/", expected: "https://docs.slack.dev/tools/bolt-js/concepts/socket-mode.md", }, - "drops an anchor": { + "keeps an anchor": { url: "https://docs.slack.dev/tools/developer-sandboxes#basic", - expected: "https://docs.slack.dev/tools/developer-sandboxes.md", + 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", From 12448e8ef51d83651d33d172678fa5c324c89398 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 6 Nov 2026 09:10:45 -0700 Subject: [PATCH 3/3] test: move the markdown URL test below the command test --- cmd/docs/search_test.go | 82 ++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go index 86f015f6..c661595b 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -37,47 +37,6 @@ func clearAgentEnvVars(t *testing.T) { } } -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)) - }) - } -} - func Test_Docs_SearchCommand(t *testing.T) { testutil.TableTestCommand(t, testutil.CommandTests{ "returns text results": { @@ -350,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)) + }) + } +}