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 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..9d17b654 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -20,10 +20,10 @@ 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 +59,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..fcf3251d 100644 --- a/internal/api/icon.go +++ b/internal/api/icon.go @@ -24,12 +24,12 @@ 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 +132,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..63ab8aa7 100644 --- a/internal/api/raw_request.go +++ b/internal/api/raw_request.go @@ -21,11 +21,11 @@ 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 +67,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..3ff7f1fa 100644 --- a/internal/api/s3_upload.go +++ b/internal/api/s3_upload.go @@ -22,11 +22,11 @@ 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 +100,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..a0f58a16 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 { + 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 1dca61e0..9f246eae 100644 --- a/internal/tracking/tracking.go +++ b/internal/tracking/tracking.go @@ -32,6 +32,7 @@ import ( "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 @@ -194,6 +195,7 @@ func (e *EventTracker) FlushToLogstash(ctx context.Context, cfg *config.Config, Timestamp: time.Now().UnixMilli(), Data: eventData, Context: EventContext{ + AIAgent: useragent.DetectName(), CLIVersion: versionString, Host: ioutils.GetHostname(), OS: runtime.GOOS, diff --git a/internal/useragent/useragent.go b/internal/useragent/useragent.go new file mode 100644 index 00000000..908f4ab6 --- /dev/null +++ b/internal/useragent/useragent.go @@ -0,0 +1,77 @@ +// 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" +) + +// 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": + 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 + } +} + +// 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 + } + 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 { + 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..dfcf5ff0 --- /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_UserAgent_BuildUserAgent(t *testing.T) { + tests := map[string]struct { + envVars map[string]string + contains string + noAgent bool + }{ + "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"}, + 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 Cline": { + envVars: map[string]string{"CLINE_ACTIVE": "true"}, + contains: "AI-Agent (name=cline)", + }, + "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"}, + contains: "AI-Agent (name=cursor)", + }, + "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 unknown agent": { + envVars: map[string]string{"AGENT": "future-agent"}, + contains: "AI-Agent (name=future-agent)", + }, + "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") + } + }) + } +} + +func Test_UserAgent_Detect(t *testing.T) { + tests := map[string]struct { + envVars map[string]string + expected *AIAgent + }{ + "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"}, + 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 Cline": { + envVars: map[string]string{"CLINE_ACTIVE": "true"}, + expected: &AIAgent{Name: "cline"}, + }, + "detects Codex": { + envVars: map[string]string{"CODEX_CI": "1"}, + expected: &AIAgent{Name: "codex"}, + }, + "detects Cursor": { + envVars: map[string]string{"CURSOR_AGENT": "1"}, + expected: &AIAgent{Name: "cursor"}, + }, + "detects Gemini CLI": { + envVars: map[string]string{"GEMINI_CLI": "1"}, + expected: &AIAgent{Name: "gemini-cli"}, + }, + "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, + }, + } + 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) + } + }) + } +} diff --git a/main.go b/main.go index b79aebf6..e3815a62 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,7 @@ import ( "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" ) @@ -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 := useragent.DetectName(); agentName != "" { + span.SetTag("ai_agent", agentName) + } // system_id is set in root.go initConfig() // project_id is set in root.go initConfig()