Skip to main content

embacle/
factory.rs

1// ABOUTME: Factory for creating LlmProvider instances from runner type identifiers
2// ABOUTME: Centralizes binary resolution and runner construction for all CLI runners
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use std::env;
8use std::sync::LazyLock;
9
10use crate::config::{CliRunnerType, RunnerConfig};
11use crate::discovery::resolve_binary;
12use crate::types::{LlmProvider, RunnerError};
13use crate::{
14    ClaudeCodeRunner, ClineCliRunner, CodexCliRunner, ContinueCliRunner, CopilotRunner,
15    CursorAgentRunner, GeminiCliRunner, GooseCliRunner, KiloCliRunner, KiroCliRunner,
16    OpenCodeRunner, WarpCliRunner,
17};
18
19/// Create an `LlmProvider` instance for the given runner type
20///
21/// Resolves the CLI binary via environment variable override or PATH lookup,
22/// then constructs the appropriate runner with default configuration.
23///
24/// # Errors
25///
26/// Returns [`RunnerError`] if the CLI binary cannot be found.
27pub async fn create_runner(
28    runner_type: CliRunnerType,
29) -> Result<Box<dyn LlmProvider>, RunnerError> {
30    // CopilotHeadless uses its own config (env-based), not RunnerConfig
31    #[cfg(feature = "copilot-headless")]
32    if runner_type == CliRunnerType::CopilotHeadless {
33        return Ok(Box::new(crate::CopilotHeadlessRunner::from_env()));
34    }
35    // CopilotSdk likewise: the runtime is resolved by the SDK, not discovered
36    #[cfg(feature = "copilot-sdk")]
37    if runner_type == CliRunnerType::CopilotSdk {
38        return Ok(Box::new(crate::CopilotSdkRunner::from_env()));
39    }
40
41    // ClaudeWeb drives a browser, not a PATH binary — env-based config
42    #[cfg(feature = "web-ui")]
43    if runner_type == CliRunnerType::ClaudeWeb {
44        return Ok(Box::new(crate::WebUiRunner::from_env()?));
45    }
46
47    let binary_name = runner_type.binary_name();
48    let env_key = runner_type.env_override_key();
49    let env_override = env::var(env_key).ok();
50
51    let binary_path = resolve_binary(binary_name, env_override.as_deref())?;
52    let config = RunnerConfig::new(binary_path);
53
54    let runner: Box<dyn LlmProvider> = match runner_type {
55        CliRunnerType::ClaudeCode => Box::new(ClaudeCodeRunner::new(config)),
56        CliRunnerType::Copilot => Box::new(CopilotRunner::new(config)),
57        CliRunnerType::CursorAgent => Box::new(CursorAgentRunner::new(config)),
58        CliRunnerType::OpenCode => Box::new(OpenCodeRunner::new(config)),
59        CliRunnerType::GeminiCli => Box::new(GeminiCliRunner::new(config)),
60        CliRunnerType::CodexCli => Box::new(CodexCliRunner::new(config)),
61        CliRunnerType::GooseCli => Box::new(GooseCliRunner::new(config)),
62        CliRunnerType::ClineCli => Box::new(ClineCliRunner::new(config)),
63        CliRunnerType::ContinueCli => Box::new(ContinueCliRunner::new(config)),
64        CliRunnerType::WarpCli => Box::new(WarpCliRunner::new(config)),
65        CliRunnerType::KiroCli => Box::new(KiroCliRunner::new(config)),
66        CliRunnerType::KiloCli => Box::new(KiloCliRunner::new(config)),
67        #[cfg(feature = "copilot-headless")]
68        CliRunnerType::CopilotHeadless => unreachable!("handled above"),
69        #[cfg(feature = "copilot-sdk")]
70        CliRunnerType::CopilotSdk => unreachable!("handled above"),
71        #[cfg(feature = "web-ui")]
72        CliRunnerType::ClaudeWeb => unreachable!("handled above"),
73    };
74
75    Ok(runner)
76}
77
78/// Create an `LlmProvider` instance for the given runner type with a pre-built config.
79///
80/// Unlike [`create_runner()`], this function does not perform binary discovery;
81/// it uses the provided `RunnerConfig` directly.
82///
83/// # Panics
84///
85/// With the `web-ui` feature, panics only if the compiled-in `claude_web`
86/// provider TOML is malformed — a compile-time constant guarded by a unit test,
87/// so never in practice.
88pub async fn create_runner_with_config(
89    runner_type: CliRunnerType,
90    config: RunnerConfig,
91) -> Result<Box<dyn LlmProvider>, RunnerError> {
92    Ok(match runner_type {
93        CliRunnerType::ClaudeCode => Box::new(ClaudeCodeRunner::new(config)),
94        CliRunnerType::Copilot => Box::new(CopilotRunner::new(config)),
95        CliRunnerType::CursorAgent => Box::new(CursorAgentRunner::new(config)),
96        CliRunnerType::OpenCode => Box::new(OpenCodeRunner::new(config)),
97        CliRunnerType::GeminiCli => Box::new(GeminiCliRunner::new(config)),
98        CliRunnerType::CodexCli => Box::new(CodexCliRunner::new(config)),
99        CliRunnerType::GooseCli => Box::new(GooseCliRunner::new(config)),
100        CliRunnerType::ClineCli => Box::new(ClineCliRunner::new(config)),
101        CliRunnerType::ContinueCli => Box::new(ContinueCliRunner::new(config)),
102        CliRunnerType::WarpCli => Box::new(WarpCliRunner::new(config)),
103        CliRunnerType::KiroCli => Box::new(KiroCliRunner::new(config)),
104        CliRunnerType::KiloCli => Box::new(KiloCliRunner::new(config)),
105        // CopilotHeadless ignores RunnerConfig — uses env-based config
106        #[cfg(feature = "copilot-headless")]
107        CliRunnerType::CopilotHeadless => Box::new(crate::CopilotHeadlessRunner::from_env()),
108        // CopilotSdk ignores RunnerConfig — uses env-based config
109        #[cfg(feature = "copilot-sdk")]
110        CliRunnerType::CopilotSdk => Box::new(crate::CopilotSdkRunner::from_env()),
111        // ClaudeWeb ignores RunnerConfig — drives a browser via the embedded provider config
112        #[cfg(feature = "web-ui")]
113        CliRunnerType::ClaudeWeb => {
114            let provider = crate::WebProviderConfig::claude_web_default()?;
115            Box::new(crate::WebUiRunner::new(
116                crate::WebUiConfig::from_env(),
117                provider,
118            ))
119        }
120    })
121}
122
123/// All provider types supported by embacle, in discovery priority order.
124///
125/// One list, with the feature-gated Copilot transports slotted in behind the
126/// Copilot CLI: a `const` slice cannot carry `#[cfg]` on its elements, and a
127/// list per feature combination would drift.
128pub static ALL_PROVIDERS: LazyLock<Vec<CliRunnerType>> = LazyLock::new(|| {
129    let mut providers = vec![CliRunnerType::ClaudeCode, CliRunnerType::Copilot];
130    #[cfg(feature = "copilot-headless")]
131    providers.push(CliRunnerType::CopilotHeadless);
132    #[cfg(feature = "copilot-sdk")]
133    providers.push(CliRunnerType::CopilotSdk);
134    providers.extend([
135        CliRunnerType::CursorAgent,
136        CliRunnerType::OpenCode,
137        CliRunnerType::GeminiCli,
138        CliRunnerType::CodexCli,
139        CliRunnerType::GooseCli,
140        CliRunnerType::ClineCli,
141        CliRunnerType::ContinueCli,
142        CliRunnerType::WarpCli,
143        CliRunnerType::KiroCli,
144        CliRunnerType::KiloCli,
145    ]);
146    providers
147});
148
149/// Parse a provider name string into a `CliRunnerType`
150///
151/// Accepts multiple naming conventions: `snake_case`, kebab-case, and
152/// short forms for flexible input.
153pub fn parse_runner_type(s: &str) -> Option<CliRunnerType> {
154    match s.to_lowercase().as_str() {
155        "claude_code" | "claude" | "claudecode" => Some(CliRunnerType::ClaudeCode),
156        "copilot" => Some(CliRunnerType::Copilot),
157        "cursor_agent" | "cursoragent" | "cursor-agent" => Some(CliRunnerType::CursorAgent),
158        "opencode" | "open_code" => Some(CliRunnerType::OpenCode),
159        "gemini" | "gemini_cli" | "geminicli" | "gemini-cli" => Some(CliRunnerType::GeminiCli),
160        "codex" | "codex_cli" | "codexcli" | "codex-cli" => Some(CliRunnerType::CodexCli),
161        "goose" | "goose_cli" | "goosecli" | "goose-cli" => Some(CliRunnerType::GooseCli),
162        "cline" | "cline_cli" | "clinecli" | "cline-cli" => Some(CliRunnerType::ClineCli),
163        "continue" | "continue_cli" | "continuecli" | "continue-cli" | "cn" => {
164            Some(CliRunnerType::ContinueCli)
165        }
166        "warp" | "warp_cli" | "warpcli" | "warp-cli" | "oz" => Some(CliRunnerType::WarpCli),
167        "kiro" | "kiro_cli" | "kirocli" | "kiro-cli" => Some(CliRunnerType::KiroCli),
168        "kilo" | "kilo_cli" | "kilocli" | "kilo-cli" | "kilocode" => Some(CliRunnerType::KiloCli),
169        #[cfg(feature = "copilot-headless")]
170        "copilot_headless" | "copilot-headless" | "copilotheadless" | "headless" => {
171            Some(CliRunnerType::CopilotHeadless)
172        }
173        #[cfg(feature = "copilot-sdk")]
174        "copilot_sdk" | "copilot-sdk" | "copilotsdk" => Some(CliRunnerType::CopilotSdk),
175        #[cfg(feature = "web-ui")]
176        "claude_web" | "claude-web" | "claudeweb" | "web" => Some(CliRunnerType::ClaudeWeb),
177        _ => None,
178    }
179}
180
181/// The valid provider names for error messages, comma-separated, derived
182/// from [`ALL_PROVIDERS`] so the list cannot drift from what the crate serves.
183#[must_use]
184pub fn valid_provider_names() -> String {
185    ALL_PROVIDERS
186        .iter()
187        .map(ToString::to_string)
188        .collect::<Vec<_>>()
189        .join(", ")
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn parse_snake_case_variants() {
198        assert_eq!(
199            parse_runner_type("claude_code"),
200            Some(CliRunnerType::ClaudeCode)
201        );
202        assert_eq!(parse_runner_type("copilot"), Some(CliRunnerType::Copilot));
203        assert_eq!(
204            parse_runner_type("cursor_agent"),
205            Some(CliRunnerType::CursorAgent)
206        );
207        assert_eq!(parse_runner_type("opencode"), Some(CliRunnerType::OpenCode));
208        assert_eq!(
209            parse_runner_type("gemini_cli"),
210            Some(CliRunnerType::GeminiCli)
211        );
212        assert_eq!(
213            parse_runner_type("codex_cli"),
214            Some(CliRunnerType::CodexCli)
215        );
216        assert_eq!(
217            parse_runner_type("goose_cli"),
218            Some(CliRunnerType::GooseCli)
219        );
220        assert_eq!(
221            parse_runner_type("cline_cli"),
222            Some(CliRunnerType::ClineCli)
223        );
224        assert_eq!(
225            parse_runner_type("continue_cli"),
226            Some(CliRunnerType::ContinueCli)
227        );
228        assert_eq!(parse_runner_type("warp_cli"), Some(CliRunnerType::WarpCli));
229        assert_eq!(parse_runner_type("kiro_cli"), Some(CliRunnerType::KiroCli));
230        assert_eq!(parse_runner_type("kilo_cli"), Some(CliRunnerType::KiloCli));
231    }
232
233    #[test]
234    fn parse_short_forms() {
235        assert_eq!(parse_runner_type("claude"), Some(CliRunnerType::ClaudeCode));
236        assert_eq!(
237            parse_runner_type("cursor-agent"),
238            Some(CliRunnerType::CursorAgent)
239        );
240        assert_eq!(parse_runner_type("gemini"), Some(CliRunnerType::GeminiCli));
241        assert_eq!(parse_runner_type("codex"), Some(CliRunnerType::CodexCli));
242        assert_eq!(parse_runner_type("goose"), Some(CliRunnerType::GooseCli));
243        assert_eq!(parse_runner_type("cline"), Some(CliRunnerType::ClineCli));
244        assert_eq!(
245            parse_runner_type("continue"),
246            Some(CliRunnerType::ContinueCli)
247        );
248        assert_eq!(parse_runner_type("cn"), Some(CliRunnerType::ContinueCli));
249        assert_eq!(parse_runner_type("warp"), Some(CliRunnerType::WarpCli));
250        assert_eq!(parse_runner_type("oz"), Some(CliRunnerType::WarpCli));
251        assert_eq!(parse_runner_type("kiro"), Some(CliRunnerType::KiroCli));
252        assert_eq!(parse_runner_type("kiro-cli"), Some(CliRunnerType::KiroCli));
253        assert_eq!(parse_runner_type("kilo"), Some(CliRunnerType::KiloCli));
254        assert_eq!(parse_runner_type("kilo-cli"), Some(CliRunnerType::KiloCli));
255        assert_eq!(parse_runner_type("kilocode"), Some(CliRunnerType::KiloCli));
256    }
257
258    #[test]
259    fn parse_case_insensitive() {
260        assert_eq!(parse_runner_type("COPILOT"), Some(CliRunnerType::Copilot));
261        assert_eq!(
262            parse_runner_type("Claude_Code"),
263            Some(CliRunnerType::ClaudeCode)
264        );
265    }
266
267    #[test]
268    fn parse_unknown_returns_none() {
269        assert_eq!(parse_runner_type("gpt4"), None);
270        assert_eq!(parse_runner_type(""), None);
271    }
272
273    #[test]
274    fn all_providers_count() {
275        let expected = 12
276            + usize::from(cfg!(feature = "copilot-headless"))
277            + usize::from(cfg!(feature = "copilot-sdk"));
278        assert_eq!(ALL_PROVIDERS.len(), expected);
279    }
280
281    #[test]
282    fn valid_provider_names_lists_every_provider_once() {
283        let names = valid_provider_names();
284        let listed: Vec<&str> = names.split(", ").collect();
285        for provider in ALL_PROVIDERS.iter() {
286            let name = provider.to_string();
287            assert_eq!(
288                listed.iter().filter(|n| **n == name).count(),
289                1,
290                "{provider} appears once in {names}"
291            );
292        }
293        assert!(names.starts_with("claude_code, copilot"));
294    }
295}