Skip to main content

phi_core/agent_loop/
script_callback.rs

1//! Script-based callbacks — run shell or Python scripts as lifecycle hooks.
2//!
3//! `ScriptCallback` wraps an external script that receives JSON on stdin and
4//! returns JSON on stdout. The builder uses this to bridge config-specified
5//! script paths into Rust closure callbacks.
6//!
7//! # Protocol
8//!
9//! **Input (stdin):**
10//! ```json
11//! { "hook": "before_loop", "session_id": "...", "loop_id": "...", ... }
12//! ```
13//!
14//! **Output (stdout) for `before_*` hooks:**
15//! ```json
16//! { "allow": true }
17//! ```
18//!
19//! **Output (stdout) for `after_*` hooks:**
20//! Any JSON (logged but not acted upon).
21
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25/// A callback that executes an external script (shell or Python).
26#[derive(Debug, Clone)]
27pub struct ScriptCallback {
28    /// Path to the script (.sh, .py, or any executable).
29    pub path: PathBuf,
30    /// Working directory for script execution.
31    pub working_dir: Option<PathBuf>,
32}
33
34impl ScriptCallback {
35    /// Create a new script callback.
36    pub fn new(path: impl Into<PathBuf>, working_dir: Option<PathBuf>) -> Self {
37        Self {
38            path: path.into(),
39            working_dir,
40        }
41    }
42
43    /// Execute the script synchronously with JSON input on stdin.
44    /// Returns the parsed JSON output from stdout.
45    pub fn execute_sync(
46        &self,
47        input: &serde_json::Value,
48    ) -> Result<serde_json::Value, ScriptCallbackError> {
49        let input_json = serde_json::to_string(input)
50            .map_err(|e| ScriptCallbackError::Serialization(e.to_string()))?;
51
52        let interpreter = detect_interpreter(&self.path);
53        let mut cmd = Command::new(&interpreter[0]);
54        for arg in &interpreter[1..] {
55            cmd.arg(arg);
56        }
57        cmd.arg(&self.path);
58
59        if let Some(ref dir) = self.working_dir {
60            cmd.current_dir(dir);
61        }
62
63        cmd.stdin(std::process::Stdio::piped());
64        cmd.stdout(std::process::Stdio::piped());
65        cmd.stderr(std::process::Stdio::piped());
66
67        let mut child = cmd.spawn().map_err(|e| ScriptCallbackError::Spawn {
68            path: self.path.display().to_string(),
69            error: e.to_string(),
70        })?;
71
72        // Write input to stdin
73        if let Some(ref mut stdin) = child.stdin.take() {
74            use std::io::Write;
75            let _ = stdin.write_all(input_json.as_bytes());
76        }
77
78        let output = child
79            .wait_with_output()
80            .map_err(|e| ScriptCallbackError::Execution {
81                path: self.path.display().to_string(),
82                error: e.to_string(),
83            })?;
84
85        if !output.status.success() {
86            let stderr = String::from_utf8_lossy(&output.stderr);
87            return Err(ScriptCallbackError::NonZeroExit {
88                path: self.path.display().to_string(),
89                code: output.status.code(),
90                stderr: stderr.to_string(),
91            });
92        }
93
94        let stdout = String::from_utf8_lossy(&output.stdout);
95        serde_json::from_str(stdout.trim())
96            .map_err(|e| ScriptCallbackError::OutputParse(e.to_string()))
97    }
98}
99
100/// Detect the interpreter for a script based on file extension.
101///
102/// Returns the argv prefix to spawn the appropriate interpreter for a script
103/// at `path`:
104///
105/// - `.py` → `["python3"]`
106/// - `.sh` → `["sh"]`
107/// - any other extension (or none) → `["sh"]` (default to shell)
108///
109/// Public so downstream consumers (e.g. i-phi's hook dispatcher) can adopt the
110/// same script-extension dispatch table phi-core uses internally for
111/// [`ScriptCallback`], rather than re-deriving it.
112pub fn detect_interpreter(path: &Path) -> Vec<String> {
113    match path.extension().and_then(|e| e.to_str()) {
114        Some("py") => vec!["python3".into()],
115        Some("sh") => vec!["sh".into()],
116        _ => vec!["sh".into()], // default to shell
117    }
118}
119
120/// Returns true if a string looks like a script path (for config detection).
121pub fn is_script_path(s: &str) -> bool {
122    s.ends_with(".sh") || s.ends_with(".py") || s.contains('/')
123}
124
125/// Errors from script callback execution.
126#[derive(Debug)]
127pub enum ScriptCallbackError {
128    Spawn {
129        path: String,
130        error: String,
131    },
132    Execution {
133        path: String,
134        error: String,
135    },
136    NonZeroExit {
137        path: String,
138        code: Option<i32>,
139        stderr: String,
140    },
141    OutputParse(String),
142    Serialization(String),
143}
144
145impl std::fmt::Display for ScriptCallbackError {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            Self::Spawn { path, error } => write!(f, "Failed to spawn script {path}: {error}"),
149            Self::Execution { path, error } => {
150                write!(f, "Script execution failed {path}: {error}")
151            }
152            Self::NonZeroExit { path, code, stderr } => {
153                write!(f, "Script {path} exited with code {code:?}: {stderr}")
154            }
155            Self::OutputParse(e) => write!(f, "Failed to parse script output as JSON: {e}"),
156            Self::Serialization(e) => write!(f, "Failed to serialize input: {e}"),
157        }
158    }
159}
160
161impl std::error::Error for ScriptCallbackError {}