pub(crate) mod claude;
pub(crate) mod codex;
pub(crate) mod copilot;
pub(crate) mod cursor;
pub(crate) mod gemini;
pub(crate) mod opencode;
use super::session::AgentKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookSupport {
RealHook,
AwarenessOnly,
}
#[derive(Debug, Clone)]
pub(crate) struct HookInput {
pub(crate) command: String,
}
#[derive(Debug)]
#[allow(dead_code)] pub(crate) struct InstallResult {
pub(crate) script_path: Option<std::path::PathBuf>,
pub(crate) config_patched: bool,
}
#[derive(Debug)]
#[allow(dead_code)] pub(crate) struct InstallOpts {
pub(crate) binary_path: std::path::PathBuf,
pub(crate) version: String,
pub(crate) config_dir: std::path::PathBuf,
pub(crate) project_scope: bool,
pub(crate) dry_run: bool,
}
#[derive(Debug)]
#[allow(dead_code)] pub(crate) struct UninstallOpts {
pub(crate) config_dir: std::path::PathBuf,
pub(crate) force: bool,
}
pub(crate) trait HookProtocol {
#[allow(dead_code)] fn agent_kind(&self) -> AgentKind;
fn hook_support(&self) -> HookSupport;
fn parse_input(&self, json: &serde_json::Value) -> Option<HookInput>;
fn format_response(&self, rewritten_command: &str) -> serde_json::Value;
#[allow(dead_code)] fn generate_script(&self, binary_path: &str, version: &str) -> String;
#[allow(dead_code)] fn install(&self, _opts: &InstallOpts) -> anyhow::Result<InstallResult> {
Ok(InstallResult {
script_path: None,
config_patched: false,
})
}
#[allow(dead_code)] fn uninstall(&self, _opts: &UninstallOpts) -> anyhow::Result<()> {
Ok(())
}
}
pub(crate) fn parse_tool_input_command(json: &serde_json::Value) -> Option<HookInput> {
let command = json
.get("tool_input")
.and_then(|ti| ti.get("command"))
.and_then(|c| c.as_str())?
.to_string();
Some(HookInput { command })
}
const SHELL_UNSAFE_CHARS: &[char] = &['"', '`', '$', '\\', '\n', '\0'];
#[allow(dead_code)] pub(crate) fn generate_hook_script(
binary_path: &str,
version: &str,
agent_cli_name: &str,
) -> String {
assert!(
!binary_path.chars().any(|c| SHELL_UNSAFE_CHARS.contains(&c)),
"binary_path contains shell-unsafe character: {binary_path}"
);
assert!(
version
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-'),
"version contains unsafe characters for shell interpolation: {version}"
);
assert!(
agent_cli_name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-'),
"agent_cli_name contains unsafe characters for shell interpolation: {agent_cli_name}"
);
format!(
"#!/usr/bin/env bash\n\
# skim-hook v{version}\n\
# Generated by: skim init --agent {agent_cli_name} -- do not edit manually\n\
export SKIM_HOOK_VERSION=\"{version}\"\n\
exec \"{binary_path}\" rewrite --hook --agent {agent_cli_name}\n"
)
}
pub(crate) fn protocol_for_agent(kind: AgentKind) -> Box<dyn HookProtocol> {
match kind {
AgentKind::ClaudeCode => Box::new(claude::ClaudeCodeHook),
AgentKind::Cursor => Box::new(cursor::CursorHook),
AgentKind::GeminiCli => Box::new(gemini::GeminiCliHook),
AgentKind::CopilotCli => Box::new(copilot::CopilotCliHook),
AgentKind::CodexCli => Box::new(codex::CodexCliHook),
AgentKind::OpenCode => Box::new(opencode::OpenCodeHook),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_support_equality() {
assert_eq!(HookSupport::RealHook, HookSupport::RealHook);
assert_ne!(HookSupport::RealHook, HookSupport::AwarenessOnly);
}
#[test]
fn test_hook_input_clone() {
let input = HookInput {
command: "cargo test".to_string(),
};
let cloned = input.clone();
assert_eq!(cloned.command, "cargo test");
}
#[test]
fn test_parse_tool_input_command_valid() {
let json = serde_json::json!({
"tool_input": {
"command": "cargo test --nocapture"
}
});
let result = parse_tool_input_command(&json);
assert!(result.is_some());
assert_eq!(result.unwrap().command, "cargo test --nocapture");
}
#[test]
fn test_parse_tool_input_command_missing_tool_input() {
let json = serde_json::json!({});
assert!(parse_tool_input_command(&json).is_none());
}
#[test]
fn test_parse_tool_input_command_missing_command() {
let json = serde_json::json!({
"tool_input": {
"file_path": "/tmp/test.rs"
}
});
assert!(parse_tool_input_command(&json).is_none());
}
#[test]
fn test_parse_tool_input_command_non_string() {
let json = serde_json::json!({
"tool_input": {
"command": 42
}
});
assert!(parse_tool_input_command(&json).is_none());
}
#[test]
fn test_generate_hook_script_structure() {
let script = generate_hook_script("/usr/local/bin/skim", "1.2.3", "test-agent");
assert!(script.starts_with("#!/usr/bin/env bash\n"));
assert!(script.contains("# skim-hook v1.2.3"));
assert!(script.contains("skim init --agent test-agent"));
assert!(script.contains("SKIM_HOOK_VERSION=\"1.2.3\""));
assert!(script.contains("exec \"/usr/local/bin/skim\" rewrite --hook --agent test-agent"));
}
#[test]
#[should_panic(expected = "binary_path contains shell-unsafe character")]
fn test_generate_hook_script_rejects_backtick_in_path() {
generate_hook_script("/usr/local/bin/`evil`", "1.0.0", "test-agent");
}
#[test]
#[should_panic(expected = "binary_path contains shell-unsafe character")]
fn test_generate_hook_script_rejects_dollar_in_path() {
generate_hook_script("/usr/local/bin/$HOME/skim", "1.0.0", "test-agent");
}
#[test]
#[should_panic(expected = "binary_path contains shell-unsafe character")]
fn test_generate_hook_script_rejects_quote_in_path() {
generate_hook_script("/usr/local/bin/sk\"im", "1.0.0", "test-agent");
}
#[test]
#[should_panic(expected = "binary_path contains shell-unsafe character")]
fn test_generate_hook_script_rejects_newline_in_path() {
generate_hook_script("/usr/local/bin/skim\n;rm -rf /", "1.0.0", "test-agent");
}
#[test]
#[should_panic(expected = "version contains unsafe characters")]
fn test_generate_hook_script_rejects_unsafe_version() {
generate_hook_script("/usr/local/bin/skim", "1.0.0$(evil)", "test-agent");
}
#[test]
#[should_panic(expected = "agent_cli_name contains unsafe characters")]
fn test_generate_hook_script_rejects_unsafe_agent_name() {
generate_hook_script("/usr/local/bin/skim", "1.0.0", "agent;rm -rf /");
}
#[test]
fn test_generate_hook_script_accepts_path_with_spaces() {
let script = generate_hook_script("/Users/my user/bin/skim", "1.0.0", "test-agent");
assert!(script.contains("exec \"/Users/my user/bin/skim\""));
}
}