Skip to main content

rightkit_hooks/
output.rs

1//! Exact host output shapes.
2//!
3//! Every shape here is one a shipped consumer already emits:
4//!
5//! - additional context: `{"hookSpecificOutput":{"hookEventName":E,"additionalContext":T}}` for
6//!   `SessionStart`, `UserPromptSubmit`, `PostToolUse` (Pulse `codex_hook.rs`, Membrane `hook.rs`);
7//! - Stop continuation: `{"decision":"block","reason":R}` (Pulse, Legion);
8//! - Claude PreToolUse: `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":
9//!   "deny"|"ask","permissionDecisionReason":R}}` (Legion `protocol.rs`);
10//! - Codex PreToolUse denial: empty stdout, reason on stderr, exit 2 (Legion `codex.rs`; Codex has
11//!   no `ask`, so it denies);
12//! - `systemMessage` is the top-level field both tools show to the person (Pulse).
13//!
14//! A builder refuses an event that does not take the field, because a host rejects a whole
15//! response that carries an unsupported variant (Membrane `HookHostResponseV1`).
16
17use crate::event::{EventKind, Wire};
18use serde_json::{json, Map, Value};
19use std::fmt;
20use std::io::Write;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum OutputError {
24    /// The event's host schema does not take this output.
25    Unsupported { event: String, output: &'static str },
26}
27
28impl fmt::Display for OutputError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::Unsupported { event, output } => {
32                write!(f, "{event} does not take {output}")
33            }
34        }
35    }
36}
37
38impl std::error::Error for OutputError {}
39
40/// What a hook process writes: stdout JSON, stderr text and an exit code.
41#[derive(Clone, Debug, PartialEq, Default)]
42pub struct Reply {
43    pub stdout: Option<Value>,
44    pub stderr: Option<String>,
45    pub exit_code: i32,
46}
47
48impl Reply {
49    /// Print nothing, exit 0.
50    pub fn none() -> Self {
51        Self::default()
52    }
53
54    pub fn is_none(&self) -> bool {
55        self.stdout.is_none() && self.stderr.is_none() && self.exit_code == 0
56    }
57
58    /// Add context for the model. Only events in [`EventKind::takes_additional_context`].
59    pub fn additional_context(kind: &EventKind, context: &str) -> Result<Self, OutputError> {
60        if !kind.takes_additional_context() {
61            return Err(OutputError::Unsupported {
62                event: kind.to_string(),
63                output: "additionalContext",
64            });
65        }
66        Ok(Self {
67            stdout: Some(json!({
68                "hookSpecificOutput": {
69                    "hookEventName": kind.as_str(),
70                    "additionalContext": context,
71                }
72            })),
73            ..Self::default()
74        })
75    }
76
77    /// Keep a `Stop` run going: `{"decision":"block","reason":R}`.
78    pub fn block_stop(reason: &str) -> Self {
79        Self {
80            stdout: Some(json!({"decision": "block", "reason": reason})),
81            ..Self::default()
82        }
83    }
84
85    /// Refuse a tool call. Claude: `permissionDecision: "deny"`, exit 0. Codex: stderr and exit 2.
86    pub fn deny_tool(wire: Wire, reason: &str) -> Self {
87        Self::refuse_tool(wire, reason, "deny")
88    }
89
90    /// Ask the person to confirm a tool call. Claude: `permissionDecision: "ask"`. Codex has no
91    /// ask outcome and denies.
92    pub fn ask_tool(wire: Wire, reason: &str) -> Self {
93        Self::refuse_tool(wire, reason, "ask")
94    }
95
96    fn refuse_tool(wire: Wire, reason: &str, decision: &str) -> Self {
97        match wire {
98            Wire::Claude => Self {
99                stdout: Some(json!({
100                    "hookSpecificOutput": {
101                        "hookEventName": "PreToolUse",
102                        "permissionDecision": decision,
103                        "permissionDecisionReason": reason,
104                    }
105                })),
106                ..Self::default()
107            },
108            Wire::Codex => Self {
109                stdout: None,
110                stderr: Some(reason.to_owned()),
111                exit_code: 2,
112            },
113        }
114    }
115
116    /// Add the top-level `systemMessage` the host shows to the person.
117    pub fn with_system_message(mut self, message: &str) -> Self {
118        if self.stderr.is_some() {
119            return self;
120        }
121        let object = match self.stdout.take() {
122            Some(Value::Object(object)) => object,
123            _ => Map::new(),
124        };
125        let mut object = object;
126        object.insert("systemMessage".into(), Value::String(message.to_owned()));
127        self.stdout = Some(Value::Object(object));
128        self
129    }
130
131    /// The single stdout line, if any.
132    pub fn stdout_line(&self) -> Option<String> {
133        self.stdout
134            .as_ref()
135            .and_then(|value| serde_json::to_string(value).ok())
136    }
137
138    /// Write stdout and stderr; returns the exit code. A write failure on stdout is ignored
139    /// for exit-0 replies so the host is never failed by a closed pipe.
140    pub fn write_to(&self, mut out: impl Write, mut err: impl Write) -> i32 {
141        if let Some(text) = &self.stderr {
142            let _ = writeln!(err, "{text}");
143        }
144        if let Some(line) = self.stdout_line() {
145            let _ = writeln!(out, "{line}");
146        }
147        self.exit_code
148    }
149}