Skip to main content

robson_connector_claude/
lib.rs

1//! Local Claude Code CLI integration (prompt builders, JSON parsing, subprocess execution).
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use tracing::{debug, warn};
7
8#[derive(Debug)]
9pub struct ClaudeSetupResult {
10    pub is_complete: bool,
11    pub reason: Option<String>,
12    pub validated_slugs: Vec<String>,
13}
14
15#[derive(Debug)]
16pub struct ClaudeImplResult {
17    pub success: bool,
18    pub exit_code: i32,
19    pub stdout: String,
20    pub stderr: String,
21}
22
23/// Dev/test helper: writes a UUID to `bullshit.txt` under `repo_dir` instead of calling Claude.
24pub async fn mock_agent(repo_dir: &Path) -> Result<String> {
25    let uuid = uuid::Uuid::new_v4().to_string();
26
27    let bullshit_path = repo_dir.join("bullshit.txt");
28    tokio::fs::write(&bullshit_path, &uuid)
29        .await
30        .with_context(|| format!("Failed to write {}", bullshit_path.display()))?;
31
32    warn!(
33        "[DEV MODE] Claude call skipped — wrote UUID to {}",
34        bullshit_path.display()
35    );
36    Ok(uuid)
37}
38
39/// Resolves the Claude Code CLI executable: `claude_bin` argument, then `ROBSON_CLAUDE_BIN` env, then `"claude"`.
40pub fn resolve_claude_executable(claude_bin: Option<&str>) -> String {
41    claude_bin
42        .map(|s| s.to_string())
43        .or_else(|| std::env::var("ROBSON_CLAUDE_BIN").ok())
44        .unwrap_or_else(|| "claude".to_string())
45}
46
47pub fn compose_setup_prompt(
48    jira_key: &str,
49    summary: &str,
50    description: &str,
51    comments: &[String],
52    available_slugs: &[&str],
53) -> String {
54    let comments_section = if comments.is_empty() {
55        "No comments.".to_string()
56    } else {
57        comments
58            .iter()
59            .enumerate()
60            .map(|(i, c)| format!("Comment {}:\n{}", i + 1, c))
61            .collect::<Vec<_>>()
62            .join("\n\n")
63    };
64
65    let slugs_list = available_slugs.join(", ");
66
67    format!(
68        r#"You are a software project manager reviewing a Jira task.
69
70## Task: {jira_key}
71**Summary:** {summary}
72
73**Description:**
74{description}
75
76**Comments:**
77{comments_section}
78
79## Known Repositories
80The following repository slugs are registered in the system:
81{slugs_list}
82
83## Your Task
841. Determine if this task is **well-defined** (has clear acceptance criteria, scope, and enough detail to implement).
852. Identify which of the known repository slugs are referenced or implied by the task description.
86
87Respond ONLY with a JSON block in the following format:
88
89```json
90{{
91  "is_complete": true,
92  "reason": null,
93  "slugs": ["repo-slug-1", "repo-slug-2"]
94}}
95```
96
97If the task is NOT well-defined, set `"is_complete": false` and explain the issue in `"reason"`. The `"slugs"` array must only contain slugs from the known repositories list above.
98"#,
99        jira_key = jira_key,
100        summary = summary,
101        description = description,
102        comments_section = comments_section,
103        slugs_list = slugs_list,
104    )
105}
106
107pub fn compose_impl_prompt(
108    jira_key: &str,
109    summary: &str,
110    description: &str,
111    comments: &[String],
112    developer_focus: Option<&str>,
113    custom_prompt: Option<&str>,
114) -> String {
115    let mut parts: Vec<String> = Vec::new();
116
117    if let Some(focus) = developer_focus {
118        if !focus.trim().is_empty() {
119            parts.push(format!("## Developer Focus\n{}", focus));
120        }
121    }
122
123    if let Some(prompt) = custom_prompt {
124        if !prompt.trim().is_empty() {
125            parts.push(format!("## Additional Instructions\n{}", prompt));
126        }
127    }
128
129    parts.push(format!(
130        "## Task: {}\n**Summary:** {}\n\n**Description:**\n{}",
131        jira_key, summary, description
132    ));
133
134    if !comments.is_empty() {
135        let comments_text = comments
136            .iter()
137            .enumerate()
138            .map(|(i, c)| format!("Comment {}:\n{}", i + 1, c))
139            .collect::<Vec<_>>()
140            .join("\n\n");
141        parts.push(format!("## Comments\n{}", comments_text));
142    }
143
144    parts.join("\n\n")
145}
146
147pub fn compose_test_correction_prompt() -> String {
148    "Move all tests to the crate's tests/ folder. Tests must never be placed inside src/ files using #[test] or #[cfg(test)]. Create or use the existing tests/ directory at the crate root for all test code.".to_string()
149}
150
151pub fn compose_docker_impl_prompt(
152    jira_key: &str,
153    summary: &str,
154    description: &str,
155    comments: &[String],
156    developer_focus: Option<&str>,
157    custom_prompt: Option<&str>,
158) -> String {
159    let base = compose_impl_prompt(
160        jira_key,
161        summary,
162        description,
163        comments,
164        developer_focus,
165        custom_prompt,
166    );
167    format!(
168        "{}\n\n## Execution Requirements\nRead the `AGENTS.md` file in this repository to understand project conventions, how to run tests, and how to run lint. After implementing the task, run tests and lint as described in `AGENTS.md`. Iterate and fix any failures until all tests and lint checks pass before finishing.",
169        base
170    )
171}
172
173pub fn parse_setup_output(stdout: &str) -> Result<ClaudeSetupResult> {
174    let json_str = extract_json_block(stdout)
175        .ok_or_else(|| anyhow::anyhow!("No JSON block found in Claude output"))?;
176
177    let value: serde_json::Value =
178        serde_json::from_str(json_str).context("Failed to parse JSON from Claude output")?;
179
180    let is_complete = value
181        .get("is_complete")
182        .and_then(|v| v.as_bool())
183        .ok_or_else(|| anyhow::anyhow!("Missing 'is_complete' field in Claude JSON output"))?;
184
185    let reason = value
186        .get("reason")
187        .and_then(|v| v.as_str())
188        .map(|s| s.to_string());
189
190    let validated_slugs = value
191        .get("slugs")
192        .and_then(|v| v.as_array())
193        .map(|arr| {
194            arr.iter()
195                .filter_map(|s| s.as_str().map(|s| s.to_string()))
196                .collect::<Vec<_>>()
197        })
198        .unwrap_or_default();
199
200    Ok(ClaudeSetupResult {
201        is_complete,
202        reason,
203        validated_slugs,
204    })
205}
206
207fn extract_json_block(text: &str) -> Option<&str> {
208    if let Some(start) = text.find("```json") {
209        let after_fence = &text[start + 7..];
210        if let Some(end) = after_fence.find("```") {
211            return Some(after_fence[..end].trim());
212        }
213    }
214    if let Some(start) = text.find('{') {
215        if let Some(end) = text.rfind('}') {
216            if end >= start {
217                return Some(&text[start..=end]);
218            }
219        }
220    }
221    None
222}
223
224pub async fn evaluate_task(
225    prompt: &str,
226    claude_bin: Option<&str>,
227    dev: bool,
228) -> Result<ClaudeSetupResult> {
229    if dev {
230        warn!("[DEV MODE] Claude setup evaluation skipped — returning is_complete: true");
231        return Ok(ClaudeSetupResult {
232            is_complete: true,
233            reason: None,
234            validated_slugs: vec![],
235        });
236    }
237
238    let bin = resolve_claude_executable(claude_bin);
239    let output = tokio::process::Command::new(&bin)
240        .args(["--allowedTools", "", "--print", prompt])
241        .stdout(std::process::Stdio::piped())
242        .stderr(std::process::Stdio::piped())
243        .output()
244        .await
245        .with_context(|| format!("Failed to invoke Claude Code CLI ({})", bin))?;
246
247    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
248    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
249
250    for line in stdout.lines() {
251        if !line.trim().is_empty() {
252            debug!("[claude:setup] {}", line);
253        }
254    }
255
256    if !output.status.success() {
257        anyhow::bail!(
258            "Claude process exited with code {:?}: {}",
259            output.status.code(),
260            stderr.trim()
261        );
262    }
263
264    parse_setup_output(&stdout)
265}
266
267/// Runs Claude Code in `working_dir` with read/write tools enabled.
268///
269/// `claude_bin`: optional path from config (same precedence as [`evaluate_task`]: argument, then `ROBSON_CLAUDE_BIN`, then `"claude"`).
270pub async fn implement_in_repo(
271    prompt: &str,
272    working_dir: &Path,
273    dev: bool,
274    claude_bin: Option<&str>,
275) -> Result<ClaudeImplResult> {
276    if dev {
277        let uuid = mock_agent(working_dir).await?;
278        return Ok(ClaudeImplResult {
279            success: true,
280            exit_code: 0,
281            stdout: uuid,
282            stderr: String::new(),
283        });
284    }
285
286    let bin = resolve_claude_executable(claude_bin);
287    let output = tokio::process::Command::new(&bin)
288        .args(["--allowedTools", "Read,Write,Edit", "--print", prompt])
289        .current_dir(working_dir)
290        .stdout(std::process::Stdio::piped())
291        .stderr(std::process::Stdio::piped())
292        .output()
293        .await
294        .with_context(|| {
295            format!(
296                "Failed to invoke Claude Code CLI for implementation ({})",
297                bin
298            )
299        })?;
300
301    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
302    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
303    let exit_code = output.status.code().unwrap_or(-1);
304    let success = output.status.success();
305
306    for line in stdout.lines() {
307        if !line.trim().is_empty() {
308            debug!("[claude:impl] {}", line);
309        }
310    }
311
312    Ok(ClaudeImplResult {
313        success,
314        exit_code,
315        stdout,
316        stderr,
317    })
318}
319
320pub const DEFAULT_CLAUDE_API_BASE: &str = "https://api.anthropic.com";
321
322fn claude_api_base() -> String {
323    std::env::var("ROBSON_CLAUDE_API_BASE").unwrap_or_else(|_| DEFAULT_CLAUDE_API_BASE.to_string())
324}
325
326fn require_api_key() -> Result<String> {
327    std::env::var("ROBSON_CLAUDE_API_KEY")
328        .context("ROBSON_CLAUDE_API_KEY is not set (required for chat)")
329}
330
331pub async fn chat(prompt: &str, system: &str) -> Result<String> {
332    let api_key = require_api_key()?;
333    let api_base = claude_api_base();
334    let model = resolve_claude_executable(None);
335    chat_with_credentials(prompt, system, &model, &api_key, &api_base).await
336}
337
338pub async fn chat_with_credentials(
339    prompt: &str,
340    system: &str,
341    model: &str,
342    api_key: &str,
343    api_base: &str,
344) -> Result<String> {
345    let client = reqwest::Client::new();
346    let url = format!("{}/v1/messages", api_base.trim_end_matches('/'));
347
348    let body = serde_json::json!({
349        "model": model,
350        "max_tokens": 4096,
351        "system": system,
352        "messages": [{"role": "user", "content": prompt}]
353    });
354
355    let resp = client
356        .post(&url)
357        .header("x-api-key", api_key)
358        .header("anthropic-version", "2023-06-01")
359        .json(&body)
360        .send()
361        .await
362        .with_context(|| format!("Failed to call Claude API ({})", url))?;
363
364    let status = resp.status();
365    let body_text = resp
366        .text()
367        .await
368        .context("Failed to read Claude API response body")?;
369
370    if !status.is_success() {
371        anyhow::bail!(
372            "Claude API returned {}: {}",
373            status.as_u16(),
374            body_text.trim()
375        );
376    }
377
378    let value: serde_json::Value =
379        serde_json::from_str(&body_text).context("Claude response was not valid JSON")?;
380
381    let text = value
382        .pointer("/content/0/text")
383        .and_then(|v| v.as_str())
384        .ok_or_else(|| {
385            anyhow::anyhow!("Unexpected Claude API response shape: missing content[0].text")
386        })?
387        .to_string();
388
389    Ok(text)
390}