Skip to main content

evolve_adapters/
claude_code.rs

1//! Claude Code adapter.
2
3use crate::signals::{ParsedSignal, SessionLog, SignalKind};
4use crate::traits::{Adapter, AdapterDetection, AdapterError};
5use async_trait::async_trait;
6use evolve_core::agent_config::AgentConfig;
7use evolve_core::ids::AdapterId;
8use serde_json::{Map, Value};
9use std::path::{Path, PathBuf};
10use tokio::fs;
11
12const MANAGED_START: &str = "<!-- evolve:start -->";
13const MANAGED_END: &str = "<!-- evolve:end -->";
14const HOOK_MARKER: &str = "evolve record-claude-code";
15
16/// Claude Code integration.
17#[derive(Debug, Clone, Default)]
18pub struct ClaudeCodeAdapter;
19
20impl ClaudeCodeAdapter {
21    /// Construct.
22    pub fn new() -> Self {
23        Self
24    }
25
26    fn settings_path(root: &Path) -> PathBuf {
27        root.join(".claude").join("settings.json")
28    }
29
30    fn claude_md_path(root: &Path) -> PathBuf {
31        root.join("CLAUDE.md")
32    }
33
34    /// Public for tests: build the `Stop` hook object inserted into settings.json.
35    pub fn stop_hook_entry() -> Value {
36        serde_json::json!({
37            "type": "command",
38            "command": HOOK_MARKER,
39        })
40    }
41
42    /// Render a config into the markdown snippet that goes inside
43    /// the managed section of CLAUDE.md.
44    pub fn render_managed_section(config: &AgentConfig) -> String {
45        let mut out = String::new();
46        out.push_str("# Evolve-managed configuration\n\n");
47        out.push_str("## System prompt prefix\n\n");
48        out.push_str(&config.system_prompt_prefix);
49        out.push_str("\n\n");
50        if !config.behavioral_rules.is_empty() {
51            out.push_str("## Behavioral rules\n\n");
52            for rule in &config.behavioral_rules {
53                out.push_str(&format!("- {rule}\n"));
54            }
55            out.push('\n');
56        }
57        out.push_str(&format!(
58            "## Response style\n\n{:?}\n\n",
59            config.response_style
60        ));
61        out.push_str(&format!("## Model preference\n\n{:?}\n", config.model_pref));
62        out
63    }
64}
65
66#[async_trait]
67impl Adapter for ClaudeCodeAdapter {
68    fn id(&self) -> AdapterId {
69        AdapterId::new("claude-code")
70    }
71
72    fn detect(&self, root: &Path) -> AdapterDetection {
73        if root.join(".claude").is_dir()
74            || root.join("CLAUDE.md").is_file()
75            || root.join(".claude").join("settings.json").is_file()
76        {
77            AdapterDetection::Detected
78        } else {
79            AdapterDetection::NotDetected
80        }
81    }
82
83    async fn install(&self, root: &Path, _config: &AgentConfig) -> Result<(), AdapterError> {
84        let settings_path = Self::settings_path(root);
85        if let Some(parent) = settings_path.parent() {
86            fs::create_dir_all(parent).await?;
87        }
88
89        let mut settings: Value = if settings_path.is_file() {
90            let raw = fs::read_to_string(&settings_path).await?;
91            if raw.trim().is_empty() {
92                Value::Object(Map::new())
93            } else {
94                serde_json::from_str(&raw)?
95            }
96        } else {
97            Value::Object(Map::new())
98        };
99
100        // Idempotency: scan hooks.Stop[] for an entry whose command contains
101        // our marker. If present, skip.
102        let hooks = settings
103            .as_object_mut()
104            .expect("settings is an object")
105            .entry("hooks".to_string())
106            .or_insert_with(|| Value::Object(Map::new()));
107        let hooks_obj = hooks
108            .as_object_mut()
109            .ok_or_else(|| AdapterError::Parse("hooks is not an object".into()))?;
110        let stop = hooks_obj
111            .entry("Stop".to_string())
112            .or_insert_with(|| Value::Array(Vec::new()));
113        let stop_arr = stop
114            .as_array_mut()
115            .ok_or_else(|| AdapterError::Parse("hooks.Stop is not an array".into()))?;
116
117        let already = stop_arr.iter().any(|entry| {
118            entry
119                .get("command")
120                .and_then(|c| c.as_str())
121                .map(|s| s.contains(HOOK_MARKER))
122                .unwrap_or(false)
123        });
124        if !already {
125            stop_arr.push(Self::stop_hook_entry());
126        }
127
128        let rendered = serde_json::to_string_pretty(&settings)?;
129        fs::write(&settings_path, rendered).await?;
130        Ok(())
131    }
132
133    async fn apply_config(&self, root: &Path, config: &AgentConfig) -> Result<(), AdapterError> {
134        let path = Self::claude_md_path(root);
135        let existing = if path.is_file() {
136            fs::read_to_string(&path).await?
137        } else {
138            String::new()
139        };
140        let new_section = Self::render_managed_section(config);
141        let updated = replace_managed_section(&existing, &new_section);
142        fs::write(&path, updated).await?;
143        Ok(())
144    }
145
146    async fn parse_session(&self, log: SessionLog) -> Result<Vec<ParsedSignal>, AdapterError> {
147        let path = match log {
148            SessionLog::Transcript(p) => p,
149            _ => return Err(AdapterError::Parse("expected Transcript log".into())),
150        };
151        let raw = fs::read_to_string(&path).await?;
152        Ok(parse_transcript_lines(&raw))
153    }
154
155    async fn forget(&self, root: &Path) -> Result<(), AdapterError> {
156        // Remove hook from settings.json (keep the file if other hooks exist).
157        let settings_path = Self::settings_path(root);
158        if settings_path.is_file() {
159            let raw = fs::read_to_string(&settings_path).await?;
160            if !raw.trim().is_empty() {
161                let mut settings: Value = serde_json::from_str(&raw)?;
162                if let Some(stop) = settings
163                    .get_mut("hooks")
164                    .and_then(|h| h.get_mut("Stop"))
165                    .and_then(|s| s.as_array_mut())
166                {
167                    stop.retain(|entry| {
168                        entry
169                            .get("command")
170                            .and_then(|c| c.as_str())
171                            .map(|s| !s.contains(HOOK_MARKER))
172                            .unwrap_or(true)
173                    });
174                }
175                fs::write(&settings_path, serde_json::to_string_pretty(&settings)?).await?;
176            }
177        }
178
179        // Strip managed section from CLAUDE.md.
180        let md_path = Self::claude_md_path(root);
181        if md_path.is_file() {
182            let raw = fs::read_to_string(&md_path).await?;
183            let stripped = strip_managed_section(&raw);
184            fs::write(&md_path, stripped).await?;
185        }
186        Ok(())
187    }
188}
189
190/// Replace (or insert) the managed section inside `existing`, returning the new content.
191fn replace_managed_section(existing: &str, new_body: &str) -> String {
192    let block = format!("{MANAGED_START}\n{}\n{MANAGED_END}", new_body.trim_end());
193    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
194        if end > start {
195            let end_full = end + MANAGED_END.len();
196            let mut out = String::new();
197            out.push_str(&existing[..start]);
198            out.push_str(&block);
199            out.push_str(&existing[end_full..]);
200            return out;
201        }
202    }
203    // No existing markers — append.
204    let mut out = String::from(existing);
205    if !out.is_empty() && !out.ends_with('\n') {
206        out.push('\n');
207    }
208    if !out.is_empty() {
209        out.push('\n');
210    }
211    out.push_str(&block);
212    out.push('\n');
213    out
214}
215
216/// Remove the managed section entirely, leaving the rest of the file.
217fn strip_managed_section(existing: &str) -> String {
218    if let (Some(start), Some(end)) = (existing.find(MANAGED_START), existing.find(MANAGED_END)) {
219        if end > start {
220            let end_full = end + MANAGED_END.len();
221            let mut out = String::new();
222            out.push_str(&existing[..start]);
223            out.push_str(existing[end_full..].trim_start_matches('\n'));
224            return out;
225        }
226    }
227    existing.to_string()
228}
229
230/// Parse a Claude Code transcript (JSONL) into signals.
231///
232/// Each line is a JSON event. We recognize:
233/// - `{ "type": "user", "text": "/clear" }` → `user_clear` signal (0.0)
234/// - `{ "type": "user", "text": "<feedback>" }` matching regex → `user_feedback`
235/// - `{ "type": "tool_use", "tool": "bash", ..., "exit_code": 0 }` → `tests_passed` if test-like command
236fn parse_transcript_lines(raw: &str) -> Vec<ParsedSignal> {
237    use regex::Regex;
238
239    let negative = Regex::new(r"(?i)\b(redo|wrong|no,? that|try again|undo)\b").unwrap();
240    let positive = Regex::new(r"(?i)\b(thanks|perfect|looks good|lgtm|nice)\b").unwrap();
241    let test_cmd =
242        Regex::new(r"(?i)\b(cargo test|pytest|npm test|jest|go test|cargo check|cargo clippy)\b")
243            .unwrap();
244
245    let mut signals = Vec::new();
246    for line in raw.lines() {
247        let line = line.trim();
248        if line.is_empty() {
249            continue;
250        }
251        let Ok(event): Result<Value, _> = serde_json::from_str(line) else {
252            continue;
253        };
254        let kind = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
255        match kind {
256            "user" => {
257                let text = event.get("text").and_then(|v| v.as_str()).unwrap_or("");
258                if text.trim() == "/clear" {
259                    signals.push(ParsedSignal {
260                        kind: SignalKind::Implicit,
261                        source: "user_clear".into(),
262                        value: 0.0,
263                        payload_json: None,
264                    });
265                    continue;
266                }
267                if negative.is_match(text) {
268                    signals.push(ParsedSignal {
269                        kind: SignalKind::Implicit,
270                        source: "user_feedback_negative".into(),
271                        value: 0.3,
272                        payload_json: None,
273                    });
274                }
275                if positive.is_match(text) {
276                    signals.push(ParsedSignal {
277                        kind: SignalKind::Implicit,
278                        source: "user_feedback_positive".into(),
279                        value: 0.9,
280                        payload_json: None,
281                    });
282                }
283            }
284            "tool_use" => {
285                let tool = event.get("tool").and_then(|v| v.as_str()).unwrap_or("");
286                if tool != "bash" {
287                    continue;
288                }
289                let cmd = event.get("command").and_then(|v| v.as_str()).unwrap_or("");
290                if !test_cmd.is_match(cmd) {
291                    continue;
292                }
293                let exit = event
294                    .get("exit_code")
295                    .and_then(|v| v.as_i64())
296                    .unwrap_or(-1);
297                signals.push(ParsedSignal {
298                    kind: SignalKind::Implicit,
299                    source: if exit == 0 {
300                        "tests_passed".into()
301                    } else {
302                        "tests_failed".into()
303                    },
304                    value: if exit == 0 { 1.0 } else { 0.0 },
305                    payload_json: None,
306                });
307            }
308            _ => {}
309        }
310    }
311    signals
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use tempfile::TempDir;
318
319    fn sample_config() -> AgentConfig {
320        AgentConfig::default_for("claude-code")
321    }
322
323    #[tokio::test]
324    async fn detect_recognizes_claude_md() {
325        let tmp = TempDir::new().unwrap();
326        std::fs::write(tmp.path().join("CLAUDE.md"), "# test").unwrap();
327        let adapter = ClaudeCodeAdapter::new();
328        assert_eq!(adapter.detect(tmp.path()), AdapterDetection::Detected);
329    }
330
331    #[tokio::test]
332    async fn detect_returns_not_detected_for_empty_dir() {
333        let tmp = TempDir::new().unwrap();
334        let adapter = ClaudeCodeAdapter::new();
335        assert_eq!(adapter.detect(tmp.path()), AdapterDetection::NotDetected);
336    }
337
338    #[tokio::test]
339    async fn install_adds_stop_hook_to_fresh_settings() {
340        let tmp = TempDir::new().unwrap();
341        let adapter = ClaudeCodeAdapter::new();
342        adapter.install(tmp.path(), &sample_config()).await.unwrap();
343        let raw =
344            std::fs::read_to_string(tmp.path().join(".claude").join("settings.json")).unwrap();
345        assert!(raw.contains(HOOK_MARKER));
346    }
347
348    #[tokio::test]
349    async fn install_is_idempotent() {
350        let tmp = TempDir::new().unwrap();
351        let adapter = ClaudeCodeAdapter::new();
352        adapter.install(tmp.path(), &sample_config()).await.unwrap();
353        let first =
354            std::fs::read_to_string(tmp.path().join(".claude").join("settings.json")).unwrap();
355        adapter.install(tmp.path(), &sample_config()).await.unwrap();
356        let second =
357            std::fs::read_to_string(tmp.path().join(".claude").join("settings.json")).unwrap();
358        assert_eq!(
359            first, second,
360            "second install must not change settings.json"
361        );
362    }
363
364    #[tokio::test]
365    async fn install_preserves_unrelated_settings() {
366        let tmp = TempDir::new().unwrap();
367        let dir = tmp.path().join(".claude");
368        std::fs::create_dir_all(&dir).unwrap();
369        let existing = r#"{"theme":"dark","permissions":{"allow":["Bash"]}}"#;
370        std::fs::write(dir.join("settings.json"), existing).unwrap();
371        let adapter = ClaudeCodeAdapter::new();
372        adapter.install(tmp.path(), &sample_config()).await.unwrap();
373        let raw = std::fs::read_to_string(dir.join("settings.json")).unwrap();
374        assert!(raw.contains("\"theme\""));
375        assert!(raw.contains("\"permissions\""));
376        assert!(raw.contains(HOOK_MARKER));
377    }
378
379    #[tokio::test]
380    async fn apply_config_writes_managed_section_between_markers() {
381        let tmp = TempDir::new().unwrap();
382        let adapter = ClaudeCodeAdapter::new();
383        adapter
384            .apply_config(tmp.path(), &sample_config())
385            .await
386            .unwrap();
387        let raw = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
388        assert!(raw.contains(MANAGED_START));
389        assert!(raw.contains(MANAGED_END));
390        assert!(raw.contains("System prompt prefix"));
391    }
392
393    #[tokio::test]
394    async fn apply_config_preserves_user_content_outside_markers() {
395        let tmp = TempDir::new().unwrap();
396        let user_content = "# My own CLAUDE.md\n\nImportant project notes.\n";
397        std::fs::write(tmp.path().join("CLAUDE.md"), user_content).unwrap();
398        let adapter = ClaudeCodeAdapter::new();
399        adapter
400            .apply_config(tmp.path(), &sample_config())
401            .await
402            .unwrap();
403        let raw = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
404        assert!(raw.contains("Important project notes."));
405        assert!(raw.contains(MANAGED_START));
406    }
407
408    #[tokio::test]
409    async fn apply_config_replaces_existing_managed_section() {
410        let tmp = TempDir::new().unwrap();
411        let initial =
412            format!("# Keep\n\n{MANAGED_START}\nold content\n{MANAGED_END}\n\n# Also keep\n",);
413        std::fs::write(tmp.path().join("CLAUDE.md"), &initial).unwrap();
414        let adapter = ClaudeCodeAdapter::new();
415        adapter
416            .apply_config(tmp.path(), &sample_config())
417            .await
418            .unwrap();
419        let raw = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
420        assert!(!raw.contains("old content"));
421        assert!(raw.contains("# Keep"));
422        assert!(raw.contains("# Also keep"));
423    }
424
425    #[tokio::test]
426    async fn forget_removes_hook_but_keeps_other_hooks() {
427        let tmp = TempDir::new().unwrap();
428        let adapter = ClaudeCodeAdapter::new();
429        // Seed with a foreign hook + evolve hook.
430        adapter.install(tmp.path(), &sample_config()).await.unwrap();
431        let path = tmp.path().join(".claude").join("settings.json");
432        let raw = std::fs::read_to_string(&path).unwrap();
433        let mut settings: Value = serde_json::from_str(&raw).unwrap();
434        settings["hooks"]["Stop"]
435            .as_array_mut()
436            .unwrap()
437            .push(serde_json::json!({"type":"command","command":"other-thing"}));
438        std::fs::write(&path, serde_json::to_string_pretty(&settings).unwrap()).unwrap();
439
440        adapter.forget(tmp.path()).await.unwrap();
441        let after = std::fs::read_to_string(&path).unwrap();
442        assert!(!after.contains(HOOK_MARKER));
443        assert!(after.contains("other-thing"));
444    }
445
446    #[tokio::test]
447    async fn forget_strips_managed_section_preserves_user_text() {
448        let tmp = TempDir::new().unwrap();
449        let path = tmp.path().join("CLAUDE.md");
450        let content = format!("# User\n\n{MANAGED_START}\nmanaged\n{MANAGED_END}\n\n# Tail\n",);
451        std::fs::write(&path, &content).unwrap();
452        ClaudeCodeAdapter::new().forget(tmp.path()).await.unwrap();
453        let after = std::fs::read_to_string(&path).unwrap();
454        assert!(after.contains("# User"));
455        assert!(after.contains("# Tail"));
456        assert!(!after.contains("managed"));
457    }
458
459    // ----- transcript parsing -----
460
461    fn jsonl(events: &[&str]) -> String {
462        events.join("\n")
463    }
464
465    #[tokio::test]
466    async fn parse_session_detects_user_clear() {
467        let tmp = TempDir::new().unwrap();
468        let path = tmp.path().join("t.jsonl");
469        std::fs::write(&path, jsonl(&[r#"{"type":"user","text":"/clear"}"#])).unwrap();
470        let signals = ClaudeCodeAdapter::new()
471            .parse_session(SessionLog::Transcript(path))
472            .await
473            .unwrap();
474        assert_eq!(signals.len(), 1);
475        assert_eq!(signals[0].source, "user_clear");
476        assert_eq!(signals[0].value, 0.0);
477    }
478
479    #[tokio::test]
480    async fn parse_session_detects_test_pass_and_fail() {
481        let tmp = TempDir::new().unwrap();
482        let path = tmp.path().join("t.jsonl");
483        std::fs::write(
484            &path,
485            jsonl(&[
486                r#"{"type":"tool_use","tool":"bash","command":"cargo test","exit_code":0}"#,
487                r#"{"type":"tool_use","tool":"bash","command":"cargo test","exit_code":1}"#,
488            ]),
489        )
490        .unwrap();
491        let signals = ClaudeCodeAdapter::new()
492            .parse_session(SessionLog::Transcript(path))
493            .await
494            .unwrap();
495        assert_eq!(signals.len(), 2);
496        assert_eq!(signals[0].source, "tests_passed");
497        assert_eq!(signals[0].value, 1.0);
498        assert_eq!(signals[1].source, "tests_failed");
499        assert_eq!(signals[1].value, 0.0);
500    }
501
502    #[tokio::test]
503    async fn parse_session_detects_positive_and_negative_feedback() {
504        let tmp = TempDir::new().unwrap();
505        let path = tmp.path().join("t.jsonl");
506        std::fs::write(
507            &path,
508            jsonl(&[
509                r#"{"type":"user","text":"perfect, thanks!"}"#,
510                r#"{"type":"user","text":"no, that's wrong, redo"}"#,
511            ]),
512        )
513        .unwrap();
514        let signals = ClaudeCodeAdapter::new()
515            .parse_session(SessionLog::Transcript(path))
516            .await
517            .unwrap();
518        let sources: Vec<&str> = signals.iter().map(|s| s.source.as_str()).collect();
519        assert!(sources.contains(&"user_feedback_positive"));
520        assert!(sources.contains(&"user_feedback_negative"));
521    }
522
523    #[tokio::test]
524    async fn parse_session_ignores_unrelated_bash_commands() {
525        let tmp = TempDir::new().unwrap();
526        let path = tmp.path().join("t.jsonl");
527        std::fs::write(
528            &path,
529            jsonl(&[r#"{"type":"tool_use","tool":"bash","command":"ls -la","exit_code":0}"#]),
530        )
531        .unwrap();
532        let signals = ClaudeCodeAdapter::new()
533            .parse_session(SessionLog::Transcript(path))
534            .await
535            .unwrap();
536        assert!(signals.is_empty());
537    }
538
539    #[tokio::test]
540    async fn parse_session_tolerates_invalid_json_lines() {
541        let tmp = TempDir::new().unwrap();
542        let path = tmp.path().join("t.jsonl");
543        std::fs::write(
544            &path,
545            "not json\n{\"type\":\"user\",\"text\":\"/clear\"}\nalso not json",
546        )
547        .unwrap();
548        let signals = ClaudeCodeAdapter::new()
549            .parse_session(SessionLog::Transcript(path))
550            .await
551            .unwrap();
552        assert_eq!(signals.len(), 1);
553        assert_eq!(signals[0].source, "user_clear");
554    }
555}