From 255fa27271e02bd94ed8fab69a2596ebbf7d97ff Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 23:00:40 +0700 Subject: [PATCH] feat(daemon): versioned Claude Code hook adapter (per-surface CLAUDE_CONFIG_DIR) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/hooks.rs | 162 +++++++++++++++++++++++++++++++++++ crates/spaceshd/src/main.rs | 1 + 2 files changed, 163 insertions(+) create mode 100644 crates/spaceshd/src/hooks.rs diff --git a/crates/spaceshd/src/hooks.rs b/crates/spaceshd/src/hooks.rs new file mode 100644 index 0000000..8da5dfa --- /dev/null +++ b/crates/spaceshd/src/hooks.rs @@ -0,0 +1,162 @@ +//! 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()); + } +} diff --git a/crates/spaceshd/src/main.rs b/crates/spaceshd/src/main.rs index 23a9f24..3715507 100644 --- a/crates/spaceshd/src/main.rs +++ b/crates/spaceshd/src/main.rs @@ -1,3 +1,4 @@ +mod hooks; mod launchd; mod lifecycle; mod persist;