remem-ai 0.6.39

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use std::path::{Path, PathBuf};

use serde_json::Value;

use super::host::HostKind;
use super::invocation::ContextInvocation;

pub(super) fn claude_hook_integrity_warning(invocation: &ContextInvocation) -> Option<String> {
    if invocation.host != HostKind::ClaudeCode || !is_claude_session_start_source(invocation) {
        return None;
    }

    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    let settings_path = home.join(".claude").join("settings.json");
    let claude_mcp_paths = crate::install::claude_mcp_paths();
    let fallback_executable = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("remem"));
    claude_hook_integrity_warning_from_paths(
        invocation,
        &settings_path,
        &claude_mcp_paths,
        fallback_executable,
    )
}

fn claude_hook_integrity_warning_from_paths(
    invocation: &ContextInvocation,
    settings_path: &Path,
    claude_mcp_paths: &[PathBuf],
    fallback_executable: PathBuf,
) -> Option<String> {
    if invocation.host != HostKind::ClaudeCode || !is_claude_session_start_source(invocation) {
        return None;
    }

    let doc = match std::fs::read_to_string(settings_path) {
        Ok(content) => match serde_json::from_str::<Value>(&content) {
            Ok(doc) => doc,
            Err(error) => {
                return Some(
                    crate::hook_integrity::failed_report(
                        "claude",
                        settings_path.to_path_buf(),
                        format!("cannot parse Claude hooks: {error}"),
                    )
                    .warning_block(),
                )
            }
        },
        Err(error) => {
            return Some(
                crate::hook_integrity::failed_report(
                    "claude",
                    settings_path.to_path_buf(),
                    format!("cannot read Claude hooks: {error}"),
                )
                .warning_block(),
            )
        }
    };

    let expected_executable =
        crate::hook_integrity::read_first_claude_mcp_command(claude_mcp_paths)
            .ok()
            .flatten()
            .map(PathBuf::from)
            .or_else(|| {
                crate::hook_integrity::expected_hook_executable_from_hooks(&doc, "claude")
                    .map(PathBuf::from)
            })
            .unwrap_or(fallback_executable);
    let report = crate::hook_integrity::evaluate_hooks(
        &doc,
        "claude",
        settings_path.to_path_buf(),
        &expected_executable,
    );
    (!report.is_healthy()).then(|| report.warning_block())
}

pub(super) fn append_hook_integrity_warning(output: &mut String, warning: Option<&str>) {
    let Some(warning) = warning else {
        return;
    };
    if !output.is_empty() && !output.ends_with('\n') {
        output.push('\n');
    }
    if !output.is_empty() {
        output.push('\n');
    }
    output.push_str(warning);
}

fn is_claude_session_start_source(invocation: &ContextInvocation) -> bool {
    invocation.source.as_deref().is_none_or(|source| {
        matches!(
            source.trim().to_ascii_lowercase().as_str(),
            "" | "startup" | "resume" | "clear" | "compact"
        )
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn warning_appends_to_suppressed_output() {
        let mut output = String::new();

        append_hook_integrity_warning(&mut output, Some("## Hook Integrity Warning\n- Repair\n"));

        assert!(output.contains("Hook Integrity Warning"));
    }

    #[test]
    fn codex_invocation_is_not_claude_session_start_source() {
        let invocation = ContextInvocation {
            cwd: ".".to_string(),
            project: ".".to_string(),
            session_id: None,
            transcript_path: None,
            source: Some("startup".to_string()),
            host: HostKind::CodexCli,
            use_colors: false,
            debug: false,
            force: false,
            gate_mode: None,
        };

        assert!(claude_hook_integrity_warning(&invocation).is_none());
    }

    #[test]
    fn claude_three_of_six_warning_names_repair_command() -> anyhow::Result<()> {
        let dir = std::env::temp_dir().join(format!(
            "remem-hook-warning-{}-{}",
            std::process::id(),
            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
        ));
        std::fs::create_dir_all(&dir)?;
        let settings_path = dir.join("settings.json");
        let mcp_paths = vec![dir.join("claude.json")];
        std::fs::write(
            &settings_path,
            r#"{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"command":"/tmp/remem context --host claude-code","timeout":15}]}],"UserPromptSubmit":[{"hooks":[{"command":"/tmp/remem session-init --host claude-code","timeout":15}]}],"PreCompact":[{"hooks":[{"command":"/tmp/remem summarize --host claude-code","timeout":120}]}]}}"#,
        )?;
        let invocation = ContextInvocation {
            cwd: ".".to_string(),
            project: ".".to_string(),
            session_id: None,
            transcript_path: None,
            source: Some("startup".to_string()),
            host: HostKind::ClaudeCode,
            use_colors: false,
            debug: false,
            force: false,
            gate_mode: None,
        };

        let warning = claude_hook_integrity_warning_from_paths(
            &invocation,
            &settings_path,
            &mcp_paths,
            PathBuf::from("/tmp/remem"),
        )
        .expect("incomplete Claude hooks should warn");

        assert!(warning.contains("3/6 registered"), "{warning}");
        assert!(
            warning.contains("remem install --target claude --repair"),
            "{warning}"
        );
        std::fs::remove_dir_all(dir)?;
        Ok(())
    }

    #[test]
    fn warning_uses_desktop_mcp_path_when_primary_is_missing() -> anyhow::Result<()> {
        let dir = std::env::temp_dir().join(format!(
            "remem-hook-warning-desktop-{}-{}",
            std::process::id(),
            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
        ));
        std::fs::create_dir_all(&dir)?;
        let settings_path = dir.join("settings.json");
        let desktop_mcp = dir.join("claude_desktop_config.json");
        std::fs::write(
            &settings_path,
            r#"{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"command":"/hook/remem context --host claude-code","timeout":15}]}],"UserPromptSubmit":[{"hooks":[{"command":"/hook/remem session-init --host claude-code","timeout":15}]}],"PostToolUse":[{"matcher":"Write|Edit|NotebookEdit|Bash|Grep|Glob|Agent|Task","hooks":[{"command":"/hook/remem observe --host claude-code","timeout":120}]}],"PreCompact":[{"hooks":[{"command":"/hook/remem summarize --host claude-code","timeout":120}]}],"Stop":[{"hooks":[{"command":"/hook/remem summarize --host claude-code","timeout":120}]}]}}"#,
        )?;
        std::fs::write(
            &desktop_mcp,
            r#"{"mcpServers":{"remem":{"command":"/mcp/remem"}}}"#,
        )?;
        let invocation = ContextInvocation {
            cwd: ".".to_string(),
            project: ".".to_string(),
            session_id: None,
            transcript_path: None,
            source: Some("startup".to_string()),
            host: HostKind::ClaudeCode,
            use_colors: false,
            debug: false,
            force: false,
            gate_mode: None,
        };

        let Some(warning) = claude_hook_integrity_warning_from_paths(
            &invocation,
            &settings_path,
            &[dir.join("missing.json"), desktop_mcp],
            PathBuf::from("/hook/remem"),
        ) else {
            panic!("desktop MCP path drift should warn");
        };

        assert!(warning.contains("executable /hook/remem"), "{warning}");
        std::fs::remove_dir_all(dir)?;
        Ok(())
    }
}