devist 0.7.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
#![allow(dead_code)]
// Spawn `claude` CLI non-interactively to get JSON output.
// Avoids embedding the Anthropic SDK — leverages user's existing claude config.

use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use std::io::Write;
use std::process::{Command, Stdio};
use std::time::Duration;

pub struct ClaudeCli {
    bin: String,
}

impl ClaudeCli {
    pub fn new(bin: impl Into<String>) -> Self {
        Self { bin: bin.into() }
    }

    /// Run `claude -p <prompt>` and return raw stdout text.
    /// Times out after `timeout` seconds (kills process on timeout).
    pub fn ask(&self, prompt: &str, timeout: Duration) -> Result<String> {
        // Pipe prompt via stdin to avoid shell-arg length issues with big diffs.
        let mut child = Command::new(&self.bin)
            .args(["-p", "--output-format", "text"])
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .with_context(|| format!("spawn `{}` (is claude CLI installed?)", self.bin))?;

        if let Some(mut stdin) = child.stdin.take() {
            stdin
                .write_all(prompt.as_bytes())
                .context("write prompt to claude stdin")?;
        }

        // Simple wall-clock timeout via wait_timeout-style poll loop.
        let start = std::time::Instant::now();
        let exit = loop {
            match child.try_wait()? {
                Some(s) => break s,
                None => {
                    if start.elapsed() > timeout {
                        let _ = child.kill();
                        return Err(anyhow!("claude CLI timed out after {:?}", timeout));
                    }
                    std::thread::sleep(Duration::from_millis(100));
                }
            }
        };

        let mut stdout = String::new();
        if let Some(mut s) = child.stdout.take() {
            use std::io::Read;
            let _ = s.read_to_string(&mut stdout);
        }
        let mut stderr = String::new();
        if let Some(mut s) = child.stderr.take() {
            use std::io::Read;
            let _ = s.read_to_string(&mut stderr);
        }

        if !exit.success() {
            return Err(anyhow!(
                "claude CLI exited {} — stderr: {}",
                exit.code().unwrap_or(-1),
                stderr.trim()
            ));
        }
        Ok(stdout)
    }

    /// Ask and parse the response as strict JSON.
    /// If the response wraps JSON in a markdown fence, strip it.
    pub fn ask_json(&self, prompt: &str, timeout: Duration) -> Result<Value> {
        let raw = self.ask(prompt, timeout)?;
        let cleaned = strip_fences(&raw);
        serde_json::from_str(&cleaned).with_context(|| format!("parse JSON from: {}", cleaned))
    }
}

fn strip_fences(s: &str) -> String {
    let trimmed = s.trim();
    // Match ```json ... ``` or ``` ... ```
    if let Some(rest) = trimmed.strip_prefix("```json") {
        if let Some(end) = rest.rfind("```") {
            return rest[..end].trim().to_string();
        }
    }
    if let Some(rest) = trimmed.strip_prefix("```") {
        if let Some(end) = rest.rfind("```") {
            return rest[..end].trim().to_string();
        }
    }
    trimmed.to_string()
}

/// Quick check whether claude CLI is invokable.
pub fn is_available(bin: &str) -> bool {
    Command::new(bin)
        .arg("--version")
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}