Skip to main content

detect_coding_agent/
providers.rs

1use crate::env::Env;
2use crate::types::{AgentKind, DetectedAgent};
3
4/// A provider definition: a static description of an AI coding agent and
5/// the function used to detect its presence in the environment.
6pub(crate) struct Provider {
7    pub id: &'static str,
8    pub name: &'static str,
9    pub kind: AgentKind,
10    /// Returns `true` when the provider is detected in `env`.
11    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
28// ---------------------------------------------------------------------------
29// Individual matcher functions
30// ---------------------------------------------------------------------------
31
32fn matches_opencode(env: &Env) -> bool {
33    // OpenCode sets any of these variables on shell executions.
34    // https://opencode.ai
35    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    // Jules (Google) runs as user "swebot" with HOME=/home/jules.
44    env.equals("HOME", "/home/jules") && env.equals("USER", "swebot")
45}
46
47fn matches_claude_code(env: &Env) -> bool {
48    // Claude Code (Anthropic) sets CLAUDECODE in spawned shells.
49    // https://docs.anthropic.com/en/docs/claude-code
50    env.contains("CLAUDECODE")
51}
52
53fn matches_cursor_agent(env: &Env) -> bool {
54    // Cursor in agent mode sets CURSOR_TRACE_ID AND overrides PAGER.
55    // The PAGER override is the distinguishing signal for the automated agent
56    // versus an interactive Cursor terminal session.
57    env.contains("CURSOR_TRACE_ID") && env.equals("PAGER", "head -n 10000 | cat")
58}
59
60fn matches_cursor(env: &Env) -> bool {
61    // Cursor IDE interactive terminal: CURSOR_TRACE_ID without the PAGER override.
62    env.contains("CURSOR_TRACE_ID") && !env.equals("PAGER", "head -n 10000 | cat")
63}
64
65fn matches_antigravity(env: &Env) -> bool {
66    // Antigravity coding agent.
67    env.contains("ANTIGRAVITY_AGENT") || env.contains("ANTIGRAVITY_PROJECT_ID")
68}
69
70fn matches_gemini_cli(env: &Env) -> bool {
71    // Gemini CLI (Google) sets GEMINI_CLI=1 on every shell command and MCP server it spawns.
72    // https://github.com/google-gemini/gemini-cli
73    env.equals("GEMINI_CLI", "1")
74}
75
76fn matches_codex(env: &Env) -> bool {
77    // OpenAI Codex injects CODEX_THREAD_ID (the session conversation id) into every shell command.
78    // https://github.com/openai/codex/blob/main/codex-rs/protocol/src/shell_environment.rs
79    env.contains("CODEX_THREAD_ID")
80}
81
82fn matches_replit_assistant(env: &Env) -> bool {
83    // Replit AI Assistant mode: REPL_ID set AND REPLIT_MODE=assistant.
84    env.contains("REPL_ID") && env.equals("REPLIT_MODE", "assistant")
85}
86
87fn matches_replit(env: &Env) -> bool {
88    // Replit interactive environment: REPL_ID without the assistant override.
89    env.contains("REPL_ID") && !env.equals("REPLIT_MODE", "assistant")
90}
91
92fn matches_aider(env: &Env) -> bool {
93    // Aider sets AIDER_API_KEY in its shell environment.
94    // https://aider.chat
95    env.contains("AIDER_API_KEY")
96}
97
98fn matches_bolt_agent(env: &Env) -> bool {
99    // Bolt.new in agent mode: /bin/jsh shell AND npm_config_yes is set.
100    env.equals("SHELL", "/bin/jsh") && env.contains("npm_config_yes")
101}
102
103fn matches_bolt(env: &Env) -> bool {
104    // Bolt.new interactive environment: /bin/jsh shell WITHOUT npm_config_yes.
105    env.equals("SHELL", "/bin/jsh") && !env.contains("npm_config_yes")
106}
107
108fn matches_zed_agent(env: &Env) -> bool {
109    // Zed editor in agent mode: TERM_PROGRAM=zed AND PAGER=cat (disables pagination).
110    env.equals("TERM_PROGRAM", "zed") && env.equals("PAGER", "cat")
111}
112
113fn matches_zed(env: &Env) -> bool {
114    // Zed editor interactive: TERM_PROGRAM=zed WITHOUT the PAGER override.
115    env.equals("TERM_PROGRAM", "zed") && !env.equals("PAGER", "cat")
116}
117
118fn matches_windsurf(env: &Env) -> bool {
119    // Windsurf (Codeium) sets CODEIUM_EDITOR_APP_ROOT.
120    // https://windsurf.com
121    env.contains("CODEIUM_EDITOR_APP_ROOT")
122}
123
124fn matches_crush(env: &Env) -> bool {
125    // Crush (Charm) sets CRUSH=1 (and AGENT=crush, AI_AGENT=crush) on every shell exec.
126    // https://github.com/charmbracelet/crush
127    env.equals("CRUSH", "1") || env.equals("AGENT", "crush") || env.equals("AI_AGENT", "crush")
128}
129
130fn matches_amp(env: &Env) -> bool {
131    // Sourcegraph Amp injects AGENT=amp or AMP_CURRENT_THREAD_ID into shell tool executions.
132    // https://ampcode.com
133    env.contains("AMP_CURRENT_THREAD_ID") || env.equals("AGENT", "amp")
134}
135
136fn matches_auggie(env: &Env) -> bool {
137    // Augment Code's Auggie CLI sets AUGMENT_AGENT=1 in the shell tool's env.
138    // https://augmentcode.com
139    env.equals("AUGMENT_AGENT", "1")
140}
141
142fn matches_qwen_code(env: &Env) -> bool {
143    // Qwen Code (Alibaba, Gemini CLI fork) sets QWEN_CODE=1 on every shell exec.
144    env.equals("QWEN_CODE", "1")
145}
146
147fn matches_copilot_cloud_agent(env: &Env) -> bool {
148    // GitHub Copilot Cloud Agent (Copilot coding agent running as a GitHub Actions job).
149    // It sets COPILOT_AGENT_SESSION_ID and/or COPILOT_CLI=1 together with GITHUB_ACTIONS.
150    (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    // GitHub Copilot agent mode inside VS Code: TERM_PROGRAM=vscode AND GIT_PAGER=cat.
158    // The GIT_PAGER override is the distinguishing signal from an ordinary VS Code terminal.
159    env.equals("TERM_PROGRAM", "vscode") && env.equals("GIT_PAGER", "cat")
160}
161
162fn matches_warp(env: &Env) -> bool {
163    // Warp terminal (hybrid: interactive + AI features).
164    env.equals("TERM_PROGRAM", "WarpTerminal")
165}
166
167// ---------------------------------------------------------------------------
168// Provider registry
169// ---------------------------------------------------------------------------
170
171/// Returns the ordered list of known AI coding agent providers.
172///
173/// Detection is attempted in this order; the **first match wins**.  More
174/// specific checks (e.g. "agent with extra env flag") are placed before their
175/// less specific counterparts (e.g. "same tool, interactive mode").
176pub(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            // --- Agents that have unique, unambiguous environment signals ---
181            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            // GitHub Copilot: cloud agent check must come before VS Code check
254            Provider {
255                id: "copilot-cloud-agent",
256                name: "GitHub Copilot Cloud Agent",
257                kind: AgentKind::Agent,
258                matches: matches_copilot_cloud_agent,
259            },
260            // --- Agents that share an env var with an interactive mode ---
261            // More specific (agent) checks first, less specific (interactive) after.
262            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            // --- Hybrid environments ---
317            Provider {
318                id: "warp",
319                name: "Warp Terminal",
320                kind: AgentKind::Hybrid,
321                matches: matches_warp,
322            },
323        ]
324    })
325}
326
327/// Run detection against the provided `env`, returning the first matching provider.
328pub(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}