use serde::Deserialize;
use serde_json::{Map, Value};
use crate::hook_input::HookInput;
#[derive(Debug, Deserialize)]
struct ClinePayload {
#[serde(rename = "hookName")]
hook_name: Option<String>,
#[serde(default)]
tool_call: Option<ClineToolCall>,
#[serde(rename = "preToolUse")]
pre_tool_use: Option<ClinePreToolUse>,
}
#[derive(Debug, Deserialize)]
struct ClineToolCall {
id: Option<String>,
name: String,
#[serde(default)]
input: Value,
}
#[derive(Debug, Deserialize)]
struct ClinePreToolUse {
#[serde(rename = "toolName")]
tool_name: String,
#[serde(default)]
parameters: Value,
}
pub(super) fn parse(body: &str) -> Result<HookInput, ClineInputError> {
if body.trim().is_empty() {
return Err(ClineInputError::Empty);
}
let payload: ClinePayload = serde_json::from_str(body).map_err(ClineInputError::Json)?;
if let Some(call) = payload.tool_call {
if payload.hook_name.as_deref() != Some("tool_call") {
return Err(ClineInputError::UnsupportedHookName(payload.hook_name));
}
return build(&call.name, call.input, call.id.as_deref());
}
if let Some(pre) = payload.pre_tool_use {
if !matches!(
payload.hook_name.as_deref(),
Some("PreToolUse" | "tool_call")
) {
return Err(ClineInputError::UnsupportedHookName(payload.hook_name));
}
return build(&pre.tool_name, pre.parameters, None);
}
Err(ClineInputError::MissingToolCall)
}
fn build(raw_name: &str, raw_input: Value, id: Option<&str>) -> Result<HookInput, ClineInputError> {
if raw_name.trim().is_empty() {
return Err(ClineInputError::EmptyToolName);
}
Ok(normalize_call(raw_name, raw_input, id))
}
#[derive(Debug)]
pub(super) enum ClineInputError {
Empty,
Json(serde_json::Error),
UnsupportedHookName(Option<String>),
MissingToolCall,
EmptyToolName,
}
impl std::fmt::Display for ClineInputError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Empty => write!(f, "hook payload is empty"),
Self::Json(err) => write!(f, "hook payload is not valid JSON ({err})"),
Self::UnsupportedHookName(Some(name)) => {
write!(
f,
"unsupported hookName: {name} (expected tool_call or PreToolUse)"
)
},
Self::UnsupportedHookName(None) => {
write!(f, "missing hookName (expected tool_call or PreToolUse)")
},
Self::MissingToolCall => {
write!(f, "hook payload has neither tool_call nor preToolUse")
},
Self::EmptyToolName => write!(f, "hook payload tool name is empty"),
}
}
}
fn normalize_call(raw_name: &str, raw_input: Value, id: Option<&str>) -> HookInput {
let mut args = to_args_map(raw_input);
let tool_name = match raw_name {
"use_mcp_tool" => normalize_mcp_tool(&mut args),
"access_mcp_resource" => normalize_mcp_resource(&mut args),
"execute_command" | "run_command" | "run_commands" | "bash" => {
normalize_command(&mut args);
"Bash".to_string()
},
"read_file" | "read_files" => {
normalize_path(&mut args);
"Read".to_string()
},
"editor" | "replace_in_file" | "edit_file" => {
normalize_path(&mut args);
"Edit".to_string()
},
"write_file" => {
normalize_path(&mut args);
"Write".to_string()
},
"apply_patch" => "apply_patch".to_string(),
"fetch_web" | "fetch_web_content" | "web_fetch" => {
normalize_url(&mut args);
"WebFetch".to_string()
},
other => normalize_by_fields(other, &mut args),
};
args.insert(
"_cline_tool_name".into(),
Value::String(raw_name.to_string()),
);
if let Some(id) = id {
args.insert("_cline_tool_call_id".into(), Value::String(id.to_string()));
}
HookInput {
tool_name,
tool_input: Value::Object(args),
}
}
fn to_args_map(raw: Value) -> Map<String, Value> {
match raw {
Value::Object(map) => map,
Value::Null => Map::new(),
other => {
let mut m = Map::new();
m.insert("raw".into(), other);
m
},
}
}
fn normalize_by_fields(raw_name: &str, args: &mut Map<String, Value>) -> String {
if has_command(args) {
normalize_command(args);
return "Bash".to_string();
}
if has_url(args) {
normalize_url(args);
return "WebFetch".to_string();
}
if has_content_and_path(args) {
normalize_path(args);
return "Write".to_string();
}
raw_name.to_string()
}
fn normalize_mcp_tool(args: &mut Map<String, Value>) -> String {
let server = string_field(args, "server_name");
let tool = string_field(args, "tool_name");
merge_arguments(args);
match (server, tool) {
(Some(server), Some(tool)) if !server.is_empty() && !tool.is_empty() => {
format!("mcp__{server}__{tool}")
},
_ => "use_mcp_tool".to_string(),
}
}
fn normalize_mcp_resource(args: &mut Map<String, Value>) -> String {
let server = string_field(args, "server_name");
merge_arguments(args);
match server {
Some(server) if !server.is_empty() => format!("mcp__{server}__access_resource"),
_ => "access_mcp_resource".to_string(),
}
}
fn merge_arguments(args: &mut Map<String, Value>) {
let inner = match args.remove("arguments") {
Some(Value::Object(obj)) => Some(obj),
Some(Value::String(s)) => {
if let Ok(Value::Object(obj)) = serde_json::from_str::<Value>(&s) {
Some(obj)
} else {
args.insert("arguments".into(), Value::String(s));
None
}
},
Some(other) => {
args.insert("arguments".into(), other);
None
},
None => None,
};
if let Some(inner) = inner {
for (key, value) in inner {
args.entry(key).or_insert(value);
}
}
}
fn normalize_command(args: &mut Map<String, Value>) {
let has_command = args
.get("command")
.and_then(Value::as_str)
.is_some_and(|s| !s.is_empty());
if has_command {
return;
}
if let Some(cmd) = first_string(args, &["cmd", "shellCommand"]) {
args.insert("command".into(), Value::String(cmd));
return;
}
if let Some(joined) = join_commands(args) {
args.insert("command".into(), Value::String(joined));
}
}
fn join_commands(args: &Map<String, Value>) -> Option<String> {
let arr = args.get("commands")?.as_array()?;
let parts: Vec<&str> = arr.iter().filter_map(Value::as_str).collect();
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
fn normalize_path(args: &mut Map<String, Value>) {
if let Some(path) = first_string(
args,
&[
"file_path",
"filePath",
"path",
"absolutePath",
"relativePath",
],
) {
args.insert("file_path".into(), Value::String(path));
}
}
fn normalize_url(args: &mut Map<String, Value>) {
if let Some(url) = first_string(args, &["url", "uri", "href"]) {
args.insert("url".into(), Value::String(url));
}
}
fn has_command(args: &Map<String, Value>) -> bool {
["command", "cmd", "shellCommand", "commands"]
.iter()
.any(|key| args.contains_key(*key))
}
fn has_url(args: &Map<String, Value>) -> bool {
["url", "uri", "href"]
.iter()
.any(|key| args.contains_key(*key))
}
fn has_content_and_path(args: &Map<String, Value>) -> bool {
args.contains_key("content")
&& [
"file_path",
"filePath",
"path",
"absolutePath",
"relativePath",
]
.iter()
.any(|key| args.contains_key(*key))
}
fn first_string(args: &Map<String, Value>, keys: &[&str]) -> Option<String> {
for key in keys {
if let Some(s) = args.get(*key).and_then(Value::as_str) {
return Some(s.to_string());
}
}
None
}
fn string_field(args: &Map<String, Value>, key: &str) -> Option<String> {
args.get(key).and_then(Value::as_str).map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cline_sdk_tool_call_command_normalizes_to_bash() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"id": "c1",
"name": "run_commands",
"input": { "command": "rm -rf /" }
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.tool_input["command"], "rm -rf /");
assert_eq!(input.tool_input["_cline_tool_name"], "run_commands");
assert_eq!(input.tool_input["_cline_tool_call_id"], "c1");
}
#[test]
fn cline_legacy_pretooluse_normalizes_to_bash() {
let body = r#"{
"hookName": "PreToolUse",
"preToolUse": {
"toolName": "execute_command",
"parameters": { "command": "rm -rf /" }
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.tool_input["command"], "rm -rf /");
assert_eq!(input.tool_input["_cline_tool_name"], "execute_command");
assert!(input.tool_input.get("_cline_tool_call_id").is_none());
}
#[test]
fn cline_tool_call_is_preferred_over_pretooluse() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "id": "c1", "name": "run_commands", "input": { "command": "a" } },
"preToolUse": { "toolName": "execute_command", "parameters": { "command": "b" } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.bash_command(), Some("a"));
}
#[test]
fn cline_read_files_file_path_normalizes_to_read() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "id": "c1", "name": "read_files", "input": { "filePath": ".env" } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Read");
assert_eq!(input.tool_input["file_path"], ".env");
assert_eq!(input.tool_input["filePath"], ".env");
}
#[test]
fn cline_mcp_tool_normalizes_to_mcp_name() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"name": "use_mcp_tool",
"input": {
"server_name": "github",
"tool_name": "create_or_update_file",
"arguments": { "path": ".env", "content": "SECRET=..." }
}
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "mcp__github__create_or_update_file");
assert_eq!(input.tool_input["path"], ".env");
assert_eq!(input.tool_input["content"], "SECRET=...");
assert!(input.tool_input.get("arguments").is_none());
assert!(input.tool_input.get("_cline_tool_call_id").is_none());
}
#[test]
fn cline_mcp_arguments_json_string_is_decoded() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"name": "use_mcp_tool",
"input": {
"server_name": "github",
"tool_name": "push_files",
"arguments": "{\"path\":\"/tmp/x\"}"
}
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "mcp__github__push_files");
assert_eq!(input.tool_input["path"], "/tmp/x");
}
#[test]
fn cline_mcp_arguments_non_json_string_is_kept() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"name": "use_mcp_tool",
"input": { "server_name": "s", "tool_name": "t", "arguments": "not-json" }
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "mcp__s__t");
assert_eq!(input.tool_input["arguments"], "not-json");
}
#[test]
fn cline_access_mcp_resource_normalizes_to_access_resource() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"name": "access_mcp_resource",
"input": { "server_name": "files", "uri": "file:///etc/passwd" }
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "mcp__files__access_resource");
}
#[test]
fn cline_commands_array_is_newline_joined() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"name": "run_commands",
"input": { "commands": ["npm install", "npm test"] }
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.bash_command(), Some("npm install\nnpm test"));
}
#[test]
fn cline_write_file_normalizes_to_write() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"name": "write_file",
"input": { "path": "/tmp/x", "content": "AKIA..." }
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Write");
assert_eq!(input.file_path(), Some("/tmp/x"));
assert_eq!(input.write_payload(), Some("AKIA..."));
}
#[test]
fn cline_apply_patch_keeps_canonical_name() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "apply_patch", "input": { "patch": "diff" } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "apply_patch");
}
#[test]
fn cline_web_fetch_normalizes_to_webfetch() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "fetch_web", "input": { "uri": "https://x" } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "WebFetch");
assert_eq!(input.web_fetch_url(), Some("https://x"));
}
#[test]
fn cline_unknown_tool_with_command_field_falls_back_to_bash() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "mystery_runner", "input": { "command": "rm -rf /" } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.bash_command(), Some("rm -rf /"));
}
#[test]
fn cline_unknown_tool_without_signals_keeps_raw_name() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "list_files", "input": { "depth": 2 } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "list_files");
assert_eq!(input.tool_input["_cline_tool_name"], "list_files");
}
#[test]
fn cline_non_object_input_falls_back_to_raw() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "weird", "input": 42 }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "weird");
assert_eq!(input.tool_input["raw"], 42);
}
#[test]
fn cline_missing_input_is_accepted() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "run_commands" }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert!(input.tool_input.is_object());
}
#[test]
fn cline_empty_body_is_rejected() {
assert!(matches!(parse(""), Err(ClineInputError::Empty)));
assert!(matches!(parse(" \n"), Err(ClineInputError::Empty)));
}
#[test]
fn cline_invalid_json_is_rejected() {
assert!(matches!(parse("not-json"), Err(ClineInputError::Json(_))));
}
#[test]
fn cline_missing_tool_call_is_rejected() {
let body = r#"{"hookName":"tool_call"}"#;
assert!(matches!(parse(body), Err(ClineInputError::MissingToolCall)));
}
#[test]
fn cline_unsupported_hook_name_is_rejected() {
let body = r#"{"hookName":"sessionStart","tool_call":{"name":"bash","input":{}}}"#;
assert!(matches!(
parse(body),
Err(ClineInputError::UnsupportedHookName(Some(_)))
));
}
#[test]
fn cline_missing_hook_name_with_tool_call_is_rejected() {
let body = r#"{"tool_call":{"name":"bash","input":{}}}"#;
assert!(matches!(
parse(body),
Err(ClineInputError::UnsupportedHookName(None))
));
}
#[test]
fn cline_legacy_pretooluse_rejects_wrong_hook_name() {
let body =
r#"{"hookName":"sessionStart","preToolUse":{"toolName":"bash","parameters":{}}}"#;
assert!(matches!(
parse(body),
Err(ClineInputError::UnsupportedHookName(_))
));
}
#[test]
fn cline_empty_tool_name_is_rejected() {
let body = r#"{"hookName":"tool_call","tool_call":{"name":" ","input":{}}}"#;
assert!(matches!(parse(body), Err(ClineInputError::EmptyToolName)));
}
#[test]
fn cline_array_payload_is_rejected() {
assert!(matches!(parse("[]"), Err(ClineInputError::Json(_))));
}
#[test]
fn cline_input_error_display_covers_all_variants() {
let bad = serde_json::from_str::<Value>("nope").unwrap_err();
assert!(format!("{}", ClineInputError::Empty).contains("empty"));
assert!(format!("{}", ClineInputError::Json(bad)).contains("not valid JSON"));
assert!(
format!("{}", ClineInputError::UnsupportedHookName(Some("x".into())))
.contains("unsupported hookName")
);
assert!(
format!("{}", ClineInputError::UnsupportedHookName(None)).contains("missing hookName")
);
assert!(format!("{}", ClineInputError::MissingToolCall).contains("neither tool_call"));
assert!(format!("{}", ClineInputError::EmptyToolName).contains("tool name is empty"));
}
#[test]
fn cline_command_alias_cmd_is_promoted() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "run_commands", "input": { "cmd": "ls -la" } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.bash_command(), Some("ls -la"));
}
#[test]
fn cline_command_alias_shell_command_is_promoted() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "bash", "input": { "shellCommand": "whoami" } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.bash_command(), Some("whoami"));
}
#[test]
fn cline_empty_commands_array_leaves_command_unset() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "run_commands", "input": { "commands": [] } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Bash");
assert!(input.tool_input.get("command").is_none());
}
#[test]
fn cline_mcp_tool_without_server_keeps_raw_name() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"name": "use_mcp_tool",
"input": { "tool_name": "t", "arguments": { "path": ".env" } }
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "use_mcp_tool");
assert_eq!(input.tool_input["path"], ".env");
}
#[test]
fn cline_mcp_resource_without_server_keeps_raw_name() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "access_mcp_resource", "input": { "uri": "x" } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "access_mcp_resource");
}
#[test]
fn cline_mcp_arguments_non_object_value_is_kept() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"name": "use_mcp_tool",
"input": { "server_name": "s", "tool_name": "t", "arguments": [1, 2] }
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "mcp__s__t");
assert_eq!(input.tool_input["arguments"], serde_json::json!([1, 2]));
}
#[test]
fn cline_unknown_tool_with_url_field_falls_back_to_webfetch() {
let body = r#"{
"hookName": "tool_call",
"tool_call": { "name": "mystery_fetcher", "input": { "href": "https://x" } }
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "WebFetch");
assert_eq!(input.web_fetch_url(), Some("https://x"));
}
#[test]
fn cline_unknown_tool_with_content_and_path_falls_back_to_write() {
let body = r#"{
"hookName": "tool_call",
"tool_call": {
"name": "mystery_writer",
"input": { "absolutePath": "/tmp/x", "content": "data" }
}
}"#;
let input = parse(body).unwrap();
assert_eq!(input.tool_name, "Write");
assert_eq!(input.file_path(), Some("/tmp/x"));
assert_eq!(input.write_payload(), Some("data"));
}
use crate::testing::proptest::arbitrary_utf8_bytes;
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_parse_is_total_on_arbitrary_utf8(bytes in arbitrary_utf8_bytes()) {
let body = String::from_utf8_lossy(&bytes);
let _ = parse(&body);
}
#[test]
fn pbt_invalid_envelope_returns_err(
body in prop_oneof![
Just("null".to_string()),
Just("true".to_string()),
Just("0".to_string()),
Just("\"x\"".to_string()),
Just("[]".to_string()),
Just("{}".to_string()),
Just(r#"{"hookName":"tool_call"}"#.to_string()),
Just(r#"{"tool_call":{"name":"bash","input":{}}}"#.to_string()),
Just(r#"{"hookName":"weird","tool_call":{"name":"bash","input":{}}}"#.to_string()),
Just(r#"{"hookName":"tool_call","tool_call":{"name":"","input":{}}}"#.to_string()),
],
) {
match parse(&body) {
Err(
ClineInputError::Empty
| ClineInputError::Json(_)
| ClineInputError::UnsupportedHookName(_)
| ClineInputError::MissingToolCall
| ClineInputError::EmptyToolName,
) => {},
other => prop_assert!(
false,
"expected a structured error for body {body:?}, got {other:?}",
),
}
}
}
}