Files
spaceshell/crates/spaceshd/src/hooks.rs
T
2026-06-09 23:00:40 +07:00

163 lines
6.0 KiB
Rust

//! 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/<surface_id>.
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());
}
}