diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go new file mode 100644 index 00000000..9b47c3e8 --- /dev/null +++ b/cmd/docs/docs.go @@ -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 + +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") + + 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") { + 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 +} diff --git a/cmd/docs/docs_test.go b/cmd/docs/docs_test.go new file mode 100644 index 00000000..b2996c40 --- /dev/null +++ b/cmd/docs/docs_test.go @@ -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) + }) +} diff --git a/cmd/root.go b/cmd/root.go index ea697b54..7b78e246 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" @@ -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`, @@ -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), } diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 0ad6dd7e..05f24c88 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -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" @@ -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 \"\"", false)), + }, + ErrEnterpriseNotFound: { Code: ErrEnterpriseNotFound, Message: "The `enterprise` was not found", diff --git a/internal/slacktrace/slacktrace.go b/internal/slacktrace/slacktrace.go index 8bd9253f..a75bb904 100644 --- a/internal/slacktrace/slacktrace.go +++ b/internal/slacktrace/slacktrace.go @@ -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"