Skip to main content

remem/
hook_cli.rs

1//! Dedicated hook command surface for `remem-hook` and `remem` dispatch.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::Result;
6use clap::{Parser, Subcommand};
7
8use crate::{context, observe, summarize};
9
10pub const HOOK_COMMANDS: &[&str] = &["context", "session-init", "observe", "summarize"];
11
12pub fn hook_subcommand_uses_slim_binary(subcommand: &str) -> bool {
13    HOOK_COMMANDS.contains(&subcommand)
14}
15
16pub fn is_full_remem_binary(path: &Path) -> bool {
17    binary_stem_eq(path, "remem")
18}
19
20pub fn is_hook_binary(path: &Path) -> bool {
21    binary_stem_eq(path, "remem-hook")
22}
23
24fn binary_stem_eq(path: &Path, expected: &str) -> bool {
25    path.file_stem()
26        .and_then(|stem| stem.to_str())
27        .is_some_and(|stem| stem == expected)
28}
29
30fn platform_binary_name(stem: &str) -> String {
31    if cfg!(windows) {
32        format!("{stem}.exe")
33    } else {
34        stem.to_string()
35    }
36}
37
38fn sibling_named_path(bin: &Path, stem: &str) -> Option<PathBuf> {
39    Some(bin.parent()?.join(platform_binary_name(stem)))
40}
41
42fn sibling_named_binary(bin: &Path, stem: &str) -> Option<PathBuf> {
43    let candidate = sibling_named_path(bin, stem)?;
44    executable_file(&candidate).then_some(candidate)
45}
46
47fn executable_file(path: &Path) -> bool {
48    let Ok(metadata) = std::fs::metadata(path) else {
49        return false;
50    };
51    if !metadata.is_file() {
52        return false;
53    }
54    #[cfg(unix)]
55    {
56        use std::os::unix::fs::PermissionsExt;
57        metadata.permissions().mode() & 0o111 != 0
58    }
59    #[cfg(not(unix))]
60    {
61        true
62    }
63}
64
65pub fn sibling_hook_binary(remem_bin: &Path) -> Option<PathBuf> {
66    sibling_named_binary(remem_bin, "remem-hook")
67}
68
69pub fn sibling_full_binary(hook_bin: &Path) -> Option<PathBuf> {
70    sibling_named_binary(hook_bin, "remem")
71}
72
73pub fn sibling_full_binary_path(hook_bin: &Path) -> Option<PathBuf> {
74    sibling_named_path(hook_bin, "remem")
75}
76
77pub fn hook_invocation_binary(remem_bin: &Path, subcommand: &str) -> PathBuf {
78    if hook_subcommand_uses_slim_binary(subcommand) {
79        if let Some(hook) = sibling_hook_binary(remem_bin) {
80            return hook;
81        }
82    }
83    remem_bin.to_path_buf()
84}
85
86pub fn hook_executable_is_allowed(
87    invocation: &Path,
88    expected_remem: &Path,
89    subcommand: &str,
90) -> bool {
91    if invocation == expected_remem {
92        return !is_hook_binary(invocation) || executable_file(invocation);
93    }
94    hook_subcommand_uses_slim_binary(subcommand)
95        && sibling_hook_binary(expected_remem).as_deref() == Some(invocation)
96}
97
98/// Prefer the full `remem` path when hooks mention both binaries. Infer it
99/// from a sibling `remem-hook` when that is the only configured executable.
100pub fn preferred_expected_hook_executable<S: AsRef<str>>(paths: &[S]) -> Option<String> {
101    if paths.is_empty() {
102        return None;
103    }
104    if let Some(full) = paths
105        .iter()
106        .find(|path| is_full_remem_binary(Path::new(path.as_ref())))
107    {
108        return Some(full.as_ref().to_string());
109    }
110    for path in paths {
111        let hook = Path::new(path.as_ref());
112        if is_hook_binary(hook) {
113            if let Some(full) = sibling_full_binary(hook) {
114                return Some(full.to_string_lossy().into_owned());
115            }
116        }
117    }
118    Some(paths[0].as_ref().to_string())
119}
120
121#[derive(Parser)]
122#[command(
123    name = "remem-hook",
124    about = "Slim remem host-hook entry (context, session-init, observe, summarize)"
125)]
126struct HookCli {
127    #[command(subcommand)]
128    command: HookCommand,
129}
130
131#[derive(Subcommand)]
132enum HookCommand {
133    Context {
134        #[arg(long)]
135        cwd: Option<String>,
136        #[arg(long)]
137        session_id: Option<String>,
138        #[arg(long)]
139        host: Option<String>,
140        #[arg(long)]
141        color: bool,
142        #[arg(long)]
143        debug: bool,
144        #[arg(long)]
145        force: bool,
146        #[arg(long, value_name = "off|auto|strict|delta")]
147        gate: Option<String>,
148    },
149    SessionInit {
150        #[arg(long)]
151        host: Option<String>,
152    },
153    Observe {
154        #[arg(long)]
155        host: Option<String>,
156    },
157    Summarize {
158        #[arg(long)]
159        host: Option<String>,
160        #[arg(long)]
161        profile: Option<String>,
162    },
163}
164
165pub async fn run() -> Result<()> {
166    crate::hook_runtime::enter_hook_runtime_mode();
167    match HookCli::parse().command {
168        HookCommand::Context {
169            cwd,
170            session_id,
171            host,
172            color,
173            debug,
174            force,
175            gate,
176        } => run_context(cwd, session_id, host, color, debug, force, gate).await,
177        HookCommand::SessionInit { host } => run_session_init(host).await,
178        HookCommand::Observe { host } => run_observe(host).await,
179        HookCommand::Summarize { host, profile } => run_summarize(host, profile).await,
180    }
181}
182
183pub(crate) async fn run_context(
184    cwd: Option<String>,
185    session_id: Option<String>,
186    host: Option<String>,
187    color: bool,
188    debug: bool,
189    force: bool,
190    gate: Option<String>,
191) -> Result<()> {
192    if remem_hooks_disabled() {
193        return Ok(());
194    }
195    match parse_explicit_hook_host(host.as_deref())? {
196        Some(crate::identity::InstallHost::Cursor) => context::generate_cursor_context_from_stdin(),
197        _ => context::generate_context_from_cli(cwd, session_id, color, host, debug, force, gate),
198    }
199}
200
201pub(crate) async fn run_session_init(host: Option<String>) -> Result<()> {
202    if remem_hooks_disabled() {
203        return Ok(());
204    }
205    if matches!(
206        parse_explicit_hook_host(host.as_deref())?,
207        Some(crate::identity::InstallHost::Cursor)
208    ) {
209        anyhow::bail!(
210            "session-init is not supported on --host cursor; \
211             Cursor beforeSubmitPrompt is permit/block-only (GH-823 B-006)"
212        );
213    }
214    observe::session_init(host.as_deref()).await
215}
216
217pub(crate) async fn run_observe(host: Option<String>) -> Result<()> {
218    if remem_hooks_disabled() {
219        return Ok(());
220    }
221    match parse_explicit_hook_host(host.as_deref())? {
222        Some(crate::identity::InstallHost::Cursor) => observe::observe_cursor().await,
223        _ => observe::observe(host.as_deref()).await,
224    }
225}
226
227pub(crate) async fn run_summarize(host: Option<String>, profile: Option<String>) -> Result<()> {
228    if remem_hooks_disabled() {
229        return Ok(());
230    }
231    match parse_explicit_hook_host(host.as_deref())? {
232        Some(crate::identity::InstallHost::Cursor) => summarize::summarize_cursor().await,
233        _ => summarize::summarize(host.as_deref(), profile.as_deref()).await,
234    }
235}
236
237pub(crate) fn parse_explicit_hook_host(
238    host: Option<&str>,
239) -> Result<Option<crate::identity::InstallHost>> {
240    host.map(crate::identity::InstallHost::parse).transpose()
241}
242
243pub(crate) fn remem_hooks_disabled() -> bool {
244    std::env::var("REMEM_DISABLE_HOOKS")
245        .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
246        .unwrap_or(false)
247}
248
249#[cfg(test)]
250mod tests {
251    use clap::Parser;
252
253    use super::{HookCli, HookCommand, HOOK_COMMANDS};
254
255    fn make_executable(path: &std::path::Path) {
256        #[cfg(unix)]
257        {
258            use std::os::unix::fs::PermissionsExt;
259            let mut permissions = std::fs::metadata(path).expect("metadata").permissions();
260            permissions.set_mode(0o755);
261            std::fs::set_permissions(path, permissions).expect("set executable mode");
262        }
263    }
264
265    #[test]
266    fn hook_command_names_are_stable() {
267        assert_eq!(
268            HOOK_COMMANDS,
269            ["context", "session-init", "observe", "summarize"]
270        );
271    }
272
273    #[test]
274    fn parses_observe_host() {
275        let cli = HookCli::try_parse_from(["remem-hook", "observe", "--host", "codex-cli"])
276            .expect("observe should parse");
277        match cli.command {
278            HookCommand::Observe { host } => assert_eq!(host.as_deref(), Some("codex-cli")),
279            _ => panic!("expected observe"),
280        }
281    }
282
283    #[test]
284    fn sibling_hook_binary_used_only_for_slim_commands() {
285        let dir = std::env::temp_dir().join(format!(
286            "remem-hook-sibling-{}-{}",
287            std::process::id(),
288            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
289        ));
290        std::fs::create_dir_all(&dir).expect("temp dir");
291        let remem = dir.join("remem");
292        let hook = dir.join("remem-hook");
293        std::fs::write(&remem, []).expect("touch remem");
294        std::fs::write(&hook, []).expect("touch remem-hook");
295        make_executable(&remem);
296        make_executable(&hook);
297
298        assert_eq!(
299            super::sibling_hook_binary(&remem).as_deref(),
300            Some(hook.as_path())
301        );
302        assert_eq!(
303            super::sibling_full_binary(&hook).as_deref(),
304            Some(remem.as_path())
305        );
306        assert_eq!(super::hook_invocation_binary(&remem, "observe"), hook);
307        assert_eq!(super::hook_invocation_binary(&remem, "rules"), remem);
308        assert!(super::hook_executable_is_allowed(&hook, &remem, "context"));
309        assert!(!super::hook_executable_is_allowed(&hook, &remem, "rules"));
310        assert_eq!(
311            super::preferred_expected_hook_executable(&[
312                hook.to_string_lossy().into_owned(),
313                remem.to_string_lossy().into_owned(),
314            ])
315            .as_deref(),
316            remem.to_str()
317        );
318
319        let _ = std::fs::remove_dir_all(&dir);
320    }
321
322    #[cfg(unix)]
323    #[test]
324    fn non_executable_sibling_hook_is_not_selected_or_allowed() {
325        let dir = std::env::temp_dir().join(format!(
326            "remem-hook-non-executable-{}-{}",
327            std::process::id(),
328            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
329        ));
330        std::fs::create_dir_all(&dir).expect("temp dir");
331        let remem = dir.join("remem");
332        let hook = dir.join("remem-hook");
333        std::fs::write(&remem, []).expect("touch remem");
334        std::fs::write(&hook, []).expect("touch remem-hook");
335        make_executable(&remem);
336
337        assert_eq!(super::sibling_hook_binary(&remem), None);
338        assert_eq!(super::hook_invocation_binary(&remem, "observe"), remem);
339        assert!(!super::hook_executable_is_allowed(&hook, &hook, "observe"));
340
341        let _ = std::fs::remove_dir_all(&dir);
342    }
343
344    #[test]
345    fn rejects_full_binary_commands() {
346        for args in [
347            ["remem-hook", "worker"].as_slice(),
348            ["remem-hook", "eval"].as_slice(),
349            ["remem-hook", "mcp"].as_slice(),
350        ] {
351            assert!(
352                HookCli::try_parse_from(args).is_err(),
353                "expected rejection for {args:?}"
354            );
355        }
356    }
357}