Skip to content

Commit 2281e96

Browse files
Use std-env for agent detection in telemetry
Co-authored-by: valentinpalkovic <5889929+valentinpalkovic@users.noreply.github.com>
1 parent 661ac9d commit 2281e96

File tree

5 files changed

+58
-150
lines changed

5 files changed

+58
-150
lines changed

code/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@
357357
"sirv": "^2.0.4",
358358
"slash": "^5.0.0",
359359
"source-map": "^0.7.4",
360+
"std-env": "^4.0.0",
360361
"store2": "^2.14.2",
361362
"strip-ansi": "^7.1.0",
362363
"strip-json-comments": "^5.0.1",
Lines changed: 38 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,65 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
22

33
import { detectAgent } from './detect-agent';
44

55
describe('detectAgent', () => {
6-
it('detects amp via AGENT=amp (highest precedence)', () => {
7-
expect(
8-
detectAgent({
9-
stdoutIsTTY: true,
10-
env: {
11-
AGENT: 'amp',
12-
CLAUDECODE: '1',
13-
GEMINI_CLI: '1',
14-
CODEX_SANDBOX: '1',
15-
CURSOR_AGENT: '1',
16-
},
17-
})
18-
).toEqual({ name: 'amp' });
6+
afterEach(() => {
7+
vi.unstubAllEnvs();
8+
});
199

20-
expect(
21-
detectAgent({
22-
stdoutIsTTY: true,
23-
env: {
24-
CLAUDECODE: '1',
25-
GEMINI_CLI: '1',
26-
CODEX_SANDBOX: '1',
27-
CURSOR_AGENT: '1',
28-
AGENT: 'something',
29-
},
30-
})
31-
).toEqual({ name: 'claude-code' });
10+
it('detects claude via CLAUDECODE', () => {
11+
vi.stubEnv('CLAUDECODE', '1');
12+
expect(detectAgent()).toEqual({ name: 'claude' });
3213
});
3314

34-
it('detects Gemini CLI via GEMINI_CLI', () => {
35-
expect(detectAgent({ stdoutIsTTY: true, env: { GEMINI_CLI: '1' } })).toEqual({
36-
name: 'gemini-cli',
37-
});
15+
it('detects claude via CLAUDE_CODE', () => {
16+
vi.stubEnv('CLAUDE_CODE', '1');
17+
expect(detectAgent()).toEqual({ name: 'claude' });
3818
});
3919

40-
it('detects OpenAI Codex via CODEX_SANDBOX', () => {
41-
expect(detectAgent({ stdoutIsTTY: true, env: { CODEX_SANDBOX: '1' } })).toEqual({
42-
name: 'codex',
43-
});
20+
it('detects gemini via GEMINI_CLI', () => {
21+
vi.stubEnv('GEMINI_CLI', '1');
22+
expect(detectAgent()).toEqual({ name: 'gemini' });
4423
});
4524

46-
it('detects Cursor Agent via CURSOR_AGENT (even if AGENT is also set)', () => {
47-
expect(
48-
detectAgent({ stdoutIsTTY: true, env: { CURSOR_AGENT: '1', AGENT: 'something' } })
49-
).toEqual({
50-
name: 'cursor',
51-
});
25+
it('detects codex via CODEX_SANDBOX', () => {
26+
vi.stubEnv('CODEX_SANDBOX', '1');
27+
expect(detectAgent()).toEqual({ name: 'codex' });
5228
});
5329

54-
it('treats generic AGENT as unknown', () => {
55-
expect(detectAgent({ stdoutIsTTY: true, env: { AGENT: 'some-agent' } })).toEqual({
56-
name: 'unknown',
57-
});
30+
it('detects codex via CODEX_THREAD_ID', () => {
31+
vi.stubEnv('CODEX_THREAD_ID', '1');
32+
expect(detectAgent()).toEqual({ name: 'codex' });
5833
});
5934

60-
it('does not use heuristics when stdout is a TTY', () => {
61-
expect(detectAgent({ stdoutIsTTY: true, env: { TERM: 'dumb' } })).toEqual(undefined);
62-
expect(detectAgent({ stdoutIsTTY: true, env: { GIT_PAGER: 'cat' } })).toEqual(undefined);
35+
it('detects cursor via CURSOR_AGENT', () => {
36+
vi.stubEnv('CURSOR_AGENT', '1');
37+
expect(detectAgent()).toEqual({ name: 'cursor' });
6338
});
6439

65-
it('detects unknown agent via TERM=dumb when stdout is not a TTY', () => {
66-
expect(detectAgent({ stdoutIsTTY: false, env: { TERM: 'dumb' } })).toEqual({
67-
name: 'unknown',
68-
});
40+
it('detects opencode via OPENCODE', () => {
41+
vi.stubEnv('OPENCODE', '1');
42+
expect(detectAgent()).toEqual({ name: 'opencode' });
6943
});
7044

71-
it('detects unknown agent via GIT_PAGER=cat when stdout is not a TTY', () => {
72-
expect(detectAgent({ stdoutIsTTY: false, env: { GIT_PAGER: 'cat' } })).toEqual({
73-
name: 'unknown',
74-
});
45+
it('detects explicit agent via AI_AGENT env var', () => {
46+
vi.stubEnv('AI_AGENT', 'copilot');
47+
expect(detectAgent()).toEqual({ name: 'copilot' });
7548
});
7649

77-
it('returns isAgent=false when there are no signals', () => {
78-
expect(detectAgent({ stdoutIsTTY: false, env: {} })).toEqual(undefined);
50+
it('normalizes AI_AGENT to lowercase', () => {
51+
vi.stubEnv('AI_AGENT', 'Copilot');
52+
expect(detectAgent()).toEqual({ name: 'copilot' });
7953
});
8054

81-
it('applies heuristics even when CI is set (no CI special-casing)', () => {
82-
expect(
83-
detectAgent({
84-
stdoutIsTTY: false,
85-
env: { CI: 'true', TERM: 'dumb' },
86-
})
87-
).toEqual({ name: 'unknown' });
55+
it('AI_AGENT takes precedence over other env vars', () => {
56+
vi.stubEnv('AI_AGENT', 'copilot');
57+
vi.stubEnv('CLAUDECODE', '1');
58+
vi.stubEnv('GEMINI_CLI', '1');
59+
expect(detectAgent()).toEqual({ name: 'copilot' });
8860
});
8961

90-
it('still detects explicit agents in CI', () => {
91-
expect(detectAgent({ stdoutIsTTY: false, env: { CI: 'true', CODEX_SANDBOX: '1' } })).toEqual({
92-
name: 'codex',
93-
});
62+
it('returns undefined when there are no signals', () => {
63+
expect(detectAgent()).toEqual(undefined);
9464
});
9565
});
Lines changed: 10 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,90 +1,19 @@
1-
export type KnownAgentName =
2-
| 'claude-code'
3-
| 'gemini-cli'
4-
| 'cursor'
5-
| 'codex'
6-
| 'opencode'
7-
| 'amp'
8-
| 'unknown';
1+
import { detectAgent as stdEnvDetectAgent } from 'std-env';
92

103
export type AgentInfo = {
11-
name: KnownAgentName;
4+
/** The name of the detected AI coding agent (e.g. `claude`, `gemini`, `codex`, `cursor`).
5+
* Can be any value supported by std-env or explicitly set via the `AI_AGENT` environment variable.
6+
*/
7+
name: string;
128
};
139

1410
export type AgentDetection = AgentInfo | undefined;
1511

16-
type DetectAgentOptions = {
17-
stdoutIsTTY: boolean;
18-
env: NodeJS.ProcessEnv;
19-
};
20-
21-
function detectExplicitAgent(env: NodeJS.ProcessEnv): AgentInfo | undefined {
22-
// Amp
23-
if (env.AGENT === 'amp') {
24-
return {
25-
name: 'amp',
26-
};
27-
}
28-
29-
// Claude Code
30-
if (env.CLAUDECODE) {
31-
return {
32-
name: 'claude-code',
33-
};
34-
}
35-
36-
// Gemini CLI
37-
if (env.GEMINI_CLI) {
38-
return {
39-
name: 'gemini-cli',
40-
};
41-
}
42-
43-
// OpenAI Codex
44-
if (env.CODEX_SANDBOX) {
45-
return {
46-
name: 'codex',
47-
};
48-
}
49-
50-
// Cursor Agent (proposed / best-effort; Cursor often sets VSCode env vars too)
51-
if (env.CURSOR_AGENT) {
52-
return {
53-
name: 'cursor',
54-
};
55-
}
56-
57-
// Generic "AGENT" marker (unknown implementation)
58-
if (env.AGENT) {
59-
return { name: 'unknown' };
60-
}
61-
62-
return undefined;
63-
}
64-
65-
/** Detect whether Storybook CLI is likely being invoked by an AI agent. */
66-
export const detectAgent = (options: DetectAgentOptions): AgentDetection => {
67-
const env = options.env;
68-
69-
// 1) Explicit agent variables (strong signal; allow even in CI/TTY)
70-
const explicit = detectExplicitAgent(env);
71-
if (explicit) {
72-
return explicit;
73-
}
74-
75-
const stdoutIsTTY = options.stdoutIsTTY;
76-
77-
// 2) Behavioral / fingerprint heuristics (exclude CI to reduce false positives)
78-
if (stdoutIsTTY) {
12+
/** Detect whether Storybook CLI is likely being invoked by an AI agent, using std-env. */
13+
export const detectAgent = (): AgentDetection => {
14+
const { name } = stdEnvDetectAgent();
15+
if (!name) {
7916
return undefined;
8017
}
81-
82-
const isDumbTerm = env.TERM === 'dumb';
83-
const hasAgentPager = env.GIT_PAGER === 'cat';
84-
85-
if (isDumbTerm || hasAgentPager) {
86-
return { name: 'unknown' };
87-
}
88-
89-
return undefined;
18+
return { name };
9019
};

code/core/src/telemetry/telemetry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const getOperatingSystem = (): 'Windows' | 'macOS' | 'Linux' | `Other: ${string}
5151
// by the app. currently:
5252
// - cliVersion
5353
const inCI = isCI();
54-
const agentDetection = detectAgent({ stdoutIsTTY: process.stdout.isTTY, env: process.env });
54+
const agentDetection = detectAgent();
5555
const globalContext = {
5656
inCI,
5757
isTTY: process.stdout.isTTY,

yarn.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28255,6 +28255,13 @@ __metadata:
2825528255
languageName: node
2825628256
linkType: hard
2825728257

28258+
"std-env@npm:^4.0.0":
28259+
version: 4.0.0
28260+
resolution: "std-env@npm:4.0.0"
28261+
checksum: 10c0/63b1716eae27947adde49e21b7225a0f75fb2c3d410273ae9de8333c07c7d5fc7a0628ae4c8af6b4b49b4274ed46c2bf118ed69b64f1261c9d8213d76ed1c16c
28262+
languageName: node
28263+
linkType: hard
28264+
2825828265
"steno@npm:^0.4.1":
2825928266
version: 0.4.4
2826028267
resolution: "steno@npm:0.4.4"
@@ -28422,6 +28429,7 @@ __metadata:
2842228429
sirv: "npm:^2.0.4"
2842328430
slash: "npm:^5.0.0"
2842428431
source-map: "npm:^0.7.4"
28432+
std-env: "npm:^4.0.0"
2842528433
store2: "npm:^2.14.2"
2842628434
strip-ansi: "npm:^7.1.0"
2842728435
strip-json-comments: "npm:^5.0.1"

0 commit comments

Comments
 (0)