1use 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#[derive(Debug, Clone, Default)]
18pub struct ClaudeCodeAdapter;
19
20impl ClaudeCodeAdapter {
21 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 pub fn stop_hook_entry() -> Value {
36 serde_json::json!({
37 "type": "command",
38 "command": HOOK_MARKER,
39 })
40 }
41
42 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 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 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 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
190fn 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 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
216fn 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
230fn 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 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 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}