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:
@@ -33,6 +33,7 @@ pub struct Agent {
|
|||||||
pub enable_file_read: bool,
|
pub enable_file_read: bool,
|
||||||
pub enable_file_write: bool,
|
pub enable_file_write: bool,
|
||||||
pub enable_network: bool,
|
pub enable_network: bool,
|
||||||
|
pub hooks: Option<String>, // JSON string of hooks configuration
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -89,6 +90,7 @@ pub struct AgentData {
|
|||||||
pub system_prompt: String,
|
pub system_prompt: String,
|
||||||
pub default_task: Option<String>,
|
pub default_task: Option<String>,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
|
pub hooks: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Database connection state
|
/// 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_read BOOLEAN NOT NULL DEFAULT 1,
|
||||||
enable_file_write BOOLEAN NOT NULL DEFAULT 1,
|
enable_file_write BOOLEAN NOT NULL DEFAULT 1,
|
||||||
enable_network BOOLEAN NOT NULL DEFAULT 0,
|
enable_network BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
hooks TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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'",
|
"ALTER TABLE agents ADD COLUMN model TEXT DEFAULT 'sonnet'",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
let _ = conn.execute("ALTER TABLE agents ADD COLUMN hooks TEXT", []);
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"ALTER TABLE agents ADD COLUMN enable_file_read BOOLEAN DEFAULT 1",
|
"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 conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let mut stmt = conn
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
let agents = stmt
|
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_read: row.get::<_, bool>(6).unwrap_or(true),
|
||||||
enable_file_write: row.get::<_, bool>(7).unwrap_or(true),
|
enable_file_write: row.get::<_, bool>(7).unwrap_or(true),
|
||||||
enable_network: row.get::<_, bool>(8).unwrap_or(false),
|
enable_network: row.get::<_, bool>(8).unwrap_or(false),
|
||||||
created_at: row.get(9)?,
|
hooks: row.get(9)?,
|
||||||
updated_at: row.get(10)?,
|
created_at: row.get(10)?,
|
||||||
|
updated_at: row.get(11)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@@ -389,6 +394,7 @@ pub async fn create_agent(
|
|||||||
enable_file_read: Option<bool>,
|
enable_file_read: Option<bool>,
|
||||||
enable_file_write: Option<bool>,
|
enable_file_write: Option<bool>,
|
||||||
enable_network: Option<bool>,
|
enable_network: Option<bool>,
|
||||||
|
hooks: Option<String>,
|
||||||
) -> Result<Agent, String> {
|
) -> Result<Agent, String> {
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
let model = model.unwrap_or_else(|| "sonnet".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);
|
let enable_network = enable_network.unwrap_or(false);
|
||||||
|
|
||||||
conn.execute(
|
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)",
|
"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],
|
params![name, icon, system_prompt, default_task, model, enable_file_read, enable_file_write, enable_network, hooks],
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
@@ -407,7 +413,7 @@ pub async fn create_agent(
|
|||||||
// Fetch the created agent
|
// Fetch the created agent
|
||||||
let agent = conn
|
let agent = conn
|
||||||
.query_row(
|
.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],
|
params![id],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(Agent {
|
Ok(Agent {
|
||||||
@@ -420,8 +426,9 @@ pub async fn create_agent(
|
|||||||
enable_file_read: row.get(6)?,
|
enable_file_read: row.get(6)?,
|
||||||
enable_file_write: row.get(7)?,
|
enable_file_write: row.get(7)?,
|
||||||
enable_network: row.get(8)?,
|
enable_network: row.get(8)?,
|
||||||
created_at: row.get(9)?,
|
hooks: row.get(9)?,
|
||||||
updated_at: row.get(10)?,
|
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_read: Option<bool>,
|
||||||
enable_file_write: Option<bool>,
|
enable_file_write: Option<bool>,
|
||||||
enable_network: Option<bool>,
|
enable_network: Option<bool>,
|
||||||
|
hooks: Option<String>,
|
||||||
) -> Result<Agent, String> {
|
) -> Result<Agent, String> {
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
let model = model.unwrap_or_else(|| "sonnet".to_string());
|
let model = model.unwrap_or_else(|| "sonnet".to_string());
|
||||||
|
|
||||||
// Build dynamic query based on provided parameters
|
// Build dynamic query based on provided parameters
|
||||||
let mut query =
|
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();
|
.to_string();
|
||||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![
|
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![
|
||||||
Box::new(name),
|
Box::new(name),
|
||||||
@@ -457,8 +465,9 @@ pub async fn update_agent(
|
|||||||
Box::new(system_prompt),
|
Box::new(system_prompt),
|
||||||
Box::new(default_task),
|
Box::new(default_task),
|
||||||
Box::new(model),
|
Box::new(model),
|
||||||
|
Box::new(hooks),
|
||||||
];
|
];
|
||||||
let mut param_count = 5;
|
let mut param_count = 6;
|
||||||
|
|
||||||
if let Some(efr) = enable_file_read {
|
if let Some(efr) = enable_file_read {
|
||||||
param_count += 1;
|
param_count += 1;
|
||||||
@@ -489,7 +498,7 @@ pub async fn update_agent(
|
|||||||
// Fetch the updated agent
|
// Fetch the updated agent
|
||||||
let agent = conn
|
let agent = conn
|
||||||
.query_row(
|
.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],
|
params![id],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(Agent {
|
Ok(Agent {
|
||||||
@@ -502,8 +511,9 @@ pub async fn update_agent(
|
|||||||
enable_file_read: row.get(6)?,
|
enable_file_read: row.get(6)?,
|
||||||
enable_file_write: row.get(7)?,
|
enable_file_write: row.get(7)?,
|
||||||
enable_network: row.get(8)?,
|
enable_network: row.get(8)?,
|
||||||
created_at: row.get(9)?,
|
hooks: row.get(9)?,
|
||||||
updated_at: row.get(10)?,
|
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
|
let agent = conn
|
||||||
.query_row(
|
.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],
|
params![id],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(Agent {
|
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_read: row.get::<_, bool>(6).unwrap_or(true),
|
||||||
enable_file_write: row.get::<_, bool>(7).unwrap_or(true),
|
enable_file_write: row.get::<_, bool>(7).unwrap_or(true),
|
||||||
enable_network: row.get::<_, bool>(8).unwrap_or(false),
|
enable_network: row.get::<_, bool>(8).unwrap_or(false),
|
||||||
created_at: row.get(9)?,
|
hooks: row.get(9)?,
|
||||||
updated_at: row.get(10)?,
|
created_at: row.get(10)?,
|
||||||
|
updated_at: row.get(11)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -684,6 +695,42 @@ pub async fn execute_agent(
|
|||||||
let agent = get_agent(db.clone(), agent_id).await?;
|
let agent = get_agent(db.clone(), agent_id).await?;
|
||||||
let execution_model = model.unwrap_or(agent.model.clone());
|
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
|
// Create a new run record
|
||||||
let run_id = {
|
let run_id = {
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -1719,7 +1766,7 @@ pub async fn export_agent(db: State<'_, AgentDb>, id: i64) -> Result<String, Str
|
|||||||
// Fetch the agent
|
// Fetch the agent
|
||||||
let agent = conn
|
let agent = conn
|
||||||
.query_row(
|
.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],
|
params![id],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(serde_json::json!({
|
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)?,
|
"icon": row.get::<_, String>(1)?,
|
||||||
"system_prompt": row.get::<_, String>(2)?,
|
"system_prompt": row.get::<_, String>(2)?,
|
||||||
"default_task": row.get::<_, Option<String>>(3)?,
|
"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
|
// Create the agent
|
||||||
conn.execute(
|
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![
|
params![
|
||||||
final_name,
|
final_name,
|
||||||
agent_data.icon,
|
agent_data.icon,
|
||||||
agent_data.system_prompt,
|
agent_data.system_prompt,
|
||||||
agent_data.default_task,
|
agent_data.default_task,
|
||||||
agent_data.model
|
agent_data.model,
|
||||||
|
agent_data.hooks
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("Failed to create agent: {}", e))?;
|
.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
|
// Fetch the created agent
|
||||||
let agent = conn
|
let agent = conn
|
||||||
.query_row(
|
.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],
|
params![id],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(Agent {
|
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_read: row.get(6)?,
|
||||||
enable_file_write: row.get(7)?,
|
enable_file_write: row.get(7)?,
|
||||||
enable_network: row.get(8)?,
|
enable_network: row.get(8)?,
|
||||||
created_at: row.get(9)?,
|
hooks: row.get(9)?,
|
||||||
updated_at: row.get(10)?,
|
created_at: row.get(10)?,
|
||||||
|
updated_at: row.get(11)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@@ -2165,7 +2165,7 @@ pub async fn get_recently_modified_files(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tracks multiple session messages at once (batch operation)
|
/// Track session messages from the frontend for checkpointing
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn track_session_messages(
|
pub async fn track_session_messages(
|
||||||
state: tauri::State<'_, crate::checkpoint::state::CheckpointState>,
|
state: tauri::State<'_, crate::checkpoint::state::CheckpointState>,
|
||||||
@@ -2174,17 +2174,148 @@ pub async fn track_session_messages(
|
|||||||
project_path: String,
|
project_path: String,
|
||||||
messages: Vec<String>,
|
messages: Vec<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mgr = state
|
log::info!(
|
||||||
|
"Tracking {} messages for session {}",
|
||||||
|
messages.len(),
|
||||||
|
session_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let manager = state
|
||||||
.get_or_create_manager(
|
.get_or_create_manager(
|
||||||
session_id,
|
session_id.clone(),
|
||||||
project_id,
|
project_id.clone(),
|
||||||
std::path::PathBuf::from(project_path),
|
PathBuf::from(&project_path),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| format!("Failed to get checkpoint manager: {}", e))?;
|
||||||
|
|
||||||
for m in messages {
|
for message in messages {
|
||||||
mgr.track_message(m).await.map_err(|e| e.to_string())?;
|
manager
|
||||||
|
.track_message(message)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to track message: {}", e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -26,6 +26,7 @@ use commands::claude::{
|
|||||||
open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code,
|
open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code,
|
||||||
save_claude_md_file, save_claude_settings, save_system_prompt, search_files,
|
save_claude_md_file, save_claude_settings, save_system_prompt, search_files,
|
||||||
track_checkpoint_message, track_session_messages, update_checkpoint_settings,
|
track_checkpoint_message, track_session_messages, update_checkpoint_settings,
|
||||||
|
get_hooks_config, update_hooks_config, validate_hook_command,
|
||||||
ClaudeProcessState,
|
ClaudeProcessState,
|
||||||
};
|
};
|
||||||
use commands::mcp::{
|
use commands::mcp::{
|
||||||
@@ -110,6 +111,9 @@ fn main() {
|
|||||||
list_directory_contents,
|
list_directory_contents,
|
||||||
search_files,
|
search_files,
|
||||||
get_recently_modified_files,
|
get_recently_modified_files,
|
||||||
|
get_hooks_config,
|
||||||
|
update_hooks_config,
|
||||||
|
validate_hook_command,
|
||||||
|
|
||||||
// Checkpoint Management
|
// Checkpoint Management
|
||||||
create_checkpoint,
|
create_checkpoint,
|
||||||
|
72
src/App.tsx
72
src/App.tsx
@@ -19,8 +19,23 @@ import { MCPManager } from "@/components/MCPManager";
|
|||||||
import { NFOCredits } from "@/components/NFOCredits";
|
import { NFOCredits } from "@/components/NFOCredits";
|
||||||
import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog";
|
import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog";
|
||||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||||
|
import { ProjectSettings } from '@/components/ProjectSettings';
|
||||||
|
|
||||||
type View = "welcome" | "projects" | "agents" | "editor" | "settings" | "claude-file-editor" | "claude-code-session" | "usage-dashboard" | "mcp";
|
type View =
|
||||||
|
| "welcome"
|
||||||
|
| "projects"
|
||||||
|
| "editor"
|
||||||
|
| "claude-file-editor"
|
||||||
|
| "claude-code-session"
|
||||||
|
| "settings"
|
||||||
|
| "cc-agents"
|
||||||
|
| "create-agent"
|
||||||
|
| "github-agents"
|
||||||
|
| "agent-execution"
|
||||||
|
| "agent-run-view"
|
||||||
|
| "mcp"
|
||||||
|
| "usage-dashboard"
|
||||||
|
| "project-settings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main App component - Manages the Claude directory browser UI
|
* Main App component - Manages the Claude directory browser UI
|
||||||
@@ -39,6 +54,8 @@ function App() {
|
|||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
|
||||||
const [activeClaudeSessionId, setActiveClaudeSessionId] = useState<string | null>(null);
|
const [activeClaudeSessionId, setActiveClaudeSessionId] = useState<string | null>(null);
|
||||||
const [isClaudeStreaming, setIsClaudeStreaming] = useState(false);
|
const [isClaudeStreaming, setIsClaudeStreaming] = useState(false);
|
||||||
|
const [projectForSettings, setProjectForSettings] = useState<Project | null>(null);
|
||||||
|
const [previousView, setPreviousView] = useState<View>("welcome");
|
||||||
|
|
||||||
// Load projects on mount when in projects view
|
// Load projects on mount when in projects view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -157,6 +174,31 @@ function App() {
|
|||||||
setView(newView);
|
setView(newView);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles navigating to hooks configuration
|
||||||
|
*/
|
||||||
|
const handleProjectSettings = (project: Project) => {
|
||||||
|
setProjectForSettings(project);
|
||||||
|
handleViewChange("project-settings");
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles navigating to hooks configuration from a project path
|
||||||
|
*/
|
||||||
|
const handleProjectSettingsFromPath = (projectPath: string) => {
|
||||||
|
// Create a temporary project object from the path
|
||||||
|
const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
|
||||||
|
const tempProject: Project = {
|
||||||
|
id: projectId,
|
||||||
|
path: projectPath,
|
||||||
|
sessions: [],
|
||||||
|
created_at: Date.now() / 1000
|
||||||
|
};
|
||||||
|
setProjectForSettings(tempProject);
|
||||||
|
setPreviousView(view);
|
||||||
|
handleViewChange("project-settings");
|
||||||
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case "welcome":
|
case "welcome":
|
||||||
@@ -186,7 +228,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className="h-64 cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg border border-border/50 shimmer-hover trailing-border"
|
className="h-64 cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg border border-border/50 shimmer-hover trailing-border"
|
||||||
onClick={() => handleViewChange("agents")}
|
onClick={() => handleViewChange("cc-agents")}
|
||||||
>
|
>
|
||||||
<div className="h-full flex flex-col items-center justify-center p-8">
|
<div className="h-full flex flex-col items-center justify-center p-8">
|
||||||
<Bot className="h-16 w-16 mb-4 text-primary" />
|
<Bot className="h-16 w-16 mb-4 text-primary" />
|
||||||
@@ -217,11 +259,11 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "agents":
|
case "cc-agents":
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-hidden">
|
<CCAgents
|
||||||
<CCAgents onBack={() => handleViewChange("welcome")} />
|
onBack={() => handleViewChange("welcome")}
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "editor":
|
case "editor":
|
||||||
@@ -334,6 +376,9 @@ function App() {
|
|||||||
<ProjectList
|
<ProjectList
|
||||||
projects={projects}
|
projects={projects}
|
||||||
onProjectClick={handleProjectClick}
|
onProjectClick={handleProjectClick}
|
||||||
|
onProjectSettings={handleProjectSettings}
|
||||||
|
loading={loading}
|
||||||
|
className="animate-fade-in"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-8 text-center">
|
<div className="py-8 text-center">
|
||||||
@@ -370,6 +415,7 @@ function App() {
|
|||||||
setIsClaudeStreaming(isStreaming);
|
setIsClaudeStreaming(isStreaming);
|
||||||
setActiveClaudeSessionId(sessionId);
|
setActiveClaudeSessionId(sessionId);
|
||||||
}}
|
}}
|
||||||
|
onProjectSettings={handleProjectSettingsFromPath}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -383,6 +429,20 @@ function App() {
|
|||||||
<MCPManager onBack={() => handleViewChange("welcome")} />
|
<MCPManager onBack={() => handleViewChange("welcome")} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "project-settings":
|
||||||
|
if (projectForSettings) {
|
||||||
|
return (
|
||||||
|
<ProjectSettings
|
||||||
|
project={projectForSettings}
|
||||||
|
onBack={() => {
|
||||||
|
setProjectForSettings(null);
|
||||||
|
handleViewChange(previousView || "projects");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@@ -11,12 +11,21 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
X
|
X,
|
||||||
|
Settings2
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Popover } from "@/components/ui/popover";
|
import { Popover } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
import { api, type Agent } from "@/lib/api";
|
import { api, type Agent } from "@/lib/api";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
@@ -25,6 +34,8 @@ import { StreamMessage } from "./StreamMessage";
|
|||||||
import { ExecutionControlBar } from "./ExecutionControlBar";
|
import { ExecutionControlBar } from "./ExecutionControlBar";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { AGENT_ICONS } from "./CCAgents";
|
||||||
|
import { HooksEditor } from "./HooksEditor";
|
||||||
|
|
||||||
interface AgentExecutionProps {
|
interface AgentExecutionProps {
|
||||||
/**
|
/**
|
||||||
@@ -78,6 +89,10 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
|
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
// Hooks configuration state
|
||||||
|
const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false);
|
||||||
|
const [activeHooksTab, setActiveHooksTab] = useState("project");
|
||||||
|
|
||||||
// Execution stats
|
// Execution stats
|
||||||
const [executionStartTime, setExecutionStartTime] = useState<number | null>(null);
|
const [executionStartTime, setExecutionStartTime] = useState<number | null>(null);
|
||||||
const [totalTokens, setTotalTokens] = useState(0);
|
const [totalTokens, setTotalTokens] = useState(0);
|
||||||
@@ -266,6 +281,10 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenHooksDialog = async () => {
|
||||||
|
setIsHooksDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleExecute = async () => {
|
const handleExecute = async () => {
|
||||||
try {
|
try {
|
||||||
setIsRunning(true);
|
setIsRunning(true);
|
||||||
@@ -501,9 +520,10 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
|||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
className="flex items-center justify-between p-4"
|
className="p-6"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -512,75 +532,29 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-full bg-primary/10 text-primary">
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<h1 className="text-xl font-bold">Execute: {agent.name}</h1>
|
||||||
<h2 className="text-lg font-semibold">{agent.name}</h2>
|
<p className="text-sm text-muted-foreground">
|
||||||
{isRunning && (
|
{model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
|
||||||
<span className="text-xs text-green-600 font-medium">Running</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{isRunning ? "Click back to return to main menu - view in CC Agents > Running Sessions" : "Execute CC Agent"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{messages.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsFullscreenModalOpen(true)}
|
onClick={() => setIsFullscreenModalOpen(true)}
|
||||||
className="flex items-center gap-2"
|
disabled={messages.length === 0}
|
||||||
>
|
>
|
||||||
<Maximize2 className="h-4 w-4" />
|
<Maximize2 className="h-4 w-4 mr-2" />
|
||||||
Fullscreen
|
Fullscreen
|
||||||
</Button>
|
</Button>
|
||||||
<Popover
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
Copy Output
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
content={
|
|
||||||
<div className="w-44 p-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={handleCopyAsJsonl}
|
|
||||||
>
|
|
||||||
Copy as JSONL
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full justify-start"
|
|
||||||
onClick={handleCopyAsMarkdown}
|
|
||||||
>
|
|
||||||
Copy as Markdown
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
open={copyPopoverOpen}
|
|
||||||
onOpenChange={setCopyPopoverOpen}
|
|
||||||
align="end"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -620,6 +594,15 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
|||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleOpenHooksDialog}
|
||||||
|
disabled={isRunning || !projectPath}
|
||||||
|
title="Configure hooks"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4 mr-2" />
|
||||||
|
Hooks
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -931,9 +914,56 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hooks Configuration Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={isHooksDialogOpen}
|
||||||
|
onOpenChange={setIsHooksDialogOpen}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configure Hooks</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure hooks that run before, during, and after tool executions. Changes are saved immediately.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Tabs value={activeHooksTab} onValueChange={setActiveHooksTab} className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="project">Project Settings</TabsTrigger>
|
||||||
|
<TabsTrigger value="local">Local Settings</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="project" className="flex-1 overflow-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Project hooks are stored in <code className="bg-muted px-1 py-0.5 rounded">.claude/settings.json</code> and
|
||||||
|
are committed to version control.
|
||||||
|
</p>
|
||||||
|
<HooksEditor
|
||||||
|
projectPath={projectPath}
|
||||||
|
scope="project"
|
||||||
|
className="border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="local" className="flex-1 overflow-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Local hooks are stored in <code className="bg-muted px-1 py-0.5 rounded">.claude/settings.local.json</code> and
|
||||||
|
are not committed to version control.
|
||||||
|
</p>
|
||||||
|
<HooksEditor
|
||||||
|
projectPath={projectPath}
|
||||||
|
scope="local"
|
||||||
|
className="border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Import AGENT_ICONS for icon rendering
|
|
||||||
import { AGENT_ICONS } from "./CCAgents";
|
|
||||||
|
@@ -8,7 +8,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Settings,
|
Settings,
|
||||||
Globe,
|
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
X,
|
X,
|
||||||
Hash
|
Hash
|
||||||
@@ -46,6 +45,10 @@ interface ClaudeCodeSessionProps {
|
|||||||
* Callback to go back
|
* Callback to go back
|
||||||
*/
|
*/
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
/**
|
||||||
|
* Callback to open hooks configuration
|
||||||
|
*/
|
||||||
|
onProjectSettings?: (projectPath: string) => void;
|
||||||
/**
|
/**
|
||||||
* Optional className for styling
|
* Optional className for styling
|
||||||
*/
|
*/
|
||||||
@@ -66,6 +69,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
session,
|
session,
|
||||||
initialProjectPath = "",
|
initialProjectPath = "",
|
||||||
onBack,
|
onBack,
|
||||||
|
onProjectSettings,
|
||||||
className,
|
className,
|
||||||
onStreamingChange,
|
onStreamingChange,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -792,8 +796,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
// Keep the previewUrl so it can be restored when reopening
|
// Keep the previewUrl so it can be restored when reopening
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handlePreviewUrlChange = (url: string) => {
|
const handlePreviewUrlChange = (url: string) => {
|
||||||
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
|
console.log('[ClaudeCodeSession] Preview URL changed to:', url);
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
@@ -971,70 +973,72 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="h-5 w-5" />
|
<Terminal className="h-5 w-5 text-muted-foreground" />
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h2 className="text-lg font-semibold">Claude Code Session</h2>
|
<h1 className="text-xl font-bold">Claude Code Session</h1>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{session ? `Resuming session ${session.id.slice(0, 8)}...` : 'Interactive session'}
|
{projectPath ? `${projectPath}` : "No project selected"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{effectiveSession && (
|
{projectPath && onProjectSettings && (
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSettings(!showSettings)}
|
onClick={() => onProjectSettings(projectPath)}
|
||||||
className="flex items-center gap-2"
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
Settings
|
Hooks
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowTimeline(!showTimeline)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<GitBranch className="h-4 w-4" />
|
|
||||||
Timeline
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/* Preview Button */}
|
{showSettings && (
|
||||||
|
<CheckpointSettings
|
||||||
|
sessionId={effectiveSession?.id || ''}
|
||||||
|
projectId={effectiveSession?.project_id || ''}
|
||||||
|
projectPath={projectPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
if (!showPreview) {
|
className="h-8 w-8"
|
||||||
// Open with current URL or empty URL to show the instruction state
|
|
||||||
setShowPreview(true);
|
|
||||||
} else {
|
|
||||||
handleClosePreview();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<Globe className="h-4 w-4" />
|
<Settings className={cn("h-4 w-4", showSettings && "text-primary")} />
|
||||||
{showPreview ? "Close Preview" : "Preview"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{showPreview
|
<p>Checkpoint Settings</p>
|
||||||
? "Close the preview pane"
|
|
||||||
: "Open a browser preview to test your web applications"
|
|
||||||
}
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
{effectiveSession && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowTimeline(!showTimeline)}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<GitBranch className={cn("h-4 w-4", showTimeline && "text-primary")} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Timeline Navigator</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<Popover
|
<Popover
|
||||||
trigger={
|
trigger={
|
||||||
@@ -1073,6 +1077,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
|
@@ -11,6 +11,7 @@ import MDEditor from "@uiw/react-md-editor";
|
|||||||
import { type AgentIconName } from "./CCAgents";
|
import { type AgentIconName } from "./CCAgents";
|
||||||
import { IconPicker, ICON_MAP } from "./IconPicker";
|
import { IconPicker, ICON_MAP } from "./IconPicker";
|
||||||
|
|
||||||
|
|
||||||
interface CreateAgentProps {
|
interface CreateAgentProps {
|
||||||
/**
|
/**
|
||||||
* Optional agent to edit (if provided, component is in edit mode)
|
* Optional agent to edit (if provided, component is in edit mode)
|
||||||
@@ -292,8 +293,6 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* System Prompt Editor */}
|
{/* System Prompt Editor */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>System Prompt</Label>
|
<Label>System Prompt</Label>
|
||||||
@@ -336,6 +335,6 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
|
|||||||
isOpen={showIconPicker}
|
isOpen={showIconPicker}
|
||||||
onClose={() => setShowIconPicker(false)}
|
onClose={() => setShowIconPicker(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
930
src/components/HooksEditor.tsx
Normal file
930
src/components/HooksEditor.tsx
Normal file
@@ -0,0 +1,930 @@
|
|||||||
|
/**
|
||||||
|
* HooksEditor component for managing Claude Code hooks configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
AlertTriangle,
|
||||||
|
Code2,
|
||||||
|
Terminal,
|
||||||
|
FileText,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Clock,
|
||||||
|
Zap,
|
||||||
|
Shield,
|
||||||
|
PlayCircle,
|
||||||
|
Info,
|
||||||
|
Save,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { HooksManager } from '@/lib/hooksManager';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
HooksConfiguration,
|
||||||
|
HookEvent,
|
||||||
|
HookMatcher,
|
||||||
|
HookCommand,
|
||||||
|
HookTemplate,
|
||||||
|
COMMON_TOOL_MATCHERS,
|
||||||
|
HOOK_TEMPLATES,
|
||||||
|
} from '@/types/hooks';
|
||||||
|
|
||||||
|
interface HooksEditorProps {
|
||||||
|
projectPath?: string;
|
||||||
|
scope: 'project' | 'local' | 'user';
|
||||||
|
readOnly?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onChange?: (hasChanges: boolean, getHooks: () => HooksConfiguration) => void;
|
||||||
|
hideActions?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditableHookCommand extends HookCommand {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditableHookMatcher extends Omit<HookMatcher, 'hooks'> {
|
||||||
|
id: string;
|
||||||
|
hooks: EditableHookCommand[];
|
||||||
|
expanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_INFO: Record<HookEvent, { label: string; description: string; icon: React.ReactNode }> = {
|
||||||
|
PreToolUse: {
|
||||||
|
label: 'Pre Tool Use',
|
||||||
|
description: 'Runs before tool calls, can block and provide feedback',
|
||||||
|
icon: <Shield className="h-4 w-4" />
|
||||||
|
},
|
||||||
|
PostToolUse: {
|
||||||
|
label: 'Post Tool Use',
|
||||||
|
description: 'Runs after successful tool completion',
|
||||||
|
icon: <PlayCircle className="h-4 w-4" />
|
||||||
|
},
|
||||||
|
Notification: {
|
||||||
|
label: 'Notification',
|
||||||
|
description: 'Customizes notifications when Claude needs attention',
|
||||||
|
icon: <Zap className="h-4 w-4" />
|
||||||
|
},
|
||||||
|
Stop: {
|
||||||
|
label: 'Stop',
|
||||||
|
description: 'Runs when Claude finishes responding',
|
||||||
|
icon: <Code2 className="h-4 w-4" />
|
||||||
|
},
|
||||||
|
SubagentStop: {
|
||||||
|
label: 'Subagent Stop',
|
||||||
|
description: 'Runs when a Claude subagent (Task) finishes',
|
||||||
|
icon: <Terminal className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HooksEditor: React.FC<HooksEditorProps> = ({
|
||||||
|
projectPath,
|
||||||
|
scope,
|
||||||
|
readOnly = false,
|
||||||
|
className,
|
||||||
|
onChange,
|
||||||
|
hideActions = false
|
||||||
|
}) => {
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<HookEvent>('PreToolUse');
|
||||||
|
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
||||||
|
const isInitialMount = React.useRef(true);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [hooks, setHooks] = useState<HooksConfiguration>({});
|
||||||
|
|
||||||
|
// Events with matchers (tool-related)
|
||||||
|
const matcherEvents = ['PreToolUse', 'PostToolUse'] as const;
|
||||||
|
// Events without matchers (non-tool-related)
|
||||||
|
const directEvents = ['Notification', 'Stop', 'SubagentStop'] as const;
|
||||||
|
|
||||||
|
// Convert hooks to editable format with IDs
|
||||||
|
const [editableHooks, setEditableHooks] = useState<{
|
||||||
|
PreToolUse: EditableHookMatcher[];
|
||||||
|
PostToolUse: EditableHookMatcher[];
|
||||||
|
Notification: EditableHookCommand[];
|
||||||
|
Stop: EditableHookCommand[];
|
||||||
|
SubagentStop: EditableHookCommand[];
|
||||||
|
}>(() => {
|
||||||
|
const result = {
|
||||||
|
PreToolUse: [],
|
||||||
|
PostToolUse: [],
|
||||||
|
Notification: [],
|
||||||
|
Stop: [],
|
||||||
|
SubagentStop: []
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Initialize matcher events
|
||||||
|
matcherEvents.forEach(event => {
|
||||||
|
const matchers = hooks?.[event] as HookMatcher[] | undefined;
|
||||||
|
if (matchers && Array.isArray(matchers)) {
|
||||||
|
result[event] = matchers.map(matcher => ({
|
||||||
|
...matcher,
|
||||||
|
id: HooksManager.generateId(),
|
||||||
|
expanded: false,
|
||||||
|
hooks: (matcher.hooks || []).map(hook => ({
|
||||||
|
...hook,
|
||||||
|
id: HooksManager.generateId()
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize direct events
|
||||||
|
directEvents.forEach(event => {
|
||||||
|
const commands = hooks?.[event] as HookCommand[] | undefined;
|
||||||
|
if (commands && Array.isArray(commands)) {
|
||||||
|
result[event] = commands.map(hook => ({
|
||||||
|
...hook,
|
||||||
|
id: HooksManager.generateId()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load hooks when projectPath or scope changes
|
||||||
|
useEffect(() => {
|
||||||
|
// For user scope, we don't need a projectPath
|
||||||
|
if (scope === 'user' || projectPath) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
|
||||||
|
api.getHooksConfig(scope, projectPath)
|
||||||
|
.then((config) => {
|
||||||
|
setHooks(config || {});
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Failed to load hooks configuration:", err);
|
||||||
|
setLoadError(err instanceof Error ? err.message : "Failed to load hooks configuration");
|
||||||
|
setHooks({});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No projectPath for project/local scopes
|
||||||
|
setHooks({});
|
||||||
|
}
|
||||||
|
}, [projectPath, scope]);
|
||||||
|
|
||||||
|
// Reset initial mount flag when hooks prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
isInitialMount.current = true;
|
||||||
|
setHasUnsavedChanges(false); // Reset unsaved changes when hooks prop changes
|
||||||
|
|
||||||
|
// Reinitialize editable hooks when hooks prop changes
|
||||||
|
const result = {
|
||||||
|
PreToolUse: [],
|
||||||
|
PostToolUse: [],
|
||||||
|
Notification: [],
|
||||||
|
Stop: [],
|
||||||
|
SubagentStop: []
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Initialize matcher events
|
||||||
|
matcherEvents.forEach(event => {
|
||||||
|
const matchers = hooks?.[event] as HookMatcher[] | undefined;
|
||||||
|
if (matchers && Array.isArray(matchers)) {
|
||||||
|
result[event] = matchers.map(matcher => ({
|
||||||
|
...matcher,
|
||||||
|
id: HooksManager.generateId(),
|
||||||
|
expanded: false,
|
||||||
|
hooks: (matcher.hooks || []).map(hook => ({
|
||||||
|
...hook,
|
||||||
|
id: HooksManager.generateId()
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize direct events
|
||||||
|
directEvents.forEach(event => {
|
||||||
|
const commands = hooks?.[event] as HookCommand[] | undefined;
|
||||||
|
if (commands && Array.isArray(commands)) {
|
||||||
|
result[event] = commands.map(hook => ({
|
||||||
|
...hook,
|
||||||
|
id: HooksManager.generateId()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setEditableHooks(result);
|
||||||
|
}, [hooks]);
|
||||||
|
|
||||||
|
// Track changes when editable hooks change (but don't save automatically)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
}, [editableHooks]);
|
||||||
|
|
||||||
|
// Notify parent of changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (onChange) {
|
||||||
|
const getHooks = () => {
|
||||||
|
const newHooks: HooksConfiguration = {};
|
||||||
|
|
||||||
|
// Handle matcher events
|
||||||
|
matcherEvents.forEach(event => {
|
||||||
|
const matchers = editableHooks[event];
|
||||||
|
if (matchers.length > 0) {
|
||||||
|
newHooks[event] = matchers.map(({ id, expanded, ...matcher }) => ({
|
||||||
|
...matcher,
|
||||||
|
hooks: matcher.hooks.map(({ id, ...hook }) => hook)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle direct events
|
||||||
|
directEvents.forEach(event => {
|
||||||
|
const commands = editableHooks[event];
|
||||||
|
if (commands.length > 0) {
|
||||||
|
newHooks[event] = commands.map(({ id, ...hook }) => hook);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newHooks;
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange(hasUnsavedChanges, getHooks);
|
||||||
|
}
|
||||||
|
}, [hasUnsavedChanges, editableHooks, onChange]);
|
||||||
|
|
||||||
|
// Save function to be called explicitly
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (scope !== 'user' && !projectPath) return;
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const newHooks: HooksConfiguration = {};
|
||||||
|
|
||||||
|
// Handle matcher events
|
||||||
|
matcherEvents.forEach(event => {
|
||||||
|
const matchers = editableHooks[event];
|
||||||
|
if (matchers.length > 0) {
|
||||||
|
newHooks[event] = matchers.map(({ id, expanded, ...matcher }) => ({
|
||||||
|
...matcher,
|
||||||
|
hooks: matcher.hooks.map(({ id, ...hook }) => hook)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle direct events
|
||||||
|
directEvents.forEach(event => {
|
||||||
|
const commands = editableHooks[event];
|
||||||
|
if (commands.length > 0) {
|
||||||
|
newHooks[event] = commands.map(({ id, ...hook }) => hook);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.updateHooksConfig(scope, newHooks, projectPath);
|
||||||
|
setHooks(newHooks);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save hooks:', error);
|
||||||
|
setLoadError(error instanceof Error ? error.message : 'Failed to save hooks');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMatcher = (event: HookEvent) => {
|
||||||
|
// Only for events with matchers
|
||||||
|
if (!matcherEvents.includes(event as any)) return;
|
||||||
|
|
||||||
|
const newMatcher: EditableHookMatcher = {
|
||||||
|
id: HooksManager.generateId(),
|
||||||
|
matcher: '',
|
||||||
|
hooks: [],
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[event]: [...(prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]), newMatcher]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDirectCommand = (event: HookEvent) => {
|
||||||
|
// Only for events without matchers
|
||||||
|
if (!directEvents.includes(event as any)) return;
|
||||||
|
|
||||||
|
const newCommand: EditableHookCommand = {
|
||||||
|
id: HooksManager.generateId(),
|
||||||
|
type: 'command',
|
||||||
|
command: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[event]: [...(prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]), newCommand]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMatcher = (event: HookEvent, matcherId: string, updates: Partial<EditableHookMatcher>) => {
|
||||||
|
if (!matcherEvents.includes(event as any)) return;
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher =>
|
||||||
|
matcher.id === matcherId ? { ...matcher, ...updates } : matcher
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMatcher = (event: HookEvent, matcherId: string) => {
|
||||||
|
if (!matcherEvents.includes(event as any)) return;
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).filter(matcher => matcher.id !== matcherId)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDirectCommand = (event: HookEvent, commandId: string, updates: Partial<EditableHookCommand>) => {
|
||||||
|
if (!directEvents.includes(event as any)) return;
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[event]: (prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).map(cmd =>
|
||||||
|
cmd.id === commandId ? { ...cmd, ...updates } : cmd
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDirectCommand = (event: HookEvent, commandId: string) => {
|
||||||
|
if (!directEvents.includes(event as any)) return;
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[event]: (prev[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).filter(cmd => cmd.id !== commandId)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTemplate = (template: HookTemplate) => {
|
||||||
|
if (matcherEvents.includes(template.event as any)) {
|
||||||
|
// For events with matchers
|
||||||
|
const newMatcher: EditableHookMatcher = {
|
||||||
|
id: HooksManager.generateId(),
|
||||||
|
matcher: template.matcher,
|
||||||
|
hooks: template.commands.map(cmd => ({
|
||||||
|
id: HooksManager.generateId(),
|
||||||
|
type: 'command' as const,
|
||||||
|
command: cmd
|
||||||
|
})),
|
||||||
|
expanded: true
|
||||||
|
};
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[template.event]: [...(prev[template.event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]), newMatcher]
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// For direct events
|
||||||
|
const newCommands: EditableHookCommand[] = template.commands.map(cmd => ({
|
||||||
|
id: HooksManager.generateId(),
|
||||||
|
type: 'command' as const,
|
||||||
|
command: cmd
|
||||||
|
}));
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[template.event]: [...(prev[template.event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]), ...newCommands]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedEvent(template.event);
|
||||||
|
setShowTemplateDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateHooks = async () => {
|
||||||
|
if (!hooks) {
|
||||||
|
setValidationErrors([]);
|
||||||
|
setValidationWarnings([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await HooksManager.validateConfig(hooks);
|
||||||
|
setValidationErrors(result.errors.map(e => e.message));
|
||||||
|
setValidationWarnings(result.warnings.map(w => `${w.message} in command: ${(w.command || '').substring(0, 50)}...`));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
validateHooks();
|
||||||
|
}, [hooks]);
|
||||||
|
|
||||||
|
const addCommand = (event: HookEvent, matcherId: string) => {
|
||||||
|
if (!matcherEvents.includes(event as any)) return;
|
||||||
|
|
||||||
|
const newCommand: EditableHookCommand = {
|
||||||
|
id: HooksManager.generateId(),
|
||||||
|
type: 'command',
|
||||||
|
command: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher =>
|
||||||
|
matcher.id === matcherId
|
||||||
|
? { ...matcher, hooks: [...matcher.hooks, newCommand] }
|
||||||
|
: matcher
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCommand = (
|
||||||
|
event: HookEvent,
|
||||||
|
matcherId: string,
|
||||||
|
commandId: string,
|
||||||
|
updates: Partial<EditableHookCommand>
|
||||||
|
) => {
|
||||||
|
if (!matcherEvents.includes(event as any)) return;
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher =>
|
||||||
|
matcher.id === matcherId
|
||||||
|
? {
|
||||||
|
...matcher,
|
||||||
|
hooks: matcher.hooks.map(cmd =>
|
||||||
|
cmd.id === commandId ? { ...cmd, ...updates } : cmd
|
||||||
|
)
|
||||||
|
}
|
||||||
|
: matcher
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCommand = (event: HookEvent, matcherId: string, commandId: string) => {
|
||||||
|
if (!matcherEvents.includes(event as any)) return;
|
||||||
|
|
||||||
|
setEditableHooks(prev => ({
|
||||||
|
...prev,
|
||||||
|
[event]: (prev[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).map(matcher =>
|
||||||
|
matcher.id === matcherId
|
||||||
|
? { ...matcher, hooks: matcher.hooks.filter(cmd => cmd.id !== commandId) }
|
||||||
|
: matcher
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMatcher = (event: HookEvent, matcher: EditableHookMatcher) => (
|
||||||
|
<Card key={matcher.id} className="p-4 space-y-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="p-0 h-6 w-6"
|
||||||
|
onClick={() => updateMatcher(event, matcher.id, { expanded: !matcher.expanded })}
|
||||||
|
>
|
||||||
|
{matcher.expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor={`matcher-${matcher.id}`}>Pattern</Label>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Tool name pattern (regex supported). Leave empty to match all tools.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id={`matcher-${matcher.id}`}
|
||||||
|
placeholder="e.g., Bash, Edit|Write, mcp__.*"
|
||||||
|
value={matcher.matcher || ''}
|
||||||
|
onChange={(e) => updateMatcher(event, matcher.id, { matcher: e.target.value })}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={matcher.matcher || 'custom'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value !== 'custom') {
|
||||||
|
updateMatcher(event, matcher.id, { matcher: value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="Common patterns" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="custom">Custom</SelectItem>
|
||||||
|
{COMMON_TOOL_MATCHERS.map(pattern => (
|
||||||
|
<SelectItem key={pattern} value={pattern}>{pattern}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeMatcher(event, matcher.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{matcher.expanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="space-y-4 pl-10"
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Commands</Label>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => addCommand(event, matcher.id)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
Add Command
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matcher.hooks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No commands added yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{matcher.hooks.map((hook) => (
|
||||||
|
<div key={hook.id} className="space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter shell command..."
|
||||||
|
value={hook.command || ''}
|
||||||
|
onChange={(e) => updateCommand(event, matcher.id, hook.id, { command: e.target.value })}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="font-mono text-sm min-h-[80px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="60"
|
||||||
|
value={hook.timeout || ''}
|
||||||
|
onChange={(e) => updateCommand(event, matcher.id, hook.id, {
|
||||||
|
timeout: e.target.value ? parseInt(e.target.value) : undefined
|
||||||
|
})}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="w-20 h-8"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeCommand(event, matcher.id, hook.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show warnings for this command */}
|
||||||
|
{(() => {
|
||||||
|
const warnings = HooksManager.checkDangerousPatterns(hook.command || '');
|
||||||
|
return warnings.length > 0 && (
|
||||||
|
<div className="flex items-start gap-2 p-2 bg-yellow-500/10 rounded-md">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
{warnings.map((warning, i) => (
|
||||||
|
<p key={i} className="text-xs text-yellow-600">{warning}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDirectCommand = (event: HookEvent, command: EditableHookCommand) => (
|
||||||
|
<Card key={command.id} className="p-4 space-y-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter shell command..."
|
||||||
|
value={command.command || ''}
|
||||||
|
onChange={(e) => updateDirectCommand(event, command.id, { command: e.target.value })}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="font-mono text-sm min-h-[80px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="60"
|
||||||
|
value={command.timeout || ''}
|
||||||
|
onChange={(e) => updateDirectCommand(event, command.id, {
|
||||||
|
timeout: e.target.value ? parseInt(e.target.value) : undefined
|
||||||
|
})}
|
||||||
|
disabled={readOnly}
|
||||||
|
className="w-20 h-8"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeDirectCommand(event, command.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show warnings for this command */}
|
||||||
|
{(() => {
|
||||||
|
const warnings = HooksManager.checkDangerousPatterns(command.command || '');
|
||||||
|
return warnings.length > 0 && (
|
||||||
|
<div className="flex items-start gap-2 p-2 bg-yellow-500/10 rounded-md">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
{warnings.map((warning, i) => (
|
||||||
|
<p key={i} className="text-xs text-yellow-600">{warning}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-6", className)}>
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||||
|
<span className="text-sm text-muted-foreground">Loading hooks configuration...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{loadError && !isLoading && (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
{!isLoading && (
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Hooks Configuration</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={scope === 'project' ? 'secondary' : scope === 'local' ? 'outline' : 'default'}>
|
||||||
|
{scope === 'project' ? 'Project' : scope === 'local' ? 'Local' : 'User'} Scope
|
||||||
|
</Badge>
|
||||||
|
{!readOnly && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowTemplateDialog(true)}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Templates
|
||||||
|
</Button>
|
||||||
|
{!hideActions && (
|
||||||
|
<Button
|
||||||
|
variant={hasUnsavedChanges ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasUnsavedChanges || isSaving || !projectPath}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Configure shell commands to execute at various points in Claude Code's lifecycle.
|
||||||
|
{scope === 'local' && ' These settings are not committed to version control.'}
|
||||||
|
</p>
|
||||||
|
{hasUnsavedChanges && !readOnly && (
|
||||||
|
<p className="text-sm text-amber-600">
|
||||||
|
You have unsaved changes. Click Save to persist them.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Messages */}
|
||||||
|
{validationErrors.length > 0 && (
|
||||||
|
<div className="p-3 bg-red-500/10 rounded-md space-y-1">
|
||||||
|
<p className="text-sm font-medium text-red-600">Validation Errors:</p>
|
||||||
|
{validationErrors.map((error, i) => (
|
||||||
|
<p key={i} className="text-xs text-red-600">• {error}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationWarnings.length > 0 && (
|
||||||
|
<div className="p-3 bg-yellow-500/10 rounded-md space-y-1">
|
||||||
|
<p className="text-sm font-medium text-yellow-600">Security Warnings:</p>
|
||||||
|
{validationWarnings.map((warning, i) => (
|
||||||
|
<p key={i} className="text-xs text-yellow-600">• {warning}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Tabs */}
|
||||||
|
<Tabs value={selectedEvent} onValueChange={(v) => setSelectedEvent(v as HookEvent)}>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{(Object.keys(EVENT_INFO) as HookEvent[]).map(event => {
|
||||||
|
const isMatcherEvent = matcherEvents.includes(event as any);
|
||||||
|
const count = isMatcherEvent
|
||||||
|
? (editableHooks[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[]).length
|
||||||
|
: (editableHooks[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsTrigger key={event} value={event} className="flex items-center gap-2">
|
||||||
|
{EVENT_INFO[event].icon}
|
||||||
|
<span className="hidden sm:inline">{EVENT_INFO[event].label}</span>
|
||||||
|
{count > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1 h-5 px-1">
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{(Object.keys(EVENT_INFO) as HookEvent[]).map(event => {
|
||||||
|
const isMatcherEvent = matcherEvents.includes(event as any);
|
||||||
|
const items = isMatcherEvent
|
||||||
|
? (editableHooks[event as 'PreToolUse' | 'PostToolUse'] as EditableHookMatcher[])
|
||||||
|
: (editableHooks[event as 'Notification' | 'Stop' | 'SubagentStop'] as EditableHookCommand[]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent key={event} value={event} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{EVENT_INFO[event].description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<p className="text-muted-foreground mb-4">No hooks configured for this event</p>
|
||||||
|
{!readOnly && (
|
||||||
|
<Button onClick={() => isMatcherEvent ? addMatcher(event) : addDirectCommand(event)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Hook
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isMatcherEvent
|
||||||
|
? (items as EditableHookMatcher[]).map(matcher => renderMatcher(event, matcher))
|
||||||
|
: (items as EditableHookCommand[]).map(command => renderDirectCommand(event, command))
|
||||||
|
}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => isMatcherEvent ? addMatcher(event) : addDirectCommand(event)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Another {isMatcherEvent ? 'Matcher' : 'Command'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Template Dialog */}
|
||||||
|
<Dialog open={showTemplateDialog} onOpenChange={setShowTemplateDialog}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Hook Templates</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose a pre-configured hook template to get started quickly
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{HOOK_TEMPLATES.map(template => (
|
||||||
|
<Card
|
||||||
|
key={template.id}
|
||||||
|
className="p-4 cursor-pointer hover:bg-accent"
|
||||||
|
onClick={() => applyTemplate(template)}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium">{template.name}</h4>
|
||||||
|
<Badge>{EVENT_INFO[template.event].label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{template.description}</p>
|
||||||
|
{matcherEvents.includes(template.event as any) && template.matcher && (
|
||||||
|
<p className="text-xs font-mono bg-muted px-2 py-1 rounded inline-block">
|
||||||
|
Matcher: {template.matcher}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -1,11 +1,26 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { FolderOpen, ChevronRight, Clock } from "lucide-react";
|
import {
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
FolderOpen,
|
||||||
import { Pagination } from "@/components/ui/pagination";
|
Calendar,
|
||||||
import { cn } from "@/lib/utils";
|
FileText,
|
||||||
import { formatUnixTimestamp } from "@/lib/date-utils";
|
ChevronRight,
|
||||||
|
Settings,
|
||||||
|
MoreVertical
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import type { Project } from "@/lib/api";
|
import type { Project } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatTimeAgo } from "@/lib/date-utils";
|
||||||
|
import { Pagination } from "@/components/ui/pagination";
|
||||||
|
|
||||||
interface ProjectListProps {
|
interface ProjectListProps {
|
||||||
/**
|
/**
|
||||||
@@ -16,13 +31,29 @@ interface ProjectListProps {
|
|||||||
* Callback when a project is clicked
|
* Callback when a project is clicked
|
||||||
*/
|
*/
|
||||||
onProjectClick: (project: Project) => void;
|
onProjectClick: (project: Project) => void;
|
||||||
|
/**
|
||||||
|
* Callback when hooks configuration is clicked
|
||||||
|
*/
|
||||||
|
onProjectSettings?: (project: Project) => void;
|
||||||
|
/**
|
||||||
|
* Whether the list is currently loading
|
||||||
|
*/
|
||||||
|
loading?: boolean;
|
||||||
/**
|
/**
|
||||||
* Optional className for styling
|
* Optional className for styling
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 5;
|
const ITEMS_PER_PAGE = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the project name from the full path
|
||||||
|
*/
|
||||||
|
const getProjectName = (path: string): string => {
|
||||||
|
const parts = path.split('/').filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProjectList component - Displays a paginated list of projects with hover animations
|
* ProjectList component - Displays a paginated list of projects with hover animations
|
||||||
@@ -36,6 +67,7 @@ const ITEMS_PER_PAGE = 5;
|
|||||||
export const ProjectList: React.FC<ProjectListProps> = ({
|
export const ProjectList: React.FC<ProjectListProps> = ({
|
||||||
projects,
|
projects,
|
||||||
onProjectClick,
|
onProjectClick,
|
||||||
|
onProjectSettings,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -66,27 +98,63 @@ export const ProjectList: React.FC<ProjectListProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className="transition-all hover:shadow-md hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
|
className="p-4 hover:shadow-md transition-all duration-200 cursor-pointer group"
|
||||||
onClick={() => onProjectClick(project)}
|
onClick={() => onProjectClick(project)}
|
||||||
>
|
>
|
||||||
<CardContent className="flex items-center justify-between p-3">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
|
||||||
<FolderOpen className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm truncate">{project.path}</p>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="flex items-center space-x-3 text-xs text-muted-foreground">
|
<FolderOpen className="h-5 w-5 text-primary shrink-0" />
|
||||||
<span>
|
<h3 className="font-semibold text-base truncate">
|
||||||
|
{getProjectName(project.path)}
|
||||||
|
</h3>
|
||||||
|
{project.sessions.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
{project.sessions.length} session{project.sessions.length !== 1 ? 's' : ''}
|
{project.sessions.length} session{project.sessions.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</Badge>
|
||||||
<div className="flex items-center space-x-1">
|
)}
|
||||||
<Clock className="h-3 w-3" />
|
</div>
|
||||||
<span>{formatUnixTimestamp(project.created_at)}</span>
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-3 font-mono truncate">
|
||||||
|
{project.path}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
<span>{formatTimeAgo(project.created_at * 1000)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
<span>{project.sessions.length} conversations</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{onProjectSettings && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onProjectSettings(project);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Hooks
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
192
src/components/ProjectSettings.tsx
Normal file
192
src/components/ProjectSettings.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* ProjectSettings component for managing project-specific hooks configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { HooksEditor } from '@/components/HooksEditor';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeft,
|
||||||
|
Settings,
|
||||||
|
FolderOpen,
|
||||||
|
GitBranch,
|
||||||
|
Shield
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Toast, ToastContainer } from '@/components/ui/toast';
|
||||||
|
import type { Project } from '@/lib/api';
|
||||||
|
|
||||||
|
interface ProjectSettingsProps {
|
||||||
|
project: Project;
|
||||||
|
onBack: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectSettings: React.FC<ProjectSettingsProps> = ({
|
||||||
|
project,
|
||||||
|
onBack,
|
||||||
|
className
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('project');
|
||||||
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
|
|
||||||
|
// Other hooks settings
|
||||||
|
const [gitIgnoreLocal, setGitIgnoreLocal] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkGitIgnore();
|
||||||
|
}, [project]);
|
||||||
|
|
||||||
|
const checkGitIgnore = async () => {
|
||||||
|
try {
|
||||||
|
// Check if .claude/settings.local.json is in .gitignore
|
||||||
|
const gitignorePath = `${project.path}/.gitignore`;
|
||||||
|
const gitignoreContent = await api.readClaudeMdFile(gitignorePath);
|
||||||
|
setGitIgnoreLocal(gitignoreContent.includes('.claude/settings.local.json'));
|
||||||
|
} catch {
|
||||||
|
// .gitignore might not exist
|
||||||
|
setGitIgnoreLocal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToGitIgnore = async () => {
|
||||||
|
try {
|
||||||
|
const gitignorePath = `${project.path}/.gitignore`;
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
content = await api.readClaudeMdFile(gitignorePath);
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, create it
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content.includes('.claude/settings.local.json')) {
|
||||||
|
content += '\n# Claude local settings (machine-specific)\n.claude/settings.local.json\n';
|
||||||
|
await api.saveClaudeMdFile(gitignorePath, content);
|
||||||
|
setGitIgnoreLocal(true);
|
||||||
|
setToast({ message: 'Added to .gitignore', type: 'success' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update .gitignore:', err);
|
||||||
|
setToast({ message: 'Failed to update .gitignore', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col h-full", className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h2 className="text-xl font-semibold">Hooks</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
<span className="font-mono">{project.path}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="project" className="gap-2">
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
Project Hooks
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="local" className="gap-2">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Local Hooks
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="project" className="space-y-6">
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Project Hooks</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
These hooks apply to all users working on this project. They are stored in
|
||||||
|
<code className="mx-1 px-2 py-1 bg-muted rounded text-xs">.claude/settings.json</code>
|
||||||
|
and should be committed to version control.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HooksEditor
|
||||||
|
projectPath={project.path}
|
||||||
|
scope="project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="local" className="space-y-6">
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Local Hooks</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
These hooks only apply to your machine. They are stored in
|
||||||
|
<code className="mx-1 px-2 py-1 bg-muted rounded text-xs">.claude/settings.local.json</code>
|
||||||
|
and should NOT be committed to version control.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!gitIgnoreLocal && (
|
||||||
|
<div className="flex items-center gap-4 p-3 bg-yellow-500/10 rounded-md">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-yellow-600">
|
||||||
|
Local settings file is not in .gitignore
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={addToGitIgnore}
|
||||||
|
>
|
||||||
|
Add to .gitignore
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HooksEditor
|
||||||
|
projectPath={project.path}
|
||||||
|
scope="local"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toast Container */}
|
||||||
|
<ToastContainer>
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onDismiss={() => setToast(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ToastContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -6,12 +6,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Save,
|
Save,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Shield,
|
|
||||||
Code,
|
|
||||||
Settings2,
|
|
||||||
Terminal,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Database
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -28,6 +23,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||||
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
||||||
import { StorageTab } from "./StorageTab";
|
import { StorageTab } from "./StorageTab";
|
||||||
|
import { HooksEditor } from "./HooksEditor";
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
/**
|
/**
|
||||||
@@ -59,12 +55,15 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||||||
onBack,
|
onBack,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState("general");
|
const [settings, setSettings] = useState<ClaudeSettings | null>(null);
|
||||||
const [settings, setSettings] = useState<ClaudeSettings>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [activeTab, setActiveTab] = useState("general");
|
||||||
|
const [currentBinaryPath, setCurrentBinaryPath] = useState<string | null>(null);
|
||||||
|
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
|
||||||
|
const [binaryPathChanged, setBinaryPathChanged] = useState(false);
|
||||||
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
|
|
||||||
// Permission rules state
|
// Permission rules state
|
||||||
const [allowRules, setAllowRules] = useState<PermissionRule[]>([]);
|
const [allowRules, setAllowRules] = useState<PermissionRule[]>([]);
|
||||||
@@ -73,11 +72,9 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||||||
// Environment variables state
|
// Environment variables state
|
||||||
const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]);
|
const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]);
|
||||||
|
|
||||||
// Claude binary path state
|
// Hooks state
|
||||||
const [currentBinaryPath, setCurrentBinaryPath] = useState<string | null>(null);
|
const [userHooksChanged, setUserHooksChanged] = useState(false);
|
||||||
const [selectedInstallation, setSelectedInstallation] = useState<ClaudeInstallation | null>(null);
|
const getUserHooks = React.useRef<(() => any) | null>(null);
|
||||||
const [binaryPathChanged, setBinaryPathChanged] = useState(false);
|
|
||||||
|
|
||||||
|
|
||||||
// Load settings on mount
|
// Load settings on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -154,7 +151,6 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the current settings
|
* Saves the current settings
|
||||||
*/
|
*/
|
||||||
@@ -189,6 +185,13 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||||||
setBinaryPathChanged(false);
|
setBinaryPathChanged(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save user hooks if changed
|
||||||
|
if (userHooksChanged && getUserHooks.current) {
|
||||||
|
const hooks = getUserHooks.current();
|
||||||
|
await api.updateHooksConfig('user', hooks);
|
||||||
|
setUserHooksChanged(false);
|
||||||
|
}
|
||||||
|
|
||||||
setToast({ message: "Settings saved successfully!", type: "success" });
|
setToast({ message: "Settings saved successfully!", type: "success" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save settings:", err);
|
console.error("Failed to save settings:", err);
|
||||||
@@ -353,28 +356,14 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="mb-6">
|
<TabsList className="grid grid-cols-6">
|
||||||
<TabsTrigger value="general" className="gap-2">
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
<Settings2 className="h-4 w-4 text-slate-500" />
|
<TabsTrigger value="permissions">Permissions</TabsTrigger>
|
||||||
General
|
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||||
</TabsTrigger>
|
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||||
<TabsTrigger value="permissions" className="gap-2">
|
<TabsTrigger value="hooks">Hooks</TabsTrigger>
|
||||||
<Shield className="h-4 w-4 text-amber-500" />
|
<TabsTrigger value="storage">Storage</TabsTrigger>
|
||||||
Permissions
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="environment" className="gap-2">
|
|
||||||
<Terminal className="h-4 w-4 text-blue-500" />
|
|
||||||
Environment
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="advanced" className="gap-2">
|
|
||||||
<Code className="h-4 w-4 text-purple-500" />
|
|
||||||
Advanced
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="storage" className="gap-2">
|
|
||||||
<Database className="h-4 w-4 text-green-500" />
|
|
||||||
Storage
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* General Settings */}
|
{/* General Settings */}
|
||||||
@@ -690,6 +679,32 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Hooks Settings */}
|
||||||
|
<TabsContent value="hooks" className="space-y-6">
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold mb-2">User Hooks</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Configure hooks that apply to all Claude Code sessions for your user account.
|
||||||
|
These are stored in <code className="mx-1 px-2 py-1 bg-muted rounded text-xs">~/.claude/settings.json</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HooksEditor
|
||||||
|
key={activeTab}
|
||||||
|
scope="user"
|
||||||
|
className="border-0"
|
||||||
|
hideActions={true}
|
||||||
|
onChange={(hasChanges, getHooks) => {
|
||||||
|
setUserHooksChanged(hasChanges);
|
||||||
|
getUserHooks.current = getHooks;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* Storage Tab */}
|
{/* Storage Tab */}
|
||||||
<TabsContent value="storage">
|
<TabsContent value="storage">
|
||||||
<StorageTab />
|
<StorageTab />
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { HooksConfiguration } from '@/types/hooks';
|
||||||
|
|
||||||
/** Process type for tracking in ProcessRegistry */
|
/** Process type for tracking in ProcessRegistry */
|
||||||
export type ProcessType =
|
export type ProcessType =
|
||||||
@@ -116,6 +117,7 @@ export interface Agent {
|
|||||||
system_prompt: string;
|
system_prompt: string;
|
||||||
default_task?: string;
|
default_task?: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
hooks?: string; // JSON string of HooksConfiguration
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,7 @@ export interface AgentExport {
|
|||||||
system_prompt: string;
|
system_prompt: string;
|
||||||
default_task?: string;
|
default_task?: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
hooks?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +638,7 @@ export const api = {
|
|||||||
* @param system_prompt - The system prompt for the agent
|
* @param system_prompt - The system prompt for the agent
|
||||||
* @param default_task - Optional default task
|
* @param default_task - Optional default task
|
||||||
* @param model - Optional model (defaults to 'sonnet')
|
* @param model - Optional model (defaults to 'sonnet')
|
||||||
|
* @param hooks - Optional hooks configuration as JSON string
|
||||||
* @returns Promise resolving to the created agent
|
* @returns Promise resolving to the created agent
|
||||||
*/
|
*/
|
||||||
async createAgent(
|
async createAgent(
|
||||||
@@ -642,7 +646,8 @@ export const api = {
|
|||||||
icon: string,
|
icon: string,
|
||||||
system_prompt: string,
|
system_prompt: string,
|
||||||
default_task?: string,
|
default_task?: string,
|
||||||
model?: string
|
model?: string,
|
||||||
|
hooks?: string
|
||||||
): Promise<Agent> {
|
): Promise<Agent> {
|
||||||
try {
|
try {
|
||||||
return await invoke<Agent>('create_agent', {
|
return await invoke<Agent>('create_agent', {
|
||||||
@@ -650,7 +655,8 @@ export const api = {
|
|||||||
icon,
|
icon,
|
||||||
systemPrompt: system_prompt,
|
systemPrompt: system_prompt,
|
||||||
defaultTask: default_task,
|
defaultTask: default_task,
|
||||||
model
|
model,
|
||||||
|
hooks
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create agent:", error);
|
console.error("Failed to create agent:", error);
|
||||||
@@ -666,6 +672,7 @@ export const api = {
|
|||||||
* @param system_prompt - The updated system prompt
|
* @param system_prompt - The updated system prompt
|
||||||
* @param default_task - Optional default task
|
* @param default_task - Optional default task
|
||||||
* @param model - Optional model
|
* @param model - Optional model
|
||||||
|
* @param hooks - Optional hooks configuration as JSON string
|
||||||
* @returns Promise resolving to the updated agent
|
* @returns Promise resolving to the updated agent
|
||||||
*/
|
*/
|
||||||
async updateAgent(
|
async updateAgent(
|
||||||
@@ -674,7 +681,8 @@ export const api = {
|
|||||||
icon: string,
|
icon: string,
|
||||||
system_prompt: string,
|
system_prompt: string,
|
||||||
default_task?: string,
|
default_task?: string,
|
||||||
model?: string
|
model?: string,
|
||||||
|
hooks?: string
|
||||||
): Promise<Agent> {
|
): Promise<Agent> {
|
||||||
try {
|
try {
|
||||||
return await invoke<Agent>('update_agent', {
|
return await invoke<Agent>('update_agent', {
|
||||||
@@ -683,7 +691,8 @@ export const api = {
|
|||||||
icon,
|
icon,
|
||||||
systemPrompt: system_prompt,
|
systemPrompt: system_prompt,
|
||||||
defaultTask: default_task,
|
defaultTask: default_task,
|
||||||
model
|
model,
|
||||||
|
hooks
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update agent:", error);
|
console.error("Failed to update agent:", error);
|
||||||
@@ -1646,4 +1655,74 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hooks configuration for a specific scope
|
||||||
|
* @param scope - The configuration scope: 'user', 'project', or 'local'
|
||||||
|
* @param projectPath - Project path (required for project and local scopes)
|
||||||
|
* @returns Promise resolving to the hooks configuration
|
||||||
|
*/
|
||||||
|
async getHooksConfig(scope: 'user' | 'project' | 'local', projectPath?: string): Promise<HooksConfiguration> {
|
||||||
|
try {
|
||||||
|
return await invoke<HooksConfiguration>("get_hooks_config", { scope, projectPath });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get hooks config:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update hooks configuration for a specific scope
|
||||||
|
* @param scope - The configuration scope: 'user', 'project', or 'local'
|
||||||
|
* @param hooks - The hooks configuration to save
|
||||||
|
* @param projectPath - Project path (required for project and local scopes)
|
||||||
|
* @returns Promise resolving to success message
|
||||||
|
*/
|
||||||
|
async updateHooksConfig(
|
||||||
|
scope: 'user' | 'project' | 'local',
|
||||||
|
hooks: HooksConfiguration,
|
||||||
|
projectPath?: string
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await invoke<string>("update_hooks_config", { scope, projectPath, hooks });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update hooks config:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a hook command syntax
|
||||||
|
* @param command - The shell command to validate
|
||||||
|
* @returns Promise resolving to validation result
|
||||||
|
*/
|
||||||
|
async validateHookCommand(command: string): Promise<{ valid: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
return await invoke<{ valid: boolean; message: string }>("validate_hook_command", { command });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to validate hook command:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get merged hooks configuration (respecting priority)
|
||||||
|
* @param projectPath - The project path
|
||||||
|
* @returns Promise resolving to merged hooks configuration
|
||||||
|
*/
|
||||||
|
async getMergedHooksConfig(projectPath: string): Promise<HooksConfiguration> {
|
||||||
|
try {
|
||||||
|
const [userHooks, projectHooks, localHooks] = await Promise.all([
|
||||||
|
this.getHooksConfig('user'),
|
||||||
|
this.getHooksConfig('project', projectPath),
|
||||||
|
this.getHooksConfig('local', projectPath)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Import HooksManager for merging
|
||||||
|
const { HooksManager } = await import('@/lib/hooksManager');
|
||||||
|
return HooksManager.mergeConfigs(userHooks, projectHooks, localHooks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get merged hooks config:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
@@ -104,3 +104,49 @@ function isWithinWeek(date: Date): boolean {
|
|||||||
function getDayName(date: Date): string {
|
function getDayName(date: Date): string {
|
||||||
return date.toLocaleDateString('en-US', { weekday: 'long' });
|
return date.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a timestamp to a relative time string (e.g., "2 hours ago", "3 days ago")
|
||||||
|
* @param timestamp - Unix timestamp in milliseconds
|
||||||
|
* @returns Relative time string
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* formatTimeAgo(Date.now() - 3600000) // "1 hour ago"
|
||||||
|
* formatTimeAgo(Date.now() - 86400000) // "1 day ago"
|
||||||
|
*/
|
||||||
|
export function formatTimeAgo(timestamp: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
|
||||||
|
if (years > 0) {
|
||||||
|
return years === 1 ? '1 year ago' : `${years} years ago`;
|
||||||
|
}
|
||||||
|
if (months > 0) {
|
||||||
|
return months === 1 ? '1 month ago' : `${months} months ago`;
|
||||||
|
}
|
||||||
|
if (weeks > 0) {
|
||||||
|
return weeks === 1 ? '1 week ago' : `${weeks} weeks ago`;
|
||||||
|
}
|
||||||
|
if (days > 0) {
|
||||||
|
return days === 1 ? '1 day ago' : `${days} days ago`;
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return minutes === 1 ? '1 minute ago' : `${minutes} minutes ago`;
|
||||||
|
}
|
||||||
|
if (seconds > 0) {
|
||||||
|
return seconds === 1 ? '1 second ago' : `${seconds} seconds ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
249
src/lib/hooksManager.ts
Normal file
249
src/lib/hooksManager.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Hooks configuration manager for Claude Code hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
HooksConfiguration,
|
||||||
|
HookMatcher,
|
||||||
|
HookValidationResult,
|
||||||
|
HookValidationError,
|
||||||
|
HookValidationWarning,
|
||||||
|
HookCommand,
|
||||||
|
} from '@/types/hooks';
|
||||||
|
|
||||||
|
export class HooksManager {
|
||||||
|
/**
|
||||||
|
* Merge hooks configurations with proper priority
|
||||||
|
* Priority: local > project > user
|
||||||
|
*/
|
||||||
|
static mergeConfigs(
|
||||||
|
user: HooksConfiguration,
|
||||||
|
project: HooksConfiguration,
|
||||||
|
local: HooksConfiguration
|
||||||
|
): HooksConfiguration {
|
||||||
|
const merged: HooksConfiguration = {};
|
||||||
|
|
||||||
|
// Events with matchers (tool-related)
|
||||||
|
const matcherEvents: (keyof HooksConfiguration)[] = ['PreToolUse', 'PostToolUse'];
|
||||||
|
|
||||||
|
// Events without matchers (non-tool-related)
|
||||||
|
const directEvents: (keyof HooksConfiguration)[] = ['Notification', 'Stop', 'SubagentStop'];
|
||||||
|
|
||||||
|
// Merge events with matchers
|
||||||
|
for (const event of matcherEvents) {
|
||||||
|
// Start with user hooks
|
||||||
|
let matchers = [...((user[event] as HookMatcher[] | undefined) || [])];
|
||||||
|
|
||||||
|
// Add project hooks (may override by matcher pattern)
|
||||||
|
if (project[event]) {
|
||||||
|
matchers = this.mergeMatchers(matchers, project[event] as HookMatcher[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add local hooks (highest priority)
|
||||||
|
if (local[event]) {
|
||||||
|
matchers = this.mergeMatchers(matchers, local[event] as HookMatcher[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchers.length > 0) {
|
||||||
|
(merged as any)[event] = matchers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge events without matchers
|
||||||
|
for (const event of directEvents) {
|
||||||
|
// Combine all hooks from all levels (local takes precedence)
|
||||||
|
const hooks: HookCommand[] = [];
|
||||||
|
|
||||||
|
// Add user hooks
|
||||||
|
if (user[event]) {
|
||||||
|
hooks.push(...(user[event] as HookCommand[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add project hooks
|
||||||
|
if (project[event]) {
|
||||||
|
hooks.push(...(project[event] as HookCommand[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add local hooks (highest priority)
|
||||||
|
if (local[event]) {
|
||||||
|
hooks.push(...(local[event] as HookCommand[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hooks.length > 0) {
|
||||||
|
(merged as any)[event] = hooks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge matcher arrays, with later items taking precedence
|
||||||
|
*/
|
||||||
|
private static mergeMatchers(
|
||||||
|
base: HookMatcher[],
|
||||||
|
override: HookMatcher[]
|
||||||
|
): HookMatcher[] {
|
||||||
|
const result = [...base];
|
||||||
|
|
||||||
|
for (const overrideMatcher of override) {
|
||||||
|
const existingIndex = result.findIndex(
|
||||||
|
m => m.matcher === overrideMatcher.matcher
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Replace existing matcher
|
||||||
|
result[existingIndex] = overrideMatcher;
|
||||||
|
} else {
|
||||||
|
// Add new matcher
|
||||||
|
result.push(overrideMatcher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate hooks configuration
|
||||||
|
*/
|
||||||
|
static async validateConfig(hooks: HooksConfiguration): Promise<HookValidationResult> {
|
||||||
|
const errors: HookValidationError[] = [];
|
||||||
|
const warnings: HookValidationWarning[] = [];
|
||||||
|
|
||||||
|
// Guard against undefined or null hooks
|
||||||
|
if (!hooks) {
|
||||||
|
return { valid: true, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events with matchers
|
||||||
|
const matcherEvents = ['PreToolUse', 'PostToolUse'] as const;
|
||||||
|
|
||||||
|
// Events without matchers
|
||||||
|
const directEvents = ['Notification', 'Stop', 'SubagentStop'] as const;
|
||||||
|
|
||||||
|
// Validate events with matchers
|
||||||
|
for (const event of matcherEvents) {
|
||||||
|
const matchers = hooks[event];
|
||||||
|
if (!matchers || !Array.isArray(matchers)) continue;
|
||||||
|
|
||||||
|
for (const matcher of matchers) {
|
||||||
|
// Validate regex pattern if provided
|
||||||
|
if (matcher.matcher) {
|
||||||
|
try {
|
||||||
|
new RegExp(matcher.matcher);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push({
|
||||||
|
event,
|
||||||
|
matcher: matcher.matcher,
|
||||||
|
message: `Invalid regex pattern: ${e instanceof Error ? e.message : 'Unknown error'}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate commands
|
||||||
|
if (matcher.hooks && Array.isArray(matcher.hooks)) {
|
||||||
|
for (const hook of matcher.hooks) {
|
||||||
|
if (!hook.command || !hook.command.trim()) {
|
||||||
|
errors.push({
|
||||||
|
event,
|
||||||
|
matcher: matcher.matcher,
|
||||||
|
message: 'Empty command'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dangerous patterns
|
||||||
|
const dangers = this.checkDangerousPatterns(hook.command || '');
|
||||||
|
warnings.push(...dangers.map(d => ({
|
||||||
|
event,
|
||||||
|
matcher: matcher.matcher,
|
||||||
|
command: hook.command || '',
|
||||||
|
message: d
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate events without matchers
|
||||||
|
for (const event of directEvents) {
|
||||||
|
const directHooks = hooks[event];
|
||||||
|
if (!directHooks || !Array.isArray(directHooks)) continue;
|
||||||
|
|
||||||
|
for (const hook of directHooks) {
|
||||||
|
if (!hook.command || !hook.command.trim()) {
|
||||||
|
errors.push({
|
||||||
|
event,
|
||||||
|
message: 'Empty command'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dangerous patterns
|
||||||
|
const dangers = this.checkDangerousPatterns(hook.command || '');
|
||||||
|
warnings.push(...dangers.map(d => ({
|
||||||
|
event,
|
||||||
|
command: hook.command || '',
|
||||||
|
message: d
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for potentially dangerous command patterns
|
||||||
|
*/
|
||||||
|
public static checkDangerousPatterns(command: string): string[] {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Guard against undefined or null commands
|
||||||
|
if (!command || typeof command !== 'string') {
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
{ pattern: /rm\s+-rf\s+\/(?:\s|$)/, message: 'Destructive command on root directory' },
|
||||||
|
{ pattern: /rm\s+-rf\s+~/, message: 'Destructive command on home directory' },
|
||||||
|
{ pattern: /:\s*\(\s*\)\s*\{.*\}\s*;/, message: 'Fork bomb pattern detected' },
|
||||||
|
{ pattern: /curl.*\|\s*(?:bash|sh)/, message: 'Downloading and executing remote code' },
|
||||||
|
{ pattern: /wget.*\|\s*(?:bash|sh)/, message: 'Downloading and executing remote code' },
|
||||||
|
{ pattern: />\/dev\/sda/, message: 'Direct disk write operation' },
|
||||||
|
{ pattern: /sudo\s+/, message: 'Elevated privileges required' },
|
||||||
|
{ pattern: /dd\s+.*of=\/dev\//, message: 'Dangerous disk operation' },
|
||||||
|
{ pattern: /mkfs\./, message: 'Filesystem formatting command' },
|
||||||
|
{ pattern: /:(){ :|:& };:/, message: 'Fork bomb detected' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { pattern, message } of patterns) {
|
||||||
|
if (pattern.test(command)) {
|
||||||
|
warnings.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unescaped variables that could lead to code injection
|
||||||
|
if (command.includes('$') && !command.includes('"$')) {
|
||||||
|
warnings.push('Unquoted shell variable detected - potential code injection risk');
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a command for safe shell execution
|
||||||
|
*/
|
||||||
|
static escapeCommand(command: string): string {
|
||||||
|
// Basic shell escaping - in production, use a proper shell escaping library
|
||||||
|
return command
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\$/g, '\\$')
|
||||||
|
.replace(/`/g, '\\`');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique ID for hooks/matchers/commands
|
||||||
|
*/
|
||||||
|
static generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
}
|
125
src/types/hooks.ts
Normal file
125
src/types/hooks.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Types for Claude Code hooks configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HookCommand {
|
||||||
|
type: 'command';
|
||||||
|
command: string;
|
||||||
|
timeout?: number; // Optional timeout in seconds (default: 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookMatcher {
|
||||||
|
matcher?: string; // Pattern to match tool names (regex supported)
|
||||||
|
hooks: HookCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HooksConfiguration {
|
||||||
|
PreToolUse?: HookMatcher[];
|
||||||
|
PostToolUse?: HookMatcher[];
|
||||||
|
Notification?: HookCommand[];
|
||||||
|
Stop?: HookCommand[];
|
||||||
|
SubagentStop?: HookCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HookEvent = keyof HooksConfiguration;
|
||||||
|
|
||||||
|
export interface ClaudeSettingsWithHooks {
|
||||||
|
hooks?: HooksConfiguration;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookValidationError {
|
||||||
|
event: string;
|
||||||
|
matcher?: string;
|
||||||
|
command?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookValidationWarning {
|
||||||
|
event: string;
|
||||||
|
matcher?: string;
|
||||||
|
command: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HookValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: HookValidationError[];
|
||||||
|
warnings: HookValidationWarning[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HookScope = 'user' | 'project' | 'local';
|
||||||
|
|
||||||
|
// Common tool matchers for autocomplete
|
||||||
|
export const COMMON_TOOL_MATCHERS = [
|
||||||
|
'Task',
|
||||||
|
'Bash',
|
||||||
|
'Glob',
|
||||||
|
'Grep',
|
||||||
|
'Read',
|
||||||
|
'Edit',
|
||||||
|
'MultiEdit',
|
||||||
|
'Write',
|
||||||
|
'WebFetch',
|
||||||
|
'WebSearch',
|
||||||
|
'Notebook.*',
|
||||||
|
'Edit|Write',
|
||||||
|
'mcp__.*',
|
||||||
|
'mcp__memory__.*',
|
||||||
|
'mcp__filesystem__.*',
|
||||||
|
'mcp__github__.*',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Hook templates
|
||||||
|
export interface HookTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
event: HookEvent;
|
||||||
|
matcher?: string;
|
||||||
|
commands: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOOK_TEMPLATES: HookTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'log-bash-commands',
|
||||||
|
name: 'Log Shell Commands',
|
||||||
|
description: 'Log all bash commands to a file for auditing',
|
||||||
|
event: 'PreToolUse',
|
||||||
|
matcher: 'Bash',
|
||||||
|
commands: ['jq -r \'"\(.tool_input.command) - \(.tool_input.description // "No description")"\' >> ~/.claude/bash-command-log.txt']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'format-on-save',
|
||||||
|
name: 'Auto-format Code',
|
||||||
|
description: 'Run code formatters after file modifications',
|
||||||
|
event: 'PostToolUse',
|
||||||
|
matcher: 'Write|Edit|MultiEdit',
|
||||||
|
commands: [
|
||||||
|
'if [[ "$( jq -r .tool_input.file_path )" =~ \\.(ts|tsx|js|jsx)$ ]]; then prettier --write "$( jq -r .tool_input.file_path )"; fi',
|
||||||
|
'if [[ "$( jq -r .tool_input.file_path )" =~ \\.go$ ]]; then gofmt -w "$( jq -r .tool_input.file_path )"; fi'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'git-commit-guard',
|
||||||
|
name: 'Protect Main Branch',
|
||||||
|
description: 'Prevent direct commits to main/master branch',
|
||||||
|
event: 'PreToolUse',
|
||||||
|
matcher: 'Bash',
|
||||||
|
commands: ['if [[ "$(jq -r .tool_input.command)" =~ "git commit" ]] && [[ "$(git branch --show-current 2>/dev/null)" =~ ^(main|master)$ ]]; then echo "Direct commits to main/master branch are not allowed"; exit 2; fi']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom-notification',
|
||||||
|
name: 'Custom Notifications',
|
||||||
|
description: 'Send custom notifications when Claude needs attention',
|
||||||
|
event: 'Notification',
|
||||||
|
commands: ['osascript -e "display notification \\"$(jq -r .message)\\" with title \\"$(jq -r .title)\\" sound name \\"Glass\\""']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'continue-on-tests',
|
||||||
|
name: 'Auto-continue on Test Success',
|
||||||
|
description: 'Automatically continue when tests pass',
|
||||||
|
event: 'Stop',
|
||||||
|
commands: ['if grep -q "All tests passed" "$( jq -r .transcript_path )"; then echo \'{"decision": "block", "reason": "All tests passed. Continue with next task."}\'; fi']
|
||||||
|
}
|
||||||
|
];
|
Reference in New Issue
Block a user