nexus_memory_hooks/agents/
claude.rs1use async_trait::async_trait;
6use std::path::PathBuf;
7
8use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
9use crate::error::{HookError, Result};
10use crate::monitor::ProcessMonitor;
11use crate::session::SessionContext;
12use crate::types::{AgentType, SessionActivity, SupportTier};
13
14pub struct ClaudeCodeHook {
28 base: BaseHook,
30
31 skill_path: PathBuf,
33
34 skill_installed: bool,
36
37 settings_hook_installed: bool,
39
40 process_monitor: ProcessMonitor,
42}
43
44impl ClaudeCodeHook {
45 pub const SKILL_NAME: &'static str = "nexus-memory-extraction";
47
48 pub const CONFIG_DIR: &'static str = ".claude";
50
51 pub const SKILLS_DIR: &'static str = "skills";
53
54 pub fn new() -> Self {
56 Self::new_with_install(true)
57 }
58
59 pub fn new_readonly() -> Self {
65 Self::new_with_install(false)
66 }
67
68 fn new_with_install(should_install: bool) -> Self {
69 let skill_path = dirs::home_dir()
70 .unwrap_or_else(|| PathBuf::from("."))
71 .join(Self::CONFIG_DIR)
72 .join(Self::SKILLS_DIR)
73 .join(Self::SKILL_NAME);
74
75 let mut hook = Self {
76 base: BaseHook::new("claude-code"),
77 skill_path,
78 skill_installed: false,
79 settings_hook_installed: Self::has_settings_hook(),
80 process_monitor: ProcessMonitor::new(),
81 };
82
83 if should_install {
84 if let Err(e) = hook.install_skill() {
86 tracing::warn!("Failed to install Claude Code skill: {}", e);
87 }
88 }
89
90 hook
91 }
92
93 fn install_skill(&mut self) -> Result<()> {
95 std::fs::create_dir_all(&self.skill_path).map_err(|e| {
97 HookError::InstallationFailed(format!("Failed to create skill dir: {}", e))
98 })?;
99
100 let skill_md = self.skill_path.join("SKILL.md");
101
102 let skill_content = r#"---
103name: nexus-memory-extraction
104description: Automatically extract session context to Nexus Memory System
105version: 1.0.0
106author: Nexus Memory System
107trigger:
108 - on_session_end
109 - on_checkpoint
110 - on_completion
111 - on_error
112priority: high
113---
114
115# Nexus Memory Extraction Skill
116
117## Overview
118
119This skill automatically triggers when your Claude Code session ends, ensuring no context is lost.
120
121## What It Does
122
1231. **Captures Context**: Extracts current conversation, decisions, and context
1242. **Summarizes**: Creates structured summary of key points
1253. **Stores**: Automatically stores to Nexus Memory System
1264. **Confirms**: Shows what was stored
127
128## Triggers
129
130- **on_session_end**: When you close Claude Code
131- **on_checkpoint**: At periodic checkpoints during long sessions
132- **on_completion**: When a task is completed
133- **on_error**: If an error occurs (stores context for debugging)
134
135## No Manual Action Required
136
137This skill runs automatically. You don't need to remember to trigger it.
138You do not need to start a Nexus server manually for normal CLI memory capture.
139
140## Configuration
141
142The skill reads from:
143- `NEXUS_AUTO_INGEST=true` environment variable
144- the local Nexus CLI runtime for default operation
145
146Optional:
147- an external Nexus endpoint only when explicitly configured for advanced remote workflows
148
149## Output
150
151After storing, you'll see:
152```
153[Nexus] Stored 3 memories from Claude Code session:
154 - 2 decisions
155 - 1 context item
156 - Memory IDs: nexus_123, nexus_124, nexus_125
157```
158"#;
159
160 std::fs::write(&skill_md, skill_content).map_err(|e| {
161 HookError::InstallationFailed(format!("Failed to write skill file: {}", e))
162 })?;
163
164 self.skill_installed = true;
165 tracing::info!("Claude Code Skill installed at: {:?}", self.skill_path);
166
167 Ok(())
168 }
169
170 fn settings_path() -> PathBuf {
172 dirs::home_dir()
173 .unwrap_or_else(|| PathBuf::from("."))
174 .join(Self::CONFIG_DIR)
175 .join("settings.json")
176 }
177
178 fn install_settings_hook(&mut self) -> Result<()> {
184 let settings_path = Self::settings_path();
185 let command = Self::desired_session_start_command();
186
187 let mut settings = if settings_path.exists() {
188 let content = std::fs::read_to_string(&settings_path).map_err(|e| {
189 HookError::InstallationFailed(format!("Failed to read settings.json: {}", e))
190 })?;
191 serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
192 HookError::InstallationFailed(format!("Failed to parse settings.json: {}", e))
193 })?
194 } else {
195 serde_json::json!({})
196 };
197
198 Self::upsert_session_start_hook(&mut settings, &command)?;
199
200 let subconscious_mode = std::env::var("NEXUS_SUBCONSCIOUS_MODE")
203 .unwrap_or_default()
204 .to_lowercase();
205 if subconscious_mode != "off" {
206 for event_type in ["UserPromptSubmit", "PreToolUse", "Stop"] {
207 let cmd = Self::desired_subconscious_command(event_type);
208 if cmd.is_empty() {
209 continue;
210 }
211 Self::upsert_hook_entry(&mut settings, event_type, &cmd, &|command: &str| {
212 Self::command_is_subconscious_hook(command, event_type)
213 })?;
214 }
215 }
216 let serialized = serde_json::to_string_pretty(&settings).map_err(|e| {
218 HookError::InstallationFailed(format!("Failed to serialize settings: {}", e))
219 })?;
220
221 if let Some(parent) = settings_path.parent() {
223 std::fs::create_dir_all(parent).map_err(|e| {
224 HookError::InstallationFailed(format!("Failed to create settings dir: {}", e))
225 })?;
226 }
227
228 std::fs::write(&settings_path, serialized).map_err(|e| {
229 HookError::InstallationFailed(format!("Failed to write settings.json: {}", e))
230 })?;
231
232 self.settings_hook_installed = true;
233 tracing::info!(
234 "Claude Code SessionStart hook written to: {:?}",
235 settings_path
236 );
237
238 Ok(())
239 }
240
241 fn find_nexus_binary() -> String {
243 if let Ok(bin) = std::env::var("NEXUS_HOOK_BINARY") {
244 if !bin.trim().is_empty() {
245 return bin;
246 }
247 }
248
249 if let Ok(current_exe) = std::env::current_exe() {
250 if current_exe
251 .file_name()
252 .and_then(|name| name.to_str())
253 .is_some_and(|name| name == "nexus")
254 {
255 return current_exe.to_string_lossy().to_string();
256 }
257 }
258
259 let candidates: Vec<PathBuf> = [
261 dirs::home_dir().map(|h| h.join(".local").join("bin").join("nexus")),
262 Some(PathBuf::from("/usr/local/bin/nexus")),
263 ]
264 .into_iter()
265 .flatten()
266 .collect();
267
268 for candidate in candidates {
269 if candidate.exists() {
270 return candidate.to_string_lossy().to_string();
271 }
272 }
273
274 "nexus".to_string()
276 }
277
278 fn desired_session_start_command() -> String {
279 let nexus_bin = Self::find_nexus_binary();
280 format!(
281 "'{}' session start --agent claude-code --mode session",
282 nexus_bin.replace('\'', "'\\''")
283 )
284 }
285
286 fn desired_subconscious_command(event_type: &str) -> String {
287 let nexus_bin = Self::find_nexus_binary();
288 let escaped = nexus_bin.replace('\'', "'\\''");
289 match event_type {
290 "UserPromptSubmit" => format!("'{}' subconscious recall --agent claude-code", escaped),
291 "PreToolUse" => format!("'{}' subconscious sync-check --agent claude-code", escaped),
292 "Stop" => format!(
293 "'{}' subconscious ingest-transcript --agent claude-code",
294 escaped
295 ),
296 _ => String::new(),
297 }
298 }
299
300 fn command_is_subconscious_hook(command: &str, event_type: &str) -> bool {
302 command.contains("nexus")
303 && command.contains("subconscious")
304 && match event_type {
305 "UserPromptSubmit" => command.contains("recall"),
306 "PreToolUse" => command.contains("sync-check"),
307 "Stop" => command.contains("ingest-transcript"),
308 _ => false,
309 }
310 }
311
312 fn upsert_hook_entry(
314 settings: &mut serde_json::Value,
315 event_type: &str,
316 desired_command: &str,
317 is_match: &dyn Fn(&str) -> bool,
318 ) -> Result<()> {
319 let settings_obj = settings.as_object_mut().ok_or_else(|| {
320 HookError::InstallationFailed(
321 "settings.json must contain a top-level JSON object".to_string(),
322 )
323 })?;
324
325 let hooks = settings_obj
326 .entry("hooks")
327 .or_insert_with(|| serde_json::json!({}));
328 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
329 HookError::InstallationFailed("'hooks' must be a JSON object".to_string())
330 })?;
331
332 let event_arr = hooks_obj
333 .entry(event_type)
334 .or_insert_with(|| serde_json::json!([]));
335 let entries = event_arr.as_array_mut().ok_or_else(|| {
336 HookError::InstallationFailed(format!("'hooks.{}' must be an array", event_type))
337 })?;
338
339 for entry in entries.iter_mut() {
341 if entry
343 .get("command")
344 .and_then(|v| v.as_str())
345 .map(is_match)
346 .unwrap_or(false)
347 {
348 *entry = serde_json::json!({
349 "matcher": "",
350 "hooks": [{
351 "type": "command",
352 "command": desired_command,
353 }]
354 });
355 return Ok(());
356 }
357
358 if let Some(hooks) = entry.get_mut("hooks").and_then(|v| v.as_array_mut()) {
360 for hook in hooks.iter_mut() {
361 if hook
362 .get("command")
363 .and_then(|v| v.as_str())
364 .map(is_match)
365 .unwrap_or(false)
366 {
367 *hook = serde_json::json!({
368 "type": "command",
369 "command": desired_command,
370 });
371 return Ok(());
372 }
373 }
374 }
375 }
376
377 entries.push(serde_json::json!({
379 "matcher": "",
380 "hooks": [{
381 "type": "command",
382 "command": desired_command,
383 }]
384 }));
385
386 Ok(())
387 }
388
389 fn has_settings_hook() -> bool {
390 let settings_path = Self::settings_path();
391 let Ok(content) = std::fs::read_to_string(settings_path) else {
392 return false;
393 };
394 let Ok(settings) = serde_json::from_str::<serde_json::Value>(&content) else {
395 return false;
396 };
397 let desired_command = Self::desired_session_start_command();
398 settings
399 .get("hooks")
400 .and_then(|hooks| hooks.get("SessionStart"))
401 .and_then(|value| value.as_array())
402 .is_some_and(|entries| {
403 entries.iter().any(|entry| {
404 Self::entry_contains_exact_session_start_hook(entry, &desired_command)
405 })
406 })
407 }
408
409 #[cfg(test)]
410 fn entry_has_session_start_hook(entry: &serde_json::Value) -> bool {
411 entry
412 .get("command")
413 .and_then(|command| command.as_str())
414 .map(Self::command_is_session_start_hook)
415 .unwrap_or(false)
416 || entry
417 .get("hooks")
418 .and_then(|hooks| hooks.as_array())
419 .is_some_and(|hooks| {
420 hooks.iter().any(|hook| {
421 hook.get("command")
422 .and_then(|command| command.as_str())
423 .map(Self::command_is_session_start_hook)
424 .unwrap_or(false)
425 })
426 })
427 }
428
429 fn command_is_session_start_hook(command: &str) -> bool {
430 command.contains("nexus")
431 && command.contains("session start")
432 && command.contains("claude-code")
433 }
434
435 fn entry_contains_exact_session_start_hook(
436 entry: &serde_json::Value,
437 desired_command: &str,
438 ) -> bool {
439 entry
440 .get("command")
441 .and_then(|command| command.as_str())
442 .map(|command| command == desired_command)
443 .unwrap_or(false)
444 || entry
445 .get("hooks")
446 .and_then(|hooks| hooks.as_array())
447 .is_some_and(|hooks| {
448 hooks.iter().any(|hook| {
449 hook.get("command")
450 .and_then(|command| command.as_str())
451 .map(|command| command == desired_command)
452 .unwrap_or(false)
453 })
454 })
455 }
456
457 fn upsert_session_start_hook(
458 settings: &mut serde_json::Value,
459 desired_command: &str,
460 ) -> Result<()> {
461 let settings_obj = settings.as_object_mut().ok_or_else(|| {
462 HookError::InstallationFailed(
463 "settings.json must contain a top-level JSON object".to_string(),
464 )
465 })?;
466
467 let hooks = settings_obj
468 .entry("hooks")
469 .or_insert_with(|| serde_json::json!({}));
470 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
471 HookError::InstallationFailed("'hooks' must be a JSON object".to_string())
472 })?;
473
474 let session_start = hooks_obj
475 .entry("SessionStart")
476 .or_insert_with(|| serde_json::json!([]));
477 let entries = session_start.as_array_mut().ok_or_else(|| {
478 HookError::InstallationFailed("'hooks.SessionStart' must be an array".to_string())
479 })?;
480
481 if Self::replace_existing_session_start_hook(entries, desired_command) {
482 return Ok(());
483 }
484
485 entries.push(serde_json::json!({
486 "matcher": "",
487 "hooks": [{
488 "type": "command",
489 "command": desired_command,
490 }]
491 }));
492
493 Ok(())
494 }
495
496 fn replace_existing_session_start_hook(
497 entries: &mut [serde_json::Value],
498 desired_command: &str,
499 ) -> bool {
500 for entry in entries {
501 if entry
502 .get("command")
503 .and_then(|value| value.as_str())
504 .is_some_and(Self::command_is_session_start_hook)
505 {
506 *entry = serde_json::json!({
507 "matcher": "",
508 "hooks": [{
509 "type": "command",
510 "command": desired_command,
511 }]
512 });
513 return true;
514 }
515
516 if let Some(hooks) = entry
517 .get_mut("hooks")
518 .and_then(|value| value.as_array_mut())
519 {
520 for hook in hooks {
521 if hook
522 .get("command")
523 .and_then(|value| value.as_str())
524 .is_some_and(Self::command_is_session_start_hook)
525 {
526 *hook = serde_json::json!({
527 "type": "command",
528 "command": desired_command,
529 });
530 return true;
531 }
532 }
533 }
534 }
535
536 false
537 }
538
539 fn read_session_file(&self) -> Option<serde_json::Value> {
541 let session_file = dirs::home_dir()?
542 .join(Self::CONFIG_DIR)
543 .join("session.json");
544
545 if session_file.exists() {
546 let content = std::fs::read_to_string(&session_file).ok()?;
547 serde_json::from_str(&content).ok()
548 } else {
549 None
550 }
551 }
552
553 fn read_checkpoint_data(&self) -> Option<Vec<serde_json::Value>> {
555 let checkpoint_dir = dirs::home_dir()?.join(Self::CONFIG_DIR).join("checkpoints");
556
557 if !checkpoint_dir.exists() {
558 return None;
559 }
560
561 let mut checkpoints = Vec::new();
562
563 if let Ok(entries) = std::fs::read_dir(&checkpoint_dir) {
564 for entry in entries.flatten() {
565 if entry
566 .path()
567 .extension()
568 .map(|e| e == "json")
569 .unwrap_or(false)
570 {
571 if let Ok(content) = std::fs::read_to_string(entry.path()) {
572 if let Ok(data) = serde_json::from_str(&content) {
573 checkpoints.push(data);
574 }
575 }
576 }
577 }
578 }
579
580 Some(checkpoints)
581 }
582}
583
584impl Default for ClaudeCodeHook {
585 fn default() -> Self {
586 Self::new()
587 }
588}
589
590#[async_trait]
591impl AgentHook for ClaudeCodeHook {
592 fn agent_type(&self) -> &str {
593 &self.base.agent_type
594 }
595
596 async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
597 self.base.add_callback(callback);
598 self.base.installed = true;
599
600 if !self.skill_installed {
601 tracing::warn!("Claude Code Skill not installed, using fallback detection");
602 }
603
604 Ok(())
605 }
606
607 async fn install_session_start_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
613 self.base.add_session_start_callback(callback);
614
615 self.install_settings_hook()?;
616
617 Ok(())
618 }
619
620 async fn install_checkpoint_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
622 self.base.add_checkpoint_callback(callback);
623 Ok(())
624 }
625
626 async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
628 self.base.add_callback(callback);
629 Ok(())
630 }
631
632 async fn install_error_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
634 self.base.add_error_callback(callback);
635 Ok(())
636 }
637
638 async fn detect_session_activity(&self) -> Result<SessionActivity> {
639 let mut recent_content = "claude session active".to_string();
641 if let Some(session) = self.read_session_file() {
642 if let Some(messages) = session.get("messages").and_then(|m| m.as_array()) {
643 if let Some(last_msg) = messages.last() {
644 if let Some(content) = last_msg.get("content").and_then(|c| c.as_str()) {
645 recent_content = content.to_string();
646 }
647 }
648 }
649 }
650
651 let mut monitor = self.process_monitor.clone();
653 let processes = monitor.find_agent_processes(AgentType::ClaudeCode);
654
655 let mut activity = SessionActivity::new(AgentType::ClaudeCode);
656
657 if !processes.is_empty() {
658 activity.is_active = true;
659 activity.processes = processes;
660 self.base.record_activity_with_content(&recent_content);
661 }
662
663 if let Some(session) = self.read_session_file() {
665 if let Some(id) = session.get("session_id").and_then(|s| s.as_str()) {
666 activity.session_id = Some(id.to_string());
667 }
668 }
669
670 Ok(activity)
671 }
672
673 async fn extract_session_context(&self) -> Result<SessionContext> {
674 let mut context = SessionContext::new("claude-code")
675 .with_source("native")
676 .with_reliability(1.0);
677
678 if let Some(session) = self.read_session_file() {
680 if let Some(messages) = session.get("messages").and_then(|m| m.as_array()) {
681 for msg in messages {
682 let role = msg
683 .get("role")
684 .and_then(|r| r.as_str())
685 .unwrap_or("unknown");
686 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
687 context.add_message(role, content);
688 }
689 }
690
691 if let Some(project_ctx) = session.get("project_context") {
692 context.add_custom("project_context", project_ctx.clone());
693 }
694 }
695
696 if let Some(checkpoints) = self.read_checkpoint_data() {
698 for checkpoint in checkpoints {
699 if let Some(decisions) = checkpoint.get("decisions").and_then(|d| d.as_array()) {
700 for decision in decisions {
701 if let Some(summary) = decision.get("summary").and_then(|s| s.as_str()) {
702 let mut dec = crate::session::Decision::new(summary);
703 if let Some(rationale) =
704 decision.get("rationale").and_then(|r| r.as_str())
705 {
706 dec.rationale = Some(rationale.to_string());
707 }
708 context.add_decision(dec);
709 }
710 }
711 }
712
713 if let Some(files) = checkpoint.get("files").and_then(|f| f.as_array()) {
714 for file in files {
715 if let Some(path) = file.get("path").and_then(|p| p.as_str()) {
716 let action = file
717 .get("action")
718 .and_then(|a| a.as_str())
719 .unwrap_or("modified");
720 let file_action = match action {
721 "created" => crate::session::FileAction::Created,
722 "deleted" => crate::session::FileAction::Deleted,
723 "read" => crate::session::FileAction::Read,
724 _ => crate::session::FileAction::Modified,
725 };
726 context.add_file(crate::session::FileInfo::new(path, file_action));
727 }
728 }
729 }
730 }
731 }
732
733 context.complete();
734 Ok(context)
735 }
736
737 fn is_hook_installed(&self) -> bool {
738 self.skill_installed || self.settings_hook_installed
739 }
740
741 fn reliability_score(&self) -> f32 {
742 if self.skill_installed && self.settings_hook_installed {
743 1.0
744 } else if self.skill_installed || self.settings_hook_installed {
745 0.98
746 } else {
747 0.95 }
749 }
750
751 fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
752 LifecycleCapabilities {
753 session_start: true,
754 session_end: true,
755 checkpoint: true,
756 error_hook: true,
757 compact: true,
758 }
759 }
760
761 fn support_tier(&self) -> SupportTier {
762 SupportTier::NativeLifecycle
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769
770 #[test]
771 fn test_claude_hook_new() {
772 let hook = ClaudeCodeHook::new();
773 assert_eq!(hook.agent_type(), "claude-code");
774 }
775
776 #[tokio::test]
777 async fn test_claude_hook_detect_activity() {
778 let hook = ClaudeCodeHook::new();
779 let activity = hook.detect_session_activity().await.unwrap();
780
781 assert_eq!(activity.agent_type, AgentType::ClaudeCode);
782 }
783
784 #[test]
785 fn test_claude_hook_lifecycle_capabilities() {
786 let hook = ClaudeCodeHook::new();
787 let caps = hook.lifecycle_capabilities();
788
789 assert!(
790 caps.session_start,
791 "Claude Code should support session_start"
792 );
793 assert!(caps.session_end, "Claude Code should support session_end");
794 assert!(caps.checkpoint, "Claude Code should support checkpoint");
795 assert!(caps.error_hook, "Claude Code should support error_hook");
796 assert!(caps.compact, "Claude Code should support compact");
797 }
798
799 #[tokio::test]
800 async fn test_claude_hook_install_session_start() {
801 let mut hook = ClaudeCodeHook::new();
802 let callback = std::sync::Arc::new(|_ctx| {});
803
804 let result = hook.install_session_start_hook(callback).await;
806 match result {
808 Ok(()) => {
809 assert!(hook.settings_hook_installed);
810 }
811 Err(HookError::InstallationFailed(_)) => {
812 }
814 Err(HookError::NotSupported(msg)) => {
815 panic!(
816 "Session start should be supported for Claude Code, got: {}",
817 msg
818 );
819 }
820 Err(e) => {
821 panic!("Unexpected error: {}", e);
822 }
823 }
824 }
825
826 #[tokio::test]
827 async fn test_claude_hook_install_checkpoint_supported() {
828 let mut hook = ClaudeCodeHook::new();
829 let callback = std::sync::Arc::new(|_ctx| {});
830
831 let result = hook.install_checkpoint_hook(callback).await;
832 assert!(
833 result.is_ok(),
834 "Checkpoint should be supported for Claude Code"
835 );
836 }
837
838 #[tokio::test]
839 async fn test_claude_hook_install_error_supported() {
840 let mut hook = ClaudeCodeHook::new();
841 let callback = std::sync::Arc::new(|_ctx| {});
842
843 let result = hook.install_error_hook(callback).await;
844 assert!(
845 result.is_ok(),
846 "Error hook should be supported for Claude Code"
847 );
848 }
849
850 #[test]
851 fn test_find_nexus_binary() {
852 let bin = ClaudeCodeHook::find_nexus_binary();
853 assert!(!bin.is_empty());
854 assert!(bin.contains("nexus"));
856 }
857
858 #[test]
859 fn test_entry_has_session_start_hook_detects_nested_command() {
860 let entry = serde_json::json!({
861 "matcher": "",
862 "hooks": [
863 {
864 "type": "command",
865 "command": "/tmp/nexus session start --agent claude-code --mode session"
866 }
867 ]
868 });
869
870 assert!(ClaudeCodeHook::entry_has_session_start_hook(&entry));
871 }
872
873 #[test]
874 fn test_upsert_session_start_hook_repairs_stale_command() {
875 let desired = "'/new/nexus' session start --agent claude-code --mode session";
876 let mut settings = serde_json::json!({
877 "hooks": {
878 "SessionStart": [{
879 "matcher": "",
880 "hooks": [{
881 "type": "command",
882 "command": "'/old/nexus' session start --agent claude-code --mode session"
883 }]
884 }]
885 }
886 });
887
888 ClaudeCodeHook::upsert_session_start_hook(&mut settings, desired).unwrap();
889
890 let hooks = settings["hooks"]["SessionStart"].as_array().unwrap();
891 assert_eq!(hooks.len(), 1);
892 assert_eq!(hooks[0]["hooks"][0]["command"], desired);
893 }
894
895 #[test]
896 fn test_upsert_session_start_hook_rejects_invalid_shapes() {
897 let mut settings = serde_json::json!({
898 "hooks": {
899 "SessionStart": {}
900 }
901 });
902
903 let error = ClaudeCodeHook::upsert_session_start_hook(
904 &mut settings,
905 "'/nexus' session start --agent claude-code --mode session",
906 )
907 .unwrap_err();
908
909 assert!(error.to_string().contains("SessionStart"));
910 }
911
912 #[test]
913 fn test_desired_subconscious_command_returns_commands() {
914 let recall = ClaudeCodeHook::desired_subconscious_command("UserPromptSubmit");
915 assert!(recall.contains("subconscious recall"));
916 let sync = ClaudeCodeHook::desired_subconscious_command("PreToolUse");
917 assert!(sync.contains("subconscious sync-check"));
918 let stop = ClaudeCodeHook::desired_subconscious_command("Stop");
919 assert!(stop.contains("subconscious ingest-transcript"));
920 }
921
922 #[test]
923 fn test_desired_subconscious_command_unknown_returns_empty() {
924 let cmd = ClaudeCodeHook::desired_subconscious_command("Unknown");
925 assert!(cmd.is_empty());
926 }
927
928 #[test]
929 fn test_command_is_subconscious_hook_matches() {
930 assert!(ClaudeCodeHook::command_is_subconscious_hook(
931 "/nexus subconscious recall --agent claude-code",
932 "UserPromptSubmit"
933 ));
934 assert!(!ClaudeCodeHook::command_is_subconscious_hook(
935 "/nexus subconscious recall --agent claude-code",
936 "PreToolUse"
937 ));
938 }
939
940 #[test]
941 fn test_upsert_hook_entry_adds_new_event() {
942 let mut settings = serde_json::json!({"hooks": {}});
943 ClaudeCodeHook::upsert_hook_entry(
944 &mut settings,
945 "UserPromptSubmit",
946 "nexus subconscious recall",
947 &|cmd: &str| cmd.contains("subconscious recall"),
948 )
949 .unwrap();
950
951 let entries = settings["hooks"]["UserPromptSubmit"].as_array().unwrap();
952 assert_eq!(entries.len(), 1);
953 assert_eq!(
954 entries[0]["hooks"][0]["command"],
955 "nexus subconscious recall"
956 );
957 }
958
959 #[test]
960 fn test_upsert_hook_entry_replaces_existing() {
961 let mut settings = serde_json::json!({
962 "hooks": {
963 "PreToolUse": [{
964 "matcher": "",
965 "hooks": [{
966 "type": "command",
967 "command": "/old/nexus subconscious sync-check"
968 }]
969 }]
970 }
971 });
972
973 ClaudeCodeHook::upsert_hook_entry(
974 &mut settings,
975 "PreToolUse",
976 "/new/nexus subconscious sync-check",
977 &|cmd: &str| cmd.contains("subconscious sync-check"),
978 )
979 .unwrap();
980
981 let entries = settings["hooks"]["PreToolUse"].as_array().unwrap();
982 assert_eq!(entries.len(), 1);
983 assert_eq!(
984 entries[0]["hooks"][0]["command"],
985 "/new/nexus subconscious sync-check"
986 );
987 }
988
989 #[test]
990 fn test_upsert_hook_entry_replaces_flat_command() {
991 let mut settings = serde_json::json!({
992 "hooks": {
993 "UserPromptSubmit": [{
994 "type": "command",
995 "command": "/old/nexus subconscious recall"
996 }]
997 }
998 });
999
1000 ClaudeCodeHook::upsert_hook_entry(
1001 &mut settings,
1002 "UserPromptSubmit",
1003 "/new/nexus subconscious recall",
1004 &|cmd: &str| cmd.contains("subconscious recall"),
1005 )
1006 .unwrap();
1007
1008 let entries = settings["hooks"]["UserPromptSubmit"].as_array().unwrap();
1009 assert_eq!(entries.len(), 1);
1010 assert!(entries[0].get("hooks").is_some(), "Should use nested shape");
1012 assert_eq!(
1013 entries[0]["hooks"][0]["command"],
1014 "/new/nexus subconscious recall"
1015 );
1016 }
1017
1018 #[test]
1019 fn test_command_is_subconscious_hook_stop_event() {
1020 assert!(ClaudeCodeHook::command_is_subconscious_hook(
1021 "/nexus subconscious ingest-transcript --agent claude-code",
1022 "Stop"
1023 ));
1024 assert!(!ClaudeCodeHook::command_is_subconscious_hook(
1025 "/nexus subconscious recall",
1026 "Stop"
1027 ));
1028 }
1029}