Skip to content
Merged
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
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 @@ -680,6 +681,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