#![allow(clippy::print_stdout, reason = "CLI tool needs to output to stdout")]
#![allow(clippy::print_stderr, reason = "CLI tool needs to output to stderr")]
use std::path::PathBuf;
use std::time::Duration;
use crate::cli::HostFormat;
use crate::{db, session};
fn notify_endpoint(session_id: &str) -> PathBuf {
#[cfg(unix)]
{
session::sessions_dir().join(session_id).join("notify.sock")
}
#[cfg(windows)]
{
PathBuf::from(format!(r"\\.\pipe\catenary-{session_id}"))
}
}
#[cfg(unix)]
fn notify_connect(endpoint: &std::path::Path) -> Option<std::os::unix::net::UnixStream> {
if !endpoint.exists() {
return None;
}
let stream = std::os::unix::net::UnixStream::connect(endpoint).ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_secs(60)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
Some(stream)
}
#[cfg(windows)]
fn notify_connect(endpoint: &std::path::Path) -> Option<std::fs::File> {
use std::os::windows::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.write(true)
.security_qos_flags(0x0001_0000)
.open(endpoint)
.ok()
}
fn ipc_exchange(
mut stream: impl std::io::Read + std::io::Write,
request: &serde_json::Value,
) -> Vec<String> {
use std::io::BufRead;
if serde_json::to_writer(&mut stream, request).is_err() {
return Vec::new();
}
if stream.write_all(b"\n").is_err() || stream.flush().is_err() {
return Vec::new();
}
let reader = std::io::BufReader::new(stream);
let mut lines = Vec::new();
for line in reader.lines() {
match line {
Ok(text) if !text.is_empty() => lines.push(text),
_ => break,
}
}
lines
}
fn format_deny(reason: &str, format: HostFormat) -> String {
match format {
HostFormat::Claude => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}
})
.to_string(),
HostFormat::Gemini => serde_json::json!({
"decision": "deny",
"reason": reason
})
.to_string(),
}
}
fn format_stop_block(reason: &str, format: HostFormat) -> String {
match format {
HostFormat::Claude => serde_json::json!({
"decision": "block",
"reason": reason
})
.to_string(),
HostFormat::Gemini => serde_json::json!({
"decision": "retry",
"reason": reason
})
.to_string(),
}
}
fn find_session_id(hook_json: &serde_json::Value, conn: &rusqlite::Connection) -> Option<String> {
let cwd = hook_json.get("cwd").and_then(|v| v.as_str()).map_or_else(
|| std::env::current_dir().unwrap_or_default(),
PathBuf::from,
);
let cwd_str = cwd.to_string_lossy();
let sessions = session::list_sessions_with_conn(conn).unwrap_or_default();
sessions
.into_iter()
.find(|(s, alive)| *alive && cwd_str.starts_with(&s.workspace))
.map(|(s, _)| s.id)
}
fn extract_agent_id(hook_json: &serde_json::Value) -> &str {
hook_json
.get("agent_id")
.and_then(|v| v.as_str())
.unwrap_or("")
}
fn extract_file_path(hook_json: &serde_json::Value) -> Option<String> {
let file_path = hook_json
.get("tool_input")
.and_then(|ti| ti.get("file_path").or_else(|| ti.get("file")))
.and_then(|fp| fp.as_str())?;
let abs_path = if std::path::Path::new(file_path).is_absolute() {
PathBuf::from(file_path)
} else {
let cwd = hook_json.get("cwd").and_then(|v| v.as_str()).map_or_else(
|| std::env::current_dir().unwrap_or_default(),
PathBuf::from,
);
cwd.join(file_path)
};
Some(abs_path.to_string_lossy().into_owned())
}
pub fn run_session_start(format: HostFormat) {
if let Err(e) = crate::config::Config::check() {
let msg =
format!("Catenary configuration error: {e:#}. Run `catenary doctor` for details.");
let output = format_session_message(&msg, format);
print!("{output}");
return;
}
let Ok(stdin_data) = std::io::read_to_string(std::io::stdin()) else {
return;
};
let Ok(hook_json) = serde_json::from_str::<serde_json::Value>(&stdin_data) else {
return;
};
let Ok(conn) = db::open_and_migrate() else {
return;
};
let Some(catenary_sid) = find_session_id(&hook_json, &conn) else {
return;
};
let endpoint = notify_endpoint(&catenary_sid);
let Some(stream) = notify_connect(&endpoint) else {
return;
};
let session_id = hook_json.get("session_id").and_then(|v| v.as_str());
let mut request = serde_json::json!({"method": "session-start/clear-editing"});
if let Some(sid) = session_id {
request["session_id"] = serde_json::json!(sid);
}
let lines = ipc_exchange(stream, &request);
if let Some(line) = lines.first()
&& let Ok(crate::hook::HookResult::Cleared(count)) =
serde_json::from_str::<crate::hook::HookResult>(line)
{
let msg = format!("Catenary: cleared {count} stale editing state entries");
let output = format_session_message(&msg, format);
print!("{output}");
}
}
fn format_session_message(msg: &str, format: HostFormat) -> String {
match format {
HostFormat::Claude | HostFormat::Gemini => {
serde_json::json!({ "systemMessage": msg }).to_string()
}
}
}
pub fn run_post_agent(format: HostFormat) {
let Ok(stdin_data) = std::io::read_to_string(std::io::stdin()) else {
return;
};
let Ok(hook_json) = serde_json::from_str::<serde_json::Value>(&stdin_data) else {
return;
};
let Ok(conn) = db::open_and_migrate() else {
return;
};
let Some(catenary_sid) = find_session_id(&hook_json, &conn) else {
return;
};
let endpoint = notify_endpoint(&catenary_sid);
let Some(stream) = notify_connect(&endpoint) else {
return;
};
let stop_hook_active = hook_json
.get("stop_hook_active")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let agent_id = extract_agent_id(&hook_json);
let request = serde_json::json!({
"method": "post-agent/require-release",
"agent_id": agent_id,
"stop_hook_active": stop_hook_active,
});
let lines = ipc_exchange(stream, &request);
if let Some(line) = lines.first()
&& let Ok(crate::hook::HookResult::Block(reason)) =
serde_json::from_str::<crate::hook::HookResult>(line)
{
print!("{}", format_stop_block(&reason, format));
}
}
pub fn run_post_tool(format: HostFormat) {
let Ok(stdin_data) = std::io::read_to_string(std::io::stdin()) else {
return;
};
let Ok(hook_json) = serde_json::from_str::<serde_json::Value>(&stdin_data) else {
return;
};
let tool_name = hook_json
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("");
if tool_name.contains("done_editing") {
run_post_tool_done_editing(&hook_json, format);
return;
}
let Some(file_path) = extract_file_path(&hook_json) else {
print!(
"{}",
notify_error(
"missing file path in hook input — diagnostics skipped",
format,
)
);
return;
};
let Ok(conn) = db::open_and_migrate() else {
print!(
"{}",
notify_error(
"state database unavailable — try running: catenary list",
format
)
);
return;
};
let Some(catenary_sid) = find_session_id(&hook_json, &conn) else {
return;
};
let endpoint = notify_endpoint(&catenary_sid);
let Some(stream) = notify_connect(&endpoint) else {
print!(
"{}",
notify_error(
&format!("session {catenary_sid} is not responding — it may have crashed"),
format,
)
);
return;
};
let agent_id = extract_agent_id(&hook_json);
let session_id = hook_json.get("session_id").and_then(|v| v.as_str());
let mut request = serde_json::json!({
"method": "post-tool/diagnostics",
"file": file_path,
"agent_id": agent_id,
});
if !tool_name.is_empty() {
request["tool"] = serde_json::json!(tool_name);
}
if let Some(sid) = session_id {
request["session_id"] = serde_json::json!(sid);
}
let lines = ipc_exchange(stream, &request);
format_post_tool_response(&lines, &file_path, format);
}
fn format_post_tool_response(lines: &[String], file_path: &str, format: HostFormat) {
let Some(line) = lines.first() else {
return; };
let filename = std::path::Path::new(file_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(file_path);
let Ok(result) = serde_json::from_str::<crate::hook::HookResult>(line) else {
print!(
"{}",
format_diagnostics(&format!("{filename}\n\t{line}"), format, "PostToolUse")
);
return;
};
match result {
crate::hook::HookResult::Content(content) => {
print!(
"{}",
format_diagnostics(&format!("{filename}\n\t{content}"), format, "PostToolUse")
);
}
crate::hook::HookResult::Courtesy(content) => {
let courtesy = "\n\t[diagnostics for this file are being deferred by another agent]";
print!(
"{}",
format_diagnostics(
&format!("{filename}\n\t{content}{courtesy}"),
format,
"PostToolUse"
)
);
}
crate::hook::HookResult::Error(msg) => {
print!("{}", notify_error(&msg, format));
}
_ => {} }
}
fn run_post_tool_done_editing(hook_json: &serde_json::Value, format: HostFormat) {
let Ok(conn) = db::open_and_migrate() else {
print!(
"{}",
notify_error(
"state database unavailable — try running: catenary list",
format,
)
);
return;
};
let Some(catenary_sid) = find_session_id(hook_json, &conn) else {
return;
};
let endpoint = notify_endpoint(&catenary_sid);
let Some(stream) = notify_connect(&endpoint) else {
print!(
"{}",
notify_error(
&format!("session {catenary_sid} is not responding — it may have crashed"),
format,
)
);
return;
};
let agent_id = extract_agent_id(hook_json);
let session_id = hook_json.get("session_id").and_then(|v| v.as_str());
let mut request = serde_json::json!({
"method": "post-tool/done-editing",
"agent_id": agent_id,
});
if let Some(sid) = session_id {
request["session_id"] = serde_json::json!(sid);
}
let lines = ipc_exchange(stream, &request);
let Some(line) = lines.first() else {
return;
};
let Ok(result) = serde_json::from_str::<crate::hook::HookResult>(line) else {
print!("{}", format_diagnostics(line, format, "PostToolUse"));
return;
};
if let crate::hook::HookResult::Content(content) = result {
print!("{}", format_diagnostics(&content, format, "PostToolUse"));
}
}
pub fn run_pre_agent(format: HostFormat) {
let _ = format;
let Ok(stdin_data) = std::io::read_to_string(std::io::stdin()) else {
return;
};
let Ok(hook_json) = serde_json::from_str::<serde_json::Value>(&stdin_data) else {
return;
};
let Ok(conn) = db::open_and_migrate() else {
return;
};
if let Some(catenary_sid) = find_session_id(&hook_json, &conn) {
let endpoint = notify_endpoint(&catenary_sid);
if let Some(stream) = notify_connect(&endpoint) {
let request = serde_json::json!({"method": "pre-agent/roots-sync"});
let _ = ipc_exchange(stream, &request);
}
}
}
pub fn run_pre_tool(format: HostFormat) {
let Ok(stdin_data) = std::io::read_to_string(std::io::stdin()) else {
return;
};
let Ok(hook_json) = serde_json::from_str::<serde_json::Value>(&stdin_data) else {
return;
};
let Ok(conn) = db::open_and_migrate() else {
return;
};
let Some(catenary_sid) = find_session_id(&hook_json, &conn) else {
return;
};
let endpoint = notify_endpoint(&catenary_sid);
let Some(stream) = notify_connect(&endpoint) else {
return;
};
let tool_name = hook_json
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let file_path = extract_file_path(&hook_json);
let agent_id = extract_agent_id(&hook_json);
let session_id = hook_json.get("session_id").and_then(|v| v.as_str());
let mut request = serde_json::json!({
"method": "pre-tool/enforce-editing",
"tool_name": tool_name,
"agent_id": agent_id,
});
if let Some(path) = &file_path {
request["file_path"] = serde_json::json!(path);
}
if let Some(sid) = session_id {
request["session_id"] = serde_json::json!(sid);
}
let lines = ipc_exchange(stream, &request);
if let Some(line) = lines.first()
&& let Ok(crate::hook::HookResult::Deny(reason)) =
serde_json::from_str::<crate::hook::HookResult>(line)
{
print!("{}", format_deny(&reason, format));
}
}
fn format_diagnostics(content: &str, format: HostFormat, hook_event: &str) -> String {
match format {
HostFormat::Gemini => serde_json::json!({
"hookSpecificOutput": {
"additionalContext": content
}
})
.to_string(),
HostFormat::Claude => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": hook_event,
"additionalContext": content
}
})
.to_string(),
}
}
const BUG_REPORT_URL: &str = "https://github.com/TwoWells/Catenary/issues";
fn notify_error(message: &str, format: HostFormat) -> String {
let full =
format!("Catenary: {message}. If this persists, please file a bug: {BUG_REPORT_URL}");
format_error(&full, format)
}
fn format_error(message: &str, format: HostFormat) -> String {
match format {
HostFormat::Claude => serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
},
"systemMessage": message
})
.to_string(),
HostFormat::Gemini => serde_json::json!({
"hookSpecificOutput": {},
"systemMessage": message
})
.to_string(),
}
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests use expect for readable assertions"
)]
#[allow(
clippy::similar_names,
reason = "content/context are distinct concepts in hook output tests"
)]
mod tests {
use super::*;
use anyhow::{Context, Result};
#[test]
fn test_format_diagnostics_claude() -> Result<()> {
let content = "error[E0308]: mismatched types\n --> src/main.rs:5:10";
let output = format_diagnostics(content, HostFormat::Claude, "PostToolUse");
let parsed: serde_json::Value =
serde_json::from_str(&output).context("claude format should produce valid JSON")?;
let hook_output = &parsed["hookSpecificOutput"];
assert_eq!(hook_output["hookEventName"], "PostToolUse");
let context = hook_output["additionalContext"]
.as_str()
.expect("additionalContext should be a string");
assert!(context.contains("error[E0308]: mismatched types"));
assert!(context.contains(" --> src/main.rs:5:10"));
Ok(())
}
#[test]
fn test_format_diagnostics_gemini() -> Result<()> {
let content = "error[E0308]: mismatched types";
let output = format_diagnostics(content, HostFormat::Gemini, "PostToolUse");
let parsed: serde_json::Value =
serde_json::from_str(&output).context("gemini format should produce valid JSON")?;
let context = parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("additionalContext should be a string");
assert_eq!(context, content);
assert!(parsed["hookSpecificOutput"]["hookEventName"].is_null());
Ok(())
}
#[test]
fn test_format_diagnostics_gemini_multiline() -> Result<()> {
let content = "warning: unused variable\n --> lib.rs:3:9";
let output = format_diagnostics(content, HostFormat::Gemini, "PostToolUse");
let parsed: serde_json::Value =
serde_json::from_str(&output).context("should produce valid JSON")?;
let context = parsed["hookSpecificOutput"]["additionalContext"]
.as_str()
.expect("additionalContext should be a string");
assert!(context.contains("warning: unused variable\n --> lib.rs:3:9"));
Ok(())
}
#[test]
fn test_format_diagnostics_claude_propagates_hook_event() -> Result<()> {
let content = "Added roots: /tmp/foo";
let output = format_diagnostics(content, HostFormat::Claude, "PreToolUse");
let parsed: serde_json::Value =
serde_json::from_str(&output).context("should produce valid JSON")?;
assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse");
Ok(())
}
#[test]
fn test_format_error_claude() -> Result<()> {
let output = format_error("Catenary: database unavailable", HostFormat::Claude);
let parsed: serde_json::Value =
serde_json::from_str(&output).context("should produce valid JSON")?;
assert_eq!(parsed["systemMessage"], "Catenary: database unavailable");
assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PostToolUse");
assert!(parsed["hookSpecificOutput"]["additionalContext"].is_null());
Ok(())
}
#[test]
fn test_format_error_gemini() -> Result<()> {
let output = format_error("Catenary: database unavailable", HostFormat::Gemini);
let parsed: serde_json::Value =
serde_json::from_str(&output).context("should produce valid JSON")?;
assert_eq!(parsed["systemMessage"], "Catenary: database unavailable");
assert!(parsed["hookSpecificOutput"]["hookEventName"].is_null());
assert!(parsed["hookSpecificOutput"]["additionalContext"].is_null());
Ok(())
}
}