Skip to main content

sgr_agent/
cli_client.rs

1//! CliClient — LlmClient backed by CLI subprocess (claude/gemini/codex).
2//!
3//! Calls `claude -p "prompt"` and parses the text response using
4//! flexible_parser to extract structured tool calls. Uses the CLI's
5//! own auth (subscription credits), no API key needed.
6//!
7//! This enables using Claude Pro/Max subscription as a full agent backend
8//! with tool calls, by putting tool schemas in the prompt and parsing
9//! the text response back into `ToolCall` structs.
10
11use 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/// Which CLI binary to invoke.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CliBackend {
22    /// `claude -p` — Claude Code CLI (uses subscription).
23    Claude,
24    /// `gemini -p` — Gemini CLI.
25    Gemini,
26    /// `codex exec` — Codex CLI.
27    Codex,
28}
29
30impl CliBackend {
31    /// Detect from model name: "claude-cli" → Claude, etc.
32    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/// LLM client that delegates to a CLI subprocess.
59///
60/// The CLI handles its own auth — no API keys needed.
61/// Tool calls are emulated: tool schemas go into the prompt as text,
62/// the CLI returns plain text, and we parse it back into `ToolCall`s.
63#[derive(Debug, Clone)]
64pub struct CliClient {
65    backend: CliBackend,
66    /// Model to pass via --model flag (e.g. "claude-sonnet-4-6").
67    /// None = use CLI's default model.
68    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        // Don't pass "claude-cli" as --model to the CLI
82        if CliBackend::from_model(&m).is_none() {
83            self.model = Some(m);
84        }
85        self
86    }
87
88    /// Flatten messages into a single prompt string for CLI stdin.
89    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    /// Build CLI command args for a prompt.
107    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                    // Disable Claude's own tools — we handle tool execution
119                    "--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    /// Run CLI subprocess and return output text.
147    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        // Force subscription billing, not API billing
157        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    /// Build tool descriptions for text-based tool calling.
211    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()); // "claude-cli" not passed as --model
350
351        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}