Skip to content
Open
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
5 changes: 3 additions & 2 deletions cmd/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
164 changes: 164 additions & 0 deletions cmd/docs/get.go
Original file line number Diff line number Diff line change
@@ -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 <page>",
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",
)
}
}
99 changes: 99 additions & 0 deletions cmd/docs/get_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
2 changes: 1 addition & 1 deletion cmd/docs/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
{
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/commands/slack_docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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

55 changes: 55 additions & 0 deletions docs/reference/commands/slack_docs_get.md
Original file line number Diff line number Diff line change
@@ -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 <page> [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

2 changes: 1 addition & 1 deletion docs/reference/commands/slack_docs_search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions internal/api/api_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading