Skip to content
Open
115 changes: 115 additions & 0 deletions cmd/docs/docs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 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 (
"fmt"
"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"
)

var searchMode bool

Comment on lines +29 to +30
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var searchMode bool

🪓 suggesetion(non-blocking): Related to the earlier comment!

func NewCommand(clients *shared.ClientFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "docs",
Short: "Open Slack developer docs",
Long: "Open the Slack developer docs in your browser, with optional search functionality",
Example: style.ExampleCommandsf([]style.ExampleCommand{
{
Meaning: "Open Slack developer docs homepage",
Command: "docs",
},
{
Meaning: "Search Slack developer docs for Block Kit",
Command: "docs --search \"Block Kit\"",
},
{
Meaning: "Open Slack docs search page",
Command: "docs --search",
},
}),
RunE: func(cmd *cobra.Command, args []string) error {
return runDocsCommand(clients, cmd, args)
},
}

cmd.Flags().BoolVar(&searchMode, "search", false, "open Slack docs search page or search with query")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cmd.Flags().BoolVar(&searchMode, "search", false, "open Slack docs search page or search with query")
cmd.Flags().Bool("search", false, "open Slack docs search page or search with query")

🪓 suggestion(non-blocking): We can perhaps remove searchMode as a variable with the lookup patterns that follow. IMHO it's a nice change that we could bring to other commands...


return cmd
}

// runDocsCommand opens Slack developer docs in the browser
func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

var docsURL string
var sectionText string

// Validate: if there are arguments, --search flag must be used
if len(args) > 0 && !cmd.Flags().Changed("search") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🪬 suggestion: We might still want to keep a max arguments setting for this command, but set to 1. It can be confusing otherwise to find:

(.venv) $ lack docs --search one two

📚 Docs Search
   https://docs.slack.dev/search/?q=one

Although we can also update this section with "variadic" arguments to join the arguments together - IMHO this might be ideal if not so complicated 👻

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it now joins the arguments together!

slack docs --search one two three now returns the search query of "one two three"

query := strings.Join(args, " ")
return slackerror.New(slackerror.ErrDocsSearchFlagRequired).WithRemediation(
"Use --search flag: %s",
style.Commandf(fmt.Sprintf("docs --search \"%s\"", query), false),
)
}

if cmd.Flags().Changed("search") {
if len(args) > 0 {
// --search "query" (space-separated) - join all args as the query
query := strings.Join(args, " ")
encodedQuery := url.QueryEscape(query)
docsURL = fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery)
sectionText = "Docs Search"
} else {
// --search (no argument) - open search page
docsURL = "https://docs.slack.dev/search/"
sectionText = "Docs Search"
}
} else {
// No search flag: default homepage
docsURL = "https://docs.slack.dev"
sectionText = "Docs Open"
}

clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "books",
Text: sectionText,
Secondary: []string{
docsURL,
},
}))

clients.Browser().OpenURL(docsURL)

if cmd.Flags().Changed("search") {
traceValue := ""
if len(args) > 0 {
traceValue = strings.Join(args, " ")
}
clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, traceValue)
} else {
clients.IO.PrintTrace(ctx, slacktrace.DocsSuccess)
}

return nil
}
134 changes: 134 additions & 0 deletions cmd/docs/docs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// 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"
"testing"

"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/slacktrace"
"github.com/slackapi/slack-cli/test/testutil"
"github.com/spf13/cobra"
"github.com/stretchr/testify/mock"
)

