1use crate::client::{LlmClient, synthesize_finish_if_empty};
12use crate::tool::ToolDef;
13use crate::types::{Message, Role, SgrError, ToolCall};
14use crate::union_schema;
15use serde_json::Value;
16use std::process::Stdio;
17use tokio::io::AsyncReadExt;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CliBackend {
22 Claude,
24 Gemini,
26 Codex,
28}
29
30impl CliBackend {
31 pub fn from_model(model: &str) -> Option<Self> {
33 match model {
34 "claude-cli" => Some(Self::Claude),
35 "gemini-cli" => Some(Self::Gemini),
36 "codex-cli" => Some(Self::Codex),
37 _ => None,
38 }
39 }
40
41 fn binary(&self) -> &'static str {
42 match self {
43 Self::Claude => "claude",
44 Self::Gemini => "gemini",
45 Self::Codex => "codex",
46 }
47 }
48
49 pub fn display_name(&self) -> &'static str {
50 match self {
51 Self::Claude => "Claude CLI (subscription)",
52 Self::Gemini => "Gemini CLI",
53 Self::Codex => "Codex CLI",
54 }
55 }
56}
57
58#[derive(Debug, Clone)]
64pub struct CliClient {
65 backend: CliBackend,
66 model: Option<String>,
69}
70
71impl CliClient {
72 pub fn new(backend: CliBackend) -> Self {
73 Self {
74 backend,
75 model: None,
76 }
77 }
78
79 pub fn with_model(mut self, model: impl Into<String>) -> Self {
80 let m = model.into();
81 if CliBackend::from_model(&m).is_none() {
83 self.model = Some(m);
84 }
85 self
86 }
87
88 fn flatten_messages(messages: &[Message]) -> String {
90 let mut parts = Vec::with_capacity(messages.len());
91 for msg in messages {
92 if msg.content.is_empty() {
93 continue;
94 }
95 let prefix = match msg.role {
96 Role::System => "System",
97 Role::User => "Human",
98 Role::Assistant => "Assistant",
99 Role::Tool => "Tool Result",
100 };
101 parts.push(format!("[{}]\n{}", prefix, msg.content));
102 }
103 parts.join("\n\n")
104 }
105
106 fn build_args(&self, prompt: &str) -> (String, Vec<String>) {
108 match self.backend {
109 CliBackend::Claude => {
110 let mut args = vec![
111 "-p".into(),
112 prompt.into(),
113 "--output-format".into(),
114 "text".into(),
115 "--no-session-persistence".into(),
116 "--max-turns".into(),
117 "1".into(),
118 "--disallowed-tools".into(),
120 "Bash,Edit,Write,Read,Glob,Grep,Agent".into(),
121 ];
122 if let Some(ref model) = self.model {
123 args.push("--model".into());
124 args.push(model.clone());
125 }
126 ("claude".into(), args)
127 }
128 CliBackend::Gemini => {
129 let mut args = vec![
130 "-p".into(),
131 prompt.into(),
132 "--sandbox".into(),
133 "--output-format".into(),
134 "text".into(),
135 ];
136 if let Some(ref model) = self.model {
137 args.push("--model".into());
138 args.push(model.clone());
139 }
140 ("gemini".into(), args)
141 }
142 CliBackend::Codex => ("codex".into(), vec!["exec".into(), prompt.into()]),
143 }
144 }
145
146 async fn run(&self, prompt: &str) -> Result<String, SgrError> {
148 let (cmd, args) = self.build_args(prompt);
149
150 let mut command = tokio::process::Command::new(&cmd);
151 command
152 .args(&args)
153 .stdout(Stdio::piped())
154 .stderr(Stdio::piped());
155
156 if self.backend == CliBackend::Claude {
158 command.env("CLAUDECODE", "");
159 command.env_remove("ANTHROPIC_API_KEY");
160 }
161
162 let mut child = command.spawn().map_err(|e| SgrError::Api {
163 status: 0,
164 body: format!("{} not found: {}. Is it installed?", cmd, e),
165 })?;
166
167 let mut output = String::new();
168 if let Some(mut out) = child.stdout.take() {
169 out.read_to_string(&mut output)
170 .await
171 .map_err(|e| SgrError::Api {
172 status: 0,
173 body: e.to_string(),
174 })?;
175 }
176
177 let mut err_output = String::new();
178 if let Some(mut err) = child.stderr.take() {
179 err.read_to_string(&mut err_output)
180 .await
181 .map_err(|e| SgrError::Api {
182 status: 0,
183 body: e.to_string(),
184 })?;
185 }
186
187 let status = child.wait().await.map_err(|e| SgrError::Api {
188 status: 0,
189 body: e.to_string(),
190 })?;
191
192 if !status.success() && output.trim().is_empty() {
193 return Err(SgrError::Api {
194 status: status.code().unwrap_or(1) as u16,
195 body: format!("{} failed: {}", cmd, err_output.trim()),
196 });
197 }
198
199 let text = output.trim().to_string();
200 tracing::info!(
201 backend = self.backend.binary(),
202 model = self.model.as_deref().unwrap_or("default"),
203 output_chars = text.len(),
204 "cli_client.complete"
205 );
206
207 Ok(text)
208 }
209
210 fn tools_prompt(tools: &[ToolDef]) -> String {
212 use crate::schema_simplifier;
213 let mut s = String::from(
214 "## Available Tools\n\n\
215 You MUST respond with ONLY valid JSON (no markdown, no explanation):\n\
216 {\"situation\": \"what you observe\", \"task\": [\"next steps\"], \
217 \"actions\": [{\"tool_name\": \"<name>\", ...args}]}\n\n",
218 );
219 for t in tools {
220 s.push_str(&schema_simplifier::simplify_tool(
221 &t.name,
222 &t.description,
223 &t.parameters,
224 ));
225 s.push_str("\n\n");
226 }
227 s
228 }
229}
230
231#[async_trait::async_trait]
232impl LlmClient for CliClient {
233 async fn structured_call(
234 &self,
235 messages: &[Message],
236 schema: &Value,
237 ) -> Result<(Option<Value>, Vec<ToolCall>, String), SgrError> {
238 let schema_hint = format!(
239 "\n\nRespond with ONLY valid JSON matching this schema:\n{}\n\
240 No markdown, no explanations, no code blocks. Raw JSON only.",
241 serde_json::to_string_pretty(schema).unwrap_or_default()
242 );
243
244 let mut prompt = Self::flatten_messages(messages);
245 prompt.push_str(&schema_hint);
246
247 let raw = self.run(&prompt).await?;
248 let parsed = crate::flexible_parser::parse_flexible::<Value>(&raw)
249 .map(|r| r.value)
250 .ok();
251 Ok((parsed, vec![], raw))
252 }
253
254 async fn tools_call(
255 &self,
256 messages: &[Message],
257 tools: &[ToolDef],
258 ) -> Result<Vec<ToolCall>, SgrError> {
259 let tools_desc = Self::tools_prompt(tools);
260 let mut prompt = Self::flatten_messages(messages);
261 prompt.push_str("\n\n");
262 prompt.push_str(&tools_desc);
263
264 let raw = self.run(&prompt).await?;
265
266 match union_schema::parse_action(&raw, tools) {
267 Ok((_situation, mut calls)) => {
268 synthesize_finish_if_empty(&mut calls, &raw);
269 Ok(calls)
270 }
271 Err(e) => {
272 tracing::warn!(error = %e, "CLI response parse failed, synthesizing finish");
273 Ok(vec![ToolCall {
274 id: "cli_finish".into(),
275 name: "finish".into(),
276 arguments: serde_json::json!({"summary": raw}),
277 }])
278 }
279 }
280 }
281
282 async fn complete(&self, messages: &[Message]) -> Result<String, SgrError> {
283 let prompt = Self::flatten_messages(messages);
284 self.run(&prompt).await
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn flatten_messages_basic() {
294 let msgs = vec![
295 Message::system("You are helpful."),
296 Message::user("Hello"),
297 Message::assistant("Hi!"),
298 ];
299 let flat = CliClient::flatten_messages(&msgs);
300 assert!(flat.contains("[System]"));
301 assert!(flat.contains("[Human]"));
302 assert!(flat.contains("[Assistant]"));
303 assert!(flat.contains("You are helpful."));
304 }
305
306 #[test]
307 fn flatten_skips_empty() {
308 let msgs = vec![Message::system(""), Message::user("test")];
309 let flat = CliClient::flatten_messages(&msgs);
310 assert!(!flat.contains("[System]"));
311 assert!(flat.contains("test"));
312 }
313
314 #[test]
315 fn tools_prompt_contains_schema() {
316 let tools = vec![ToolDef {
317 name: "read_file".into(),
318 description: "Read a file".into(),
319 parameters: serde_json::json!({
320 "type": "object",
321 "properties": {
322 "path": {"type": "string", "description": "File path"}
323 },
324 "required": ["path"]
325 }),
326 }];
327 let prompt = CliClient::tools_prompt(&tools);
328 assert!(prompt.contains("read_file"));
329 assert!(prompt.contains("File path"));
330 assert!(prompt.contains("tool_name"));
331 }
332
333 #[test]
334 fn backend_from_model() {
335 assert_eq!(
336 CliBackend::from_model("claude-cli"),
337 Some(CliBackend::Claude)
338 );
339 assert_eq!(
340 CliBackend::from_model("gemini-cli"),
341 Some(CliBackend::Gemini)
342 );
343 assert_eq!(CliBackend::from_model("gpt-4o"), None);
344 }
345
346 #[test]
347 fn with_model_skips_cli_names() {
348 let client = CliClient::new(CliBackend::Claude).with_model("claude-cli");
349 assert!(client.model.is_none()); let client2 = CliClient::new(CliBackend::Claude).with_model("claude-sonnet-4-6");
352 assert_eq!(client2.model.as_deref(), Some("claude-sonnet-4-6"));
353 }
354}