use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use async_trait::async_trait;
use gid_core::ritual::llm::{LlmClient, ToolDefinition, SkillResult};
pub struct CliLlmClient {
claude_bin: String,
}
impl Default for CliLlmClient {
fn default() -> Self {
Self::new()
}
}
impl CliLlmClient {
pub fn new() -> Self {
Self {
claude_bin: "claude".to_string(),
}
}
#[allow(dead_code)]
pub fn with_binary(bin: impl Into<String>) -> Self {
Self {
claude_bin: bin.into(),
}
}
pub fn into_arc(self) -> Arc<dyn LlmClient> {
Arc::new(self)
}
}
#[async_trait]
impl LlmClient for CliLlmClient {
async fn run_skill(
&self,
skill_prompt: &str,
tools: Vec<ToolDefinition>,
model: &str,
working_dir: &Path,
_max_iterations: usize,
) -> Result<SkillResult> {
for tool in &tools {
if tool.name.contains(',') || tool.name.chars().any(|c| c.is_control()) {
anyhow::bail!("Invalid tool name: '{}' (contains comma or control character)", tool.name);
}
}
if skill_prompt.trim().starts_with("--") {
eprintln!("Warning: Skill prompt starts with '--', may cause CLI parsing issues");
}
let allowed_tools: Vec<String> = tools.iter().map(|t| t.name.clone()).collect();
let mut cmd = tokio::process::Command::new(&self.claude_bin);
cmd.arg("-p").arg(skill_prompt);
cmd.arg("--model").arg(model);
if !allowed_tools.is_empty() {
cmd.arg("--allowedTools").arg(allowed_tools.join(","));
}
cmd.current_dir(working_dir);
let output = cmd
.output()
.await
.context("Failed to spawn claude CLI")?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let (tool_calls, tokens) = parse_usage_stats(&stderr);
let combined_output = if stderr.is_empty() {
stdout
} else if !output.status.success() {
format!("{}\n--- stderr ---\n{}", stdout, stderr)
} else {
stdout
};
Ok(SkillResult {
output: combined_output,
artifacts_created: vec![],
tool_calls_made: tool_calls,
tokens_used: tokens,
})
}
}
fn parse_usage_stats(stderr: &str) -> (usize, u64) {
let mut tool_calls: usize = 0;
let mut tokens: u64 = 0;
for line in stderr.lines() {
let lower = line.to_lowercase();
if lower.contains("token") {
if let Some(num) = extract_number(line) {
tokens = num;
}
}
if lower.contains("tool") && (lower.contains("call") || lower.contains("use")) {
if let Some(num) = extract_number(line) {
tool_calls = num as usize;
}
}
}
(tool_calls, tokens)
}
fn extract_number(s: &str) -> Option<u64> {
let mut start = None;
let mut end = 0;
for (i, c) in s.chars().enumerate() {
if c.is_ascii_digit() {
if start.is_none() {
start = Some(i);
}
end = i + 1;
} else if c == ',' && start.is_some() {
} else if start.is_some() {
break;
}
}
let start = start?;
let cleaned: String = s[start..end].chars().filter(|c| *c != ',').collect();
cleaned.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_usage_stats() {
let stderr = "Total tokens: 12,345\nTool calls: 5";
let (tool_calls, tokens) = parse_usage_stats(stderr);
assert_eq!(tokens, 12345);
assert_eq!(tool_calls, 5);
}
#[test]
fn test_extract_number() {
assert_eq!(extract_number("Total: 1,234"), Some(1234));
assert_eq!(extract_number("count: 42"), Some(42));
assert_eq!(extract_number("no number here"), None);
}
}