mod error;
mod filter;
mod input;
mod mapping;
mod project;
pub use error::HookError;
pub use input::{HookInput, HookType};
pub use kindling_types::ObservationInput;
pub use project::project_root;
pub fn map_capture(hook_type: HookType, input: &HookInput) -> Option<ObservationInput> {
match hook_type {
HookType::PostToolUse => Some(mapping::map_tool_use(input)),
HookType::PostToolUseFailure => {
let mut input = input.clone();
let resolved_error = input
.tool_error
.clone()
.filter(|s| !s.is_empty())
.or_else(|| input.error.clone().filter(|s| !s.is_empty()))
.unwrap_or_else(|| "Unknown error".to_string());
input.tool_error = Some(resolved_error);
Some(mapping::map_tool_use(&input))
}
HookType::UserPromptSubmit => {
let content = input
.content
.as_deref()
.filter(|s| !s.is_empty())
.or(input.prompt.as_deref())
.unwrap_or("");
if content.trim().is_empty() {
None
} else {
Some(mapping::map_user_prompt(input, content))
}
}
HookType::SubagentStop => Some(mapping::map_subagent_stop(input)),
HookType::SessionStart | HookType::PreCompact | HookType::Stop => None,
}
}
use std::process::ExitCode;
use kindling_client::{Client, ClientConfig, ClientError, CloseCapsuleBody};
use kindling_types::{CapsuleType, ScopeIds};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub fn run_hook(type_arg: Option<String>) -> ExitCode {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
eprintln!("[kindling] hook error: building runtime: {e}");
return ExitCode::SUCCESS;
}
};
if let Err((label, message)) = runtime.block_on(run_hook_inner(type_arg)) {
eprintln!("[kindling] {label} error: {message}");
}
ExitCode::SUCCESS
}
async fn run_hook_inner(type_arg: Option<String>) -> Result<(), (&'static str, String)> {
let Some(type_arg) = type_arg else {
return Err(("hook", "missing hook type argument".to_string()));
};
let hook_type = HookType::parse(&type_arg).map_err(|e| ("hook", e.to_string()))?;
let label = hook_type.log_label();
let mut buf = Vec::new();
let mut stdin = tokio::io::stdin();
stdin
.read_to_end(&mut buf)
.await
.map_err(|e| (label, format!("reading stdin: {e}")))?;
let input: HookInput = if buf.iter().all(u8::is_ascii_whitespace) {
HookInput::default()
} else {
serde_json::from_slice(&buf).map_err(|e| (label, format!("failed to parse stdin: {e}")))?
};
let cwd = input.cwd_or_process();
let root = project_root(&cwd);
let client = build_client(root).map_err(|e| (label, e))?;
let output = dispatch(hook_type, &input, &client)
.await
.map_err(|e| (label, e.to_string()))?;
if let Some(s) = output {
let mut stdout = tokio::io::stdout();
stdout
.write_all(s.as_bytes())
.await
.map_err(|e| (label, format!("writing stdout: {e}")))?;
stdout
.flush()
.await
.map_err(|e| (label, format!("flushing stdout: {e}")))?;
}
Ok(())
}
fn build_client(project_root: String) -> Result<Client, String> {
let mut config = ClientConfig::defaults().map_err(|e| format!("client config: {e}"))?;
config.project_root = project_root;
if let Some(sock) = std::env::var_os("KINDLING_SOCK") {
config.socket_path = sock.into();
}
Ok(Client::with_config(config))
}
const SESSION_START_EVENT: &str = "SessionStart";
const PRE_COMPACT_EVENT: &str = "PreCompact";
const SESSION_INTENT: &str = "Claude Code session";
const DEFAULT_MAX_CONTEXT: u32 = 10;
pub async fn dispatch(
hook_type: HookType,
input: &HookInput,
client: &Client,
) -> Result<Option<String>, HookError> {
match hook_type {
HookType::SessionStart => session_start(input, client).await,
HookType::PreCompact => pre_compact(client).await,
HookType::PostToolUse | HookType::PostToolUseFailure => {
post_tool_use(hook_type, input, client).await
}
HookType::UserPromptSubmit => user_prompt_submit(input, client).await,
HookType::SubagentStop => subagent_stop(input, client).await,
HookType::Stop => stop(input, client).await,
}
}
async fn session_start(input: &HookInput, client: &Client) -> Result<Option<String>, HookError> {
let session_id = session_start_session_id(input);
let project_root = client.config().project_root.clone();
let scope = ScopeIds {
session_id: Some(session_id),
repo_id: Some(project_root),
..Default::default()
};
match client
.open_capsule(CapsuleType::Session, SESSION_INTENT, scope, None)
.await
{
Ok(_) => {}
Err(ClientError::Api { status: 409, .. }) => {}
Err(e) => return Err(e.into()),
}
let max_results = max_context_from_env();
let ctx = client.session_start_context(Some(max_results)).await?;
match ctx {
Some(markdown) => Ok(Some(injection_envelope(SESSION_START_EVENT, &markdown)?)),
None => Ok(None),
}
}
async fn pre_compact(client: &Client) -> Result<Option<String>, HookError> {
match client.pre_compact_context().await? {
Some(markdown) => Ok(Some(injection_envelope(PRE_COMPACT_EVENT, &markdown)?)),
None => Ok(None),
}
}
async fn post_tool_use(
hook_type: HookType,
input: &HookInput,
client: &Client,
) -> Result<Option<String>, HookError> {
if let Some(observation) = map_capture(hook_type, input) {
client
.append_observation(observation, None, Some(true))
.await?;
}
Ok(None)
}
async fn user_prompt_submit(
input: &HookInput,
client: &Client,
) -> Result<Option<String>, HookError> {
if let Some(observation) = map_capture(HookType::UserPromptSubmit, input) {
client
.append_observation(observation, None, Some(true))
.await?;
}
Ok(None)
}
async fn subagent_stop(input: &HookInput, client: &Client) -> Result<Option<String>, HookError> {
if let Some(observation) = map_capture(HookType::SubagentStop, input) {
client
.append_observation(observation, None, Some(true))
.await?;
}
Ok(None)
}
async fn stop(input: &HookInput, client: &Client) -> Result<Option<String>, HookError> {
let session_id = input.session_id_or_unknown();
let capsule = client.get_open_capsule(&session_id).await?;
let Some(capsule) = capsule else {
return Ok(None);
};
let summary = input.summary.clone();
let body = CloseCapsuleBody {
generate_summary: Some(summary.is_some()),
summary_content: summary,
confidence: None,
};
client.close_capsule(&capsule.id, body).await?;
Ok(None)
}
fn injection_envelope(event: &str, markdown: &str) -> Result<String, HookError> {
let value = serde_json::json!({
"continue": true,
"hookSpecificOutput": {
"hookEventName": event,
"additionalContext": markdown,
}
});
serde_json::to_string(&value).map_err(|e| HookError::Serialize(e.to_string()))
}
fn session_start_session_id(input: &HookInput) -> String {
match input.session_id.as_deref() {
Some(s) if !s.is_empty() => s.to_string(),
_ => format!("session-{}", now_ms()),
}
}
fn max_context_from_env() -> u32 {
std::env::var("KINDLING_MAX_CONTEXT")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.unwrap_or(DEFAULT_MAX_CONTEXT)
}
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hook_type_parses_all_seven() {
for (s, expected) in [
("session-start", HookType::SessionStart),
("post-tool-use", HookType::PostToolUse),
("post-tool-use-failure", HookType::PostToolUseFailure),
("user-prompt-submit", HookType::UserPromptSubmit),
("subagent-stop", HookType::SubagentStop),
("stop", HookType::Stop),
("pre-compact", HookType::PreCompact),
] {
assert_eq!(HookType::parse(s).unwrap(), expected);
assert_eq!(expected.as_str(), s);
}
assert!(matches!(
HookType::parse("nope"),
Err(HookError::UnknownHookType(_))
));
}
#[test]
fn injection_envelope_shape() {
let s = injection_envelope("SessionStart", "# md").unwrap();
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["continue"], serde_json::json!(true));
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "SessionStart");
assert_eq!(v["hookSpecificOutput"]["additionalContext"], "# md");
}
}