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 !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
116async 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 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 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 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}