|
1 | | -import { beforeEach, describe, expect, it } from 'vitest' |
| 1 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' |
2 | 2 | import { detectOperator, detectCliMode } from '../cli-mode.js' |
3 | 3 |
|
4 | 4 | const operatorEnvVars = [ |
5 | | - 'CLAUDECODE', 'CURSOR_TRACE_ID', 'CURSOR_AGENT', 'TERM_PROGRAM', 'GITHUB_COPILOT', |
6 | | - 'AIDER', 'WINDSURF', 'CODEIUM_ENV', 'CLINE_ACTIVE', 'CODEX_SANDBOX', 'CODEX_THREAD_ID', |
7 | | - 'GEMINI_CLI', 'OPENCODE', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CI', |
8 | | -] |
| 5 | + 'AI_AGENT', 'AGENT', 'CLAUDECODE', 'CLAUDE_CODE', 'CLAUDE_CODE_IS_COWORK', |
| 6 | + 'AMP_CURRENT_THREAD_ID', 'CURSOR_TRACE_ID', 'CURSOR_AGENT', 'CURSOR_EXTENSION_HOST_ROLE', |
| 7 | + 'TERM_PROGRAM', 'VSCODE_AGENT', 'COPILOT_AGENT', 'COPILOT_CLI', 'GITHUB_COPILOT', |
| 8 | + 'COPILOT_MODEL', 'COPILOT_ALLOW_ALL', 'COPILOT_GITHUB_TOKEN', 'AIDER', 'WINDSURF', |
| 9 | + 'WINDSURF_AGENT', 'CODEIUM_ENV', 'CLINE_ACTIVE', 'CODEX_SANDBOX', 'CODEX_CI', |
| 10 | + 'CODEX_THREAD_ID', 'GEMINI_CLI', 'OPENCODE', 'ANTIGRAVITY_AGENT', 'AUGMENT_AGENT', |
| 11 | + 'ANDROID_STUDIO_AGENT', 'KIRO_AGENT_PATH', 'TRAE_AI_SHELL_ID', 'GOOSE_TERMINAL', |
| 12 | + 'GITHUB_AW', 'REPL_ID', 'MONOSPACE_ENV', 'GITHUB_ACTIONS', 'GITLAB_CI', 'CI', |
| 13 | + 'CHECKLY_CLI_MODE', 'HOME', 'PATH', |
| 14 | +] as const |
| 15 | + |
| 16 | +const originalEnv = Object.fromEntries(operatorEnvVars.map(key => [key, process.env[key]])) |
| 17 | +const noAgentFiles = () => false |
| 18 | + |
| 19 | +beforeEach(() => { |
| 20 | + for (const key of operatorEnvVars) delete process.env[key] |
| 21 | +}) |
9 | 22 |
|
10 | | -describe('detectOperator', () => { |
11 | | - beforeEach(() => { |
12 | | - for (const key of operatorEnvVars) delete process.env[key] |
13 | | - }) |
| 23 | +afterEach(() => { |
| 24 | + for (const key of operatorEnvVars) { |
| 25 | + const value = originalEnv[key] |
| 26 | + if (value === undefined) delete process.env[key] |
| 27 | + else process.env[key] = value |
| 28 | + } |
| 29 | +}) |
14 | 30 |
|
| 31 | +describe('detectOperator', () => { |
15 | 32 | it('returns "manual" when no agent env vars are set', () => { |
16 | | - expect(detectOperator()).toBe('manual') |
| 33 | + expect(detectOperator(noAgentFiles)).toBe('manual') |
17 | 34 | }) |
18 | 35 |
|
19 | 36 | it('detects Claude Code', () => { |
20 | 37 | process.env.CLAUDECODE = '1' |
21 | | - expect(detectOperator()).toBe('claude-code') |
| 38 | + expect(detectOperator(noAgentFiles)).toBe('claude-code') |
22 | 39 | }) |
23 | 40 |
|
24 | 41 | it('detects Cursor via CURSOR_TRACE_ID', () => { |
25 | 42 | process.env.CURSOR_TRACE_ID = 'some-trace-id' |
26 | | - expect(detectOperator()).toBe('cursor') |
| 43 | + expect(detectOperator(noAgentFiles)).toBe('cursor') |
27 | 44 | }) |
28 | 45 |
|
29 | 46 | it('detects Cursor via CURSOR_AGENT', () => { |
30 | 47 | process.env.CURSOR_AGENT = '1' |
31 | | - expect(detectOperator()).toBe('cursor') |
| 48 | + expect(detectOperator(noAgentFiles)).toBe('cursor') |
32 | 49 | }) |
33 | 50 |
|
34 | 51 | it('detects VS Code', () => { |
35 | 52 | process.env.TERM_PROGRAM = 'vscode' |
36 | | - expect(detectOperator()).toBe('vscode') |
| 53 | + expect(detectOperator(noAgentFiles)).toBe('vscode') |
37 | 54 | }) |
38 | 55 |
|
39 | 56 | it('does not detect VS Code for other TERM_PROGRAM values', () => { |
40 | 57 | process.env.TERM_PROGRAM = 'iTerm.app' |
41 | | - expect(detectOperator()).toBe('manual') |
| 58 | + expect(detectOperator(noAgentFiles)).toBe('manual') |
42 | 59 | }) |
43 | 60 |
|
44 | 61 | it('detects GitHub Copilot', () => { |
45 | 62 | process.env.GITHUB_COPILOT = '1' |
46 | | - expect(detectOperator()).toBe('github-copilot') |
| 63 | + expect(detectOperator(noAgentFiles)).toBe('github-copilot') |
47 | 64 | }) |
48 | 65 |
|
49 | 66 | it('detects Aider', () => { |
50 | 67 | process.env.AIDER = '1' |
51 | | - expect(detectOperator()).toBe('aider') |
| 68 | + expect(detectOperator(noAgentFiles)).toBe('aider') |
52 | 69 | }) |
53 | 70 |
|
54 | 71 | it('detects Windsurf via WINDSURF env var', () => { |
55 | 72 | process.env.WINDSURF = '1' |
56 | | - expect(detectOperator()).toBe('windsurf') |
| 73 | + expect(detectOperator(noAgentFiles)).toBe('windsurf') |
57 | 74 | }) |
58 | 75 |
|
59 | 76 | it('detects Windsurf via CODEIUM_ENV env var', () => { |
60 | 77 | process.env.CODEIUM_ENV = 'production' |
61 | | - expect(detectOperator()).toBe('windsurf') |
| 78 | + expect(detectOperator(noAgentFiles)).toBe('windsurf') |
62 | 79 | }) |
63 | 80 |
|
64 | 81 | it('detects GitHub Actions', () => { |
65 | 82 | process.env.GITHUB_ACTIONS = 'true' |
66 | | - expect(detectOperator()).toBe('github-actions') |
| 83 | + expect(detectOperator(noAgentFiles)).toBe('github-actions') |
67 | 84 | }) |
68 | 85 |
|
69 | 86 | it('detects GitLab CI', () => { |
70 | 87 | process.env.GITLAB_CI = 'true' |
71 | | - expect(detectOperator()).toBe('gitlab-ci') |
| 88 | + expect(detectOperator(noAgentFiles)).toBe('gitlab-ci') |
72 | 89 | }) |
73 | 90 |
|
74 | 91 | it('detects Cline', () => { |
75 | 92 | process.env.CLINE_ACTIVE = '1' |
76 | | - expect(detectOperator()).toBe('cline') |
| 93 | + expect(detectOperator(noAgentFiles)).toBe('cline') |
77 | 94 | }) |
78 | 95 |
|
79 | 96 | it('detects Codex CLI via CODEX_SANDBOX', () => { |
80 | 97 | process.env.CODEX_SANDBOX = '1' |
81 | | - expect(detectOperator()).toBe('codex-cli') |
| 98 | + expect(detectOperator(noAgentFiles)).toBe('codex-cli') |
82 | 99 | }) |
83 | 100 |
|
84 | 101 | it('detects Codex CLI via CODEX_THREAD_ID', () => { |
85 | 102 | process.env.CODEX_THREAD_ID = 'thread-123' |
86 | | - expect(detectOperator()).toBe('codex-cli') |
| 103 | + expect(detectOperator(noAgentFiles)).toBe('codex-cli') |
87 | 104 | }) |
88 | 105 |
|
89 | 106 | it('detects Gemini CLI', () => { |
90 | 107 | process.env.GEMINI_CLI = '1' |
91 | | - expect(detectOperator()).toBe('gemini-cli') |
| 108 | + expect(detectOperator(noAgentFiles)).toBe('gemini-cli') |
92 | 109 | }) |
93 | 110 |
|
94 | 111 | it('detects OpenCode', () => { |
95 | 112 | process.env.OPENCODE = '1' |
96 | | - expect(detectOperator()).toBe('opencode') |
| 113 | + expect(detectOperator(noAgentFiles)).toBe('opencode') |
| 114 | + }) |
| 115 | + |
| 116 | + it.each([ |
| 117 | + { key: 'CLAUDE_CODE', value: '1', expected: 'claude-code' }, |
| 118 | + { key: 'AMP_CURRENT_THREAD_ID', value: 'thread-123', expected: 'amp' }, |
| 119 | + { key: 'CURSOR_EXTENSION_HOST_ROLE', value: 'agent-exec', expected: 'cursor' }, |
| 120 | + { key: 'VSCODE_AGENT', value: '1', expected: 'vscode-agent' }, |
| 121 | + { key: 'COPILOT_AGENT', value: '1', expected: 'vscode-agent' }, |
| 122 | + { key: 'COPILOT_CLI', value: '1', expected: 'github-copilot-cli' }, |
| 123 | + { key: 'COPILOT_MODEL', value: 'gpt-5', expected: 'github-copilot' }, |
| 124 | + { key: 'WINDSURF_AGENT', value: '1', expected: 'windsurf' }, |
| 125 | + { key: 'CODEX_CI', value: '1', expected: 'codex-cli' }, |
| 126 | + { key: 'ANTIGRAVITY_AGENT', value: '1', expected: 'antigravity' }, |
| 127 | + { key: 'AUGMENT_AGENT', value: '1', expected: 'augment-cli' }, |
| 128 | + { key: 'ANDROID_STUDIO_AGENT', value: '1', expected: 'android-studio-agent' }, |
| 129 | + { key: 'KIRO_AGENT_PATH', value: '/path/to/kiro-agent', expected: 'kiro-agent' }, |
| 130 | + { key: 'TRAE_AI_SHELL_ID', value: 'shell-123', expected: 'trae' }, |
| 131 | + { key: 'GOOSE_TERMINAL', value: '1', expected: 'goose' }, |
| 132 | + { key: 'GITHUB_AW', value: 'true', expected: 'github-agentic-workflow' }, |
| 133 | + { key: 'REPL_ID', value: 'workspace-123', expected: 'replit' }, |
| 134 | + ])('detects $key as $expected', ({ key, value, expected }) => { |
| 135 | + process.env[key] = value |
| 136 | + expect(detectOperator(noAgentFiles)).toBe(expected) |
| 137 | + expect(detectCliMode(noAgentFiles)).toBe(key === 'REPL_ID' ? 'interactive' : 'agent') |
| 138 | + }) |
| 139 | + |
| 140 | + it('detects named generic agent markers and normalizes known aliases', () => { |
| 141 | + process.env.AI_AGENT = ' github_copilot_vscode_agent ' |
| 142 | + expect(detectOperator(noAgentFiles)).toBe('vscode-agent') |
| 143 | + |
| 144 | + delete process.env.AI_AGENT |
| 145 | + process.env.AGENT = 'goose' |
| 146 | + expect(detectOperator(noAgentFiles)).toBe('goose') |
| 147 | + }) |
| 148 | + |
| 149 | + it('uses a specific agent marker over a boolean generic marker', () => { |
| 150 | + process.env.AGENT = '1' |
| 151 | + process.env.OPENCODE = '1' |
| 152 | + expect(detectOperator(noAgentFiles)).toBe('opencode') |
| 153 | + }) |
| 154 | + |
| 155 | + it('uses a safe generic operator for invalid explicit agent names', () => { |
| 156 | + process.env.AI_AGENT = 'invalid\nheader' |
| 157 | + expect(detectOperator(noAgentFiles)).toBe('agent') |
| 158 | + }) |
| 159 | + |
| 160 | + it('does not allow an explicit agent marker to report itself as manual', () => { |
| 161 | + process.env.AI_AGENT = 'manual' |
| 162 | + expect(detectOperator(noAgentFiles)).toBe('agent') |
| 163 | + }) |
| 164 | + |
| 165 | + it('ignores explicitly disabled generic agent markers', () => { |
| 166 | + process.env.AI_AGENT = 'false' |
| 167 | + process.env.AGENT = '0' |
| 168 | + expect(detectOperator(noAgentFiles)).toBe('manual') |
| 169 | + }) |
| 170 | + |
| 171 | + it('prioritizes Claude Cowork over its shared Claude Code marker', () => { |
| 172 | + process.env.CLAUDE_CODE_IS_COWORK = '1' |
| 173 | + process.env.CLAUDECODE = '1' |
| 174 | + expect(detectOperator(noAgentFiles)).toBe('claude-cowork') |
| 175 | + }) |
| 176 | + |
| 177 | + it('detects Devin via its sandbox filesystem marker', () => { |
| 178 | + expect(detectOperator(path => path === '/opt/.devin')).toBe('devin') |
| 179 | + }) |
| 180 | + |
| 181 | + it('detects Jules only when its user and VM marker agree', () => { |
| 182 | + process.env.HOME = '/home/jules' |
| 183 | + expect(detectOperator(path => path === '/opt/environment_summary.sh')).toBe('jules') |
| 184 | + expect(detectCliMode(path => path === '/opt/environment_summary.sh')).toBe('agent') |
| 185 | + }) |
| 186 | + |
| 187 | + it('detects Pi from its agent path segment', () => { |
| 188 | + process.env.PATH = '/usr/bin:/home/user/.pi/agent/bin' |
| 189 | + expect(detectOperator(noAgentFiles)).toBe('pi') |
| 190 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
| 191 | + }) |
| 192 | + |
| 193 | + it('detects Firebase Studio without claiming its terminal is agent-driven', () => { |
| 194 | + process.env.MONOSPACE_ENV = 'true' |
| 195 | + expect(detectOperator(noAgentFiles)).toBe('firebase-studio') |
| 196 | + expect(detectCliMode(noAgentFiles)).toBe('interactive') |
97 | 197 | }) |
98 | 198 |
|
99 | 199 | it('detects generic CI', () => { |
100 | 200 | process.env.CI = 'true' |
101 | | - expect(detectOperator()).toBe('ci') |
| 201 | + expect(detectOperator(noAgentFiles)).toBe('ci') |
102 | 202 | }) |
103 | 203 |
|
104 | 204 | it('prioritizes Cline over VS Code (Cline is a VS Code extension)', () => { |
105 | 205 | process.env.CLINE_ACTIVE = '1' |
106 | 206 | process.env.TERM_PROGRAM = 'vscode' |
107 | | - expect(detectOperator()).toBe('cline') |
| 207 | + expect(detectOperator(noAgentFiles)).toBe('cline') |
108 | 208 | }) |
109 | 209 |
|
110 | 210 | it('prioritizes Claude Code over CI', () => { |
111 | 211 | process.env.CLAUDECODE = '1' |
112 | 212 | process.env.CI = 'true' |
113 | | - expect(detectOperator()).toBe('claude-code') |
| 213 | + expect(detectOperator(noAgentFiles)).toBe('claude-code') |
114 | 214 | }) |
115 | 215 |
|
116 | 216 | it('prioritizes GitHub Actions over generic CI', () => { |
117 | 217 | process.env.GITHUB_ACTIONS = 'true' |
118 | 218 | process.env.CI = 'true' |
119 | | - expect(detectOperator()).toBe('github-actions') |
| 219 | + expect(detectOperator(noAgentFiles)).toBe('github-actions') |
120 | 220 | }) |
121 | | -}) |
122 | 221 |
|
123 | | -describe('detectCliMode', () => { |
124 | | - beforeEach(() => { |
125 | | - delete process.env.CHECKLY_CLI_MODE |
126 | | - for (const key of operatorEnvVars) delete process.env[key] |
| 222 | + it('prioritizes CI over broad IDE environment markers', () => { |
| 223 | + process.env.CI = 'true' |
| 224 | + process.env.TERM_PROGRAM = 'vscode' |
| 225 | + expect(detectOperator(noAgentFiles)).toBe('ci') |
127 | 226 | }) |
| 227 | +}) |
128 | 228 |
|
| 229 | +describe('detectCliMode', () => { |
129 | 230 | it('returns "interactive" by default', () => { |
130 | | - expect(detectCliMode()).toBe('interactive') |
| 231 | + expect(detectCliMode(noAgentFiles)).toBe('interactive') |
131 | 232 | }) |
132 | 233 |
|
133 | 234 | it('returns "agent" when operator is claude-code', () => { |
134 | 235 | process.env.CLAUDECODE = '1' |
135 | | - expect(detectCliMode()).toBe('agent') |
| 236 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
136 | 237 | }) |
137 | 238 |
|
138 | 239 | it('returns "agent" when operator is cursor', () => { |
139 | 240 | process.env.CURSOR_TRACE_ID = 'trace' |
140 | | - expect(detectCliMode()).toBe('agent') |
| 241 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
141 | 242 | }) |
142 | 243 |
|
143 | 244 | it('returns "agent" when operator is windsurf', () => { |
144 | 245 | process.env.WINDSURF = '1' |
145 | | - expect(detectCliMode()).toBe('agent') |
| 246 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
146 | 247 | }) |
147 | 248 |
|
148 | 249 | it('returns "agent" when operator is aider', () => { |
149 | 250 | process.env.AIDER = '1' |
150 | | - expect(detectCliMode()).toBe('agent') |
| 251 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
151 | 252 | }) |
152 | 253 |
|
153 | 254 | it('returns "agent" when operator is github-copilot', () => { |
154 | 255 | process.env.GITHUB_COPILOT = '1' |
155 | | - expect(detectCliMode()).toBe('agent') |
| 256 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
156 | 257 | }) |
157 | 258 |
|
158 | 259 | it('returns "agent" when operator is cline', () => { |
159 | 260 | process.env.CLINE_ACTIVE = '1' |
160 | | - expect(detectCliMode()).toBe('agent') |
| 261 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
161 | 262 | }) |
162 | 263 |
|
163 | 264 | it('returns "agent" when operator is codex-cli', () => { |
164 | 265 | process.env.CODEX_SANDBOX = '1' |
165 | | - expect(detectCliMode()).toBe('agent') |
| 266 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
166 | 267 | }) |
167 | 268 |
|
168 | 269 | it('returns "agent" when operator is gemini-cli', () => { |
169 | 270 | process.env.GEMINI_CLI = '1' |
170 | | - expect(detectCliMode()).toBe('agent') |
| 271 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
171 | 272 | }) |
172 | 273 |
|
173 | 274 | it('returns "agent" when operator is opencode', () => { |
174 | 275 | process.env.OPENCODE = '1' |
175 | | - expect(detectCliMode()).toBe('agent') |
| 276 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
| 277 | + }) |
| 278 | + |
| 279 | + it('returns "agent" for current VS Code agent commands', () => { |
| 280 | + process.env.VSCODE_AGENT = '1' |
| 281 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
| 282 | + }) |
| 283 | + |
| 284 | + it('returns "agent" for custom explicit agent names', () => { |
| 285 | + process.env.AI_AGENT = 'my-company-agent' |
| 286 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
| 287 | + }) |
| 288 | + |
| 289 | + it('returns "agent" for Devin filesystem markers', () => { |
| 290 | + expect(detectCliMode(path => path === '/opt/.devin')).toBe('agent') |
| 291 | + }) |
| 292 | + |
| 293 | + it('keeps a generic Replit workspace interactive', () => { |
| 294 | + process.env.REPL_ID = 'workspace-123' |
| 295 | + expect(detectCliMode(noAgentFiles)).toBe('interactive') |
| 296 | + }) |
| 297 | + |
| 298 | + it('keeps a generic Kiro terminal interactive', () => { |
| 299 | + process.env.TERM_PROGRAM = 'kiro' |
| 300 | + expect(detectCliMode(noAgentFiles)).toBe('interactive') |
176 | 301 | }) |
177 | 302 |
|
178 | 303 | it('returns "ci" when operator is github-actions', () => { |
179 | 304 | process.env.GITHUB_ACTIONS = 'true' |
180 | | - expect(detectCliMode()).toBe('ci') |
| 305 | + expect(detectCliMode(noAgentFiles)).toBe('ci') |
181 | 306 | }) |
182 | 307 |
|
183 | 308 | it('returns "ci" when operator is gitlab-ci', () => { |
184 | 309 | process.env.GITLAB_CI = 'true' |
185 | | - expect(detectCliMode()).toBe('ci') |
| 310 | + expect(detectCliMode(noAgentFiles)).toBe('ci') |
186 | 311 | }) |
187 | 312 |
|
188 | 313 | it('returns "ci" when operator is generic ci', () => { |
189 | 314 | process.env.CI = 'true' |
190 | | - expect(detectCliMode()).toBe('ci') |
| 315 | + expect(detectCliMode(noAgentFiles)).toBe('ci') |
191 | 316 | }) |
192 | 317 |
|
193 | 318 | it('returns "interactive" for vscode (not an agent)', () => { |
194 | 319 | process.env.TERM_PROGRAM = 'vscode' |
195 | | - expect(detectCliMode()).toBe('interactive') |
| 320 | + expect(detectCliMode(noAgentFiles)).toBe('interactive') |
196 | 321 | }) |
197 | 322 |
|
198 | 323 | it('env var CHECKLY_CLI_MODE overrides auto-detection', () => { |
199 | 324 | process.env.CLAUDECODE = '1' |
200 | 325 | process.env.CHECKLY_CLI_MODE = 'interactive' |
201 | | - expect(detectCliMode()).toBe('interactive') |
| 326 | + expect(detectCliMode(noAgentFiles)).toBe('interactive') |
202 | 327 | }) |
203 | 328 |
|
204 | 329 | it('env var CHECKLY_CLI_MODE=agent forces agent mode', () => { |
205 | 330 | process.env.CHECKLY_CLI_MODE = 'agent' |
206 | | - expect(detectCliMode()).toBe('agent') |
| 331 | + expect(detectCliMode(noAgentFiles)).toBe('agent') |
207 | 332 | }) |
208 | 333 |
|
209 | 334 | it('env var CHECKLY_CLI_MODE=ci forces ci mode', () => { |
210 | 335 | process.env.CHECKLY_CLI_MODE = 'ci' |
211 | | - expect(detectCliMode()).toBe('ci') |
| 336 | + expect(detectCliMode(noAgentFiles)).toBe('ci') |
212 | 337 | }) |
213 | 338 |
|
214 | 339 | it('ignores invalid CHECKLY_CLI_MODE values and falls back to auto-detect', () => { |
215 | 340 | process.env.CHECKLY_CLI_MODE = 'bogus' |
216 | | - expect(detectCliMode()).toBe('interactive') |
| 341 | + expect(detectCliMode(noAgentFiles)).toBe('interactive') |
217 | 342 | }) |
218 | 343 | }) |
0 commit comments