//! Versioned Claude Code hook adapter. For a Claude agent surface, writes a //! per-surface CLAUDE_CONFIG_DIR with hooks that call `spacesh notify`, and //! returns the env to inject. Isolated so hook-format drift is a local fix. use std::path::PathBuf; use spacesh_proto::ids::SurfaceId; /// Is this command a Claude Code agent we should hook? (heuristic) pub fn is_agent(command: &str, agent_label: Option<&str>) -> bool { let base = std::path::Path::new(command) .file_name() .and_then(|s| s.to_str()) .unwrap_or(command); base == "claude" || agent_label == Some("claude") } /// Per-surface config dir under ~/.spacesh/hooks/. fn dir_for(home: &PathBuf, sid: &SurfaceId) -> PathBuf { home.join(".spacesh").join("hooks").join(&sid.0) } /// Build the settings.json contents wiring Stop/Notification/UserPromptSubmit /// to `spacesh notify`. `spacesh_bin` is the absolute path to the CLI. pub fn settings_json(spacesh_bin: &str) -> String { let line = |state: &str| { format!( "{{\"hooks\":[{{\"type\":\"command\",\"command\":\"{spacesh_bin} notify --surface $SPACESH_SURFACE_ID --state {state}\"}}]}}" ) }; format!( "{{\"hooks\":{{\"Stop\":[{}],\"Notification\":[{}],\"UserPromptSubmit\":[{}]}}}}", line("done"), line("wait"), line("work") ) } /// Prepare the per-surface hook config; return env pairs to merge into the spawn. /// Best-effort: on any I/O error returns an empty vec (spawn proceeds without hooks). pub fn prepare(sid: &SurfaceId, spacesh_bin: &str) -> Vec<(String, String)> { let Some(home) = dirs::home_dir() else { return vec![] }; let dir = dir_for(&home, sid); if std::fs::create_dir_all(&dir).is_err() { return vec![]; } if std::fs::write(dir.join("settings.json"), settings_json(spacesh_bin)).is_err() { return vec![]; } vec![("CLAUDE_CONFIG_DIR".to_string(), dir.to_string_lossy().to_string())] } /// Remove the per-surface hook dir (best-effort) on close. pub fn cleanup(sid: &SurfaceId) { if let Some(home) = dirs::home_dir() { let _ = std::fs::remove_dir_all(dir_for(&home, sid)); } } /// Absolute path to the `spacesh` CLI binary, sibling of the running daemon. pub fn spacesh_bin() -> String { std::env::current_exe() .ok() .map(|p| p.with_file_name("spacesh")) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| "spacesh".to_string()) } /// zsh OSC 133 integration script, embedded at build time. const ZSH_INTEGRATION: &str = include_str!("shell_integration/spacesh.zsh"); /// Is this command a zsh shell we can OSC-133-integrate via ZDOTDIR? pub fn is_zsh(command: &str) -> bool { std::path::Path::new(command).file_name().and_then(|s| s.to_str()) == Some("zsh") } /// Prepare a per-surface ZDOTDIR whose .zshrc sources the user's rc then our /// integration. Returns env pairs (ZDOTDIR, and SPACESH_ZDOTDIR=original) to /// inject. Best-effort: empty vec on I/O failure. pub fn shell_env(sid: &SurfaceId) -> Vec<(String, String)> { let Some(home) = dirs::home_dir() else { return vec![] }; let dir = home.join(".spacesh").join("shellint").join(&sid.0); if std::fs::create_dir_all(&dir).is_err() { return vec![]; } if std::fs::write(dir.join("spacesh.zsh"), ZSH_INTEGRATION).is_err() { return vec![]; } let orig_zdotdir = std::env::var("ZDOTDIR").unwrap_or_else(|_| home.to_string_lossy().to_string()); // .zshrc: source user rc, then our integration. let zshrc = format!( "[ -f \"{orig}/.zshrc\" ] && source \"{orig}/.zshrc\"\nsource \"{dir}/spacesh.zsh\"\n", orig = orig_zdotdir, dir = dir.to_string_lossy(), ); if std::fs::write(dir.join(".zshrc"), zshrc).is_err() { return vec![]; } vec![("ZDOTDIR".to_string(), dir.to_string_lossy().to_string())] } /// Remove the per-surface shellint dir (best-effort). pub fn cleanup_shell(sid: &SurfaceId) { if let Some(home) = dirs::home_dir() { let _ = std::fs::remove_dir_all(home.join(".spacesh").join("shellint").join(&sid.0)); } } #[cfg(test)] mod tests { use super::*; #[test] fn is_agent_matches_claude_by_command_or_label() { assert!(is_agent("claude", None)); assert!(is_agent("/usr/local/bin/claude", None)); assert!(is_agent("/bin/sh", Some("claude"))); assert!(!is_agent("/bin/zsh", None)); assert!(!is_agent("node", Some("codex"))); } #[test] fn settings_json_has_three_events_with_abs_bin_and_env_var() { let j = settings_json("/abs/spacesh"); assert!(j.contains("\"Stop\"")); assert!(j.contains("\"Notification\"")); assert!(j.contains("\"UserPromptSubmit\"")); assert!(j.contains("/abs/spacesh notify --surface $SPACESH_SURFACE_ID --state done")); assert!(j.contains("--state wait")); assert!(j.contains("--state work")); // Valid JSON. let _: serde_json::Value = serde_json::from_str(&j).unwrap(); } #[test] fn prepare_writes_config_and_cleanup_removes_it() { let sid = SurfaceId(format!("s_test_{}", std::process::id())); let env = prepare(&sid, "/abs/spacesh"); assert_eq!(env.len(), 1); assert_eq!(env[0].0, "CLAUDE_CONFIG_DIR"); let dir = std::path::PathBuf::from(&env[0].1); assert!(dir.join("settings.json").exists()); cleanup(&sid); assert!(!dir.exists()); } #[test] fn is_zsh_detects_zsh() { assert!(is_zsh("/bin/zsh")); assert!(is_zsh("zsh")); assert!(!is_zsh("/bin/bash")); } #[test] fn shell_env_writes_zdotdir_with_integration() { let sid = SurfaceId(format!("s_shtest_{}", std::process::id())); let env = shell_env(&sid); assert_eq!(env.len(), 1); assert_eq!(env[0].0, "ZDOTDIR"); let dir = std::path::PathBuf::from(&env[0].1); assert!(dir.join(".zshrc").exists()); assert!(dir.join("spacesh.zsh").exists()); cleanup_shell(&sid); assert!(!dir.exists()); } }