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