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