255fa27271
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
163 lines
6.0 KiB
Rust
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());
|
|
}
|
|
}
|