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 with full installation.
55    pub fn new() -> Self {
56        Self::new_with_install(true)
57    }
58
59    /// Create a new Claude Code hook without mutating user state.
60    ///
61    /// Skips skill installation and session-start injection so the
62    /// hook can be used for inspection/status reporting without side
63    /// effects on the filesystem.
64    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            // Try to install skill
85            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    /// Install the SKILL.md file
94    fn install_skill(&mut self) -> Result<()> {
95        // Create skill directory
96        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    /// Settings file path for Claude Code hooks configuration.
171    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    /// Install a SessionStart hook entry into Claude Code's settings.json.
179    ///
180    /// Claude Code natively supports `SessionStart` as a hook event type.
181    /// This writes a hook entry that invokes `nexus session start` when a
182    /// new Claude Code session begins.
183    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        // Install subconscious retrieval hooks (UserPromptSubmit, PreToolUse, Stop)
201        // Respect NEXUS_SUBCONSCIOUS_MODE — skip when set to 'off'
202        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        // Write back
217        let serialized = serde_json::to_string_pretty(&settings).map_err(|e| {
218            HookError::InstallationFailed(format!("Failed to serialize settings: {}", e))
219        })?;
220
221        // Create parent dir if needed
222        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    /// Find the nexus binary path for use in hook commands.
242    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        // Check common installation paths
260        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        // Fallback: assume it's in PATH
275        "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    /// Detect whether a command string is a nexus subconscious hook.
301    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    /// Generic hook upsert for any event type (used for subconscious hooks).
313    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        // Try to replace existing entry
340        for entry in entries.iter_mut() {
341            // Check flat command
342            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            // Check nested hooks array
359            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        // No existing entry found — append new one
378        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    /// Read session file
540    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    /// Read checkpoint data
554    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    /// Install a SessionStart hook via Claude Code's settings.json.
608    ///
609    /// Claude Code natively supports the `SessionStart` hook event type,
610    /// which fires when a new Claude Code session begins. This writes a
611    /// hook entry that invokes `nexus session start --agent claude-code`.
612    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    /// Checkpoint hooks are supported via the installed skill's on_checkpoint trigger.
621    async fn install_checkpoint_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
622        self.base.add_checkpoint_callback(callback);
623        Ok(())
624    }
625
626    /// Compact hooks are supported via the installed skill's on_completion trigger.
627    async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
628        self.base.add_callback(callback);
629        Ok(())
630    }
631
632    /// Error hooks are supported via the installed skill's on_error trigger.
633    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        // Also check for session file to get recent turn content
640        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        // Refresh process monitor
652        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        // Also check for session file
664        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        // Read session file
679        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        // Read checkpoint data
697        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 // Fallback to process monitoring
748        }
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        // Should succeed (may write to settings.json)
805        let result = hook.install_session_start_hook(callback).await;
806        // Result depends on whether settings.json is writable, but should not be NotSupported
807        match result {
808            Ok(()) => {
809                assert!(hook.settings_hook_installed);
810            }
811            Err(HookError::InstallationFailed(_)) => {
812                // Acceptable if file system is not writable in test env
813            }
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        // Should either be a full path or "nexus" fallback
855        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        // Should have converged to nested shape
1011        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}