func Test_Docs_DocsCommand(t *testing.T) {
testutil.TableTestCommand(t, testutil.CommandTests{
"opens docs homepage without search": {
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
expectedURL := "https://docs.slack.dev"
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSuccess, mock.Anything)
},
ExpectedOutputs: []string{
"Docs Open",
"https://docs.slack.dev",
},
},
"fails when positional argument provided without search flag": {
CmdArgs: []string{"Block Kit"},
ExpectedErrorStrings: []string{"Invalid docs command. Did you mean to search?"},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
// No browser calls should be made when command fails
cm.Browser.AssertNotCalled(t, "OpenURL")
},
},
"fails when multiple positional arguments provided without search flag": {
CmdArgs: []string{"webhook", "send", "message"},
ExpectedErrorStrings: []string{"Invalid docs command. Did you mean to search?"},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
// No browser calls should be made when command fails
cm.Browser.AssertNotCalled(t, "OpenURL")
},
},
"opens docs with search query using space syntax": {
CmdArgs: []string{"--search", "messaging"},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
expectedURL := "https://docs.slack.dev/search/?q=messaging"
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
},
ExpectedOutputs: []string{
"Docs Search",
"https://docs.slack.dev/search/?q=messaging",
},
},
"handles search with multiple arguments": {
CmdArgs: []string{"--search", "Block", "Kit", "Element"},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
expectedURL := "https://docs.slack.dev/search/?q=Block+Kit+Element"
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
},
ExpectedOutputs: []string{
"Docs Search",
"https://docs.slack.dev/search/?q=Block+Kit+Element",
},
},
"handles search query with multiple words": {
CmdArgs: []string{"--search", "socket mode"},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
expectedURL := "https://docs.slack.dev/search/?q=socket+mode"
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
},
ExpectedOutputs: []string{
"Docs Search",
"https://docs.slack.dev/search/?q=socket+mode",
},
},
"handles special characters in search query": {
CmdArgs: []string{"--search", "messages & webhooks"},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
expectedURL := "https://docs.slack.dev/search/?q=messages+%26+webhooks"
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
},
ExpectedOutputs: []string{
"Docs Search",
"https://docs.slack.dev/search/?q=messages+%26+webhooks",
},
},
"handles search query with quotes": {
CmdArgs: []string{"--search", "webhook \"send message\""},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
expectedURL := "https://docs.slack.dev/search/?q=webhook+%22send+message%22"
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
},
ExpectedOutputs: []string{
"Docs Search",
"https://docs.slack.dev/search/?q=webhook+%22send+message%22",
},
},
"handles search flag without argument": {
CmdArgs: []string{"--search"},
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
expectedURL := "https://docs.slack.dev/search/"
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
},
ExpectedOutputs: []string{
"Docs Search",
"https://docs.slack.dev/search/",
},
},
}, func(cf *shared.ClientFactory) *cobra.Command {
return NewCommand(cf)
})
}
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/slackapi/slack-cli/cmd/collaborators"
"github.com/slackapi/slack-cli/cmd/datastore"
"github.com/slackapi/slack-cli/cmd/docgen"
"github.com/slackapi/slack-cli/cmd/docs"
"github.com/slackapi/slack-cli/cmd/doctor"
"github.com/slackapi/slack-cli/cmd/env"
"github.com/slackapi/slack-cli/cmd/externalauth"
Expand Down Expand Up @@ -95,6 +96,7 @@ func NewRootCommand(clients *shared.ClientFactory, updateNotification *update.Up
{Command: "init", Meaning: "Initialize an existing Slack app"},
{Command: "run", Meaning: "Start a local development server"},
{Command: "deploy", Meaning: "Deploy to the Slack Platform"},
{Command: "docs", Meaning: "Open Slack developer docs"},
}),
Long: strings.Join([]string{
`{{Emoji "sparkles"}}CLI to create, run, and deploy Slack apps`,
Expand Down Expand Up @@ -184,6 +186,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) {
rootCmd.CompletionOptions.HiddenDefaultCmd = true

topLevelCommands := []*cobra.Command{
docs.NewCommand(clients),
doctor.NewDoctorCommand(clients),
feedback.NewFeedbackCommand(clients),
}
Expand Down
7 changes: 7 additions & 0 deletions internal/slackerror/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const (
ErrDenoNotFound = "deno_not_found"
ErrDeployedAppNotSupported = "deployed_app_not_supported"
ErrDocumentationGenerationFailed = "documentation_generation_failed"
ErrDocsSearchFlagRequired = "docs_search_flag_required"
ErrEnterpriseNotFound = "enterprise_not_found"
ErrFailedAddingCollaborator = "failed_adding_collaborator"
ErrFailedCreatingApp = "failed_creating_app"
Expand Down Expand Up @@ -679,6 +680,12 @@ Otherwise start your app for local development with: %s`,
Message: "Failed to generate documentation",
},

ErrDocsSearchFlagRequired: {
Code: ErrDocsSearchFlagRequired,
Message: "Invalid docs command. Did you mean to search?",
Remediation: fmt.Sprintf("Use --search flag: %s", style.Commandf("docs --search \"<query>\"", false)),
},

ErrEnterpriseNotFound: {
Code: ErrEnterpriseNotFound,
Message: "The `enterprise` was not found",
Expand Down
2 changes: 2 additions & 0 deletions internal/slacktrace/slacktrace.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ const (
DatastoreCountDatastore = "SLACK_TRACE_DATASTORE_COUNT_DATASTORE"
DatastoreCountSuccess = "SLACK_TRACE_DATASTORE_COUNT_SUCCESS"
DatastoreCountTotal = "SLACK_TRACE_DATASTORE_COUNT_TOTAL"
DocsSearchSuccess = "SLACK_TRACE_DOCS_SEARCH_SUCCESS"
DocsSuccess = "SLACK_TRACE_DOCS_SUCCESS"
EnvAddSuccess = "SLACK_TRACE_ENV_ADD_SUCCESS"
EnvListCount = "SLACK_TRACE_ENV_LIST_COUNT"
EnvListVariables = "SLACK_TRACE_ENV_LIST_VARIABLES"
Expand Down
Loading