diff --git a/src/cli/admin.rs b/src/cli/admin.rs index f4e179d..c139f5a 100644 --- a/src/cli/admin.rs +++ b/src/cli/admin.rs @@ -40,7 +40,7 @@ pub fn cmd_init() -> Result<(), String> { println!("Indexed {} memory units", count); // Install hooks - crate::daemon::install_hook()?; + crate::subconscious::hook::install_hook()?; // Create config if none exists let config_path = std::env::var("POC_MEMORY_CONFIG") diff --git a/src/subconscious/daemon.rs b/src/subconscious/daemon.rs index 8c49644..0c17ab8 100644 --- a/src/subconscious/daemon.rs +++ b/src/subconscious/daemon.rs @@ -1534,7 +1534,7 @@ WantedBy=default.target install_notify_daemon(&unit_dir, &home)?; // Install memory-search + poc-hook into Claude settings - install_hook()?; + crate::subconscious::hook::install_hook()?; Ok(()) } @@ -1590,111 +1590,6 @@ WantedBy=default.target Ok(()) } -/// Install memory-search and poc-hook into Claude Code settings.json. -/// Public so `poc-memory init` can call it too. -/// -/// Hook layout: -/// UserPromptSubmit: memory-search (10s), poc-hook (5s) -/// PostToolUse: poc-hook (5s) -/// Stop: poc-hook (5s) -pub fn install_hook() -> Result<(), String> { - let home = std::env::var("HOME").map_err(|e| format!("HOME: {}", e))?; - let exe = std::env::current_exe() - .map_err(|e| format!("current_exe: {}", e))?; - let settings_path = PathBuf::from(&home).join(".claude/settings.json"); - - let memory_search = exe.with_file_name("memory-search"); - let poc_hook = exe.with_file_name("poc-hook"); - - let mut settings: serde_json::Value = if settings_path.exists() { - let content = fs::read_to_string(&settings_path) - .map_err(|e| format!("read settings: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("parse settings: {}", e))? - } else { - serde_json::json!({}) - }; - - let obj = settings.as_object_mut().ok_or("settings not an object")?; - let hooks_obj = obj.entry("hooks") - .or_insert_with(|| serde_json::json!({})) - .as_object_mut().ok_or("hooks not an object")?; - - let mut changed = false; - - // Helper: ensure a hook binary is present in an event's hook list - let ensure_hook = |hooks_obj: &mut serde_json::Map, - event: &str, - binary: &Path, - timeout: u32, - changed: &mut bool| { - if !binary.exists() { - eprintln!("Warning: {} not found — skipping", binary.display()); - return; - } - let cmd = binary.to_string_lossy().to_string(); - let name = binary.file_name().unwrap().to_string_lossy().to_string(); - - let event_array = hooks_obj.entry(event) - .or_insert_with(|| serde_json::json!([{"hooks": []}])) - .as_array_mut().unwrap(); - if event_array.is_empty() { - event_array.push(serde_json::json!({"hooks": []})); - } - let inner = event_array[0] - .as_object_mut().unwrap() - .entry("hooks") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut().unwrap(); - - // Remove legacy load-memory.sh - let before = inner.len(); - inner.retain(|h| { - let c = h.get("command").and_then(|c| c.as_str()).unwrap_or(""); - !c.contains("load-memory") - }); - if inner.len() < before { - eprintln!("Removed load-memory.sh from {event}"); - *changed = true; - } - - let already = inner.iter().any(|h| { - h.get("command").and_then(|c| c.as_str()) - .is_some_and(|c| c.contains(&name)) - }); - - if !already { - inner.push(serde_json::json!({ - "type": "command", - "command": cmd, - "timeout": timeout - })); - *changed = true; - eprintln!("Installed {name} in {event}"); - } - }; - - // UserPromptSubmit: memory-search + poc-hook - ensure_hook(hooks_obj, "UserPromptSubmit", &memory_search, 10, &mut changed); - ensure_hook(hooks_obj, "UserPromptSubmit", &poc_hook, 5, &mut changed); - - // PostToolUse + Stop: poc-hook only - ensure_hook(hooks_obj, "PostToolUse", &poc_hook, 5, &mut changed); - ensure_hook(hooks_obj, "Stop", &poc_hook, 5, &mut changed); - - if changed { - let json = serde_json::to_string_pretty(&settings) - .map_err(|e| format!("serialize settings: {}", e))?; - fs::write(&settings_path, json) - .map_err(|e| format!("write settings: {}", e))?; - eprintln!("Updated {}", settings_path.display()); - } else { - eprintln!("All hooks already installed in {}", settings_path.display()); - } - - Ok(()) -} - /// Drill down into a task's log file. Finds the log path from: /// 1. Running task status (daemon-status.json) /// 2. daemon.log started events (for completed/failed tasks) diff --git a/src/subconscious/hook.rs b/src/subconscious/hook.rs index 2d8844d..8c52bb9 100644 --- a/src/subconscious/hook.rs +++ b/src/subconscious/hook.rs @@ -204,3 +204,109 @@ fn hook(session: &HookSession) -> String { out } + +/// Install memory-search and poc-hook into Claude Code settings.json. +/// +/// Hook layout: +/// UserPromptSubmit: memory-search (10s), poc-hook (5s) +/// PostToolUse: poc-hook (5s) +/// Stop: poc-hook (5s) +pub fn install_hook() -> Result<(), String> { + use std::path::PathBuf; + + let home = std::env::var("HOME").map_err(|e| format!("HOME: {}", e))?; + let exe = std::env::current_exe() + .map_err(|e| format!("current_exe: {}", e))?; + let settings_path = PathBuf::from(&home).join(".claude/settings.json"); + + let memory_search = exe.with_file_name("memory-search"); + let poc_hook = exe.with_file_name("poc-hook"); + + let mut settings: serde_json::Value = if settings_path.exists() { + let content = fs::read_to_string(&settings_path) + .map_err(|e| format!("read settings: {}", e))?; + serde_json::from_str(&content) + .map_err(|e| format!("parse settings: {}", e))? + } else { + serde_json::json!({}) + }; + + let obj = settings.as_object_mut().ok_or("settings not an object")?; + let hooks_obj = obj.entry("hooks") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut().ok_or("hooks not an object")?; + + let mut changed = false; + + // Helper: ensure a hook binary is present in an event's hook list + let ensure_hook = |hooks_obj: &mut serde_json::Map, + event: &str, + binary: &Path, + timeout: u32, + changed: &mut bool| { + if !binary.exists() { + eprintln!("Warning: {} not found — skipping", binary.display()); + return; + } + let cmd = binary.to_string_lossy().to_string(); + let name = binary.file_name().unwrap().to_string_lossy().to_string(); + + let event_array = hooks_obj.entry(event) + .or_insert_with(|| serde_json::json!([{"hooks": []}])) + .as_array_mut().unwrap(); + if event_array.is_empty() { + event_array.push(serde_json::json!({"hooks": []})); + } + let inner = event_array[0] + .as_object_mut().unwrap() + .entry("hooks") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut().unwrap(); + + // Remove legacy load-memory.sh + let before = inner.len(); + inner.retain(|h| { + let c = h.get("command").and_then(|c| c.as_str()).unwrap_or(""); + !c.contains("load-memory") + }); + if inner.len() < before { + eprintln!("Removed load-memory.sh from {event}"); + *changed = true; + } + + let already = inner.iter().any(|h| { + h.get("command").and_then(|c| c.as_str()) + .is_some_and(|c| c.contains(&name)) + }); + + if !already { + inner.push(serde_json::json!({ + "type": "command", + "command": cmd, + "timeout": timeout + })); + *changed = true; + eprintln!("Installed {name} in {event}"); + } + }; + + // UserPromptSubmit: memory-search + poc-hook + ensure_hook(hooks_obj, "UserPromptSubmit", &memory_search, 10, &mut changed); + ensure_hook(hooks_obj, "UserPromptSubmit", &poc_hook, 5, &mut changed); + + // PostToolUse + Stop: poc-hook only + ensure_hook(hooks_obj, "PostToolUse", &poc_hook, 5, &mut changed); + ensure_hook(hooks_obj, "Stop", &poc_hook, 5, &mut changed); + + if changed { + let json = serde_json::to_string_pretty(&settings) + .map_err(|e| format!("serialize settings: {}", e))?; + fs::write(&settings_path, json) + .map_err(|e| format!("write settings: {}", e))?; + eprintln!("Updated {}", settings_path.display()); + } else { + eprintln!("All hooks already installed in {}", settings_path.display()); + } + + Ok(()) +}