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 } => {
74 let success = exit_code == Some(0);
75 let text = if !success && state.result_text.is_none() {
76 crate::adapters::extract_error_message(stderr.as_deref())
77 .or_else(|| crate::adapters::describe_signal(signal))
78 } else {
79 state.result_text
80 };
81 Ok(RunResult {
82 success,
83 text,
84 exit_code,
85 signal,
86 stats: state.stats,
87 session_id: state.session_id,
88 stderr,
89 cost_usd: None,
90 })
91 }
92 }
93 }
94}
95
96fn build_args(opts: &RunOptions) -> Vec<String> {
97 let mut args = vec![
98 "-p".into(),
99 opts.task.clone(),
100 "--output-format".into(),
101 "stream-json".into(),
102 ];
103
104 if let Some(model) = &opts.model {
105 args.push("--model".into());
106 args.push(model.clone());
107 }
108
109 if opts.resume_session_id.is_some() {
112 args.push("--resume".into());
113 args.push("latest".into());
114 }
115
116 if opts.skip_permissions {
118 args.push("--yolo".into());
119 }
120
121 if let Some(gemini) = opts.providers.as_ref().and_then(|p| p.gemini.as_ref()) {
122 if gemini.sandbox == Some(true) {
123 args.push("-s".into());
124 }
125 if !opts.skip_permissions {
127 if let Some(mode) = &gemini.approval_mode {
128 args.push("--approval-mode".into());
129 args.push(mode.clone());
130 }
131 }
132 if let Some(extra) = &gemini.extra_args {
133 args.extend(extra.clone());
134 }
135 }
136
137 args
138}
139
140async fn write_configs(
145 opts: &RunOptions,
146) -> Result<(
147 HashMap<String, String>,
148 Option<String>,
149 Option<tempfile::TempDir>,
150)> {
151 let has_mcp = opts.mcp_servers.as_ref().is_some_and(|s| !s.is_empty());
152 let needs_prompt_file = opts.system_prompt_file.is_none() && opts.system_prompt.is_some();
153
154 if !has_mcp && !needs_prompt_file {
156 let mut env = HashMap::new();
157 if let Some(path) = &opts.system_prompt_file {
158 env.insert("GEMINI_SYSTEM_MD".into(), path.clone());
159 }
160 return Ok((env, None, None));
161 }
162
163 let tmp_dir = tempfile::tempdir().map_err(Error::Io)?;
164 let mut env = HashMap::new();
165
166 let cwd_override = if let Some(servers) = &opts.mcp_servers {
173 if !servers.is_empty() {
174 let config_dir = if let Some(cwd) = &opts.cwd {
175 std::path::PathBuf::from(cwd)
176 } else {
177 tmp_dir.path().to_path_buf()
178 };
179
180 let gemini_dir = config_dir.join(".gemini");
181 tokio::fs::create_dir_all(&gemini_dir)
182 .await
183 .map_err(Error::Io)?;
184
185 let settings = build_mcp_settings(servers);
186 let settings_path = gemini_dir.join("settings.json");
187 tokio::fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)
188 .await
189 .map_err(Error::Io)?;
190
191 if opts.cwd.is_none() {
194 Some(tmp_dir.path().to_string_lossy().into_owned())
195 } else {
196 None
197 }
198 } else {
199 None
200 }
201 } else {
202 None
203 };
204
205 if let Some(path) = &opts.system_prompt_file {
208 env.insert("GEMINI_SYSTEM_MD".into(), path.clone());
209 } else if let Some(prompt) = &opts.system_prompt {
210 let prompt_path = tmp_dir.path().join("system-prompt.md");
211 tokio::fs::write(&prompt_path, prompt)
212 .await
213 .map_err(Error::Io)?;
214 env.insert(
215 "GEMINI_SYSTEM_MD".into(),
216 prompt_path.to_string_lossy().into_owned(),
217 );
218 }
219
220 Ok((env, cwd_override, Some(tmp_dir)))
221}
222
223fn build_mcp_settings(servers: &HashMap<String, McpServer>) -> serde_json::Value {
224 let mut mcp_map = serde_json::Map::new();
225
226 for (name, server) in servers {
227 let mut entry = serde_json::Map::new();
228
229 if let Some(url) = &server.url {
230 entry.insert("url".into(), serde_json::Value::String(url.clone()));
231 let t = match server.transport_type {
232 Some(crate::types::McpTransport::Http) => "http",
233 _ => "sse",
234 };
235 entry.insert("type".into(), serde_json::Value::String(t.into()));
236 if let Some(headers) = &server.headers {
237 entry.insert(
238 "headers".into(),
239 serde_json::to_value(headers).unwrap_or_default(),
240 );
241 }
242 } else {
243 if let Some(cmd) = &server.command {
244 entry.insert("command".into(), serde_json::Value::String(cmd.clone()));
245 }
246 if let Some(a) = &server.args {
247 entry.insert("args".into(), serde_json::to_value(a).unwrap_or_default());
248 }
249 if let Some(e) = &server.env {
250 entry.insert("env".into(), serde_json::to_value(e).unwrap_or_default());
251 }
252 if let Some(cwd) = &server.cwd {
253 entry.insert("cwd".into(), serde_json::Value::String(cwd.clone()));
254 }
255 }
256
257 if let Some(include) = &server.include_tools {
258 entry.insert(
259 "includeTools".into(),
260 serde_json::to_value(include).unwrap_or_default(),
261 );
262 }
263 if let Some(exclude) = &server.exclude_tools {
264 entry.insert(
265 "excludeTools".into(),
266 serde_json::to_value(exclude).unwrap_or_default(),
267 );
268 }
269 if let Some(timeout) = server.timeout {
270 entry.insert("timeout".into(), serde_json::Value::Number(timeout.into()));
271 }
272
273 mcp_map.insert(name.clone(), serde_json::Value::Object(entry));
274 }
275
276 let mut root = serde_json::Map::new();
277 root.insert("mcpServers".into(), serde_json::Value::Object(mcp_map));
278 serde_json::Value::Object(root)
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn build_args_minimal() {
287 let opts = RunOptions {
288 task: "hello".into(),
289 ..Default::default()
290 };
291 let args = build_args(&opts);
292 assert_eq!(args, vec!["-p", "hello", "--output-format", "stream-json"]);
293 }
294
295 #[test]
296 fn build_args_skip_permissions() {
297 let opts = RunOptions {
298 task: "hello".into(),
299 skip_permissions: true,
300 ..Default::default()
301 };
302 let args = build_args(&opts);
303 assert!(args.contains(&"--yolo".to_string()));
304 }
305
306 #[test]
307 fn build_args_no_permission_bypass_by_default() {
308 let opts = RunOptions {
309 task: "hello".into(),
310 ..Default::default()
311 };
312 let args = build_args(&opts);
313 assert!(!args.contains(&"--yolo".to_string()));
314 }
315
316 #[test]
317 fn build_args_with_options() {
318 let opts = RunOptions {
319 task: "do something".into(),
320 model: Some("gemini-2.0-flash".into()),
321 resume_session_id: Some("sess-1".into()),
322 providers: Some(crate::types::ProviderOptions {
323 gemini: Some(crate::types::GeminiOptions {
324 sandbox: Some(true),
325 approval_mode: Some("auto".into()),
326 extra_args: Some(vec!["--verbose".into()]),
327 }),
328 ..Default::default()
329 }),
330 ..Default::default()
331 };
332 let args = build_args(&opts);
333 assert!(args.contains(&"-s".to_string()));
334 assert!(args.contains(&"--model".to_string()));
335 assert!(args.contains(&"gemini-2.0-flash".to_string()));
336 assert!(args.contains(&"--resume".to_string()));
337 assert!(args.contains(&"--approval-mode".to_string()));
338 assert!(args.contains(&"--verbose".to_string()));
339 }
340}