use crate::{FrameContext, IntegrationMode, LifecycleEventKind, PayloadEnvelope, PlacementClass};
use serde::{Deserialize, Serialize};
use serde_json::Value;
mod claude;
mod codex;
pub use claude::{ClaudeHookEvent, claude_hook_event_for};
pub use codex::{CodexHookEvent, codex_hook_event_for};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProtocolAdapter {
Claude,
Codex,
}
impl ProtocolAdapter {
pub const ALL: &'static [Self] = &[Self::Claude, Self::Codex];
pub fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
}
}
pub fn from_id(value: &str) -> Option<Self> {
match value {
"claude" | "claude-code" => Some(Self::Claude),
"codex" => Some(Self::Codex),
_ => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum FrameAdmissionDirective {
Allow,
Block { reason: String },
}
impl FrameAdmissionDirective {
pub fn allow() -> Self {
Self::Allow
}
pub fn block(reason: impl Into<String>) -> Self {
Self::Block {
reason: reason.into(),
}
}
pub fn is_block(&self) -> bool {
matches!(self, Self::Block { .. })
}
}
#[derive(Clone, Debug)]
pub struct ProtocolPayload<'a> {
pub envelope: &'a PayloadEnvelope,
pub placement: PlacementClass,
}
impl<'a> ProtocolPayload<'a> {
pub fn new(envelope: &'a PayloadEnvelope, placement: PlacementClass) -> Self {
Self {
envelope,
placement,
}
}
}
#[derive(Clone, Debug)]
pub struct RenderRequest<'a> {
pub adapter: ProtocolAdapter,
pub adapter_id: &'a str,
pub adapter_version: &'a str,
pub integration_mode: IntegrationMode,
pub event: LifecycleEventKind,
pub frame: Option<&'a FrameContext>,
pub payloads: &'a [ProtocolPayload<'a>],
pub directive: Option<&'a FrameAdmissionDirective>,
}
impl<'a> RenderRequest<'a> {
pub fn minimal(
adapter: ProtocolAdapter,
adapter_id: &'a str,
adapter_version: &'a str,
integration_mode: IntegrationMode,
event: LifecycleEventKind,
) -> Self {
Self {
adapter,
adapter_id,
adapter_version,
integration_mode,
event,
frame: None,
payloads: &[],
directive: None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RenderedHookPayload {
pub hook_event_name: Option<&'static str>,
pub body: Value,
}
impl RenderedHookPayload {
pub fn body_string(&self) -> String {
serde_json::to_string(&self.body).unwrap_or_else(|_| "{}".to_string())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
pub enum RenderError {
UnsupportedEvent {
adapter: ProtocolAdapter,
event: LifecycleEventKind,
},
AdapterIdMismatch {
adapter: ProtocolAdapter,
adapter_id: String,
},
InvalidDirective(String),
}
impl std::fmt::Display for RenderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnsupportedEvent { adapter, event } => {
write!(
f,
"adapter `{}` does not surface a hook payload for lifecycle event `{:?}`",
adapter.as_str(),
event
)
}
Self::AdapterIdMismatch {
adapter,
adapter_id,
} => write!(
f,
"adapter id `{}` does not match requested adapter `{}`",
adapter_id,
adapter.as_str()
),
Self::InvalidDirective(msg) => write!(f, "invalid frame-admission directive: {msg}"),
}
}
}
impl std::error::Error for RenderError {}
pub fn render_hook_payload(req: &RenderRequest<'_>) -> Result<RenderedHookPayload, RenderError> {
if let Some(d) = req.directive {
validate_directive(d)?;
}
if !matches_adapter_id(req.adapter, req.adapter_id) {
return Err(RenderError::AdapterIdMismatch {
adapter: req.adapter,
adapter_id: req.adapter_id.to_string(),
});
}
match req.adapter {
ProtocolAdapter::Claude => claude::render(req),
ProtocolAdapter::Codex => codex::render(req),
}
}
fn validate_directive(d: &FrameAdmissionDirective) -> Result<(), RenderError> {
if let FrameAdmissionDirective::Block { reason } = d
&& reason.trim().is_empty()
{
return Err(RenderError::InvalidDirective(
"Block directive requires a non-empty reason".into(),
));
}
Ok(())
}
fn matches_adapter_id(adapter: ProtocolAdapter, id: &str) -> bool {
ProtocolAdapter::from_id(id) == Some(adapter)
}
pub(crate) fn placement_targets_pre_prompt(p: PlacementClass) -> bool {
matches!(
p,
PlacementClass::PrePromptFrame | PlacementClass::DeveloperEquivalentFrame
)
}
pub(crate) fn render_additional_context(payloads: &[ProtocolPayload<'_>]) -> String {
let mut entries: Vec<Value> = Vec::new();
for p in payloads {
if !placement_targets_pre_prompt(p.placement) {
continue;
}
let mut entry = serde_json::Map::new();
entry.insert(
"payload_id".to_string(),
Value::String(p.envelope.payload_id.clone()),
);
entry.insert(
"payload_kind".to_string(),
Value::String(p.envelope.payload_kind.clone()),
);
match (&p.envelope.body, &p.envelope.body_ref) {
(Some(b), _) => {
entry.insert("body".to_string(), Value::String(b.clone()));
}
(None, Some(r)) => {
entry.insert("body_ref".to_string(), Value::String(r.clone()));
}
(None, None) => continue,
}
entries.push(Value::Object(entry));
}
if entries.is_empty() {
return "{}".to_string();
}
let mut wrapper = serde_json::Map::new();
wrapper.insert("payloads".to_string(), Value::Array(entries));
serde_json::to_string_pretty(&Value::Object(wrapper)).unwrap_or_else(|_| "{}".to_string())
}