1use crate::env::Env;
2use crate::types::{AgentKind, DetectedAgent};
3
4pub(crate) struct Provider {
7 pub id: &'static str,
8 pub name: &'static str,
9 pub kind: AgentKind,
10 pub matches: fn(&Env) -> bool,
12}
13
14impl Provider {
15 fn detect(&self, env: &Env) -> Option<DetectedAgent> {
16 if (self.matches)(env) {
17 Some(DetectedAgent {
18 id: self.id,
19 name: self.name,
20 kind: self.kind.clone(),
21 })
22 } else {
23 None
24 }
25 }
26}
27
28fn matches_opencode(env: &Env) -> bool {
33 env.contains("OPENCODE")
36 || env.contains("OPENCODE_BIN_PATH")
37 || env.contains("OPENCODE_SERVER")
38 || env.contains("OPENCODE_APP_INFO")
39 || env.contains("OPENCODE_MODES")
40}
41
42fn matches_jules(env: &Env) -> bool {
43 env.equals("HOME", "/home/jules") && env.equals("USER", "swebot")
45}
46
47fn matches_claude_code(env: &Env) -> bool {
48 env.contains("CLAUDECODE")
51}
52
53fn matches_cursor_agent(env: &Env) -> bool {
54 env.contains("CURSOR_TRACE_ID") && env.equals("PAGER", "head -n 10000 | cat")
58}
59
60fn matches_cursor(env: &Env) -> bool {
61 env.contains("CURSOR_TRACE_ID") && !env.equals("PAGER", "head -n 10000 | cat")
63}
64
65fn matches_antigravity(env: &Env) -> bool {
66 env.contains("ANTIGRAVITY_AGENT") || env.contains("ANTIGRAVITY_PROJECT_ID")
68}
69
70fn matches_gemini_cli(env: &Env) -> bool {
71 env.equals("GEMINI_CLI", "1")
74}
75
76fn matches_codex(env: &Env) -> bool {
77 env.contains("CODEX_THREAD_ID")
80}
81
82fn matches_replit_assistant(env: &Env) -> bool {
83 env.contains("REPL_ID") && env.equals("REPLIT_MODE", "assistant")
85}
86
87fn matches_replit(env: &Env) -> bool {
88 env.contains("REPL_ID") && !env.equals("REPLIT_MODE", "assistant")
90}
91
92fn matches_aider(env: &Env) -> bool {
93 env.contains("AIDER_API_KEY")
96}
97
98fn matches_bolt_agent(env: &Env) -> bool {
99 env.equals("SHELL", "/bin/jsh") && env.contains("npm_config_yes")
101}
102
103fn matches_bolt(env: &Env) -> bool {
104 env.equals("SHELL", "/bin/jsh") && !env.contains("npm_config_yes")
106}
107
108fn matches_zed_agent(env: &Env) -> bool {
109 env.equals("TERM_PROGRAM", "zed") && env.equals("PAGER", "cat")
111}
112
113fn matches_zed(env: &Env) -> bool {
114 env.equals("TERM_PROGRAM", "zed") && !env.equals("PAGER", "cat")
116}
117
118fn matches_windsurf(env: &Env) -> bool {
119 env.contains("CODEIUM_EDITOR_APP_ROOT")
122}
123
124fn matches_crush(env: &Env) -> bool {
125 env.equals("CRUSH", "1") || env.equals("AGENT", "crush") || env.equals("AI_AGENT", "crush")
128}
129
130fn matches_amp(env: &Env) -> bool {
131 env.contains("AMP_CURRENT_THREAD_ID") || env.equals("AGENT", "amp")
134}
135
136fn matches_auggie(env: &Env) -> bool {
137 env.equals("AUGMENT_AGENT", "1")
140}
141
142fn matches_qwen_code(env: &Env) -> bool {
143 env.equals("QWEN_CODE", "1")
145}
146
147fn matches_copilot_cloud_agent(env: &Env) -> bool {
148 (env.contains("COPILOT_AGENT_SESSION_ID")
151 || env.contains("COPILOT_AGENT_JOB_ID")
152 || env.equals("COPILOT_CLI", "1"))
153 && env.equals("GITHUB_ACTIONS", "true")
154}
155
156fn matches_copilot_vscode(env: &Env) -> bool {
157 env.equals("TERM_PROGRAM", "vscode") && env.equals("GIT_PAGER", "cat")
160}
161
162fn matches_warp(env: &Env) -> bool {
163 env.equals("TERM_PROGRAM", "WarpTerminal")
165}
166
167pub(crate) fn all_providers() -> &'static [Provider] {
177 static PROVIDERS: std::sync::OnceLock<Vec<Provider>> = std::sync::OnceLock::new();
178 PROVIDERS.get_or_init(|| {
179 vec![
180 Provider {
182 id: "opencode",
183 name: "OpenCode",
184 kind: AgentKind::Agent,
185 matches: matches_opencode,
186 },
187 Provider {
188 id: "jules",
189 name: "Jules",
190 kind: AgentKind::Agent,
191 matches: matches_jules,
192 },
193 Provider {
194 id: "claude-code",
195 name: "Claude Code",
196 kind: AgentKind::Agent,
197 matches: matches_claude_code,
198 },
199 Provider {
200 id: "gemini-cli",
201 name: "Gemini CLI",
202 kind: AgentKind::Agent,
203 matches: matches_gemini_cli,
204 },
205 Provider {
206 id: "codex",
207 name: "OpenAI Codex",
208 kind: AgentKind::Agent,
209 matches: matches_codex,
210 },
211 Provider {
212 id: "aider",
213 name: "Aider",
214 kind: AgentKind::Agent,
215 matches: matches_aider,
216 },
217 Provider {
218 id: "windsurf",
219 name: "Windsurf",
220 kind: AgentKind::Agent,
221 matches: matches_windsurf,
222 },
223 Provider {
224 id: "antigravity",
225 name: "Antigravity",
226 kind: AgentKind::Agent,
227 matches: matches_antigravity,
228 },
229 Provider {
230 id: "crush",
231 name: "Crush",
232 kind: AgentKind::Agent,
233 matches: matches_crush,
234 },
235 Provider {
236 id: "amp",
237 name: "Amp",
238 kind: AgentKind::Agent,
239 matches: matches_amp,
240 },
241 Provider {
242 id: "auggie",
243 name: "Auggie",
244 kind: AgentKind::Agent,
245 matches: matches_auggie,
246 },
247 Provider {
248 id: "qwen-code",
249 name: "Qwen Code",
250 kind: AgentKind::Agent,
251 matches: matches_qwen_code,
252 },
253 Provider {
255 id: "copilot-cloud-agent",
256 name: "GitHub Copilot Cloud Agent",
257 kind: AgentKind::Agent,
258 matches: matches_copilot_cloud_agent,
259 },
260 Provider {
263 id: "cursor-agent",
264 name: "Cursor Agent",
265 kind: AgentKind::Agent,
266 matches: matches_cursor_agent,
267 },
268 Provider {
269 id: "cursor",
270 name: "Cursor",
271 kind: AgentKind::Interactive,
272 matches: matches_cursor,
273 },
274 Provider {
275 id: "replit-assistant",
276 name: "Replit Assistant",
277 kind: AgentKind::Agent,
278 matches: matches_replit_assistant,
279 },
280 Provider {
281 id: "replit",
282 name: "Replit",
283 kind: AgentKind::Interactive,
284 matches: matches_replit,
285 },
286 Provider {
287 id: "bolt-agent",
288 name: "Bolt.new Agent",
289 kind: AgentKind::Agent,
290 matches: matches_bolt_agent,
291 },
292 Provider {
293 id: "bolt",
294 name: "Bolt.new",
295 kind: AgentKind::Interactive,
296 matches: matches_bolt,
297 },
298 Provider {
299 id: "zed-agent",
300 name: "Zed Agent",
301 kind: AgentKind::Agent,
302 matches: matches_zed_agent,
303 },
304 Provider {
305 id: "zed",
306 name: "Zed",
307 kind: AgentKind::Interactive,
308 matches: matches_zed,
309 },
310 Provider {
311 id: "copilot-vscode",
312 name: "GitHub Copilot in VS Code",
313 kind: AgentKind::Agent,
314 matches: matches_copilot_vscode,
315 },
316 Provider {
318 id: "warp",
319 name: "Warp Terminal",
320 kind: AgentKind::Hybrid,
321 matches: matches_warp,
322 },
323 ]
324 })
325}
326
327pub(crate) fn detect(env: &Env) -> Option<DetectedAgent> {
329 all_providers()
330 .iter()
331 .find_map(|provider| provider.detect(env))
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use crate::env::EnvBuilder;
338
339 fn env_with(key: &str, value: &str) -> Env {
340 EnvBuilder::new().set(key, value).build()
341 }
342
343 fn env_with2(k1: &str, v1: &str, k2: &str, v2: &str) -> Env {
344 EnvBuilder::new().set(k1, v1).set(k2, v2).build()
345 }
346
347 #[test]
348 fn detects_opencode() {
349 let env = env_with("OPENCODE", "1");
350 let result = detect(&env).unwrap();
351 assert_eq!(result.id, "opencode");
352 assert_eq!(result.kind, AgentKind::Agent);
353 }
354
355 #[test]
356 fn detects_opencode_by_bin_path() {
357 let env = env_with("OPENCODE_BIN_PATH", "/usr/local/bin/opencode");
358 let result = detect(&env).unwrap();
359 assert_eq!(result.id, "opencode");
360 }
361
362 #[test]
363 fn detects_jules() {
364 let env = env_with2("HOME", "/home/jules", "USER", "swebot");
365 let result = detect(&env).unwrap();
366 assert_eq!(result.id, "jules");
367 assert_eq!(result.kind, AgentKind::Agent);
368 }
369
370 #[test]
371 fn does_not_detect_jules_wrong_user() {
372 let env = env_with2("HOME", "/home/jules", "USER", "notjules");
373 assert!(detect(&env).is_none());
374 }
375
376 #[test]
377 fn detects_claude_code() {
378 let env = env_with("CLAUDECODE", "1");
379 let result = detect(&env).unwrap();
380 assert_eq!(result.id, "claude-code");
381 assert_eq!(result.kind, AgentKind::Agent);
382 }
383
384 #[test]
385 fn detects_gemini_cli() {
386 let env = env_with("GEMINI_CLI", "1");
387 let result = detect(&env).unwrap();
388 assert_eq!(result.id, "gemini-cli");
389 assert_eq!(result.kind, AgentKind::Agent);
390 }
391
392 #[test]
393 fn does_not_detect_gemini_cli_wrong_value() {
394 let env = env_with("GEMINI_CLI", "0");
395 assert!(detect(&env).is_none());
396 }
397
398 #[test]
399 fn detects_codex() {
400 let env = env_with("CODEX_THREAD_ID", "thread-abc-123");
401 let result = detect(&env).unwrap();
402 assert_eq!(result.id, "codex");
403 assert_eq!(result.kind, AgentKind::Agent);
404 }
405
406 #[test]
407 fn detects_aider() {
408 let env = env_with("AIDER_API_KEY", "sk-abc123");
409 let result = detect(&env).unwrap();
410 assert_eq!(result.id, "aider");
411 assert_eq!(result.kind, AgentKind::Agent);
412 }
413
414 #[test]
415 fn detects_windsurf() {
416 let env = env_with("CODEIUM_EDITOR_APP_ROOT", "/opt/windsurf");
417 let result = detect(&env).unwrap();
418 assert_eq!(result.id, "windsurf");
419 assert_eq!(result.kind, AgentKind::Agent);
420 }
421
422 #[test]
423 fn detects_antigravity_by_agent_var() {
424 let env = env_with("ANTIGRAVITY_AGENT", "true");
425 let result = detect(&env).unwrap();
426 assert_eq!(result.id, "antigravity");
427 assert_eq!(result.kind, AgentKind::Agent);
428 }
429
430 #[test]
431 fn detects_antigravity_by_project_id() {
432 let env = env_with("ANTIGRAVITY_PROJECT_ID", "proj-abc");
433 let result = detect(&env).unwrap();
434 assert_eq!(result.id, "antigravity");
435 }
436
437 #[test]
438 fn detects_crush_by_crush_var() {
439 let env = env_with("CRUSH", "1");
440 let result = detect(&env).unwrap();
441 assert_eq!(result.id, "crush");
442 assert_eq!(result.kind, AgentKind::Agent);
443 }
444
445 #[test]
446 fn detects_crush_by_agent_var() {
447 let env = env_with("AGENT", "crush");
448 let result = detect(&env).unwrap();
449 assert_eq!(result.id, "crush");
450 }
451
452 #[test]
453 fn detects_amp_by_thread_id() {
454 let env = env_with("AMP_CURRENT_THREAD_ID", "thread-xyz");
455 let result = detect(&env).unwrap();
456 assert_eq!(result.id, "amp");
457 assert_eq!(result.kind, AgentKind::Agent);
458 }
459
460 #[test]
461 fn detects_amp_by_agent_var() {
462 let env = env_with("AGENT", "amp");
463 let result = detect(&env).unwrap();
464 assert_eq!(result.id, "amp");
465 }
466
467 #[test]
468 fn detects_auggie() {
469 let env = env_with("AUGMENT_AGENT", "1");
470 let result = detect(&env).unwrap();
471 assert_eq!(result.id, "auggie");
472 assert_eq!(result.kind, AgentKind::Agent);
473 }
474
475 #[test]
476 fn detects_qwen_code() {
477 let env = env_with("QWEN_CODE", "1");
478 let result = detect(&env).unwrap();
479 assert_eq!(result.id, "qwen-code");
480 assert_eq!(result.kind, AgentKind::Agent);
481 }
482
483 #[test]
484 fn detects_copilot_cloud_agent() {
485 let env = env_with2(
486 "COPILOT_AGENT_SESSION_ID",
487 "sess-abc",
488 "GITHUB_ACTIONS",
489 "true",
490 );
491 let result = detect(&env).unwrap();
492 assert_eq!(result.id, "copilot-cloud-agent");
493 assert_eq!(result.kind, AgentKind::Agent);
494 }
495
496 #[test]
497 fn detects_copilot_cloud_agent_by_cli_flag() {
498 let env = env_with2("COPILOT_CLI", "1", "GITHUB_ACTIONS", "true");
499 let result = detect(&env).unwrap();
500 assert_eq!(result.id, "copilot-cloud-agent");
501 assert_eq!(result.kind, AgentKind::Agent);
502 }
503
504 #[test]
505 fn does_not_detect_copilot_cloud_without_github_actions() {
506 let env = env_with("COPILOT_AGENT_SESSION_ID", "sess-abc");
507 assert!(detect(&env).is_none());
508 }
509
510 #[test]
511 fn detects_cursor_agent() {
512 let env = env_with2(
513 "CURSOR_TRACE_ID",
514 "trace-abc",
515 "PAGER",
516 "head -n 10000 | cat",
517 );
518 let result = detect(&env).unwrap();
519 assert_eq!(result.id, "cursor-agent");
520 assert_eq!(result.kind, AgentKind::Agent);
521 }
522
523 #[test]
524 fn detects_cursor_interactive() {
525 let env = env_with("CURSOR_TRACE_ID", "trace-abc");
526 let result = detect(&env).unwrap();
527 assert_eq!(result.id, "cursor");
528 assert_eq!(result.kind, AgentKind::Interactive);
529 }
530
531 #[test]
532 fn detects_replit_assistant() {
533 let env = env_with2("REPL_ID", "repl-123", "REPLIT_MODE", "assistant");
534 let result = detect(&env).unwrap();
535 assert_eq!(result.id, "replit-assistant");
536 assert_eq!(result.kind, AgentKind::Agent);
537 }
538
539 #[test]
540 fn detects_replit_interactive() {
541 let env = env_with("REPL_ID", "repl-123");
542 let result = detect(&env).unwrap();
543 assert_eq!(result.id, "replit");
544 assert_eq!(result.kind, AgentKind::Interactive);
545 }
546
547 #[test]
548 fn detects_bolt_agent() {
549 let env = env_with2("SHELL", "/bin/jsh", "npm_config_yes", "true");
550 let result = detect(&env).unwrap();
551 assert_eq!(result.id, "bolt-agent");
552 assert_eq!(result.kind, AgentKind::Agent);
553 }
554
555 #[test]
556 fn detects_bolt_interactive() {
557 let env = env_with("SHELL", "/bin/jsh");
558 let result = detect(&env).unwrap();
559 assert_eq!(result.id, "bolt");
560 assert_eq!(result.kind, AgentKind::Interactive);
561 }
562
563 #[test]
564 fn detects_zed_agent() {
565 let env = env_with2("TERM_PROGRAM", "zed", "PAGER", "cat");
566 let result = detect(&env).unwrap();
567 assert_eq!(result.id, "zed-agent");
568 assert_eq!(result.kind, AgentKind::Agent);
569 }
570
571 #[test]
572 fn detects_zed_interactive() {
573 let env = env_with("TERM_PROGRAM", "zed");
574 let result = detect(&env).unwrap();
575 assert_eq!(result.id, "zed");
576 assert_eq!(result.kind, AgentKind::Interactive);
577 }
578
579 #[test]
580 fn detects_copilot_vscode_agent() {
581 let env = env_with2("TERM_PROGRAM", "vscode", "GIT_PAGER", "cat");
582 let result = detect(&env).unwrap();
583 assert_eq!(result.id, "copilot-vscode");
584 assert_eq!(result.kind, AgentKind::Agent);
585 }
586
587 #[test]
588 fn detects_warp() {
589 let env = env_with("TERM_PROGRAM", "WarpTerminal");
590 let result = detect(&env).unwrap();
591 assert_eq!(result.id, "warp");
592 assert_eq!(result.kind, AgentKind::Hybrid);
593 }
594
595 #[test]
596 fn no_detection_in_empty_env() {
597 let env = Env::from_map(std::collections::HashMap::new());
598 assert!(detect(&env).is_none());
599 }
600
601 #[test]
602 fn no_detection_for_unrelated_env() {
603 let env = env_with("PATH", "/usr/bin:/bin");
604 assert!(detect(&env).is_none());
605 }
606}