Skip to content

Commit a9a85ac

Browse files
[PECOBLR-1928] Add AI coding agent detection to User-Agent header (#326)
## Summary - Adds `internal/agent` package that detects 7 AI coding agents (Claude Code, Cursor, Gemini CLI, Cline, Codex, OpenCode, Antigravity) by checking well-known environment variables they set in spawned shell processes - Integrates detection into `InitThriftClient` to append `agent/<product>` to the User-Agent header - Uses exactly-one detection rule: if zero or multiple agent env vars are set, no agent is attributed (avoids ambiguity) ## Approach Mirrors the implementation in [databricks/cli#4287](databricks/cli#4287) and aligns with the latest agent list in [`libs/agent/agent.go`](https://github.com/databricks/cli/blob/main/libs/agent/agent.go#L35). | Agent | Product String | Environment Variable | |-------|---------------|---------------------| | Google Antigravity | `antigravity` | `ANTIGRAVITY_AGENT` | | Claude Code | `claude-code` | `CLAUDECODE` | | Cline | `cline` | `CLINE_ACTIVE` | | OpenAI Codex | `codex` | `CODEX_CI` | | Cursor | `cursor` | `CURSOR_AGENT` | | Gemini CLI | `gemini-cli` | `GEMINI_CLI` | | OpenCode | `opencode` | `OPENCODE` | Adding a new agent requires only a new constant and a new entry in `knownAgents`. ## Changes - **New**: `internal/agent/agent.go` — environment-variable-based agent detection with injectable env lookup for testability - **New**: `internal/agent/agent_test.go` — 11 test cases covering all agents, no agent, multiple agents, empty values, and real `os.Getenv` - **Modified**: `internal/client/client.go` — calls `agent.Detect()` when building User-Agent in `InitThriftClient` ## Test plan - [x] `internal/agent` — 11 tests pass - [x] `internal/client` — all existing tests continue to pass - [x] Manual: verified User-Agent contains `agent/claude-code` when run from Claude Code via `GODEBUG=http2debug=2` ``` http2: Transport encoding header "user-agent" = "godatabrickssqlconnector/1.10.0 agent/claude-code" ``` - [x] Executed `SELECT 1` successfully against dogfood warehouse with the new header 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Vikrant Puppala <vikrant.puppala@databricks.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6dd935f commit a9a85ac

File tree

3 files changed

+141
-0
lines changed

3 files changed

+141
-0
lines changed

internal/agent/agent.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Package agent detects whether the Go SQL driver is being invoked by an AI
2+
// coding agent by checking for well-known environment variables that agents set
3+
// in their spawned shell processes.
4+
//
5+
// Detection only succeeds when exactly one agent environment variable is
6+
// present, to avoid ambiguous attribution when multiple agent environments
7+
// overlap.
8+
//
9+
// Adding a new agent requires only a new constant and a new entry in
10+
// knownAgents.
11+
//
12+
// References for each environment variable:
13+
// - ANTIGRAVITY_AGENT: Closed source. Google Antigravity sets this variable.
14+
// - CLAUDECODE: https://github.com/anthropics/claude-code (sets CLAUDECODE=1)
15+
// - CLINE_ACTIVE: https://github.com/cline/cline (shipped in v3.24.0)
16+
// - CODEX_CI: https://github.com/openai/codex (part of UNIFIED_EXEC_ENV array in codex-rs)
17+
// - CURSOR_AGENT: Closed source. Referenced in a gist by johnlindquist.
18+
// - GEMINI_CLI: https://google-gemini.github.io/gemini-cli/docs/tools/shell.html (sets GEMINI_CLI=1)
19+
// - OPENCODE: https://github.com/opencode-ai/opencode (sets OPENCODE=1)
20+
package agent
21+
22+
import "os"
23+
24+
const (
25+
Antigravity = "antigravity"
26+
ClaudeCode = "claude-code"
27+
Cline = "cline"
28+
Codex = "codex"
29+
Cursor = "cursor"
30+
GeminiCLI = "gemini-cli"
31+
OpenCode = "opencode"
32+
)
33+
34+
var knownAgents = []struct {
35+
envVar string
36+
product string
37+
}{
38+
{"ANTIGRAVITY_AGENT", Antigravity},
39+
{"CLAUDECODE", ClaudeCode},
40+
{"CLINE_ACTIVE", Cline},
41+
{"CODEX_CI", Codex},
42+
{"CURSOR_AGENT", Cursor},
43+
{"GEMINI_CLI", GeminiCLI},
44+
{"OPENCODE", OpenCode},
45+
}
46+
47+
// Detect returns the product string of the AI coding agent driving the current
48+
// process, or an empty string if no agent (or multiple agents) are detected.
49+
func Detect() string {
50+
return detect(os.Getenv)
51+
}
52+
53+
// detect is the internal implementation that accepts an env lookup function
54+
// for testability.
55+
func detect(getenv func(string) string) string {
56+
var detected []string
57+
for _, a := range knownAgents {
58+
if getenv(a.envVar) != "" {
59+
detected = append(detected, a.product)
60+
}
61+
}
62+
if len(detected) == 1 {
63+
return detected[0]
64+
}
65+
return ""
66+
}

internal/agent/agent_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package agent
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func envWith(vars map[string]string) func(string) string {
8+
return func(key string) string {
9+
return vars[key]
10+
}
11+
}
12+
13+
func TestDetectsSingleAgent(t *testing.T) {
14+
cases := []struct {
15+
envVar string
16+
product string
17+
}{
18+
{"ANTIGRAVITY_AGENT", Antigravity},
19+
{"CLAUDECODE", ClaudeCode},
20+
{"CLINE_ACTIVE", Cline},
21+
{"CODEX_CI", Codex},
22+
{"CURSOR_AGENT", Cursor},
23+
{"GEMINI_CLI", GeminiCLI},
24+
{"OPENCODE", OpenCode},
25+
}
26+
for _, tc := range cases {
27+
t.Run(tc.product, func(t *testing.T) {
28+
got := detect(envWith(map[string]string{tc.envVar: "1"}))
29+
if got != tc.product {
30+
t.Errorf("detect() = %q, want %q", got, tc.product)
31+
}
32+
})
33+
}
34+
}
35+
36+
func TestReturnsEmptyWhenNoAgent(t *testing.T) {
37+
got := detect(envWith(map[string]string{}))
38+
if got != "" {
39+
t.Errorf("detect() = %q, want empty", got)
40+
}
41+
}
42+
43+
func TestReturnsEmptyWhenMultipleAgents(t *testing.T) {
44+
got := detect(envWith(map[string]string{
45+
"CLAUDECODE": "1",
46+
"CURSOR_AGENT": "1",
47+
}))
48+
if got != "" {
49+
t.Errorf("detect() = %q, want empty", got)
50+
}
51+
}
52+
53+
func TestIgnoresEmptyValues(t *testing.T) {
54+
got := detect(envWith(map[string]string{"CLAUDECODE": ""}))
55+
if got != "" {
56+
t.Errorf("detect() = %q, want empty", got)
57+
}
58+
}
59+
60+
func TestDetectUsesOsGetenv(t *testing.T) {
61+
// Clear all known agent env vars, then set one
62+
for _, a := range knownAgents {
63+
t.Setenv(a.envVar, "")
64+
}
65+
t.Setenv("CLAUDECODE", "1")
66+
67+
got := Detect()
68+
if got != ClaudeCode {
69+
t.Errorf("Detect() = %q, want %q", got, ClaudeCode)
70+
}
71+
}

internal/client/client.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"time"
2121

2222
dbsqlerr "github.com/databricks/databricks-sql-go/errors"
23+
"github.com/databricks/databricks-sql-go/internal/agent"
2324
dbsqlerrint "github.com/databricks/databricks-sql-go/internal/errors"
2425

2526
"github.com/apache/thrift/lib/go/thrift"
@@ -295,6 +296,9 @@ func InitThriftClient(cfg *config.Config, httpclient *http.Client) (*ThriftServi
295296
if cfg.UserAgentEntry != "" {
296297
userAgent = fmt.Sprintf("%s/%s (%s)", cfg.DriverName, cfg.DriverVersion, cfg.UserAgentEntry)
297298
}
299+
if agentProduct := agent.Detect(); agentProduct != "" {
300+
userAgent = fmt.Sprintf("%s agent/%s", userAgent, agentProduct)
301+
}
298302
thriftHttpClient.SetHeader("User-Agent", userAgent)
299303

300304
default:

0 commit comments

Comments
 (0)