feat: implement hooks system for project configuration

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

View File

@@ -33,6 +33,7 @@ pub struct Agent {
pub enable_file_read: bool, pub enable_file_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)?,
}) })
}, },
) )

View File

@@ -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))
}
}

View File

@@ -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,

View File

@@ -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;
} }

View File

@@ -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";

View File

@@ -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 */}

View File

@@ -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>
); );
}; };

View 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>
);
};

View File

@@ -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>
))} ))}

View 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>
);
};

View File

@@ -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 />

View File

@@ -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;
}
}
}; };

View File

@@ -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
View 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
View 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']
}
];