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