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

@@ -1,4 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { HooksConfiguration } from '@/types/hooks';
/** Process type for tracking in ProcessRegistry */
export type ProcessType =
@@ -116,6 +117,7 @@ export interface Agent {
system_prompt: string;
default_task?: string;
model: string;
hooks?: string; // JSON string of HooksConfiguration
created_at: string;
updated_at: string;
}
@@ -129,6 +131,7 @@ export interface AgentExport {
system_prompt: string;
default_task?: string;
model: string;
hooks?: string;
};
}
@@ -635,6 +638,7 @@ export const api = {
* @param system_prompt - The system prompt for the agent
* @param default_task - Optional default task
* @param model - Optional model (defaults to 'sonnet')
* @param hooks - Optional hooks configuration as JSON string
* @returns Promise resolving to the created agent
*/
async createAgent(
@@ -642,7 +646,8 @@ export const api = {
icon: string,
system_prompt: string,
default_task?: string,
model?: string
model?: string,
hooks?: string
): Promise<Agent> {
try {
return await invoke<Agent>('create_agent', {
@@ -650,7 +655,8 @@ export const api = {
icon,
systemPrompt: system_prompt,
defaultTask: default_task,
model
model,
hooks
});
} catch (error) {
console.error("Failed to create agent:", error);
@@ -666,6 +672,7 @@ export const api = {
* @param system_prompt - The updated system prompt
* @param default_task - Optional default task
* @param model - Optional model
* @param hooks - Optional hooks configuration as JSON string
* @returns Promise resolving to the updated agent
*/
async updateAgent(
@@ -674,7 +681,8 @@ export const api = {
icon: string,
system_prompt: string,
default_task?: string,
model?: string
model?: string,
hooks?: string
): Promise<Agent> {
try {
return await invoke<Agent>('update_agent', {
@@ -683,7 +691,8 @@ export const api = {
icon,
systemPrompt: system_prompt,
defaultTask: default_task,
model
model,
hooks
});
} catch (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

@@ -103,4 +103,50 @@ function isWithinWeek(date: Date): boolean {
function getDayName(date: Date): string {
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)}`;
}
}