From 96469020d1624e5e595a5ae6d21a0269e70320a9 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 15 May 2026 14:22:55 -0700 Subject: [PATCH 1/9] feat: detect AI coding agents and append identifier to user-agent When the Slack CLI is invoked by an AI coding agent (Claude Code, Codex, Gemini CLI, Cline, Cursor, Goose, Amp, or others), detect the agent via environment variables and append an AI-Agent product token to the HTTP user-agent header. Also propagates the agent name to telemetry events and Jaeger tracing spans. User-Agent format: slack-cli/2.38.1 (os: darwin) AI-Agent (name=claude-code, entry=cli) --- internal/api/client.go | 11 +- internal/api/docs.go | 5 +- internal/api/icon.go | 6 +- internal/api/raw_request.go | 5 +- internal/api/s3_upload.go | 6 +- internal/tracking/logstash_event.go | 1 + internal/tracking/tracking.go | 9 ++ internal/useragent/useragent.go | 62 ++++++++++ internal/useragent/useragent_test.go | 167 +++++++++++++++++++++++++++ main.go | 11 ++ 10 files changed, 266 insertions(+), 17 deletions(-) create mode 100644 internal/useragent/useragent.go create mode 100644 internal/useragent/useragent_test.go diff --git a/internal/api/client.go b/internal/api/client.go index df536b0c..86ef489a 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -22,7 +22,6 @@ import ( "io" "net/http" "net/url" - "runtime" "strconv" "strings" "time" @@ -35,6 +34,7 @@ import ( "github.com/slackapi/slack-cli/internal/slackcontext" "github.com/slackapi/slack-cli/internal/slackdeps" "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/uber/jaeger-client-go" ) @@ -128,10 +128,9 @@ func (c *Client) postForm(ctx context.Context, endpoint string, formValues url.V if err != nil { return nil, err } - var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS) request.Header.Add("content-type", "application/x-www-form-urlencoded") - request.Header.Add("User-Agent", userAgent) + request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion)) if jaegerSpanContext, ok := span.Context().(jaeger.SpanContext); ok { request.Header.Add("x-b3-sampled", "0") request.Header.Add("x-b3-spanid", jaegerSpanContext.SpanID().String()) @@ -179,8 +178,7 @@ func (c *Client) postJSON(ctx context.Context, endpoint, token string, cookie st if err != nil { return nil, err } - var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS) - request.Header.Add("User-Agent", userAgent) + request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion)) if jaegerSpanContext, ok := span.Context().(jaeger.SpanContext); ok { request.Header.Add("x-b3-sampled", "0") request.Header.Add("x-b3-spanid", jaegerSpanContext.SpanID().String()) @@ -232,9 +230,8 @@ func (c *Client) get(ctx context.Context, endpoint, token string, cookie string) if err != nil { return nil, err } - var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS) - request.Header.Add("User-Agent", userAgent) + request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion)) if jaegerSpanContext, ok := span.Context().(jaeger.SpanContext); ok { request.Header.Add("x-b3-sampled", "0") request.Header.Add("x-b3-spanid", jaegerSpanContext.SpanID().String()) diff --git a/internal/api/docs.go b/internal/api/docs.go index 25d0b214..aefdc49d 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -20,10 +20,11 @@ import ( "fmt" "net/http" "net/url" - "runtime" + "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/slackapi/slack-cli/internal/useragent" ) var docsBaseURL = "https://docs.slack.dev" @@ -59,7 +60,7 @@ func buildDocsSearchRequest(ctx context.Context, urlStr, cliVersion string) (*ht if err != nil { return nil, err } - req.Header.Add("User-Agent", fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)) + req.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion)) return req, nil } diff --git a/internal/api/icon.go b/internal/api/icon.go index 20210e10..eacbee74 100644 --- a/internal/api/icon.go +++ b/internal/api/icon.go @@ -24,12 +24,13 @@ import ( "net/http" "net/textproto" "net/url" - "runtime" + "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/image" "github.com/slackapi/slack-cli/internal/slackcontext" "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/spf13/afero" ) @@ -132,8 +133,7 @@ func (c *Client) uploadIcon(ctx context.Context, fs afero.Fs, token, appID, icon if err != nil { return IconResult{}, err } - var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS) - request.Header.Add("User-Agent", userAgent) + request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion)) c.io.PrintDebug(ctx, "HTTP Request: %v %v %v", request.Method, request.URL, request.Proto) c.io.PrintDebug(ctx, "HTTP Request User-Agent: %s", request.Header.Get("User-Agent")) diff --git a/internal/api/raw_request.go b/internal/api/raw_request.go index a08a2fc4..6d093455 100644 --- a/internal/api/raw_request.go +++ b/internal/api/raw_request.go @@ -21,11 +21,12 @@ import ( "io" "net/http" "net/url" - "runtime" + "time" "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/uber/jaeger-client-go" ) @@ -67,7 +68,7 @@ func (c *Client) RawRequest(ctx context.Context, httpMethod, endpoint, token str if err != nil { return nil, err } - request.Header.Set("User-Agent", fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)) + request.Header.Set("User-Agent", useragent.BuildUserAgent(cliVersion)) if jaegerSpanContext, ok := span.Context().(jaeger.SpanContext); ok { request.Header.Set("x-b3-sampled", "0") diff --git a/internal/api/s3_upload.go b/internal/api/s3_upload.go index 8462a540..7d351b8a 100644 --- a/internal/api/s3_upload.go +++ b/internal/api/s3_upload.go @@ -22,11 +22,12 @@ import ( "mime/multipart" "net/http" "net/textproto" - "runtime" + "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/slackcontext" "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/spf13/afero" ) @@ -100,8 +101,7 @@ func (c *Client) UploadPackageToS3(ctx context.Context, fs afero.Fs, appID strin if err != nil { return fileName, err } - var userAgent = fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS) - request.Header.Add("User-Agent", userAgent) + request.Header.Add("User-Agent", useragent.BuildUserAgent(cliVersion)) var s3span = opentracing.StartSpan("apiclient.UploadPackageToS3.FileUpload", opentracing.ChildOf(span.Context())) s3span.SetTag("app", appID) diff --git a/internal/tracking/logstash_event.go b/internal/tracking/logstash_event.go index 50731012..6f51122d 100644 --- a/internal/tracking/logstash_event.go +++ b/internal/tracking/logstash_event.go @@ -48,6 +48,7 @@ type EventData struct { // EventContext contains information / metadata about the CLI session type EventContext struct { + Agent string `json:"agent,omitempty"` Arch string `json:"arch"` Binary string `json:"bin"` CLIVersion string `json:"cli_version"` diff --git a/internal/tracking/tracking.go b/internal/tracking/tracking.go index 1dca61e0..6dc4dba0 100644 --- a/internal/tracking/tracking.go +++ b/internal/tracking/tracking.go @@ -26,6 +26,7 @@ import ( "sync" "time" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/slackapi/slack-cli/internal/config" "github.com/slackapi/slack-cli/internal/goutils" "github.com/slackapi/slack-cli/internal/iostreams" @@ -194,6 +195,7 @@ func (e *EventTracker) FlushToLogstash(ctx context.Context, cfg *config.Config, Timestamp: time.Now().UnixMilli(), Data: eventData, Context: EventContext{ + Agent: detectAgentName(), CLIVersion: versionString, Host: ioutils.GetHostname(), OS: runtime.GOOS, @@ -242,3 +244,10 @@ func (e *EventTracker) FlushToLogstash(ctx context.Context, cfg *config.Config, return nil } + +func detectAgentName() string { + if agent := useragent.Detect(); agent != nil { + return agent.Name + } + return "" +} diff --git a/internal/useragent/useragent.go b/internal/useragent/useragent.go new file mode 100644 index 00000000..338106e8 --- /dev/null +++ b/internal/useragent/useragent.go @@ -0,0 +1,62 @@ +// 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" + "os" + "runtime" + "strings" +) + +type AIAgent struct { + Name string + Entry string +} + +func Detect() *AIAgent { + switch { + case os.Getenv("CLAUDECODE") == "1": + return &AIAgent{ + Name: "claude-code", + Entry: os.Getenv("CLAUDE_CODE_ENTRYPOINT"), + } + case os.Getenv("CODEX_CI") == "1": + return &AIAgent{Name: "codex"} + case os.Getenv("GEMINI_CLI") == "1": + return &AIAgent{Name: "gemini-cli"} + case os.Getenv("CLINE_ACTIVE") == "true": + return &AIAgent{Name: "cline"} + case os.Getenv("CURSOR_AGENT") == "1": + return &AIAgent{Name: "cursor"} + case os.Getenv("AGENT") != "": + return &AIAgent{Name: os.Getenv("AGENT")} + default: + return nil + } +} + +func BuildUserAgent(cliVersion string) string { + ua := fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS) + if agent := Detect(); agent != nil { + var parts []string + parts = append(parts, "name="+agent.Name) + if agent.Entry != "" { + parts = append(parts, "entry="+agent.Entry) + } + ua += " AI-Agent (" + strings.Join(parts, ", ") + ")" + } + return ua +} diff --git a/internal/useragent/useragent_test.go b/internal/useragent/useragent_test.go new file mode 100644 index 00000000..c14f8861 --- /dev/null +++ b/internal/useragent/useragent_test.go @@ -0,0 +1,167 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func clearEnvVars(t *testing.T) { + t.Helper() + for _, env := range []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CODEX_CI", "GEMINI_CLI", "CLINE_ACTIVE", "CURSOR_AGENT", "AGENT"} { + t.Setenv(env, "") + } +} + +func Test_Detect(t *testing.T) { + tests := map[string]struct { + envVars map[string]string + expected *AIAgent + }{ + "detects Claude Code": { + envVars: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "cli"}, + expected: &AIAgent{Name: "claude-code", Entry: "cli"}, + }, + "detects Claude Code with vscode entrypoint": { + envVars: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "vscode"}, + expected: &AIAgent{Name: "claude-code", Entry: "vscode"}, + }, + "detects Claude Code without entrypoint": { + envVars: map[string]string{"CLAUDECODE": "1"}, + expected: &AIAgent{Name: "claude-code", Entry: ""}, + }, + "detects Codex": { + envVars: map[string]string{"CODEX_CI": "1"}, + expected: &AIAgent{Name: "codex"}, + }, + "detects Gemini CLI": { + envVars: map[string]string{"GEMINI_CLI": "1"}, + expected: &AIAgent{Name: "gemini-cli"}, + }, + "detects Cline": { + envVars: map[string]string{"CLINE_ACTIVE": "true"}, + expected: &AIAgent{Name: "cline"}, + }, + "detects Cursor": { + envVars: map[string]string{"CURSOR_AGENT": "1"}, + expected: &AIAgent{Name: "cursor"}, + }, + "detects agent via AGENT env var": { + envVars: map[string]string{"AGENT": "goose"}, + expected: &AIAgent{Name: "goose"}, + }, + "detects unknown agent via AGENT env var": { + envVars: map[string]string{"AGENT": "future-agent"}, + expected: &AIAgent{Name: "future-agent"}, + }, + "returns nil when no agent detected": { + envVars: map[string]string{}, + expected: nil, + }, + "CLAUDECODE takes priority over AGENT": { + envVars: map[string]string{"CLAUDECODE": "1", "AGENT": "goose"}, + expected: &AIAgent{Name: "claude-code", Entry: ""}, + }, + "CODEX_CI takes priority over AGENT": { + envVars: map[string]string{"CODEX_CI": "1", "AGENT": "goose"}, + expected: &AIAgent{Name: "codex"}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + clearEnvVars(t) + for k, v := range tc.envVars { + t.Setenv(k, v) + } + result := Detect() + if tc.expected == nil { + assert.Nil(t, result) + } else { + assert.Equal(t, tc.expected, result) + } + }) + } +} + +func Test_BuildUserAgent(t *testing.T) { + tests := map[string]struct { + envVars map[string]string + contains string + noAgent bool + }{ + "includes AI-Agent suffix for Claude Code with entrypoint": { + envVars: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "cli"}, + contains: "AI-Agent (name=claude-code, entry=cli)", + }, + "includes AI-Agent suffix for Claude Code with vscode entrypoint": { + envVars: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "vscode"}, + contains: "AI-Agent (name=claude-code, entry=vscode)", + }, + "includes AI-Agent suffix for Claude Code without entrypoint": { + envVars: map[string]string{"CLAUDECODE": "1"}, + contains: "AI-Agent (name=claude-code)", + }, + "includes AI-Agent suffix for Codex": { + envVars: map[string]string{"CODEX_CI": "1"}, + contains: "AI-Agent (name=codex)", + }, + "includes AI-Agent suffix for Gemini CLI": { + envVars: map[string]string{"GEMINI_CLI": "1"}, + contains: "AI-Agent (name=gemini-cli)", + }, + "includes AI-Agent suffix for Cline": { + envVars: map[string]string{"CLINE_ACTIVE": "true"}, + contains: "AI-Agent (name=cline)", + }, + "includes AI-Agent suffix for Cursor": { + envVars: map[string]string{"CURSOR_AGENT": "1"}, + contains: "AI-Agent (name=cursor)", + }, + "includes AI-Agent suffix for AGENT env var": { + envVars: map[string]string{"AGENT": "goose"}, + contains: "AI-Agent (name=goose)", + }, + "includes AI-Agent suffix for unknown agent": { + envVars: map[string]string{"AGENT": "future-agent"}, + contains: "AI-Agent (name=future-agent)", + }, + "CLAUDECODE takes priority over AGENT": { + envVars: map[string]string{"CLAUDECODE": "1", "AGENT": "goose", "CLAUDE_CODE_ENTRYPOINT": "cli"}, + contains: "AI-Agent (name=claude-code, entry=cli)", + }, + "no AI-Agent suffix when no agent detected": { + envVars: map[string]string{}, + noAgent: true, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + clearEnvVars(t) + for k, v := range tc.envVars { + t.Setenv(k, v) + } + ua := BuildUserAgent("2.38.1") + assert.Contains(t, ua, "slack-cli/2.38.1") + if tc.contains != "" { + assert.Contains(t, ua, tc.contains) + } + if tc.noAgent { + assert.NotContains(t, ua, "AI-Agent") + } + }) + } +} diff --git a/main.go b/main.go index b79aebf6..2a7423da 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,7 @@ import ( "github.com/google/uuid" "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/cmd" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/slackapi/slack-cli/internal/goutils" "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/ioutils" @@ -60,6 +61,9 @@ func main() { span.SetTag("slack_cli_sessionID", sessionID) span.SetTag("hashed_hostname", ioutils.GetHostname()) span.SetTag("slack_cli_process", processName) + if agentName := detectAIAgentName(); agentName != "" { + span.SetTag("ai_agent", agentName) + } // system_id is set in root.go initConfig() // project_id is set in root.go initConfig() @@ -76,6 +80,13 @@ func main() { cmd.ExecuteContext(ctx, rootCmd, clients) } +func detectAIAgentName() string { + if agent := useragent.Detect(); agent != nil { + return agent.Name + } + return "" +} + // TODO(slackcontext) Use closure to pass in the ctx, which includes the sessionID func recoveryFunc() { // in the event of a panic, log panic From e172f13ce4b55ff66605c4b29f1b844b5075ad57 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 15 May 2026 14:27:41 -0700 Subject: [PATCH 2/9] refactor: extract DetectName() into useragent package Removes duplicate detectAIAgentName/detectAgentName helpers from main.go and tracking.go in favor of useragent.DetectName(). --- internal/tracking/tracking.go | 8 +------- internal/useragent/useragent.go | 7 +++++++ main.go | 9 +-------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/internal/tracking/tracking.go b/internal/tracking/tracking.go index 6dc4dba0..23c03a1f 100644 --- a/internal/tracking/tracking.go +++ b/internal/tracking/tracking.go @@ -195,7 +195,7 @@ func (e *EventTracker) FlushToLogstash(ctx context.Context, cfg *config.Config, Timestamp: time.Now().UnixMilli(), Data: eventData, Context: EventContext{ - Agent: detectAgentName(), + Agent: useragent.DetectName(), CLIVersion: versionString, Host: ioutils.GetHostname(), OS: runtime.GOOS, @@ -245,9 +245,3 @@ func (e *EventTracker) FlushToLogstash(ctx context.Context, cfg *config.Config, return nil } -func detectAgentName() string { - if agent := useragent.Detect(); agent != nil { - return agent.Name - } - return "" -} diff --git a/internal/useragent/useragent.go b/internal/useragent/useragent.go index 338106e8..31b373b1 100644 --- a/internal/useragent/useragent.go +++ b/internal/useragent/useragent.go @@ -48,6 +48,13 @@ func Detect() *AIAgent { } } +func DetectName() string { + if agent := Detect(); agent != nil { + return agent.Name + } + return "" +} + func BuildUserAgent(cliVersion string) string { ua := fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS) if agent := Detect(); agent != nil { diff --git a/main.go b/main.go index 2a7423da..61fb2d3a 100644 --- a/main.go +++ b/main.go @@ -61,7 +61,7 @@ func main() { span.SetTag("slack_cli_sessionID", sessionID) span.SetTag("hashed_hostname", ioutils.GetHostname()) span.SetTag("slack_cli_process", processName) - if agentName := detectAIAgentName(); agentName != "" { + if agentName := useragent.DetectName(); agentName != "" { span.SetTag("ai_agent", agentName) } // system_id is set in root.go initConfig() @@ -80,13 +80,6 @@ func main() { cmd.ExecuteContext(ctx, rootCmd, clients) } -func detectAIAgentName() string { - if agent := useragent.Detect(); agent != nil { - return agent.Name - } - return "" -} - // TODO(slackcontext) Use closure to pass in the ctx, which includes the sessionID func recoveryFunc() { // in the event of a panic, log panic From ce067b0feec206575ffcc5f8a3a33ef6f5af5077 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 15 May 2026 15:13:03 -0700 Subject: [PATCH 3/9] docs: add godoc comments to useragent package functions --- internal/useragent/useragent.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/useragent/useragent.go b/internal/useragent/useragent.go index 31b373b1..908f4ab6 100644 --- a/internal/useragent/useragent.go +++ b/internal/useragent/useragent.go @@ -21,11 +21,15 @@ import ( "strings" ) +// AIAgent represents a detected AI coding agent that invoked the CLI. type AIAgent struct { Name string Entry string } +// Detect checks environment variables to determine if the CLI is being run by +// an AI coding agent. Returns nil if no agent is detected. Detection priority: +// CLAUDECODE > CODEX_CI > GEMINI_CLI > CLINE_ACTIVE > CURSOR_AGENT > AGENT. func Detect() *AIAgent { switch { case os.Getenv("CLAUDECODE") == "1": @@ -48,6 +52,8 @@ func Detect() *AIAgent { } } +// DetectName returns the normalized name of the detected AI agent, or an empty +// string if no agent is detected. func DetectName() string { if agent := Detect(); agent != nil { return agent.Name @@ -55,6 +61,8 @@ func DetectName() string { return "" } +// BuildUserAgent constructs the HTTP User-Agent header value for the CLI. If an +// AI agent is detected, an "AI-Agent (name=..., entry=...)" suffix is appended. func BuildUserAgent(cliVersion string) string { ua := fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS) if agent := Detect(); agent != nil { From 4251b279fbbc1bf869e5f93d5c49a284a08be61a Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 15 May 2026 15:15:49 -0700 Subject: [PATCH 4/9] fix: align test function names with Test_Package_Method convention --- internal/useragent/useragent_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/useragent/useragent_test.go b/internal/useragent/useragent_test.go index c14f8861..67456de3 100644 --- a/internal/useragent/useragent_test.go +++ b/internal/useragent/useragent_test.go @@ -27,7 +27,7 @@ func clearEnvVars(t *testing.T) { } } -func Test_Detect(t *testing.T) { +func Test_UserAgent_Detect(t *testing.T) { tests := map[string]struct { envVars map[string]string expected *AIAgent @@ -97,7 +97,7 @@ func Test_Detect(t *testing.T) { } } -func Test_BuildUserAgent(t *testing.T) { +func Test_UserAgent_BuildUserAgent(t *testing.T) { tests := map[string]struct { envVars map[string]string contains string From 61ead3972d7396449ff0f34c4bfe6d6caf62c022 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 15 May 2026 15:22:43 -0700 Subject: [PATCH 5/9] chore: fix gofmt formatting in import blocks --- internal/api/docs.go | 1 - internal/api/icon.go | 1 - internal/api/s3_upload.go | 1 - internal/tracking/tracking.go | 3 +-- main.go | 2 +- 5 files changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/api/docs.go b/internal/api/docs.go index aefdc49d..9d17b654 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -21,7 +21,6 @@ import ( "net/http" "net/url" - "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/slackcontext" "github.com/slackapi/slack-cli/internal/useragent" diff --git a/internal/api/icon.go b/internal/api/icon.go index eacbee74..fcf3251d 100644 --- a/internal/api/icon.go +++ b/internal/api/icon.go @@ -25,7 +25,6 @@ import ( "net/textproto" "net/url" - "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/image" "github.com/slackapi/slack-cli/internal/slackcontext" diff --git a/internal/api/s3_upload.go b/internal/api/s3_upload.go index 7d351b8a..3ff7f1fa 100644 --- a/internal/api/s3_upload.go +++ b/internal/api/s3_upload.go @@ -23,7 +23,6 @@ import ( "net/http" "net/textproto" - "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/slackcontext" "github.com/slackapi/slack-cli/internal/slackerror" diff --git a/internal/tracking/tracking.go b/internal/tracking/tracking.go index 23c03a1f..c89eaf77 100644 --- a/internal/tracking/tracking.go +++ b/internal/tracking/tracking.go @@ -26,13 +26,13 @@ import ( "sync" "time" - "github.com/slackapi/slack-cli/internal/useragent" "github.com/slackapi/slack-cli/internal/config" "github.com/slackapi/slack-cli/internal/goutils" "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/ioutils" "github.com/slackapi/slack-cli/internal/slackcontext" "github.com/slackapi/slack-cli/internal/style" + "github.com/slackapi/slack-cli/internal/useragent" ) // TrackingManager is an interface for tracking metrics and events related to CLI activity @@ -244,4 +244,3 @@ func (e *EventTracker) FlushToLogstash(ctx context.Context, cfg *config.Config, return nil } - diff --git a/main.go b/main.go index 61fb2d3a..e3815a62 100644 --- a/main.go +++ b/main.go @@ -23,13 +23,13 @@ import ( "github.com/google/uuid" "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/cmd" - "github.com/slackapi/slack-cli/internal/useragent" "github.com/slackapi/slack-cli/internal/goutils" "github.com/slackapi/slack-cli/internal/iostreams" "github.com/slackapi/slack-cli/internal/ioutils" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/slackcontext" "github.com/slackapi/slack-cli/internal/tracer" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/slackapi/slack-cli/internal/version" "github.com/uber/jaeger-client-go" ) From dfb062c242e39c2b918bdcbb608ddf45564307b3 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 15 May 2026 15:41:09 -0700 Subject: [PATCH 6/9] feat: print user-agent in verbose debug output during startup --- cmd/root.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/root.go b/cmd/root.go index b638502b..0182fcee 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -52,6 +52,7 @@ import ( "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/style" "github.com/slackapi/slack-cli/internal/update" + "github.com/slackapi/slack-cli/internal/useragent" "github.com/slackapi/slack-cli/internal/version" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -286,6 +287,7 @@ func InitConfig(ctx context.Context, clients *shared.ClientFactory, rootCmd *cob rootCmd.SetContext(ctx) // Debug logging clients.IO.PrintDebug(ctx, "system_id: %s", clients.Config.SystemID) + clients.IO.PrintDebug(ctx, "user_agent: %s", useragent.BuildUserAgent(version.Raw())) } // Init Project ID, if current directory is a project From eedcf9dfcd1b1233ad012ba6239618229aa4f26a Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 15 May 2026 15:55:18 -0700 Subject: [PATCH 7/9] refactor: rename logstash field from agent to ai_agent for consistency --- internal/tracking/logstash_event.go | 2 +- internal/tracking/tracking.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tracking/logstash_event.go b/internal/tracking/logstash_event.go index 6f51122d..a0f58a16 100644 --- a/internal/tracking/logstash_event.go +++ b/internal/tracking/logstash_event.go @@ -48,7 +48,7 @@ type EventData struct { // EventContext contains information / metadata about the CLI session type EventContext struct { - Agent string `json:"agent,omitempty"` + AIAgent string `json:"ai_agent,omitempty"` Arch string `json:"arch"` Binary string `json:"bin"` CLIVersion string `json:"cli_version"` diff --git a/internal/tracking/tracking.go b/internal/tracking/tracking.go index c89eaf77..9f246eae 100644 --- a/internal/tracking/tracking.go +++ b/internal/tracking/tracking.go @@ -195,7 +195,7 @@ func (e *EventTracker) FlushToLogstash(ctx context.Context, cfg *config.Config, Timestamp: time.Now().UnixMilli(), Data: eventData, Context: EventContext{ - Agent: useragent.DetectName(), + AIAgent: useragent.DetectName(), CLIVersion: versionString, Host: ioutils.GetHostname(), OS: runtime.GOOS, From 96cd3e393632d65cb065941497260a001fc352f2 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 15 May 2026 16:03:01 -0700 Subject: [PATCH 8/9] chore: alphabetize test functions and rename logstash field to ai_agent --- internal/useragent/useragent_test.go | 162 +++++++++++++-------------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/internal/useragent/useragent_test.go b/internal/useragent/useragent_test.go index 67456de3..dfcf5ff0 100644 --- a/internal/useragent/useragent_test.go +++ b/internal/useragent/useragent_test.go @@ -27,58 +27,55 @@ func clearEnvVars(t *testing.T) { } } -func Test_UserAgent_Detect(t *testing.T) { +func Test_UserAgent_BuildUserAgent(t *testing.T) { tests := map[string]struct { envVars map[string]string - expected *AIAgent + contains string + noAgent bool }{ - "detects Claude Code": { + "CLAUDECODE takes priority over AGENT": { + envVars: map[string]string{"CLAUDECODE": "1", "AGENT": "goose", "CLAUDE_CODE_ENTRYPOINT": "cli"}, + contains: "AI-Agent (name=claude-code, entry=cli)", + }, + "includes AI-Agent suffix for AGENT env var": { + envVars: map[string]string{"AGENT": "goose"}, + contains: "AI-Agent (name=goose)", + }, + "includes AI-Agent suffix for Claude Code with entrypoint": { envVars: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "cli"}, - expected: &AIAgent{Name: "claude-code", Entry: "cli"}, + contains: "AI-Agent (name=claude-code, entry=cli)", }, - "detects Claude Code with vscode entrypoint": { + "includes AI-Agent suffix for Claude Code with vscode entrypoint": { envVars: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "vscode"}, - expected: &AIAgent{Name: "claude-code", Entry: "vscode"}, + contains: "AI-Agent (name=claude-code, entry=vscode)", }, - "detects Claude Code without entrypoint": { + "includes AI-Agent suffix for Claude Code without entrypoint": { envVars: map[string]string{"CLAUDECODE": "1"}, - expected: &AIAgent{Name: "claude-code", Entry: ""}, - }, - "detects Codex": { - envVars: map[string]string{"CODEX_CI": "1"}, - expected: &AIAgent{Name: "codex"}, - }, - "detects Gemini CLI": { - envVars: map[string]string{"GEMINI_CLI": "1"}, - expected: &AIAgent{Name: "gemini-cli"}, + contains: "AI-Agent (name=claude-code)", }, - "detects Cline": { + "includes AI-Agent suffix for Cline": { envVars: map[string]string{"CLINE_ACTIVE": "true"}, - expected: &AIAgent{Name: "cline"}, + contains: "AI-Agent (name=cline)", }, - "detects Cursor": { + "includes AI-Agent suffix for Codex": { + envVars: map[string]string{"CODEX_CI": "1"}, + contains: "AI-Agent (name=codex)", + }, + "includes AI-Agent suffix for Cursor": { envVars: map[string]string{"CURSOR_AGENT": "1"}, - expected: &AIAgent{Name: "cursor"}, + contains: "AI-Agent (name=cursor)", }, - "detects agent via AGENT env var": { - envVars: map[string]string{"AGENT": "goose"}, - expected: &AIAgent{Name: "goose"}, + "includes AI-Agent suffix for Gemini CLI": { + envVars: map[string]string{"GEMINI_CLI": "1"}, + contains: "AI-Agent (name=gemini-cli)", }, - "detects unknown agent via AGENT env var": { + "includes AI-Agent suffix for unknown agent": { envVars: map[string]string{"AGENT": "future-agent"}, - expected: &AIAgent{Name: "future-agent"}, - }, - "returns nil when no agent detected": { - envVars: map[string]string{}, - expected: nil, - }, - "CLAUDECODE takes priority over AGENT": { - envVars: map[string]string{"CLAUDECODE": "1", "AGENT": "goose"}, - expected: &AIAgent{Name: "claude-code", Entry: ""}, + contains: "AI-Agent (name=future-agent)", }, - "CODEX_CI takes priority over AGENT": { - envVars: map[string]string{"CODEX_CI": "1", "AGENT": "goose"}, - expected: &AIAgent{Name: "codex"}, + "no AI-Agent suffix when no agent detected": { + envVars: map[string]string{}, + noAgent: true, }, } for name, tc := range tests { @@ -87,65 +84,70 @@ func Test_UserAgent_Detect(t *testing.T) { for k, v := range tc.envVars { t.Setenv(k, v) } - result := Detect() - if tc.expected == nil { - assert.Nil(t, result) - } else { - assert.Equal(t, tc.expected, result) + ua := BuildUserAgent("2.38.1") + assert.Contains(t, ua, "slack-cli/2.38.1") + if tc.contains != "" { + assert.Contains(t, ua, tc.contains) + } + if tc.noAgent { + assert.NotContains(t, ua, "AI-Agent") } }) } } -func Test_UserAgent_BuildUserAgent(t *testing.T) { +func Test_UserAgent_Detect(t *testing.T) { tests := map[string]struct { envVars map[string]string - contains string - noAgent bool + expected *AIAgent }{ - "includes AI-Agent suffix for Claude Code with entrypoint": { + "CLAUDECODE takes priority over AGENT": { + envVars: map[string]string{"CLAUDECODE": "1", "AGENT": "goose"}, + expected: &AIAgent{Name: "claude-code", Entry: ""}, + }, + "CODEX_CI takes priority over AGENT": { + envVars: map[string]string{"CODEX_CI": "1", "AGENT": "goose"}, + expected: &AIAgent{Name: "codex"}, + }, + "detects agent via AGENT env var": { + envVars: map[string]string{"AGENT": "goose"}, + expected: &AIAgent{Name: "goose"}, + }, + "detects Claude Code": { envVars: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "cli"}, - contains: "AI-Agent (name=claude-code, entry=cli)", + expected: &AIAgent{Name: "claude-code", Entry: "cli"}, }, - "includes AI-Agent suffix for Claude Code with vscode entrypoint": { + "detects Claude Code with vscode entrypoint": { envVars: map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "vscode"}, - contains: "AI-Agent (name=claude-code, entry=vscode)", + expected: &AIAgent{Name: "claude-code", Entry: "vscode"}, }, - "includes AI-Agent suffix for Claude Code without entrypoint": { + "detects Claude Code without entrypoint": { envVars: map[string]string{"CLAUDECODE": "1"}, - contains: "AI-Agent (name=claude-code)", - }, - "includes AI-Agent suffix for Codex": { - envVars: map[string]string{"CODEX_CI": "1"}, - contains: "AI-Agent (name=codex)", - }, - "includes AI-Agent suffix for Gemini CLI": { - envVars: map[string]string{"GEMINI_CLI": "1"}, - contains: "AI-Agent (name=gemini-cli)", + expected: &AIAgent{Name: "claude-code", Entry: ""}, }, - "includes AI-Agent suffix for Cline": { + "detects Cline": { envVars: map[string]string{"CLINE_ACTIVE": "true"}, - contains: "AI-Agent (name=cline)", + expected: &AIAgent{Name: "cline"}, }, - "includes AI-Agent suffix for Cursor": { + "detects Codex": { + envVars: map[string]string{"CODEX_CI": "1"}, + expected: &AIAgent{Name: "codex"}, + }, + "detects Cursor": { envVars: map[string]string{"CURSOR_AGENT": "1"}, - contains: "AI-Agent (name=cursor)", + expected: &AIAgent{Name: "cursor"}, }, - "includes AI-Agent suffix for AGENT env var": { - envVars: map[string]string{"AGENT": "goose"}, - contains: "AI-Agent (name=goose)", + "detects Gemini CLI": { + envVars: map[string]string{"GEMINI_CLI": "1"}, + expected: &AIAgent{Name: "gemini-cli"}, }, - "includes AI-Agent suffix for unknown agent": { + "detects unknown agent via AGENT env var": { envVars: map[string]string{"AGENT": "future-agent"}, - contains: "AI-Agent (name=future-agent)", - }, - "CLAUDECODE takes priority over AGENT": { - envVars: map[string]string{"CLAUDECODE": "1", "AGENT": "goose", "CLAUDE_CODE_ENTRYPOINT": "cli"}, - contains: "AI-Agent (name=claude-code, entry=cli)", + expected: &AIAgent{Name: "future-agent"}, }, - "no AI-Agent suffix when no agent detected": { - envVars: map[string]string{}, - noAgent: true, + "returns nil when no agent detected": { + envVars: map[string]string{}, + expected: nil, }, } for name, tc := range tests { @@ -154,13 +156,11 @@ func Test_UserAgent_BuildUserAgent(t *testing.T) { for k, v := range tc.envVars { t.Setenv(k, v) } - ua := BuildUserAgent("2.38.1") - assert.Contains(t, ua, "slack-cli/2.38.1") - if tc.contains != "" { - assert.Contains(t, ua, tc.contains) - } - if tc.noAgent { - assert.NotContains(t, ua, "AI-Agent") + result := Detect() + if tc.expected == nil { + assert.Nil(t, result) + } else { + assert.Equal(t, tc.expected, result) } }) } From 5bd7a8c7efe82d5146543fde1561b07c18196a0c Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Fri, 15 May 2026 16:11:55 -0700 Subject: [PATCH 9/9] style: remove stray blank line in raw_request.go import block --- internal/api/raw_request.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/api/raw_request.go b/internal/api/raw_request.go index 6d093455..63ab8aa7 100644 --- a/internal/api/raw_request.go +++ b/internal/api/raw_request.go @@ -21,7 +21,6 @@ import ( "io" "net/http" "net/url" - "time" "github.com/opentracing/opentracing-go"