feat: implement hooks system for project configuration

- Add HooksEditor component for managing project hooks
- Add ProjectSettings component for project-specific configurations
- Create hooksManager utility for hook operations
- Add hooks type definitions
- Update backend commands to support hooks functionality
- Integrate hooks into main app, agent execution, and Claude sessions
- Update API and utilities to handle hooks data
This commit is contained in:
Vivek R
2025-07-06 14:10:44 +05:30
parent 1922ffc145
commit 6b9393f4d3
15 changed files with 2294 additions and 311 deletions

View File

@@ -33,6 +33,7 @@ pub struct Agent {
pub enable_file_read: bool,
pub enable_file_write: bool,
pub enable_network: bool,
pub hooks: Option<String>, // JSON string of hooks configuration
pub created_at: String,
pub updated_at: String,
}
@@ -89,6 +90,7 @@ pub struct AgentData {
pub system_prompt: String,
pub default_task: Option<String>,
pub model: String,
pub hooks: Option<String>,
}
/// Database connection state
@@ -235,6 +237,7 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
enable_file_read BOOLEAN NOT NULL DEFAULT 1,
enable_file_write BOOLEAN NOT NULL DEFAULT 1,
enable_network BOOLEAN NOT NULL DEFAULT 0,
hooks TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)",
@@ -247,6 +250,7 @@ pub fn init_database(app: &AppHandle) -> SqliteResult<Connection> {
"ALTER TABLE agents ADD COLUMN model TEXT DEFAULT 'sonnet'",
[],
);
let _ = conn.execute("ALTER TABLE agents ADD COLUMN hooks TEXT", []);
let _ = conn.execute(
"ALTER TABLE agents ADD COLUMN enable_file_read BOOLEAN DEFAULT 1",
[],
@@ -349,7 +353,7 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result<Vec<Agent>, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare("SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents ORDER BY created_at DESC")
.prepare("SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents ORDER BY created_at DESC")
.map_err(|e| e.to_string())?;
let agents = stmt
@@ -366,8 +370,9 @@ pub async fn list_agents(db: State<'_, AgentDb>) -> Result<Vec<Agent>, String> {
enable_file_read: row.get::<_, bool>(6).unwrap_or(true),
enable_file_write: row.get::<_, bool>(7).unwrap_or(true),
enable_network: row.get::<_, bool>(8).unwrap_or(false),
created_at: row.get(9)?,
updated_at: row.get(10)?,
hooks: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
})
})
.map_err(|e| e.to_string())?
@@ -389,6 +394,7 @@ pub async fn create_agent(
enable_file_read: Option<bool>,
enable_file_write: Option<bool>,
enable_network: Option<bool>,
hooks: Option<String>,
) -> Result<Agent, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let model = model.unwrap_or_else(|| "sonnet".to_string());
@@ -397,8 +403,8 @@ pub async fn create_agent(
let enable_network = enable_network.unwrap_or(false);
conn.execute(
"INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network],
"INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks],
)
.map_err(|e| e.to_string())?;
@@ -407,7 +413,7 @@ pub async fn create_agent(
// Fetch the created agent
let agent = conn
.query_row(
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(Agent {
@@ -420,8 +426,9 @@ pub async fn create_agent(
enable_file_read: row.get(6)?,
enable_file_write: row.get(7)?,
enable_network: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
hooks: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
})
},
)
@@ -443,13 +450,14 @@ pub async fn update_agent(
enable_file_read: Option<bool>,
enable_file_write: Option<bool>,
enable_network: Option<bool>,
hooks: Option<String>,
) -> Result<Agent, String> {
let conn = db.0.lock().map_err(|e| e.to_string())?;
let model = model.unwrap_or_else(|| "sonnet".to_string());
// Build dynamic query based on provided parameters
let mut query =
"UPDATE agents SET name = ?1, icon = ?2, system_prompt = ?3, default_task = ?4, model = ?5"
"UPDATE agents SET name = ?1, icon = ?2, system_prompt = ?3, default_task = ?4, model = ?5, hooks = ?6"
.to_string();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(name),
@@ -457,8 +465,9 @@ pub async fn update_agent(
Box::new(system_prompt),
Box::new(default_task),
Box::new(model),
Box::new(hooks),
];
let mut param_count = 5;
let mut param_count = 6;
if let Some(efr) = enable_file_read {
param_count += 1;
@@ -489,7 +498,7 @@ pub async fn update_agent(
// Fetch the updated agent
let agent = conn
.query_row(
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(Agent {
@@ -502,8 +511,9 @@ pub async fn update_agent(
enable_file_read: row.get(6)?,
enable_file_write: row.get(7)?,
enable_network: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
hooks: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
})
},
)
@@ -530,7 +540,7 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result<Agent, String>
let agent = conn
.query_row(
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(Agent {
@@ -543,8 +553,9 @@ pub async fn get_agent(db: State<'_, AgentDb>, id: i64) -> Result<Agent, String>
enable_file_read: row.get::<_, bool>(6).unwrap_or(true),
enable_file_write: row.get::<_, bool>(7).unwrap_or(true),
enable_network: row.get::<_, bool>(8).unwrap_or(false),
created_at: row.get(9)?,
updated_at: row.get(10)?,
hooks: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
})
},
)
@@ -683,6 +694,42 @@ pub async fn execute_agent(
// Get the agent from database
let agent = get_agent(db.clone(), agent_id).await?;
let execution_model = model.unwrap_or(agent.model.clone());
// Create .claude/settings.json with agent hooks if it doesn't exist
if let Some(hooks_json) = &agent.hooks {
let claude_dir = std::path::Path::new(&project_path).join(".claude");
let settings_path = claude_dir.join("settings.json");
// Create .claude directory if it doesn't exist
if !claude_dir.exists() {
std::fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Failed to create .claude directory: {}", e))?;
info!("Created .claude directory at: {:?}", claude_dir);
}
// Check if settings.json already exists
if !settings_path.exists() {
// Parse the hooks JSON
let hooks: serde_json::Value = serde_json::from_str(hooks_json)
.map_err(|e| format!("Failed to parse agent hooks: {}", e))?;
// Create a settings object with just the hooks
let settings = serde_json::json!({
"hooks": hooks
});
// Write the settings file
let settings_content = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
std::fs::write(&settings_path, settings_content)
.map_err(|e| format!("Failed to write settings.json: {}", e))?;
info!("Created settings.json with agent hooks at: {:?}", settings_path);
} else {
info!("settings.json already exists at: {:?}", settings_path);
}
}
// Create a new run record
let run_id = {
@@ -1719,7 +1766,7 @@ pub async fn export_agent(db: State<'_, AgentDb>, id: i64) -> Result<String, Str
// Fetch the agent
let agent = conn
.query_row(
"SELECT name, icon, system_prompt, default_task, model FROM agents WHERE id = ?1",
"SELECT name, icon, system_prompt, default_task, model, hooks FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(serde_json::json!({
@@ -1727,7 +1774,8 @@ pub async fn export_agent(db: State<'_, AgentDb>, id: i64) -> Result<String, Str
"icon": row.get::<_, String>(1)?,
"system_prompt": row.get::<_, String>(2)?,
"default_task": row.get::<_, Option<String>>(3)?,
"model": row.get::<_, String>(4)?
"model": row.get::<_, String>(4)?,
"hooks": row.get::<_, Option<String>>(5)?
}))
},
)
@@ -2023,13 +2071,14 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result<A
// Create the agent
conn.execute(
"INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network) VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, 0)",
"INSERT INTO agents (name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks) VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, 0, ?6)",
params![
final_name,
agent_data.icon,
agent_data.system_prompt,
agent_data.default_task,
agent_data.model
agent_data.model,
agent_data.hooks
],
)
.map_err(|e| format!("Failed to create agent: {}", e))?;
@@ -2039,7 +2088,7 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result<A
// Fetch the created agent
let agent = conn
.query_row(
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, created_at, updated_at FROM agents WHERE id = ?1",
"SELECT id, name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks, created_at, updated_at FROM agents WHERE id = ?1",
params![id],
|row| {
Ok(Agent {
@@ -2052,8 +2101,9 @@ pub async fn import_agent(db: State<'_, AgentDb>, json_data: String) -> Result<A
enable_file_read: row.get(6)?,
enable_file_write: row.get(7)?,
enable_network: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
hooks: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
})
},
)

View File

@@ -2165,7 +2165,7 @@ pub async fn get_recently_modified_files(
.collect())
}
/// Tracks multiple session messages at once (batch operation)
/// Track session messages from the frontend for checkpointing
#[tauri::command]
pub async fn track_session_messages(
state: tauri::State<'_, crate::checkpoint::state::CheckpointState>,
@@ -2174,17 +2174,148 @@ pub async fn track_session_messages(
project_path: String,
messages: Vec<String>,
) -> Result<(), String> {
let mgr = state
log::info!(
"Tracking {} messages for session {}",
messages.len(),
session_id
);
let manager = state
.get_or_create_manager(
session_id,
project_id,
std::path::PathBuf::from(project_path),
session_id.clone(),
project_id.clone(),
PathBuf::from(&project_path),
)
.await
.map_err(|e| e.to_string())?;
.map_err(|e| format!("Failed to get checkpoint manager: {}", e))?;
for m in messages {
mgr.track_message(m).await.map_err(|e| e.to_string())?;
for message in messages {
manager
.track_message(message)
.await
.map_err(|e| format!("Failed to track message: {}", e))?;
}
Ok(())
}
/// Gets hooks configuration from settings at specified scope
#[tauri::command]
pub async fn get_hooks_config(scope: String, project_path: Option<String>) -> Result<serde_json::Value, String> {
log::info!("Getting hooks config for scope: {}, project: {:?}", scope, project_path);
let settings_path = match scope.as_str() {
"user" => {
get_claude_dir()
.map_err(|e| e.to_string())?
.join("settings.json")
},
"project" => {
let path = project_path.ok_or("Project path required for project scope")?;
PathBuf::from(path).join(".claude").join("settings.json")
},
"local" => {
let path = project_path.ok_or("Project path required for local scope")?;
PathBuf::from(path).join(".claude").join("settings.local.json")
},
_ => return Err("Invalid scope".to_string())
};
if !settings_path.exists() {
log::info!("Settings file does not exist at {:?}, returning empty hooks", settings_path);
return Ok(serde_json::json!({}));
}
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
let settings: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse settings: {}", e))?;
Ok(settings.get("hooks").cloned().unwrap_or(serde_json::json!({})))
}
/// Updates hooks configuration in settings at specified scope
#[tauri::command]
pub async fn update_hooks_config(
scope: String,
hooks: serde_json::Value,
project_path: Option<String>
) -> Result<String, String> {
log::info!("Updating hooks config for scope: {}, project: {:?}", scope, project_path);
let settings_path = match scope.as_str() {
"user" => {
get_claude_dir()
.map_err(|e| e.to_string())?
.join("settings.json")
},
"project" => {
let path = project_path.ok_or("Project path required for project scope")?;
let claude_dir = PathBuf::from(path).join(".claude");
fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Failed to create .claude directory: {}", e))?;
claude_dir.join("settings.json")
},
"local" => {
let path = project_path.ok_or("Project path required for local scope")?;
let claude_dir = PathBuf::from(path).join(".claude");
fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Failed to create .claude directory: {}", e))?;
claude_dir.join("settings.local.json")
},
_ => return Err("Invalid scope".to_string())
};
// Read existing settings or create new
let mut settings = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read settings: {}", e))?;
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse settings: {}", e))?
} else {
serde_json::json!({})
};
// Update hooks section
settings["hooks"] = hooks;
// Write back with pretty formatting
let json_string = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
fs::write(&settings_path, json_string)
.map_err(|e| format!("Failed to write settings: {}", e))?;
Ok("Hooks configuration updated successfully".to_string())
}
/// Validates a hook command by dry-running it
#[tauri::command]
pub async fn validate_hook_command(command: String) -> Result<serde_json::Value, String> {
log::info!("Validating hook command syntax");
// Validate syntax without executing
let mut cmd = std::process::Command::new("bash");
cmd.arg("-n") // Syntax check only
.arg("-c")
.arg(&command);
match cmd.output() {
Ok(output) => {
if output.status.success() {
Ok(serde_json::json!({
"valid": true,
"message": "Command syntax is valid"
}))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(serde_json::json!({
"valid": false,
"message": format!("Syntax error: {}", stderr)
}))
}
}
Err(e) => Err(format!("Failed to validate command: {}", e))
}
}

View File

@@ -26,6 +26,7 @@ use commands::claude::{
open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code,
save_claude_md_file, save_claude_settings, save_system_prompt, search_files,
track_checkpoint_message, track_session_messages, update_checkpoint_settings,
get_hooks_config, update_hooks_config, validate_hook_command,
ClaudeProcessState,
};
use commands::mcp::{
@@ -110,6 +111,9 @@ fn main() {
list_directory_contents,
search_files,
get_recently_modified_files,
get_hooks_config,
update_hooks_config,
validate_hook_command,
// Checkpoint Management
create_checkpoint,