use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)]
#[allow(dead_code)]
pub enum AgentType {
Claude,
Codex,
Opencode,
}
impl AgentType {
pub fn as_str(&self) -> &'static str {
match self {
AgentType::Claude => "claude",
AgentType::Codex => "codex",
AgentType::Opencode => "opencode",
}
}
pub fn config_dir(&self) -> Option<PathBuf> {
match self {
AgentType::Claude => dirs::config_dir().map(|d| d.join("claude")),
AgentType::Codex => dirs::config_dir().map(|d| d.join("codex")),
AgentType::Opencode => dirs::config_dir().map(|d| d.join("opencode")),
}
}
pub fn hook_script(&self) -> String {
match self {
AgentType::Claude => r#"#!/bin/bash
# Terraphim Agent Hook for Claude Code
# This hook captures failed commands for learning
set -e
# Capture the tool execution result and pipe to terraphim-agent
if command -v terraphim-agent >/dev/null 2>&1; then
# Read stdin
INPUT=$(cat)
# Pass through to terraphim-agent hook
echo "$INPUT" | terraphim-agent learn hook --format claude 2>/dev/null || true
# Always pass through original input (fail-open)
echo "$INPUT"
else
# terraphim-agent not installed, pass through unchanged
cat
fi
"#
.to_string(),
AgentType::Codex => r#"#!/bin/bash
# Terraphim Agent Hook for OpenAI Codex
# This hook captures failed commands for learning
set -e
# Capture the tool execution result and pipe to terraphim-agent
if command -v terraphim-agent >/dev/null 2>&1; then
# Read stdin
INPUT=$(cat)
# Pass through to terraphim-agent hook
echo "$INPUT" | terraphim-agent learn hook --format codex 2>/dev/null || true
# Always pass through original input (fail-open)
echo "$INPUT"
else
# terraphim-agent not installed, pass through unchanged
cat
fi
"#
.to_string(),
AgentType::Opencode => r#"#!/bin/bash
# Terraphim Agent Hook for Opencode
# This hook captures failed commands for learning
set -e
# Capture the tool execution result and pipe to terraphim-agent
if command -v terraphim-agent >/dev/null 2>&1; then
# Read stdin
INPUT=$(cat)
# Pass through to terraphim-agent hook
echo "$INPUT" | terraphim-agent learn hook --format opencode 2>/dev/null || true
# Always pass through original input (fail-open)
echo "$INPUT"
else
# terraphim-agent not installed, pass through unchanged
cat
fi
"#
.to_string(),
}
}
pub fn hook_path(&self) -> Option<PathBuf> {
self.config_dir().map(|d| d.join("terraphim-hook.sh"))
}
}
#[derive(Debug, Error)]
#[allow(dead_code)]
pub enum InstallError {
#[error("failed to create config directory: {0}")]
ConfigDirError(String),
#[error("failed to write hook file: {0}")]
WriteError(#[from] std::io::Error),
#[error("agent config directory not found")]
ConfigNotFound,
#[error("hook already exists at {0}")]
AlreadyExists(String),
}
pub async fn install_hook(agent: AgentType) -> Result<(), InstallError> {
let config_dir = agent.config_dir().ok_or(InstallError::ConfigNotFound)?;
let hook_path = agent.hook_path().ok_or(InstallError::ConfigNotFound)?;
tokio::fs::create_dir_all(&config_dir)
.await
.map_err(|e| InstallError::ConfigDirError(e.to_string()))?;
if hook_path.exists() {
return Err(InstallError::AlreadyExists(
hook_path.to_string_lossy().to_string(),
));
}
let hook_content = agent.hook_script();
tokio::fs::write(&hook_path, hook_content)
.await
.map_err(InstallError::WriteError)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = tokio::fs::metadata(&hook_path)
.await
.map_err(InstallError::WriteError)?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o755);
tokio::fs::set_permissions(&hook_path, permissions)
.await
.map_err(InstallError::WriteError)?;
}
println!(
"Installed Terraphim hook for {} at: {}",
agent.as_str(),
hook_path.display()
);
println!();
println!("To activate the hook, add the following to your agent configuration:");
println!();
match agent {
AgentType::Claude => {
println!(" Claude Code: Set the CLAUDE_HOOK environment variable:");
println!(" export CLAUDE_HOOK={}", hook_path.display());
}
AgentType::Codex => {
println!(" OpenAI Codex: Set the CODEX_HOOK environment variable:");
println!(" export CODEX_HOOK={}", hook_path.display());
}
AgentType::Opencode => {
println!(" Opencode: Set the OPCODE_HOOK environment variable:");
println!(" export OPCODE_HOOK={}", hook_path.display());
}
}
println!();
println!("Or add the above line to your shell profile (~/.bashrc, ~/.zshrc, etc.)");
Ok(())
}
#[allow(dead_code)]
pub async fn uninstall_hook(agent: AgentType) -> Result<(), InstallError> {
let hook_path = agent.hook_path().ok_or(InstallError::ConfigNotFound)?;
if !hook_path.exists() {
println!(
"No hook found for {} at: {}",
agent.as_str(),
hook_path.display()
);
return Ok(());
}
tokio::fs::remove_file(&hook_path)
.await
.map_err(InstallError::WriteError)?;
println!(
"Uninstalled Terraphim hook for {} from: {}",
agent.as_str(),
hook_path.display()
);
Ok(())
}
#[allow(dead_code)]
pub fn is_hook_installed(agent: AgentType) -> bool {
agent.hook_path().map(|p| p.exists()).unwrap_or(false)
}
#[allow(dead_code)]
pub fn get_installation_status() -> Vec<(AgentType, bool)> {
vec![
(AgentType::Claude, is_hook_installed(AgentType::Claude)),
(AgentType::Codex, is_hook_installed(AgentType::Codex)),
(AgentType::Opencode, is_hook_installed(AgentType::Opencode)),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_type_as_str() {
assert_eq!(AgentType::Claude.as_str(), "claude");
assert_eq!(AgentType::Codex.as_str(), "codex");
assert_eq!(AgentType::Opencode.as_str(), "opencode");
}
#[test]
fn test_agent_type_variants_distinct() {
assert_ne!(AgentType::Claude, AgentType::Codex);
assert_ne!(AgentType::Claude, AgentType::Opencode);
assert_ne!(AgentType::Codex, AgentType::Opencode);
}
#[test]
fn test_hook_script_contains_agent_name() {
let claude_script = AgentType::Claude.hook_script();
assert!(claude_script.contains("terraphim-agent"));
assert!(claude_script.contains("learn hook"));
let codex_script = AgentType::Codex.hook_script();
assert!(codex_script.contains("terraphim-agent"));
assert!(codex_script.contains("learn hook"));
let opencode_script = AgentType::Opencode.hook_script();
assert!(opencode_script.contains("terraphim-agent"));
assert!(opencode_script.contains("learn hook"));
}
#[test]
fn test_hook_script_fail_open() {
let script = AgentType::Claude.hook_script();
assert!(script.contains("2>/dev/null || true"));
assert!(script.contains("cat"));
}
#[test]
fn test_install_error_display() {
let err = InstallError::ConfigNotFound;
assert_eq!(err.to_string(), "agent config directory not found");
let err = InstallError::ConfigDirError("permission denied".to_string());
assert_eq!(
err.to_string(),
"failed to create config directory: permission denied"
);
}
}