Skip to content

Commit 1df465c

Browse files
feat: detect operator (agent/CI/manual) and send as x-checkly-operator header [TIM-14] (#1213)
Detect who/what is driving the CLI by checking known environment variables for AI agents (Claude Code, Cursor, Copilot, etc.) and CI systems. Sends the result as x-checkly-operator on all API requests. Also sends x-checkly-source: CLI explicitly (previously inferred via x-checkly-cli-version on the backend side).
1 parent 2bf6477 commit 1df465c

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { detectOperator } from '../api'
3+
4+
describe('detectOperator', () => {
5+
const envVarsToClean = [
6+
'CLAUDE_CODE',
7+
'CURSOR_TRACE_ID',
8+
'TERM_PROGRAM',
9+
'GITHUB_COPILOT',
10+
'AIDER',
11+
'WINDSURF',
12+
'CODEIUM_ENV',
13+
'GITHUB_ACTIONS',
14+
'GITLAB_CI',
15+
'CI',
16+
]
17+
18+
beforeEach(() => {
19+
for (const key of envVarsToClean) {
20+
delete process.env[key]
21+
}
22+
})
23+
24+
it('returns "manual" when no agent env vars are set', () => {
25+
expect(detectOperator()).toBe('manual')
26+
})
27+
28+
it('detects Claude Code', () => {
29+
process.env.CLAUDE_CODE = '1'
30+
expect(detectOperator()).toBe('claude-code')
31+
})
32+
33+
it('detects Cursor', () => {
34+
process.env.CURSOR_TRACE_ID = 'some-trace-id'
35+
expect(detectOperator()).toBe('cursor')
36+
})
37+
38+
it('detects VS Code', () => {
39+
process.env.TERM_PROGRAM = 'vscode'
40+
expect(detectOperator()).toBe('vscode')
41+
})
42+
43+
it('does not detect VS Code for other TERM_PROGRAM values', () => {
44+
process.env.TERM_PROGRAM = 'iTerm.app'
45+
expect(detectOperator()).toBe('manual')
46+
})
47+
48+
it('detects GitHub Copilot', () => {
49+
process.env.GITHUB_COPILOT = '1'
50+
expect(detectOperator()).toBe('github-copilot')
51+
})
52+
53+
it('detects Aider', () => {
54+
process.env.AIDER = '1'
55+
expect(detectOperator()).toBe('aider')
56+
})
57+
58+
it('detects Windsurf via WINDSURF env var', () => {
59+
process.env.WINDSURF = '1'
60+
expect(detectOperator()).toBe('windsurf')
61+
})
62+
63+
it('detects Windsurf via CODEIUM_ENV env var', () => {
64+
process.env.CODEIUM_ENV = 'production'
65+
expect(detectOperator()).toBe('windsurf')
66+
})
67+
68+
it('detects GitHub Actions', () => {
69+
process.env.GITHUB_ACTIONS = 'true'
70+
expect(detectOperator()).toBe('github-actions')
71+
})
72+
73+
it('detects GitLab CI', () => {
74+
process.env.GITLAB_CI = 'true'
75+
expect(detectOperator()).toBe('gitlab-ci')
76+
})
77+
78+
it('detects generic CI', () => {
79+
process.env.CI = 'true'
80+
expect(detectOperator()).toBe('ci')
81+
})
82+
83+
it('prioritizes Claude Code over CI', () => {
84+
process.env.CLAUDE_CODE = '1'
85+
process.env.CI = 'true'
86+
expect(detectOperator()).toBe('claude-code')
87+
})
88+
89+
it('prioritizes GitHub Actions over generic CI', () => {
90+
process.env.GITHUB_ACTIONS = 'true'
91+
process.env.CI = 'true'
92+
expect(detectOperator()).toBe('github-actions')
93+
})
94+
})

packages/cli/src/rest/api.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ export async function validateAuthentication (): Promise<Account | undefined> {
5454
}
5555
}
5656

57+
export function detectOperator (): string {
58+
if (process.env.CLAUDE_CODE) return 'claude-code'
59+
if (process.env.CURSOR_TRACE_ID) return 'cursor'
60+
if (process.env.TERM_PROGRAM === 'vscode') return 'vscode'
61+
if (process.env.GITHUB_COPILOT) return 'github-copilot'
62+
if (process.env.AIDER) return 'aider'
63+
if (process.env.WINDSURF || process.env.CODEIUM_ENV) return 'windsurf'
64+
if (process.env.GITHUB_ACTIONS) return 'github-actions'
65+
if (process.env.GITLAB_CI) return 'gitlab-ci'
66+
if (process.env.CI) return 'ci'
67+
return 'manual'
68+
}
69+
5770
export function requestInterceptor (config: InternalAxiosRequestConfig) {
5871
const { Authorization, accountId } = getDefaults()
5972
if (Authorization && config.headers) {
@@ -64,7 +77,9 @@ export function requestInterceptor (config: InternalAxiosRequestConfig) {
6477
config.headers['x-checkly-account'] = accountId
6578
}
6679

80+
config.headers['x-checkly-source'] = 'CLI'
6781
config.headers['x-checkly-ci-name'] = CIname
82+
config.headers['x-checkly-operator'] = detectOperator()
6883

6984
return config
7085
}

0 commit comments

Comments
 (0)