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                strip_env: &[],
55                cwd: effective_cwd,
56                max_bytes,
57                cancel: &cancel,
58            },
59            |line| parse::parse_line(line, &mut state, emit),
60        )
61        .await?;
62
63        match outcome {
64            crate::adapters::SpawnOutcome::Cancelled => Ok(RunResult {
65                success: false,
66                text: Some("Cancelled.".into()),
67                ..Default::default()
68            }),
69            crate::adapters::SpawnOutcome::Done {
70                exit_code,
71                signal,
72                stderr,
73                dropped_lines,
74            } => {
75                crate::adapters::warn_dropped_lines(dropped_lines, max_bytes, emit);
76                let success = exit_code == Some(0);
77                let text = if !success && state.result_text.is_none() {
78                    crate::adapters::extract_error_message(stderr.as_deref())
79                        .or_else(|| crate::adapters::describe_signal(signal))
80                } else {
81                    state.result_text
82                };
83                Ok(RunResult {
84                    success,
85                    text,
86                    exit_code,
87                    signal,
88                    stats: state.stats,
89                    session_id: state.session_id,
90                    stderr,
91                    cost_usd: None,
92                })
93            }
94        }
95    }
96}
97
98fn build_args(opts: &RunOptions) -> Vec<String> {
99    let mut args = vec![
100        "-p".into(),
101        opts.task.clone(),
102        "--output-format".into(),
103        "stream-json".into(),
104    ];
105
106    if let Some(model) = &opts.model {
107        args.push("--model".into());
108        args.push(model.clone());
109    }
110
111    // Gemini's --resume takes "latest" or an index, not a UUID session ID.
112    // Use "latest" to resume the most recent session in the cwd.
113    if opts.resume_session_id.is_some() {
114        args.push("--resume".into());
115        args.push("latest".into());
116    }
117
118    // Permission bypass for non-interactive use (opt-in)
119    if opts.skip_permissions {
120        args.push("--yolo".into());
121    }
122
123    if let Some(gemini) = opts.providers.as_ref().and_then(|p| p.gemini.as_ref()) {
124        if gemini.sandbox == Some(true) {
125            args.push("-s".into());
126        }
127        // Skip --approval-mode when --yolo is already set (they conflict in Gemini CLI).
128        if !opts.skip_permissions {
129            if let Some(mode) = &gemini.approval_mode {
130                args.push("--approval-mode".into());
131                args.push(mode.clone());
132            }
133        }
134        if let Some(extra) = &gemini.extra_args {
135            args.extend(extra.clone());
136        }
137    }
138
139    args
140}
141
142/// Write temporary config files for MCP servers and system prompts.
143///
144/// Returns env vars, an optional cwd override, and the temp dir handle
145/// (must be kept alive until the child process exits).
146async fn write_configs(
147    opts: &RunOptions,
148) -> Result<(
149    HashMap<String, String>,
150    Option<String>,
151    Option<tempfile::TempDir>,
152)> {
153    let has_mcp = opts.mcp_servers.as_ref().is_some_and(|s| !s.is_empty());
154    let needs_prompt_file = opts.system_prompt_file.is_none() && opts.system_prompt.is_some();
155
156    // system_prompt_file doesn't need a temp dir — it points to the file directly.
157    if !has_mcp && !needs_prompt_file {
158        let mut env = HashMap::new();
159        if let Some(path) = &opts.system_prompt_file {
160            env.insert("GEMINI_SYSTEM_MD".into(), path.clone());
161        }
162        return Ok((env, None, None));
163    }
164
165    let tmp_dir = tempfile::tempdir().map_err(Error::Io)?;
166    let mut env = HashMap::new();
167
168    // MCP servers → workspace-level .gemini/settings.json.
169    // Gemini CLI merges workspace config (from cwd) with user config (~/.gemini/),
170    // preserving auth credentials while adding our MCP servers.
171    //
172    // If opts.cwd is set, write config there (persistent, enables session resume).
173    // Otherwise, use the temp dir (ephemeral, no session resume).
174    let cwd_override = if let Some(servers) = &opts.mcp_servers {
175        if !servers.is_empty() {
176            let config_dir = if let Some(cwd) = &opts.cwd {
177                std::path::PathBuf::from(cwd)
178            } else {
179                tmp_dir.path().to_path_buf()
180            };
181
182            let gemini_dir = config_dir.join(".gemini");
183            tokio::fs::create_dir_all(&gemini_dir)
184                .await
185                .map_err(Error::Io)?;
186
187            let settings = build_mcp_settings(servers);
188            let settings_path = gemini_dir.join("settings.json");
189            tokio::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)
190                .await
191                .map_err(Error::Io)?;
192
193            // If we wrote to opts.cwd, no override needed — the adapter
194            // already uses opts.cwd. If we wrote to temp dir, override cwd.
195            if opts.cwd.is_none() {
196                Some(tmp_dir.path().to_string_lossy().into_owned())
197            } else {
198                None
199            }
200        } else {
201            None
202        }
203    } else {
204        None
205    };
206
207    // System prompt → file referenced by GEMINI_SYSTEM_MD
208    // system_prompt_file takes precedence (use the file directly).
209    if let Some(path) = &opts.system_prompt_file {
210        env.insert("GEMINI_SYSTEM_MD".into(), path.clone());
211    } else if let Some(prompt) = &opts.system_prompt {
212        let prompt_path = tmp_dir.path().join("system-prompt.md");
213        tokio::fs::write(&prompt_path, prompt)
214            .await
215            .map_err(Error::Io)?;
216        env.insert(
217            "GEMINI_SYSTEM_MD".into(),
218            prompt_path.to_string_lossy().into_owned(),
219        );
220    }
221
222    Ok((env, cwd_override, Some(tmp_dir)))
223}
224
225fn build_mcp_settings(servers: &HashMap<String, McpServer>) -> serde_json::Value {
226    let mut mcp_map = serde_json::Map::new();
227
228    for (name, server) in servers {
229        let mut entry = serde_json::Map::new();
230
231        if let Some(url) = &server.url {
232            entry.insert("url".into(), serde_json::Value::String(url.clone()));
233            let t = match server.transport_type {
234                Some(crate::types::McpTransport::Http) => "http",
235                _ => "sse",
236            };
237            entry.insert("type".into(), serde_json::Value::String(t.into()));
238            if let Some(headers) = &server.headers {
239                entry.insert(
240                    "headers".into(),
241                    serde_json::to_value(headers).unwrap_or_default(),
242                );
243            }
244        } else {
245            if let Some(cmd) = &server.command {
246                entry.insert("command".into(), serde_json::Value::String(cmd.clone()));
247            }
248            if let Some(a) = &server.args {
249                entry.insert("args".into(), serde_json::to_value(a).unwrap_or_default());
250            }
251            if let Some(e) = &server.env {
252                entry.insert("env".into(), serde_json::to_value(e).unwrap_or_default());
253            }
254            if let Some(cwd) = &server.cwd {
255                entry.insert("cwd".into(), serde_json::Value::String(cwd.clone()));
256            }
257        }
258
259        if let Some(include) = &server.include_tools {
260            entry.insert(
261                "includeTools".into(),
262                serde_json::to_value(include).unwrap_or_default(),
263            );
264        }
265        if let Some(exclude) = &server.exclude_tools {
266            entry.insert(
267                "excludeTools".into(),
268                serde_json::to_value(exclude).unwrap_or_default(),
269            );
270        }
271        if let Some(timeout) = server.timeout {
272            entry.insert("timeout".into(), serde_json::Value::Number(timeout.into()));
273        }
274
275        mcp_map.insert(name.clone(), serde_json::Value::Object(entry));
276    }
277
278    let mut root = serde_json::Map::new();
279    root.insert("mcpServers".into(), serde_json::Value::Object(mcp_map));
280    serde_json::Value::Object(root)
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn build_args_minimal() {
289        let opts = RunOptions {
290            task: "hello".into(),
291            ..Default::default()
292        };
293        let args = build_args(&opts);
294        assert_eq!(args, vec!["-p", "hello", "--output-format", "stream-json"]);
295    }
296
297    #[test]
298    fn build_args_skip_permissions() {
299        let opts = RunOptions {
300            task: "hello".into(),
301            skip_permissions: true,
302            ..Default::default()
303        };
304        let args = build_args(&opts);
305        assert!(args.contains(&"--yolo".to_string()));
306    }
307
308    #[test]
309    fn build_args_no_permission_bypass_by_default() {
310        let opts = RunOptions {
311            task: "hello".into(),
312            ..Default::default()
313        };
314        let args = build_args(&opts);
315        assert!(!args.contains(&"--yolo".to_string()));
316    }
317
318    #[test]
319    fn build_args_with_options() {
320        let opts = RunOptions {
321            task: "do something".into(),
322            model: Some("gemini-2.0-flash".into()),
323            resume_session_id: Some("sess-1".into()),
324            providers: Some(crate::types::ProviderOptions {
325                gemini: Some(crate::types::GeminiOptions {
326                    sandbox: Some(true),
327                    approval_mode: Some("auto".into()),
328                    extra_args: Some(vec!["--verbose".into()]),
329                }),
330                ..Default::default()
331            }),
332            ..Default::default()
333        };
334        let args = build_args(&opts);
335        assert!(args.contains(&"-s".to_string()));
336        assert!(args.contains(&"--model".to_string()));
337        assert!(args.contains(&"gemini-2.0-flash".to_string()));
338        assert!(args.contains(&"--resume".to_string()));
339        assert!(args.contains(&"--approval-mode".to_string()));
340        assert!(args.contains(&"--verbose".to_string()));
341    }
342}