cli_agents/adapters/gemini/
mod.rs1mod 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 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 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 if opts.resume_session_id.is_some() {
114 args.push("--resume".into());
115 args.push("latest".into());
116 }
117
118 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 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
142async 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 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 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 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 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}