use anyhow::Result;
use async_trait::async_trait;
use std::process::Stdio;
use std::time::Duration;
use tempfile::NamedTempFile;
use tokio::process::Command;
use tokio::time::timeout;
use tracing::{debug, warn};
use super::claude_cli::{build_prompt, parse_inner_response};
use crate::ai::{AiProvider, AiRequest, AiResponse, ProviderCapabilities};
pub struct DevinCliProvider {
pub model: Option<String>,
pub agent_config: Option<String>,
pub config: Option<String>,
}
pub fn build_devin_args(
prompt_file: &str,
model: Option<&str>,
agent_config: Option<&str>,
config: Option<&str>,
) -> Vec<String> {
let mut args = vec![
"--print".to_string(),
"--prompt-file".to_string(),
prompt_file.to_string(),
];
if let Some(m) = model {
args.push("--model".to_string());
args.push(m.to_string());
}
if let Some(ac) = agent_config {
args.push("--agent-config".to_string());
args.push(ac.to_string());
}
if let Some(c) = config {
args.push("--config".to_string());
args.push(c.to_string());
}
args
}
#[async_trait]
impl AiProvider for DevinCliProvider {
async fn generate_content(&self, request: AiRequest) -> Result<AiResponse> {
let prompt = build_prompt(&request);
debug!("devin-cli prompt length: {} chars", prompt.len());
let prompt_file = tokio::task::spawn_blocking({
let prompt = prompt.clone();
move || -> Result<NamedTempFile> {
use std::io::Write;
let mut f = tempfile::Builder::new()
.prefix("sashiko-devin-")
.suffix(".txt")
.tempfile()?;
f.write_all(prompt.as_bytes())?;
f.flush()?;
Ok(f)
}
})
.await
.map_err(|e| anyhow::anyhow!("failed to write devin prompt file: {}", e))??;
let args = build_devin_args(
&prompt_file.path().to_string_lossy(),
self.model.as_deref(),
self.agent_config.as_deref(),
self.config.as_deref(),
);
let child = Command::new("devin")
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn devin CLI: {}. Is it installed?", e))?;
let output = timeout(Duration::from_secs(600), child.wait_with_output())
.await
.map_err(|_| anyhow::anyhow!("devin CLI timed out after 10 minutes"))?
.map_err(|e| anyhow::anyhow!("devin CLI wait error: {}", e))?;
drop(prompt_file);
if !output.stderr.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
if !line.trim().is_empty() {
debug!("[devin-cli stderr] {}", line);
}
}
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("devin CLI exited with {}: {}", output.status, stderr.trim());
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if raw.is_empty() {
warn!("devin-cli produced empty stdout");
}
parse_inner_response(&raw, None)
}
fn estimate_tokens(&self, request: &AiRequest) -> usize {
let chars: usize = request
.messages
.iter()
.filter_map(|m| m.content.as_ref())
.map(|c| c.len())
.sum();
chars / 4
}
fn get_capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
model_name: self.model.clone().unwrap_or_else(|| "default".to_string()),
context_window_size: 200_000,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_args_minimal() {
let args = build_devin_args("/tmp/p.txt", None, None, None);
assert_eq!(args, vec!["--print", "--prompt-file", "/tmp/p.txt",]);
}
#[test]
fn test_build_args_with_model_and_overrides() {
let args = build_devin_args(
"/tmp/p.txt",
Some("opus"),
Some("/etc/agent.json"),
Some("/etc/devin.json"),
);
assert!(args.contains(&"--model".to_string()));
assert!(args.contains(&"opus".to_string()));
assert!(args.contains(&"--agent-config".to_string()));
assert!(args.contains(&"/etc/agent.json".to_string()));
assert!(args.contains(&"--config".to_string()));
assert!(args.contains(&"/etc/devin.json".to_string()));
}
#[test]
fn test_prompt_never_in_argv() {
let args = build_devin_args("/tmp/p.txt", Some("opus"), None, None);
assert!(args.contains(&"--prompt-file".to_string()));
assert!(!args.iter().any(|a| a == "-p" || a == "--print=prompt"));
}
}