Skip to main content

nexus_memory_hooks/agents/
claude.rs

1//! Claude Code hook implementation
2//!
3//! Uses Skills-based lifecycle hooks for native integration.
4
5use 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
14/// Claude Code hook using Skills lifecycle
15///
16/// Installation:
17/// 1. Creates Claude Code Skill at ~/.claude/skills/nexus-memory/SKILL.md
18/// 2. Skill auto-triggers on session_end, checkpoint, completion
19/// 3. Skill calls MCP tool to store memory
20///
21/// Lifecycle support:
22/// - **session_start**: Via settings.json `SessionStart` hook entry
23/// - **session_end**: Via skill (on_session_end trigger)
24/// - **checkpoint**: Via skill (on_checkpoint trigger)
25/// - **error**: Via skill (on_error trigger)
26/// - **compact**: Via skill (on_completion trigger)
27pub struct ClaudeCodeHook {
28    /// Base hook functionality
29    base: BaseHook,
30
31    /// Skill path
32    skill_path: PathBuf,
33
34    /// Whether skill is installed
35    skill_installed: bool,
36
37    /// Whether a SessionStart hook was written to settings.json
38    settings_hook_installed: bool,
39
40    /// Process monitor for fallback detection
41    process_monitor: ProcessMonitor,
42}
43
44impl ClaudeCodeHook {
45    /// Skill name
46    pub const SKILL_NAME: &'static str = "nexus-memory-extraction";
47
48    /// Config directory
49    pub const CONFIG_DIR: &'static str = ".claude";
50
51    /// Skills subdirectory
52    pub const SKILLS_DIR: &'static str = "skills";
53
54    /// Create a new Claude Code hook
55    pub fn new() -> Self {
56        let skill_path = dirs::home_dir()
57            .unwrap_or_else(|| PathBuf::from("."))
58            .join(Self::CONFIG_DIR)
59            .join(Self::SKILLS_DIR)
60            .join(Self::SKILL_NAME);
61
62        let mut hook = Self {
63            base: BaseHook::new("claude-code"),
64            skill_path,
65            skill_installed: false,
66            settings_hook_installed: Self::has_settings_hook(),
67            process_monitor: ProcessMonitor::new(),
68        };
69
70        // Try to install skill
71        if let Err(e) = hook.install_skill() {
72            tracing::warn!("Failed to install Claude Code skill: {}", e);
73        }
74
75        hook
76    }
77
78    /// Install the SKILL.md file
79    fn install_skill(&mut self) -> Result<()> {
80        // Create skill directory
81        std::fs::create_dir_all(&self.skill_path).map_err(|e| {
82            HookError::InstallationFailed(format!("Failed to create skill dir: {}", e))
83        })?;
84
85        let skill_md = self.skill_path.join("SKILL.md");
86
87        let skill_content = r#"---
88name: nexus-memory-extraction
89description: Automatically extract session context to Nexus Memory System
90version: 1.0.0
91author: Nexus Memory System
92trigger:
93  - on_session_end
94  - on_checkpoint
95  - on_completion
96  - on_error
97priority: high
98---
99
100# Nexus Memory Extraction Skill
101
102## Overview
103
104This skill automatically triggers when your Claude Code session ends, ensuring no context is lost.
105
106## What It Does
107
1081. **Captures Context**: Extracts current conversation, decisions, and context
1092. **Summarizes**: Creates structured summary of key points
1103. **Stores**: Automatically stores to Nexus Memory System
1114. **Confirms**: Shows what was stored
112
113## Triggers
114
115- **on_session_end**: When you close Claude Code
116- **on_checkpoint**: At periodic checkpoints during long sessions
117- **on_completion**: When a task is completed
118- **on_error**: If an error occurs (stores context for debugging)
119
120## No Manual Action Required
121
122This skill runs automatically. You don't need to remember to trigger it.
123You do not need to start a Nexus server manually for normal CLI memory capture.
124
125## Configuration
126
127The skill reads from:
128- `NEXUS_AUTO_INGEST=true` environment variable
129- the local Nexus CLI runtime for default operation
130
131Optional:
132- an external Nexus endpoint only when explicitly configured for advanced remote workflows
133
134## Output
135
136After storing, you'll see:
137```
138[Nexus] Stored 3 memories from Claude Code session:
139  - 2 decisions
140  - 1 context item
141  - Memory IDs: nexus_123, nexus_124, nexus_125
142```
143"#;
144
145        std::fs::write(&skill_md, skill_content).map_err(|e| {
146            HookError::InstallationFailed(format!("Failed to write skill file: {}", e))
147        })?;
148
149        self.skill_installed = true;
150        tracing::info!("Claude Code Skill installed at: {:?}", self.skill_path);
151
152        Ok(())
153    }
154
155    /// Settings file path for Claude Code hooks configuration.
156    fn settings_path() -> PathBuf {
157        dirs::home_dir()
158            .unwrap_or_else(|| PathBuf::from("."))
159            .join(Self::CONFIG_DIR)
160            .join("settings.json")
161    }
162
163    /// Install a SessionStart hook entry into Claude Code's settings.json.
164    ///
165    /// Claude Code natively supports `SessionStart` as a hook event type.
166    /// This writes a hook entry that invokes `nexus session start` when a
167    /// new Claude Code session begins.
168    fn install_settings_hook(&mut self) -> Result<()> {
169        let settings_path = Self::settings_path();
170        let command = Self::desired_session_start_command();
171
172        let mut settings = if settings_path.exists() {
173            let content = std::fs::read_to_string(&settings_path).map_err(|e| {
174                HookError::InstallationFailed(format!("Failed to read settings.json: {}", e))
175            })?;
176            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
177                HookError::InstallationFailed(format!("Failed to parse settings.json: {}", e))
178            })?
179        } else {
180            serde_json::json!({})
181        };
182
183        Self::upsert_session_start_hook(&mut settings, &command)?;
184
185        // Write back
186        let serialized = serde_json::to_string_pretty(&settings).map_err(|e| {
187            HookError::InstallationFailed(format!("Failed to serialize settings: {}", e))
188        })?;
189
190        // Create parent dir if needed
191        if let Some(parent) = settings_path.parent() {
192            std::fs::create_dir_all(parent).map_err(|e| {
193                HookError::InstallationFailed(format!("Failed to create settings dir: {}", e))
194            })?;
195        }
196
197        std::fs::write(&settings_path, serialized).map_err(|e| {
198            HookError::InstallationFailed(format!("Failed to write settings.json: {}", e))
199        })?;
200
201        self.settings_hook_installed = true;
202        tracing::info!(
203            "Claude Code SessionStart hook written to: {:?}",
204            settings_path
205        );
206
207        Ok(())
208    }
209
210    /// Find the nexus binary path for use in hook commands.
211    fn find_nexus_binary() -> String {
212        if let Ok(bin) = std::env::var("NEXUS_HOOK_BINARY") {
213            if !bin.trim().is_empty() {
214                return bin;
215            }
216        }
217
218        if let Ok(current_exe) = std::env::current_exe() {
219            if current_exe
220                .file_name()
221                .and_then(|name| name.to_str())
222                .is_some_and(|name| name == "nexus")
223            {
224                return current_exe.to_string_lossy().to_string();
225            }
226        }
227
228        // Check common installation paths
229        let candidates: Vec<PathBuf> = [
230            dirs::home_dir().map(|h| h.join(".local").join("bin").join("nexus")),
231            Some(PathBuf::from("/usr/local/bin/nexus")),
232        ]
233        .into_iter()
234        .flatten()
235        .collect();
236
237        for candidate in candidates {
238            if candidate.exists() {
239                return candidate.to_string_lossy().to_string();
240            }
241        }
242
243        // Fallback: assume it's in PATH
244        "nexus".to_string()
245    }
246
247    fn desired_session_start_command() -> String {
248        let nexus_bin = Self::find_nexus_binary();
249        format!(
250            "'{}' session start --agent claude-code --mode session",
251            nexus_bin.replace('\'', "'\\''")
252        )
253    }
254
255    fn has_settings_hook() -> bool {
256        let settings_path = Self::settings_path();
257        let Ok(content) = std::fs::read_to_string(settings_path) else {
258            return false;
259        };
260        let Ok(settings) = serde_json::from_str::<serde_json::Value>(&content) else {
261            return false;
262        };
263        let desired_command = Self::desired_session_start_command();
264        settings
265            .get("hooks")
266            .and_then(|hooks| hooks.get("SessionStart"))
267            .and_then(|value| value.as_array())
268            .is_some_and(|entries| {
269                entries.iter().any(|entry| {
270                    Self::entry_contains_exact_session_start_hook(entry, &desired_command)
271                })
272            })
273    }
274
275    #[cfg(test)]
276    fn entry_has_session_start_hook(entry: &serde_json::Value) -> bool {
277        entry
278            .get("command")
279            .and_then(|command| command.as_str())
280            .map(Self::command_is_session_start_hook)
281            .unwrap_or(false)
282            || entry
283                .get("hooks")
284                .and_then(|hooks| hooks.as_array())
285                .is_some_and(|hooks| {
286                    hooks.iter().any(|hook| {
287                        hook.get("command")
288                            .and_then(|command| command.as_str())
289                            .map(Self::command_is_session_start_hook)
290                            .unwrap_or(false)
291                    })
292                })
293    }
294
295    fn command_is_session_start_hook(command: &str) -> bool {
296        command.contains("nexus")
297            && command.contains("session start")
298            && command.contains("claude-code")
299    }
300
301    fn entry_contains_exact_session_start_hook(
302        entry: &serde_json::Value,
303        desired_command: &str,
304    ) -> bool {
305        entry
306            .get("command")
307            .and_then(|command| command.as_str())
308            .map(|command| command == desired_command)
309            .unwrap_or(false)
310            || entry
311                .get("hooks")
312                .and_then(|hooks| hooks.as_array())
313                .is_some_and(|hooks| {
314                    hooks.iter().any(|hook| {
315                        hook.get("command")
316                            .and_then(|command| command.as_str())
317                            .map(|command| command == desired_command)
318                            .unwrap_or(false)
319                    })
320                })
321    }
322
323    fn upsert_session_start_hook(
324        settings: &mut serde_json::Value,
325        desired_command: &str,
326    ) -> Result<()> {
327        let settings_obj = settings.as_object_mut().ok_or_else(|| {
328            HookError::InstallationFailed(
329                "settings.json must contain a top-level JSON object".to_string(),
330            )
331        })?;
332
333        let hooks = settings_obj
334            .entry("hooks")
335            .or_insert_with(|| serde_json::json!({}));
336        let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
337            HookError::InstallationFailed("'hooks' must be a JSON object".to_string())
338        })?;
339
340        let session_start = hooks_obj
341            .entry("SessionStart")
342            .or_insert_with(|| serde_json::json!([]));
343        let entries = session_start.as_array_mut().ok_or_else(|| {
344            HookError::InstallationFailed("'hooks.SessionStart' must be an array".to_string())
345        })?;
346
347        if Self::replace_existing_session_start_hook(entries, desired_command) {
348            return Ok(());
349        }
350
351        entries.push(serde_json::json!({
352            "matcher": "",
353            "hooks": [{
354                "type": "command",
355                "command": desired_command,
356            }]
357        }));
358
359        Ok(())
360    }
361
362    fn replace_existing_session_start_hook(
363        entries: &mut [serde_json::Value],
364        desired_command: &str,
365    ) -> bool {
366        for entry in entries {
367            if entry
368                .get("command")
369                .and_then(|value| value.as_str())
370                .is_some_and(Self::command_is_session_start_hook)
371            {
372                *entry = serde_json::json!({
373                    "type": "command",
374                    "command": desired_command,
375                });
376                return true;
377            }
378
379            if let Some(hooks) = entry
380                .get_mut("hooks")
381                .and_then(|value| value.as_array_mut())
382            {
383                for hook in hooks {
384                    if hook
385                        .get("command")
386                        .and_then(|value| value.as_str())
387                        .is_some_and(Self::command_is_session_start_hook)
388                    {
389                        *hook = serde_json::json!({
390                            "type": "command",
391                            "command": desired_command,
392                        });
393                        return true;
394                    }
395                }
396            }
397        }
398
399        false
400    }
401
402    /// Read session file
403    fn read_session_file(&self) -> Option<serde_json::Value> {
404        let session_file = dirs::home_dir()?
405            .join(Self::CONFIG_DIR)
406            .join("session.json");
407
408        if session_file.exists() {
409            let content = std::fs::read_to_string(&session_file).ok()?;
410            serde_json::from_str(&content).ok()
411        } else {
412            None
413        }
414    }
415
416    /// Read checkpoint data
417    fn read_checkpoint_data(&self) -> Option<Vec<serde_json::Value>> {
418        let checkpoint_dir = dirs::home_dir()?.join(Self::CONFIG_DIR).join("checkpoints");
419
420        if !checkpoint_dir.exists() {
421            return None;
422        }
423
424        let mut checkpoints = Vec::new();
425
426        if let Ok(entries) = std::fs::read_dir(&checkpoint_dir) {
427            for entry in entries.flatten() {
428                if entry
429                    .path()
430                    .extension()
431                    .map(|e| e == "json")
432                    .unwrap_or(false)
433                {
434                    if let Ok(content) = std::fs::read_to_string(entry.path()) {
435                        if let Ok(data) = serde_json::from_str(&content) {
436                            checkpoints.push(data);
437                        }
438                    }
439                }
440            }
441        }
442
443        Some(checkpoints)
444    }
445}
446
447impl Default for ClaudeCodeHook {
448    fn default() -> Self {
449        Self::new()
450    }
451}
452
453#[async_trait]
454impl AgentHook for ClaudeCodeHook {
455    fn agent_type(&self) -> &str {
456        &self.base.agent_type
457    }
458
459    async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
460        self.base.add_callback(callback);
461        self.base.installed = true;
462
463        if !self.skill_installed {
464            tracing::warn!("Claude Code Skill not installed, using fallback detection");
465        }
466
467        Ok(())
468    }
469
470    /// Install a SessionStart hook via Claude Code's settings.json.
471    ///
472    /// Claude Code natively supports the `SessionStart` hook event type,
473    /// which fires when a new Claude Code session begins. This writes a
474    /// hook entry that invokes `nexus session start --agent claude-code`.
475    async fn install_session_start_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
476        self.base.add_callback(callback);
477
478        self.install_settings_hook()?;
479
480        Ok(())
481    }
482
483    /// Checkpoint hooks are supported via the installed skill's on_checkpoint trigger.
484    async fn install_checkpoint_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
485        self.base.add_callback(callback);
486        Ok(())
487    }
488
489    /// Compact hooks are supported via the installed skill's on_completion trigger.
490    async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
491        self.base.add_callback(callback);
492        Ok(())
493    }
494
495    /// Error hooks are supported via the installed skill's on_error trigger.
496    async fn install_error_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
497        self.base.add_callback(callback);
498        Ok(())
499    }
500
501    async fn detect_session_activity(&self) -> Result<SessionActivity> {
502        // Refresh process monitor
503        let mut monitor = self.process_monitor.clone();
504        let processes = monitor.find_agent_processes(AgentType::ClaudeCode);
505
506        let mut activity = SessionActivity::new(AgentType::ClaudeCode);
507
508        if !processes.is_empty() {
509            activity.is_active = true;
510            activity.processes = processes;
511        }
512
513        // Also check for session file
514        if let Some(session) = self.read_session_file() {
515            if let Some(id) = session.get("session_id").and_then(|s| s.as_str()) {
516                activity.session_id = Some(id.to_string());
517            }
518        }
519
520        Ok(activity)
521    }
522
523    async fn extract_session_context(&self) -> Result<SessionContext> {
524        let mut context = SessionContext::new("claude-code")
525            .with_source("native")
526            .with_reliability(1.0);
527
528        // Read session file
529        if let Some(session) = self.read_session_file() {
530            if let Some(messages) = session.get("messages").and_then(|m| m.as_array()) {
531                for msg in messages {
532                    let role = msg
533                        .get("role")
534                        .and_then(|r| r.as_str())
535                        .unwrap_or("unknown");
536                    let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
537                    context.add_message(role, content);
538                }
539            }
540
541            if let Some(project_ctx) = session.get("project_context") {
542                context.add_custom("project_context", project_ctx.clone());
543            }
544        }
545
546        // Read checkpoint data
547        if let Some(checkpoints) = self.read_checkpoint_data() {
548            for checkpoint in checkpoints {
549                if let Some(decisions) = checkpoint.get("decisions").and_then(|d| d.as_array()) {
550                    for decision in decisions {
551                        if let Some(summary) = decision.get("summary").and_then(|s| s.as_str()) {
552                            let mut dec = crate::session::Decision::new(summary);
553                            if let Some(rationale) =
554                                decision.get("rationale").and_then(|r| r.as_str())
555                            {
556                                dec.rationale = Some(rationale.to_string());
557                            }
558                            context.add_decision(dec);
559                        }
560                    }
561                }
562
563                if let Some(files) = checkpoint.get("files").and_then(|f| f.as_array()) {
564                    for file in files {
565                        if let Some(path) = file.get("path").and_then(|p| p.as_str()) {
566                            let action = file
567                                .get("action")
568                                .and_then(|a| a.as_str())
569                                .unwrap_or("modified");
570                            let file_action = match action {
571                                "created" => crate::session::FileAction::Created,
572                                "deleted" => crate::session::FileAction::Deleted,
573                                "read" => crate::session::FileAction::Read,
574                                _ => crate::session::FileAction::Modified,
575                            };
576                            context.add_file(crate::session::FileInfo::new(path, file_action));
577                        }
578                    }
579                }
580            }
581        }
582
583        context.complete();
584        Ok(context)
585    }
586
587    fn is_hook_installed(&self) -> bool {
588        self.skill_installed || self.settings_hook_installed
589    }
590
591    fn reliability_score(&self) -> f32 {
592        if self.skill_installed && self.settings_hook_installed {
593            1.0
594        } else if self.skill_installed || self.settings_hook_installed {
595            0.98
596        } else {
597            0.95 // Fallback to process monitoring
598        }
599    }
600
601    fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
602        LifecycleCapabilities {
603            session_start: true,
604            session_end: true,
605            checkpoint: true,
606            error_hook: true,
607            compact: true,
608        }
609    }
610
611    fn support_tier(&self) -> SupportTier {
612        SupportTier::NativeLifecycle
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_claude_hook_new() {
622        let hook = ClaudeCodeHook::new();
623        assert_eq!(hook.agent_type(), "claude-code");
624    }
625
626    #[tokio::test]
627    async fn test_claude_hook_detect_activity() {
628        let hook = ClaudeCodeHook::new();
629        let activity = hook.detect_session_activity().await.unwrap();
630
631        assert_eq!(activity.agent_type, AgentType::ClaudeCode);
632    }
633
634    #[test]
635    fn test_claude_hook_lifecycle_capabilities() {
636        let hook = ClaudeCodeHook::new();
637        let caps = hook.lifecycle_capabilities();
638
639        assert!(
640            caps.session_start,
641            "Claude Code should support session_start"
642        );
643        assert!(caps.session_end, "Claude Code should support session_end");
644        assert!(caps.checkpoint, "Claude Code should support checkpoint");
645        assert!(caps.error_hook, "Claude Code should support error_hook");
646        assert!(caps.compact, "Claude Code should support compact");
647    }
648
649    #[tokio::test]
650    async fn test_claude_hook_install_session_start() {
651        let mut hook = ClaudeCodeHook::new();
652        let callback = std::sync::Arc::new(|_ctx| {});
653
654        // Should succeed (may write to settings.json)
655        let result = hook.install_session_start_hook(callback).await;
656        // Result depends on whether settings.json is writable, but should not be NotSupported
657        match result {
658            Ok(()) => {
659                assert!(hook.settings_hook_installed);
660            }
661            Err(HookError::InstallationFailed(_)) => {
662                // Acceptable if file system is not writable in test env
663            }
664            Err(HookError::NotSupported(msg)) => {
665                panic!(
666                    "Session start should be supported for Claude Code, got: {}",
667                    msg
668                );
669            }
670            Err(e) => {
671                panic!("Unexpected error: {}", e);
672            }
673        }
674    }
675
676    #[tokio::test]
677    async fn test_claude_hook_install_checkpoint_supported() {
678        let mut hook = ClaudeCodeHook::new();
679        let callback = std::sync::Arc::new(|_ctx| {});
680
681        let result = hook.install_checkpoint_hook(callback).await;
682        assert!(
683            result.is_ok(),
684            "Checkpoint should be supported for Claude Code"
685        );
686    }
687
688    #[tokio::test]
689    async fn test_claude_hook_install_error_supported() {
690        let mut hook = ClaudeCodeHook::new();
691        let callback = std::sync::Arc::new(|_ctx| {});
692
693        let result = hook.install_error_hook(callback).await;
694        assert!(
695            result.is_ok(),
696            "Error hook should be supported for Claude Code"
697        );
698    }
699
700    #[test]
701    fn test_find_nexus_binary() {
702        let bin = ClaudeCodeHook::find_nexus_binary();
703        assert!(!bin.is_empty());
704        // Should either be a full path or "nexus" fallback
705        assert!(bin.contains("nexus"));
706    }
707
708    #[test]
709    fn test_entry_has_session_start_hook_detects_nested_command() {
710        let entry = serde_json::json!({
711            "matcher": "",
712            "hooks": [
713                {
714                    "type": "command",
715                    "command": "/tmp/nexus session start --agent claude-code --mode session"
716                }
717            ]
718        });
719
720        assert!(ClaudeCodeHook::entry_has_session_start_hook(&entry));
721    }
722
723    #[test]
724    fn test_upsert_session_start_hook_repairs_stale_command() {
725        let desired = "'/new/nexus' session start --agent claude-code --mode session";
726        let mut settings = serde_json::json!({
727            "hooks": {
728                "SessionStart": [{
729                    "matcher": "",
730                    "hooks": [{
731                        "type": "command",
732                        "command": "'/old/nexus' session start --agent claude-code --mode session"
733                    }]
734                }]
735            }
736        });
737
738        ClaudeCodeHook::upsert_session_start_hook(&mut settings, desired).unwrap();
739
740        let hooks = settings["hooks"]["SessionStart"].as_array().unwrap();
741        assert_eq!(hooks.len(), 1);
742        assert_eq!(hooks[0]["hooks"][0]["command"], desired);
743    }
744
745    #[test]
746    fn test_upsert_session_start_hook_rejects_invalid_shapes() {
747        let mut settings = serde_json::json!({
748            "hooks": {
749                "SessionStart": {}
750            }
751        });
752
753        let error = ClaudeCodeHook::upsert_session_start_hook(
754            &mut settings,
755            "'/nexus' session start --agent claude-code --mode session",
756        )
757        .unwrap_err();
758
759        assert!(error.to_string().contains("SessionStart"));
760    }
761}