diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 5b8c2311..2bf5f229 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -27,7 +27,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "docs", Short: "Search and open Slack developer docs", - Long: "Search the Slack developer docs from the terminal with the search subcommand, or open them in your browser", + Long: "Search the Slack developer docs and print their pages from the terminal with the search and get subcommands, or open them in your browser", Example: style.ExampleCommandsf([]style.ExampleCommand{ { Meaning: "Open Slack developer docs homepage", @@ -50,7 +50,8 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { DisableSuggestions: true, } - // Add the search subcommand + // Add the subcommands + cmd.AddCommand(NewGetCommand(clients)) cmd.AddCommand(NewSearchCommand(clients)) return cmd diff --git a/cmd/docs/get.go b/cmd/docs/get.go new file mode 100644 index 00000000..e7f702b0 --- /dev/null +++ b/cmd/docs/get.go @@ -0,0 +1,164 @@ +// 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 docs + +import ( + "encoding/json" + "net/url" + "strings" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/slacktrace" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +// getConfig holds the flag values of the get command +type getConfig struct { + output string +} + +// docsPage is the JSON output of the get command +type docsPage struct { + URL string `json:"url"` + MarkdownURL string `json:"markdown_url"` + Content string `json:"content"` +} + +// resolvePageURL turns a docs page reference, either a site path or a full +// docs URL with or without ".md", into the page's absolute URL +func resolvePageURL(page string) (string, error) { + docs, _ := url.Parse(docsURL) + if strings.HasPrefix(page, docs.Host+"/") { + page = docs.Scheme + "://" + page + } + u, err := url.Parse(page) + if err != nil { + return "", err + } + if u.Host != "" && !strings.EqualFold(u.Hostname(), docs.Hostname()) { + return "", slackerror.New(slackerror.ErrInvalidArgs). + WithMessage("The page %s is not on %s", page, docs.Host). + WithRemediation("Find the page with %s", style.Commandf("docs search", false)) + } + path := u.Path + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + path = strings.TrimSuffix(path, ".md") + page = docsURL + path + if u.Fragment != "" { + page += "#" + u.Fragment + } + return page, nil +} + +// NewGetCommand returns the docs get command, which prints a docs page as +// markdown or JSON, or opens it in the browser +func NewGetCommand(clients *shared.ClientFactory) *cobra.Command { + cfg := &getConfig{} + + cmd := &cobra.Command{ + Use: "get ", + Short: "Print a Slack developer docs page", + Long: strings.Join([]string{ + "Print a page of the Slack developer docs as markdown, return it as JSON, or open", + "it in your browser.", + "", + "The page can be a path or a full URL on docs.slack.dev, like a link returned by", + "the search subcommand.", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Print the Socket Mode guide for Bolt for JavaScript", + Command: "docs get /tools/bolt-js/concepts/socket-mode", + }, + { + Meaning: "Return a page and its links as JSON", + Command: "docs get https://docs.slack.dev/reference/methods/chat.postMessage --output=json", + }, + { + Meaning: "Open a page in your browser", + Command: "docs get /block-kit --output=browser", + }, + }), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDocsGetCommand(clients, cmd, args[0], cfg) + }, + } + + cmd.Flags().StringVar(&cfg.output, "output", "text", "output format: text, json, browser") + + return cmd +} + +// runDocsGetCommand resolves the page reference, then prints the page in the +// chosen output format or opens it in the browser +func runDocsGetCommand(clients *shared.ClientFactory, cmd *cobra.Command, page string, cfg *getConfig) error { + ctx := cmd.Context() + + pageURL, err := resolvePageURL(page) + if err != nil { + return err + } + + switch cfg.output { + case "text", "json": + markdownURL := makeMarkdownURL(pageURL) + if markdownURL == pageURL { + return slackerror.New(slackerror.ErrInvalidArgs). + WithMessage("The page %s has no markdown version", pageURL). + WithRemediation("Open it in your browser with %s", style.Highlight("--output=browser")) + } + u, _ := url.Parse(markdownURL) + content, err := clients.API().DocsGetPage(ctx, u.Path) + if err != nil { + return slackerror.New(slackerror.ErrHTTPRequestFailed). + WithMessage("The page %s could not be loaded", markdownURL). + WithRootCause(err). + WithRemediation("Check the page path, or find the page with %s", style.Commandf("docs search", false)) + } + if cfg.output == "json" { + encoder := json.NewEncoder(clients.IO.WriteOut()) + encoder.SetIndent("", " ") + if err := encoder.Encode(docsPage{URL: pageURL, MarkdownURL: markdownURL, Content: content}); err != nil { + return slackerror.New(slackerror.ErrUnableToParseJSON).WithRootCause(err) + } + } else { + if _, err := clients.IO.WriteOut().Write([]byte(content)); err != nil { + return err + } + } + clients.IO.PrintTrace(ctx, slacktrace.DocsGetSuccess, pageURL) + return nil + case "browser": + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "Docs", + Secondary: []string{pageURL}, + })) + clients.Browser().OpenURL(pageURL) + clients.IO.PrintTrace(ctx, slacktrace.DocsGetSuccess, pageURL) + return nil + default: + return slackerror.New(slackerror.ErrInvalidFlag).WithMessage( + "Invalid output format: %s", cfg.output, + ).WithRemediation( + "Use one of: text, json, browser", + ) + } +} diff --git a/cmd/docs/get_test.go b/cmd/docs/get_test.go new file mode 100644 index 00000000..413a5ce4 --- /dev/null +++ b/cmd/docs/get_test.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 docs + +import ( + "context" + "errors" + "testing" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "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" +) + +func Test_Docs_GetCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "prints the markdown of a page path": { + CmdArgs: []string{"get", "/tools/bolt-js/concepts/socket-mode"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("DocsGetPage", mock.Anything, "/tools/bolt-js/concepts/socket-mode.md").Return("# Using Socket Mode\n", nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + assert.Equal(t, "# Using Socket Mode\n", cm.GetStdoutOutput()) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsGetSuccess, mock.Anything) + }, + }, + "prints the markdown of a search result link": { + CmdArgs: []string{"get", "https://docs.slack.dev/tools/bolt-js/concepts/socket-mode.md#custom-socketmode-receiver"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("DocsGetPage", mock.Anything, "/tools/bolt-js/concepts/socket-mode.md").Return("# Using Socket Mode\n", nil) + }, + ExpectedStdoutOutputs: []string{"# Using Socket Mode"}, + }, + "accepts a docs URL with an uppercase host": { + CmdArgs: []string{"get", "https://DOCS.SLACK.DEV/block-kit"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("DocsGetPage", mock.Anything, "/block-kit.md").Return("# Block Kit\n", nil) + }, + ExpectedStdoutOutputs: []string{"# Block Kit"}, + }, + "returns the page as JSON": { + CmdArgs: []string{"get", "docs.slack.dev/block-kit/", "--output=json"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("DocsGetPage", mock.Anything, "/block-kit.md").Return("# Block Kit\n", nil) + }, + ExpectedStdoutOutputs: []string{ + `"url": "https://docs.slack.dev/block-kit/"`, + `"markdown_url": "https://docs.slack.dev/block-kit.md"`, + `"content": "# Block Kit\n"`, + }, + }, + "opens the page in the browser": { + CmdArgs: []string{"get", "/block-kit", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", "https://docs.slack.dev/block-kit") + cm.API.AssertNotCalled(t, "DocsGetPage", mock.Anything, mock.Anything) + }, + }, + "errors for a page with no markdown version": { + CmdArgs: []string{"get", "/tools/python-slack-sdk/reference/web/base_client.html"}, + ExpectedErrorStrings: []string{"has no markdown version", "--output=browser"}, + }, + "errors for a page on another site": { + CmdArgs: []string{"get", "https://example.com/block-kit"}, + ExpectedErrorStrings: []string{"is not on docs.slack.dev"}, + }, + "errors when the page cannot be fetched": { + CmdArgs: []string{"get", "/missing"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("DocsGetPage", mock.Anything, "/missing.md").Return("", errors.New("unexpected status code 404")) + }, + ExpectedErrorStrings: []string{"unexpected status code 404", "docs search"}, + }, + "errors for an invalid output format": { + CmdArgs: []string{"get", "/block-kit", "--output=yaml"}, + ExpectedError: slackerror.New(slackerror.ErrInvalidFlag). + WithMessage("Invalid output format: %s", "yaml"). + WithRemediation("Use one of: text, json, browser"), + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCommand(cf) + }) +} diff --git a/cmd/docs/search.go b/cmd/docs/search.go index ac5b69a7..781be243 100644 --- a/cmd/docs/search.go +++ b/cmd/docs/search.go @@ -83,7 +83,7 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { "categories: " + strings.Join(api.DocsSearchCategories, ", ") + ".", "", "When a coding agent runs this command, text results link to the markdown", - "version of each page.", + "version of each page. Print a result's page with the get subcommand.", }, "\n"), Example: style.ExampleCommandsf([]style.ExampleCommand{ { diff --git a/docs/reference/commands/slack_docs.md b/docs/reference/commands/slack_docs.md index 0882123c..712e963b 100644 --- a/docs/reference/commands/slack_docs.md +++ b/docs/reference/commands/slack_docs.md @@ -4,7 +4,7 @@ Search and open Slack developer docs ## Description -Search the Slack developer docs from the terminal with the search subcommand, or open them in your browser +Search the Slack developer docs and print their pages from the terminal with the search and get subcommands, or open them in your browser ``` slack docs [flags] @@ -47,5 +47,6 @@ $ slack docs search "Block Kit" --output=browser ## See also * [slack](/tools/slack-cli/reference/commands/slack/) - Slack command-line tool +* [slack docs get](/tools/slack-cli/reference/commands/slack_docs_get/) - Print a Slack developer docs page * [slack docs search](/tools/slack-cli/reference/commands/slack_docs_search/) - Search Slack developer docs diff --git a/docs/reference/commands/slack_docs_get.md b/docs/reference/commands/slack_docs_get.md new file mode 100644 index 00000000..9d7d9d03 --- /dev/null +++ b/docs/reference/commands/slack_docs_get.md @@ -0,0 +1,55 @@ +# `slack docs get` + +Print a Slack developer docs page + +## Description + +Print a page of the Slack developer docs as markdown, return it as JSON, or open +it in your browser. + +The page can be a path or a full URL on docs.slack.dev, like a link returned by +the search subcommand. + +``` +slack docs get [flags] +``` + +## Flags + +``` + -h, --help help for get + --output string output format: text, json, browser (default "text") +``` + +## Global flags + +``` + --accessible use accessible prompts for screen readers + -a, --app string use a specific app ID or environment + --config-dir string use a custom path for system config directory + -e, --experiment strings use the experiment(s) in the command + -f, --force ignore warnings and continue executing command + --no-color remove styles and formatting from outputs + -s, --skip-update skip checking for latest version of CLI + -w, --team string select workspace or organization by team name or ID + --token string set the access token associated with a team + -v, --verbose print debug logging and additional info +``` + +## Examples + +``` +# Print the Socket Mode guide for Bolt for JavaScript +$ slack docs get /tools/bolt-js/concepts/socket-mode + +# Return a page and its links as JSON +$ slack docs get https://docs.slack.dev/reference/methods/chat.postMessage --output=json + +# Open a page in your browser +$ slack docs get /block-kit --output=browser +``` + +## See also + +* [slack docs](/tools/slack-cli/reference/commands/slack_docs/) - Search and open Slack developer docs + diff --git a/docs/reference/commands/slack_docs_search.md b/docs/reference/commands/slack_docs_search.md index 848ce102..cd080c47 100644 --- a/docs/reference/commands/slack_docs_search.md +++ b/docs/reference/commands/slack_docs_search.md @@ -11,7 +11,7 @@ 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. +version of each page. Print a result's page with the get subcommand. ``` slack docs search [query] [flags] diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 9b7e21e9..10ed3ab9 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -389,6 +389,12 @@ func (m *APIMock) DeveloperAppInstall(ctx context.Context, IO iostreams.IOStream // DocsClient +// DocsGetPage mocks fetching the markdown of a docs page +func (m *APIMock) DocsGetPage(ctx context.Context, path string) (string, error) { + args := m.Called(ctx, path) + 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) if args.Get(0) == nil { diff --git a/internal/api/docs.go b/internal/api/docs.go index c8b46a95..78c6ef06 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -47,6 +47,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) } @@ -73,7 +74,7 @@ func buildDocsSearchURL(baseURL, query string, limit int, category string) (stri return sURL.String(), nil } -func buildDocsSearchRequest(ctx context.Context, urlStr, cliVersion string) (*http.Request, error) { +func buildDocsRequest(ctx context.Context, urlStr, cliVersion string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) if err != nil { return nil, err @@ -101,7 +102,7 @@ func (c *Client) DocsSearch(ctx context.Context, query string, limit int, catego return nil, err } - req, err := buildDocsSearchRequest(ctx, urlStr, cliVersion) + req, err := buildDocsRequest(ctx, urlStr, cliVersion) if err != nil { return nil, errHTTPRequestFailed.WithRootCause(err) } @@ -120,3 +121,36 @@ func (c *Client) DocsSearch(ctx context.Context, query string, limit int, catego return &searchResponse, nil } + +// DocsGetPage returns the markdown of a Slack developer docs page, given its +// site path ending in ".md" +func (c *Client) DocsGetPage(ctx context.Context, path string) (string, error) { + var span opentracing.Span + span, _ = opentracing.StartSpanFromContext(ctx, "apiclient.DocsGetPage") + defer span.Finish() + + sURL, err := url.Parse(docsBaseURL + path) + if err != nil { + return "", errHTTPRequestFailed.WithRootCause(err) + } + span.SetTag("request_url", sURL) + + cliVersion, err := slackcontext.Version(ctx) + if err != nil { + return "", err + } + + req, err := buildDocsRequest(ctx, sURL.String(), cliVersion) + if err != nil { + return "", errHTTPRequestFailed.WithRootCause(err) + } + + c.printRequest(ctx, req, false) + + respBytes, err := c.DoWithRetry(ctx, req, span, false, sURL) + if err != nil { + return "", errHTTPRequestFailed.WithRootCause(err) + } + + return string(respBytes), nil +} diff --git a/internal/api/docs_test.go b/internal/api/docs_test.go index b3da049f..8bf86bcc 100644 --- a/internal/api/docs_test.go +++ b/internal/api/docs_test.go @@ -80,7 +80,7 @@ func Test_buildDocsSearchURL(t *testing.T) { } } -func Test_buildDocsSearchRequest(t *testing.T) { +func Test_buildDocsRequest(t *testing.T) { tests := map[string]struct { url string cliVersion string @@ -99,7 +99,7 @@ func Test_buildDocsSearchRequest(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { ctx := context.Background() - req, err := buildDocsSearchRequest(ctx, tc.url, tc.cliVersion) + req, err := buildDocsRequest(ctx, tc.url, tc.cliVersion) if tc.expectedErrorContains != "" { require.Error(t, err) @@ -223,3 +223,49 @@ func Test_Client_DocsSearch(t *testing.T) { }) } } + +func Test_Client_DocsGetPage(t *testing.T) { + tests := map[string]struct { + path string + response string + statusCode int + expectedContent string + expectedErrorContains string + }{ + "returns the page markdown": { + path: "/tools/bolt-js/concepts/socket-mode.md", + response: "# Using Socket Mode", + expectedContent: "# Using Socket Mode\n", + }, + "returns error for non-OK status": { + path: "/missing.md", + statusCode: 404, + expectedErrorContains: slackerror.ErrHTTPRequestFailed, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: tc.path, + Response: tc.response, + StatusCode: tc.statusCode, + }) + defer teardown() + + originalURL := docsBaseURL + docsBaseURL = c.Host() + defer func() { docsBaseURL = originalURL }() + + content, err := c.DocsGetPage(ctx, tc.path) + + if tc.expectedErrorContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErrorContains) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedContent, content) + } + }) + } +} diff --git a/internal/slacktrace/slacktrace.go b/internal/slacktrace/slacktrace.go index 727b0690..66199a96 100644 --- a/internal/slacktrace/slacktrace.go +++ b/internal/slacktrace/slacktrace.go @@ -76,6 +76,7 @@ const ( DatastoreCountDatastore = "SLACK_TRACE_DATASTORE_COUNT_DATASTORE" DatastoreCountSuccess = "SLACK_TRACE_DATASTORE_COUNT_SUCCESS" DatastoreCountTotal = "SLACK_TRACE_DATASTORE_COUNT_TOTAL" + DocsGetSuccess = "SLACK_TRACE_DOCS_GET_SUCCESS" DocsSearchSuccess = "SLACK_TRACE_DOCS_SEARCH_SUCCESS" DocsSuccess = "SLACK_TRACE_DOCS_SUCCESS" EnvInitSuccess = "SLACK_TRACE_ENV_INIT_SUCCESS"