Skip to main content

cli_agents/adapters/gemini/
mod.rs

1mod parse;
2
3use crate::DEFAULT_MAX_OUTPUT_BYTES;
4use crate::adapters::CliAdapter;
5use crate::discovery::discover_binary;
6use crate::error::{Error, Result};
7use crate::events::StreamEvent;
8use crate::types::{CliName, McpServer, RunOptions, RunResult};
9use std::collections::HashMap;
10use tokio_util::sync::CancellationToken;
11
12pub struct GeminiAdapter;
13
14impl CliAdapter for GeminiAdapter {
15    fn name(&self) -> CliName {
16        CliName::Gemini
17    }
18
19    async fn run(
20        &self,
21        opts: &RunOptions,
22        emit: &(dyn Fn(StreamEvent) + Send + Sync),
23        cancel: CancellationToken,
24    ) -> Result<RunResult> {
25        let binary = match &opts.executable_path {
26            Some(p) => p.clone(),
27            None => discover_binary(CliName::Gemini).await.ok_or(Error::NoCli)?,
28        };
29
30        // Write temp configs if needed.
31        // Hold the TempDir so it lives until the child process exits.
32        let (config_env, cwd_override, _tmp_dir) = write_configs(opts).await?;
33
34        let cli_args = build_args(opts);
35        let mut extra_env = opts.env.clone().unwrap_or_default();
36        extra_env.extend(config_env);
37        let max_bytes = opts.max_output_bytes.unwrap_or(DEFAULT_MAX_OUTPUT_BYTES);
38
39        // Use cwd override (temp dir with workspace MCP config) if set,
40        // otherwise use the user-specified cwd.
41        let effective_cwd = cwd_override
42            .as_deref()
43            .or(opts.cwd.as_deref())
44            .unwrap_or(".");
45
46        let mut state = parse::ParseState::default();
47
48        let outcome = crate::adapters::spawn_and_stream(
49            crate::adapters::SpawnParams {
50                cli_label: "gemini",
51                binary: &binary,
52                args: &cli_args,
53                extra_env: &extra_env,
54                cwd: effective_cwd,
55                max_bytes,
56                cancel: &cancel,
57            },
58            |line| parse::parse_line(line, &mut state, emit),
59        )
60        .await?;
61
62        match outcome {
63            crate::adapters::SpawnOutcome::Cancelled => Ok(RunResult {
64                success: false,
65                text: Some("Cancelled.".into()),
66                ..Default::default()
67            }),
68            crate::adapters::SpawnOutcome::Done { exit_code, stderr } => Ok(RunResult {
69                success: exit_code == 0,
70                text: state.result_text,
71                exit_code: Some(exit_code),
72                stats: state.stats,
73                session_id: state.session_id,
74                stderr,
75                cost_usd: None,
76            }),
77        }
78    }
79}
80
81fn build_args(opts: &RunOptions) -> Vec<String> {
82    let mut args = vec![
83        "-p".into(),
84        opts.task.clone(),
85        "--output-format".into(),
86        "stream-json".into(),
87    ];
88
89    if let Some(model) = &opts.model {
90        args.push("--model".into());
91        args.push(model.clone());
92    }
93
94    if let Some(session_id) = &opts.resume_session_id {
95        args.push("--resume".into());
96        args.push(session_id.clone());
97    }
98
99    // Permission bypass for non-interactive use (opt-in)
100    if opts.skip_permissions {
101        args.push("--yolo".into());
102    }
103
104    if let Some(gemini) = opts.providers.as_ref().and_then(|p| p.gemini.as_ref()) {
105        if gemini.sandbox == Some(true) {
106            args.push("-s".into());
107        }
108        // Skip --approval-mode when --yolo is already set (they conflict in Gemini CLI).
109        if !opts.skip_permissions {
110            if let Some(mode) = &gemini.approval_mode {
111                args.push("--approval-mode".into());
112                args.push(mode.clone());
113            }
114        }
115        if let Some(extra) = &gemini.extra_args {
116            args.extend(extra.clone());
117        }
118    }
119
120    args
121}
122
123/// Write temporary config files for MCP servers and system prompts.
124///
125/// Returns env vars, an optional cwd override, and the temp dir handle
126/// (must be kept alive until the child process exits).
127async fn write_configs(
128    opts: &RunOptions,
129) -> Result<(HashMap<String, String>, Option<String>, Option<tempfile::TempDir>)> {
130    let has_mcp = opts.mcp_servers.as_ref().is_some_and(|s| !s.is_empty());
131    let needs_prompt_file = opts.system_prompt_file.is_none() && opts.system_prompt.is_some();
132
133    // system_prompt_file doesn't need a temp dir — it points to the file directly.
134    if !has_mcp && !needs_prompt_file {
135        let mut env = HashMap::new();
136        if let Some(path) = &opts.system_prompt_file {
137            env.insert("GEMINI_SYSTEM_MD".into(), path.clone());
138        }
139        return Ok((env, None, None));
140    }
141
142    let tmp_dir = tempfile::tempdir().map_err(Error::Io)?;
143    let mut env = HashMap::new();
144
145    // MCP servers → workspace-level .gemini/settings.json in the temp dir.
146    // Gemini CLI merges workspace config (from cwd) with user config (~/.gemini/),
147    // preserving auth credentials while adding our MCP servers.
148    let cwd_override = if let Some(servers) = &opts.mcp_servers {
149        if !servers.is_empty() {
150            let gemini_dir = tmp_dir.path().join(".gemini");
151            tokio::fs::create_dir_all(&gemini_dir)
152                .await
153                .map_err(Error::Io)?;
154
155            let settings = build_mcp_settings(servers);
156            let settings_path = gemini_dir.join("settings.json");
157            tokio::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)
158                .await
159                .map_err(Error::Io)?;
160
161            // Use the temp dir as cwd so Gemini finds the workspace config.
162            Some(tmp_dir.path().to_string_lossy().into_owned())
163        } else {
164            None
165        }
166    } else {
167        None
168    };
169
170    // System prompt → file referenced by GEMINI_SYSTEM_MD
171    // system_prompt_file takes precedence (use the file directly).
172    if let Some(path) = &opts.system_prompt_file {
173        env.insert("GEMINI_SYSTEM_MD".into(), path.clone());
174    } else if let Some(prompt) = &opts.system_prompt {
175        let prompt_path = tmp_dir.path().join("system-prompt.md");
176        tokio::fs::write(&prompt_path, prompt)
177            .await
178            .map_err(Error::Io)?;
179        env.insert(
180            "GEMINI_SYSTEM_MD".into(),
181            prompt_path.to_string_lossy().into_owned(),
182        );
183    }
184
185    Ok((env, cwd_override, Some(tmp_dir)))
186}
187
188fn build_mcp_settings(servers: &HashMap<String, McpServer>) -> serde_json::Value {
189    let mut mcp_map = serde_json::Map::new();
190
191    for (name, server) in servers {
192        let mut entry = serde_json::Map::new();
193
194        if let Some(url) = &server.url {
195            entry.insert("url".into(), serde_json::Value::String(url.clone()));
196            let t = match server.transport_type {
197                Some(crate::types::McpTransport::Http) => "http",
198                _ => "sse",
199            };
200            entry.insert("type".into(), serde_json::Value::String(t.into()));
201            if let Some(headers) = &server.headers {
202                entry.insert(
203                    "headers".into(),
204                    serde_json::to_value(headers).unwrap_or_default(),
205                );
206            }
207        } else {
208            if let Some(cmd) = &server.command {
209                entry.insert("command".into(), serde_json::Value::String(cmd.clone()));
210            }
211            if let Some(a) = &server.args {
212                entry.insert("args".into(), serde_json::to_value(a).unwrap_or_default());
213            }
214            if let Some(e) = &server.env {
215                entry.insert("env".into(), serde_json::to_value(e).unwrap_or_default());
216            }
217            if let Some(cwd) = &server.cwd {
218                entry.insert("cwd".into(), serde_json::Value::String(cwd.clone()));
219            }
220        }
221
222        if let Some(include) = &server.include_tools {
223            entry.insert(
224                "includeTools".into(),
225                serde_json::to_value(include).unwrap_or_default(),
226            );
227        }
228        if let Some(exclude) = &server.exclude_tools {
229            entry.insert(
230                "excludeTools".into(),
231                serde_json::to_value(exclude).unwrap_or_default(),
232            );
233        }
234        if let Some(timeout) = server.timeout {
235            entry.insert("timeout".into(), serde_json::Value::Number(timeout.into()));
236        }
237
238        mcp_map.insert(name.clone(), serde_json::Value::Object(entry));
239    }
240
241    let mut root = serde_json::Map::new();
242    root.insert("mcpServers".into(), serde_json::Value::Object(mcp_map));
243    serde_json::Value::Object(root)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn build_args_minimal() {
252        let opts = RunOptions {
253            task: "hello".into(),
254            ..Default::default()
255        };
256        let args = build_args(&opts);
257        assert_eq!(args, vec!["-p", "hello", "--output-format", "stream-json"]);
258    }
259
260    #[test]
261    fn build_args_skip_permissions() {
262        let opts = RunOptions {
263            task: "hello".into(),
264            skip_permissions: true,
265            ..Default::default()
266        };
267        let args = build_args(&opts);
268        assert!(args.contains(&"--yolo".to_string()));
269    }
270
271    #[test]
272    fn build_args_no_permission_bypass_by_default() {
273        let opts = RunOptions {
274            task: "hello".into(),
275            ..Default::default()
276        };
277        let args = build_args(&opts);
278        assert!(!args.contains(&"--yolo".to_string()));
279    }
280
281    #[test]
282    fn build_args_with_options() {
283        let opts = RunOptions {
284            task: "do something".into(),
285            model: Some("gemini-2.0-flash".into()),
286            resume_session_id: Some("sess-1".into()),
287            providers: Some(crate::types::ProviderOptions {
288                gemini: Some(crate::types::GeminiOptions {
289                    sandbox: Some(true),
290                    approval_mode: Some("auto".into()),
291                    extra_args: Some(vec!["--verbose".into()]),
292                }),
293                ..Default::default()
294            }),
295            ..Default::default()
296        };
297        let args = build_args(&opts);
298        assert!(args.contains(&"-s".to_string()));
299        assert!(args.contains(&"--model".to_string()));
300        assert!(args.contains(&"gemini-2.0-flash".to_string()));
301        assert!(args.contains(&"--resume".to_string()));
302        assert!(args.contains(&"--approval-mode".to_string()));
303        assert!(args.contains(&"--verbose".to_string()));
304    }
305}