diff --git a/cmd/help/help.go b/cmd/help/help.go index 53e1965e..967c9e0a 100644 --- a/cmd/help/help.go +++ b/cmd/help/help.go @@ -21,6 +21,7 @@ import ( "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/style" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/spf13/cobra" ) @@ -75,6 +76,9 @@ func PrintHelpTemplate(cmd *cobra.Command, data style.TemplateData) { if err != nil { cmd.PrintErrln(err) } + + // Recommend the official Slack plugin when running inside Claude Code + useragent.EmitClaudeCodePluginHint(cmd.ErrOrStderr()) } // ════════════════════════════════════════════════════════════════════════════════ diff --git a/cmd/help/help_test.go b/cmd/help/help_test.go index 7210d022..97f7ce37 100644 --- a/cmd/help/help_test.go +++ b/cmd/help/help_test.go @@ -28,9 +28,11 @@ import ( func TestHelpFunc(t *testing.T) { tests := map[string]struct { - exampleCommands []style.ExampleCommand - experiments []string - expectedOutput []string + exampleCommands []style.ExampleCommand + experiments []string + envVars map[string]string + expectedOutput []string + expectedErrorOutput []string }{ "basic command information is included": { expectedOutput: []string{ @@ -63,6 +65,10 @@ func TestHelpFunc(t *testing.T) { "unknown (invalid)", }, }, + "the claude code plugin hint is emitted inside claude code": { + envVars: map[string]string{"CLAUDECODE": "1"}, + expectedErrorOutput: []string{"claude-code-hint"}, + }, } for name, tc := range tests { @@ -74,6 +80,9 @@ func TestHelpFunc(t *testing.T) { // Restore original EnabledExperiments experiment.EnabledExperiments = _EnabledExperiments }() + for name, value := range tc.envVars { + t.Setenv(name, value) + } ctx := slackcontext.MockContext(t.Context()) clientsMock := shared.NewClientsMock() @@ -102,6 +111,9 @@ func TestHelpFunc(t *testing.T) { for _, expectedString := range tc.expectedOutput { assert.Contains(t, clientsMock.GetStdoutOutput(), expectedString) } + for _, expectedString := range tc.expectedErrorOutput { + assert.Contains(t, clientsMock.GetStderrOutput(), expectedString) + } }) } } diff --git a/cmd/root.go b/cmd/root.go index 0182fcee..f5d7573f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -129,6 +129,9 @@ func NewRootCommand(clients *shared.ClientFactory, updateNotification *update.Up // Check for an CLI update in the background while the command runs updateNotification = update.New(clients, version.Raw(), "SLACK_SKIP_UPDATE") updateNotification.CheckForUpdateInBackground(ctx, false) + + // Recommend the official Slack plugin when running inside Claude Code + useragent.EmitClaudeCodePluginHint(clients.IO.WriteErr()) return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/useragent/hint.go b/internal/useragent/hint.go new file mode 100644 index 00000000..036a5346 --- /dev/null +++ b/internal/useragent/hint.go @@ -0,0 +1,37 @@ +// 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 useragent + +import ( + "fmt" + "io" + "os" +) + +// claudeCodePluginHint is the marker to recommend the official Slack plugin to +// users of Claude Code. +// +// https://code.claude.com/docs/en/plugin-hints +const claudeCodePluginHint = `` + +// EmitClaudeCodePluginHint writes the Claude Code plugin recommendation marker +// to a writer that must be stderr to prompt installation without an appearance +// in actual outputs. +func EmitClaudeCodePluginHint(w io.Writer) { + if os.Getenv("CLAUDECODE") == "" { + return + } + fmt.Fprintln(w, claudeCodePluginHint) +} diff --git a/internal/useragent/hint_test.go b/internal/useragent/hint_test.go new file mode 100644 index 00000000..84e21dda --- /dev/null +++ b/internal/useragent/hint_test.go @@ -0,0 +1,53 @@ +// 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 useragent + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_EmitClaudeCodePluginHint(t *testing.T) { + tests := map[string]struct { + claudeCode string + expected string + }{ + "emits the hint on its own line inside claude code": { + claudeCode: "1", + expected: claudeCodePluginHint + "\n", + }, + "emits the hint for any non-empty CLAUDECODE value": { + claudeCode: "true", + expected: claudeCodePluginHint + "\n", + }, + "emits nothing when CLAUDECODE is unset": { + claudeCode: "", + expected: "", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + clearEnvVars(t) + t.Setenv("CLAUDECODE", tc.claudeCode) + + var buf bytes.Buffer + EmitClaudeCodePluginHint(&buf) + + assert.Equal(t, tc.expected, buf.String()) + }) + } +}