From 2c6fba838d716ec6522b4cfa8992025741a8eeaa Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 14 Jul 2026 13:59:55 -0700 Subject: [PATCH 01/10] feat: recommend the official Slack plugin to Claude Code users When the CLI runs inside Claude Code (detected via CLAUDECODE=1), emit a one-line marker to stderr recommending the official slack@claude-plugins-official plugin. Claude Code strips the marker before it reaches the model, dedupes it per plugin/session, and shows a one-time install prompt. It is a no-op in every other environment. The emitter lives beside the existing CLAUDECODE detection in internal/useragent. It is called from root.go's PersistentPreRunE (covers every normal subcommand) and from the custom help func (Cobra serves --help before PersistentPreRunE runs, so those paths need their own emit). See https://code.claude.com/docs/en/plugin-hints. --- cmd/help/help.go | 6 ++++ cmd/help/help_test.go | 46 ++++++++++++++++++++++++++++ cmd/root.go | 4 +++ internal/useragent/hint.go | 41 +++++++++++++++++++++++++ internal/useragent/hint_test.go | 53 +++++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 internal/useragent/hint.go create mode 100644 internal/useragent/hint_test.go diff --git a/cmd/help/help.go b/cmd/help/help.go index 53e1965e..75230303 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" ) @@ -50,6 +51,11 @@ func HelpFunc( "Experiments": experiments, } PrintHelpTemplate(cmd, data) + + // Cobra serves help before PersistentPreRunE runs, so the root emit is + // skipped on `--help` paths. Recommend the official Slack plugin here too + // when running inside Claude Code. No-op in every other environment. + useragent.EmitClaudeCodePluginHint(clients.IO.WriteErr()) } } diff --git a/cmd/help/help_test.go b/cmd/help/help_test.go index 7210d022..549b8b99 100644 --- a/cmd/help/help_test.go +++ b/cmd/help/help_test.go @@ -105,3 +105,49 @@ func TestHelpFunc(t *testing.T) { }) } } + +func TestHelpFunc_ClaudeCodePluginHint(t *testing.T) { + tests := map[string]struct { + claudeCode string + expectHint bool + }{ + "emits the plugin hint on stderr inside Claude Code": { + claudeCode: "1", + expectHint: true, + }, + "does not emit the plugin hint outside Claude Code": { + claudeCode: "", + expectHint: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Setenv("CLAUDECODE", tc.claudeCode) + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + rootCmd := &cobra.Command{ + Use: "root", + Run: func(cmd *cobra.Command, args []string) {}, + } + rootCmd.SetContext(ctx) + rootCmd.Flags().Bool("help", true, "mock help flag") + clientsMock.Config.SetFlags(rootCmd) + testutil.MockCmdIO(clientsMock.IO, rootCmd) + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + helpFunc := HelpFunc(clients, map[string]string{}) + helpFunc(rootCmd, []string{}) + + // The hint belongs on stderr so it stays out of the help text on stdout. + assert.NotContains(t, clientsMock.GetStdoutOutput(), "claude-code-hint") + if tc.expectHint { + assert.Contains(t, clientsMock.GetStderrOutput(), "claude-code-hint") + } else { + assert.NotContains(t, clientsMock.GetStderrOutput(), "claude-code-hint") + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go index 0182fcee..46af32ff 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -129,6 +129,10 @@ 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. + // No-op in every other environment. See internal/useragent/hint.go. + 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..8006c5a8 --- /dev/null +++ b/internal/useragent/hint.go @@ -0,0 +1,41 @@ +// 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 one-line marker that recommends the official Slack +// plugin to Claude Code users. Claude Code scans command output for this tag, +// strips it before the output reaches the model (so it never counts toward token +// usage), and shows a one-time install prompt. The tag must occupy its own line +// and the plugin must live in Anthropic's official marketplace to have any +// effect. See https://code.claude.com/docs/en/plugin-hints. +const claudeCodePluginHint = `` + +// EmitClaudeCodePluginHint writes the Claude Code plugin-recommendation marker to +// w on its own line when the CLI is running inside Claude Code, prompting a +// one-time install of the official Slack plugin. It is a no-op in every other +// environment. Claude Code deduplicates the hint by plugin and per session, so +// callers may emit it on every invocation without spamming the user. +func EmitClaudeCodePluginHint(w io.Writer) { + if os.Getenv("CLAUDECODE") != "1" { + 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..443508a7 --- /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 nothing when CLAUDECODE is unset": { + claudeCode: "", + expected: "", + }, + "emits nothing when CLAUDECODE is set to another value": { + claudeCode: "true", + 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()) + }) + } +} From 4b298d1414aaec96c679a1ff689f0515a027a313 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 14 Jul 2026 14:03:00 -0700 Subject: [PATCH 02/10] test: fold plugin-hint help cases into the TestHelpFunc table --- cmd/help/help_test.go | 49 ++++++++++--------------------------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/cmd/help/help_test.go b/cmd/help/help_test.go index 549b8b99..d614677f 100644 --- a/cmd/help/help_test.go +++ b/cmd/help/help_test.go @@ -30,7 +30,9 @@ func TestHelpFunc(t *testing.T) { tests := map[string]struct { exampleCommands []style.ExampleCommand experiments []string + claudeCode string expectedOutput []string + expectHint bool }{ "basic command information is included": { expectedOutput: []string{ @@ -63,6 +65,14 @@ func TestHelpFunc(t *testing.T) { "unknown (invalid)", }, }, + "the Claude Code plugin hint is emitted inside Claude Code": { + claudeCode: "1", + expectHint: true, + }, + "the Claude Code plugin hint is not emitted outside Claude Code": { + claudeCode: "", + expectHint: false, + }, } for name, tc := range tests { @@ -74,6 +84,7 @@ func TestHelpFunc(t *testing.T) { // Restore original EnabledExperiments experiment.EnabledExperiments = _EnabledExperiments }() + t.Setenv("CLAUDECODE", tc.claudeCode) ctx := slackcontext.MockContext(t.Context()) clientsMock := shared.NewClientsMock() @@ -102,44 +113,6 @@ func TestHelpFunc(t *testing.T) { for _, expectedString := range tc.expectedOutput { assert.Contains(t, clientsMock.GetStdoutOutput(), expectedString) } - }) - } -} - -func TestHelpFunc_ClaudeCodePluginHint(t *testing.T) { - tests := map[string]struct { - claudeCode string - expectHint bool - }{ - "emits the plugin hint on stderr inside Claude Code": { - claudeCode: "1", - expectHint: true, - }, - "does not emit the plugin hint outside Claude Code": { - claudeCode: "", - expectHint: false, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - t.Setenv("CLAUDECODE", tc.claudeCode) - - ctx := slackcontext.MockContext(t.Context()) - clientsMock := shared.NewClientsMock() - clientsMock.AddDefaultMocks() - rootCmd := &cobra.Command{ - Use: "root", - Run: func(cmd *cobra.Command, args []string) {}, - } - rootCmd.SetContext(ctx) - rootCmd.Flags().Bool("help", true, "mock help flag") - clientsMock.Config.SetFlags(rootCmd) - testutil.MockCmdIO(clientsMock.IO, rootCmd) - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - helpFunc := HelpFunc(clients, map[string]string{}) - helpFunc(rootCmd, []string{}) // The hint belongs on stderr so it stays out of the help text on stdout. assert.NotContains(t, clientsMock.GetStdoutOutput(), "claude-code-hint") From bba55af53fb61e0b1cdd86020eeed5c8bd899179 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 14 Jul 2026 14:03:48 -0700 Subject: [PATCH 03/10] test: drive TestHelpFunc env with a map[string]string field --- cmd/help/help_test.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/cmd/help/help_test.go b/cmd/help/help_test.go index d614677f..5d8fea32 100644 --- a/cmd/help/help_test.go +++ b/cmd/help/help_test.go @@ -30,7 +30,7 @@ func TestHelpFunc(t *testing.T) { tests := map[string]struct { exampleCommands []style.ExampleCommand experiments []string - claudeCode string + envVars map[string]string expectedOutput []string expectHint bool }{ @@ -66,11 +66,11 @@ func TestHelpFunc(t *testing.T) { }, }, "the Claude Code plugin hint is emitted inside Claude Code": { - claudeCode: "1", + envVars: map[string]string{"CLAUDECODE": "1"}, expectHint: true, }, "the Claude Code plugin hint is not emitted outside Claude Code": { - claudeCode: "", + envVars: map[string]string{"CLAUDECODE": ""}, expectHint: false, }, } @@ -84,7 +84,12 @@ func TestHelpFunc(t *testing.T) { // Restore original EnabledExperiments experiment.EnabledExperiments = _EnabledExperiments }() - t.Setenv("CLAUDECODE", tc.claudeCode) + // Clear CLAUDECODE first so cases without an explicit value assert + // the hint's absence deterministically, then apply per-case vars. + t.Setenv("CLAUDECODE", "") + for name, value := range tc.envVars { + t.Setenv(name, value) + } ctx := slackcontext.MockContext(t.Context()) clientsMock := shared.NewClientsMock() From 6c1ac82b18af056cc64ef7e5dfd2e37eade34cb0 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 14 Jul 2026 14:06:31 -0700 Subject: [PATCH 04/10] test: assert the plugin hint via expectedErrorOutput --- cmd/help/help_test.go | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/cmd/help/help_test.go b/cmd/help/help_test.go index 5d8fea32..932cae6e 100644 --- a/cmd/help/help_test.go +++ b/cmd/help/help_test.go @@ -28,11 +28,11 @@ import ( func TestHelpFunc(t *testing.T) { tests := map[string]struct { - exampleCommands []style.ExampleCommand - experiments []string - envVars map[string]string - expectedOutput []string - expectHint bool + exampleCommands []style.ExampleCommand + experiments []string + envVars map[string]string + expectedOutput []string + expectedErrorOutput []string }{ "basic command information is included": { expectedOutput: []string{ @@ -66,12 +66,8 @@ func TestHelpFunc(t *testing.T) { }, }, "the Claude Code plugin hint is emitted inside Claude Code": { - envVars: map[string]string{"CLAUDECODE": "1"}, - expectHint: true, - }, - "the Claude Code plugin hint is not emitted outside Claude Code": { - envVars: map[string]string{"CLAUDECODE": ""}, - expectHint: false, + envVars: map[string]string{"CLAUDECODE": "1"}, + expectedErrorOutput: []string{"claude-code-hint"}, }, } @@ -84,9 +80,6 @@ func TestHelpFunc(t *testing.T) { // Restore original EnabledExperiments experiment.EnabledExperiments = _EnabledExperiments }() - // Clear CLAUDECODE first so cases without an explicit value assert - // the hint's absence deterministically, then apply per-case vars. - t.Setenv("CLAUDECODE", "") for name, value := range tc.envVars { t.Setenv(name, value) } @@ -118,13 +111,8 @@ func TestHelpFunc(t *testing.T) { for _, expectedString := range tc.expectedOutput { assert.Contains(t, clientsMock.GetStdoutOutput(), expectedString) } - - // The hint belongs on stderr so it stays out of the help text on stdout. - assert.NotContains(t, clientsMock.GetStdoutOutput(), "claude-code-hint") - if tc.expectHint { - assert.Contains(t, clientsMock.GetStderrOutput(), "claude-code-hint") - } else { - assert.NotContains(t, clientsMock.GetStderrOutput(), "claude-code-hint") + for _, expectedString := range tc.expectedErrorOutput { + assert.Contains(t, clientsMock.GetStderrOutput(), expectedString) } }) } From cbb8bff9116e96119f9248f7e93e697f5b28da85 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 14 Jul 2026 14:09:35 -0700 Subject: [PATCH 05/10] fix: gate plugin hint on any non-empty CLAUDECODE per the protocol --- internal/useragent/hint.go | 4 +++- internal/useragent/hint_test.go | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/useragent/hint.go b/internal/useragent/hint.go index 8006c5a8..5d51dcb6 100644 --- a/internal/useragent/hint.go +++ b/internal/useragent/hint.go @@ -34,7 +34,9 @@ const claudeCodePluginHint = ` Date: Tue, 14 Jul 2026 14:15:32 -0700 Subject: [PATCH 06/10] test: lowercase claude code in plugin hint case names --- cmd/help/help_test.go | 2 +- internal/useragent/hint_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/help/help_test.go b/cmd/help/help_test.go index 932cae6e..97f7ce37 100644 --- a/cmd/help/help_test.go +++ b/cmd/help/help_test.go @@ -65,7 +65,7 @@ func TestHelpFunc(t *testing.T) { "unknown (invalid)", }, }, - "the Claude Code plugin hint is emitted inside Claude Code": { + "the claude code plugin hint is emitted inside claude code": { envVars: map[string]string{"CLAUDECODE": "1"}, expectedErrorOutput: []string{"claude-code-hint"}, }, diff --git a/internal/useragent/hint_test.go b/internal/useragent/hint_test.go index 367187fb..84e21dda 100644 --- a/internal/useragent/hint_test.go +++ b/internal/useragent/hint_test.go @@ -26,7 +26,7 @@ func Test_EmitClaudeCodePluginHint(t *testing.T) { claudeCode string expected string }{ - "emits the hint on its own line inside Claude Code": { + "emits the hint on its own line inside claude code": { claudeCode: "1", expected: claudeCodePluginHint + "\n", }, From e5c008dff8d57f3bba6c1df73df967e2f41e3fd4 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 14 Jul 2026 14:35:02 -0700 Subject: [PATCH 07/10] fix: emit plugin hint from PrintHelpTemplate via cmd.ErrOrStderr --- cmd/help/help.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cmd/help/help.go b/cmd/help/help.go index 75230303..967c9e0a 100644 --- a/cmd/help/help.go +++ b/cmd/help/help.go @@ -51,11 +51,6 @@ func HelpFunc( "Experiments": experiments, } PrintHelpTemplate(cmd, data) - - // Cobra serves help before PersistentPreRunE runs, so the root emit is - // skipped on `--help` paths. Recommend the official Slack plugin here too - // when running inside Claude Code. No-op in every other environment. - useragent.EmitClaudeCodePluginHint(clients.IO.WriteErr()) } } @@ -81,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()) } // ════════════════════════════════════════════════════════════════════════════════ From 0adfdf5df59be96e8a9e0586a23748f8a7c600d0 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 14 Jul 2026 14:36:17 -0700 Subject: [PATCH 08/10] docs: add trailing period to plugin hint comments --- cmd/help/help.go | 2 +- cmd/root.go | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cmd/help/help.go b/cmd/help/help.go index 967c9e0a..82acf5f2 100644 --- a/cmd/help/help.go +++ b/cmd/help/help.go @@ -77,7 +77,7 @@ func PrintHelpTemplate(cmd *cobra.Command, data style.TemplateData) { cmd.PrintErrln(err) } - // Recommend the official Slack plugin when running inside Claude Code + // Recommend the official Slack plugin when running inside Claude Code. useragent.EmitClaudeCodePluginHint(cmd.ErrOrStderr()) } diff --git a/cmd/root.go b/cmd/root.go index 46af32ff..25de8f11 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -131,7 +131,6 @@ func NewRootCommand(clients *shared.ClientFactory, updateNotification *update.Up updateNotification.CheckForUpdateInBackground(ctx, false) // Recommend the official Slack plugin when running inside Claude Code. - // No-op in every other environment. See internal/useragent/hint.go. useragent.EmitClaudeCodePluginHint(clients.IO.WriteErr()) return nil }, From c43b3c358507524686b818c307fe04915dcfbf4b Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 14 Jul 2026 14:37:40 -0700 Subject: [PATCH 09/10] docs: trim plugin hint doc comments --- internal/useragent/hint.go | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/internal/useragent/hint.go b/internal/useragent/hint.go index 5d51dcb6..036a5346 100644 --- a/internal/useragent/hint.go +++ b/internal/useragent/hint.go @@ -20,22 +20,16 @@ import ( "os" ) -// claudeCodePluginHint is the one-line marker that recommends the official Slack -// plugin to Claude Code users. Claude Code scans command output for this tag, -// strips it before the output reaches the model (so it never counts toward token -// usage), and shows a one-time install prompt. The tag must occupy its own line -// and the plugin must live in Anthropic's official marketplace to have any -// effect. See https://code.claude.com/docs/en/plugin-hints. +// 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 -// w on its own line when the CLI is running inside Claude Code, prompting a -// one-time install of the official Slack plugin. It is a no-op in every other -// environment. Claude Code deduplicates the hint by plugin and per session, so -// callers may emit it on every invocation without spamming the user. +// 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) { - // Gate on any non-empty CLAUDECODE value, matching the published protocol - // example, so the hint keeps working if Claude Code ever changes the value. if os.Getenv("CLAUDECODE") == "" { return } From 7c560ad1a16e090fe2ce6d81d4f368b990c3b4f1 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 14 Jul 2026 14:40:33 -0700 Subject: [PATCH 10/10] docs: drop trailing period from plugin hint comments --- cmd/help/help.go | 2 +- cmd/root.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/help/help.go b/cmd/help/help.go index 82acf5f2..967c9e0a 100644 --- a/cmd/help/help.go +++ b/cmd/help/help.go @@ -77,7 +77,7 @@ func PrintHelpTemplate(cmd *cobra.Command, data style.TemplateData) { cmd.PrintErrln(err) } - // Recommend the official Slack plugin when running inside Claude Code. + // Recommend the official Slack plugin when running inside Claude Code useragent.EmitClaudeCodePluginHint(cmd.ErrOrStderr()) } diff --git a/cmd/root.go b/cmd/root.go index 25de8f11..f5d7573f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -130,7 +130,7 @@ func NewRootCommand(clients *shared.ClientFactory, updateNotification *update.Up updateNotification = update.New(clients, version.Raw(), "SLACK_SKIP_UPDATE") updateNotification.CheckForUpdateInBackground(ctx, false) - // Recommend the official Slack plugin when running inside Claude Code. + // Recommend the official Slack plugin when running inside Claude Code useragent.EmitClaudeCodePluginHint(clients.IO.WriteErr()) return nil },