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