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:
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@@ -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
249
src/lib/hooksManager.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Hooks configuration manager for Claude Code hooks
|
||||
*/
|
||||
|
||||
import {
|
||||
HooksConfiguration,
|
||||
HookMatcher,
|
||||
HookValidationResult,
|
||||
HookValidationError,
|
||||
HookValidationWarning,
|
||||
HookCommand,
|
||||
} from '@/types/hooks';
|
||||
|
||||
export class HooksManager {
|
||||
/**
|
||||
* Merge hooks configurations with proper priority
|
||||
* Priority: local > project > user
|
||||
*/
|
||||
static mergeConfigs(
|
||||
user: HooksConfiguration,
|
||||
project: HooksConfiguration,
|
||||
local: HooksConfiguration
|
||||
): HooksConfiguration {
|
||||
const merged: HooksConfiguration = {};
|
||||
|
||||
// Events with matchers (tool-related)
|
||||
const matcherEvents: (keyof HooksConfiguration)[] = ['PreToolUse', 'PostToolUse'];
|
||||
|
||||
// Events without matchers (non-tool-related)
|
||||
const directEvents: (keyof HooksConfiguration)[] = ['Notification', 'Stop', 'SubagentStop'];
|
||||
|
||||
// Merge events with matchers
|
||||
for (const event of matcherEvents) {
|
||||
// Start with user hooks
|
||||
let matchers = [...((user[event] as HookMatcher[] | undefined) || [])];
|
||||
|
||||
// Add project hooks (may override by matcher pattern)
|
||||
if (project[event]) {
|
||||
matchers = this.mergeMatchers(matchers, project[event] as HookMatcher[]);
|
||||
}
|
||||
|
||||
// Add local hooks (highest priority)
|
||||
if (local[event]) {
|
||||
matchers = this.mergeMatchers(matchers, local[event] as HookMatcher[]);
|
||||
}
|
||||
|
||||
if (matchers.length > 0) {
|
||||
(merged as any)[event] = matchers;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge events without matchers
|
||||
for (const event of directEvents) {
|
||||
// Combine all hooks from all levels (local takes precedence)
|
||||
const hooks: HookCommand[] = [];
|
||||
|
||||
// Add user hooks
|
||||
if (user[event]) {
|
||||
hooks.push(...(user[event] as HookCommand[]));
|
||||
}
|
||||
|
||||
// Add project hooks
|
||||
if (project[event]) {
|
||||
hooks.push(...(project[event] as HookCommand[]));
|
||||
}
|
||||
|
||||
// Add local hooks (highest priority)
|
||||
if (local[event]) {
|
||||
hooks.push(...(local[event] as HookCommand[]));
|
||||
}
|
||||
|
||||
if (hooks.length > 0) {
|
||||
(merged as any)[event] = hooks;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge matcher arrays, with later items taking precedence
|
||||
*/
|
||||
private static mergeMatchers(
|
||||
base: HookMatcher[],
|
||||
override: HookMatcher[]
|
||||
): HookMatcher[] {
|
||||
const result = [...base];
|
||||
|
||||
for (const overrideMatcher of override) {
|
||||
const existingIndex = result.findIndex(
|
||||
m => m.matcher === overrideMatcher.matcher
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Replace existing matcher
|
||||
result[existingIndex] = overrideMatcher;
|
||||
} else {
|
||||
// Add new matcher
|
||||
result.push(overrideMatcher);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate hooks configuration
|
||||
*/
|
||||
static async validateConfig(hooks: HooksConfiguration): Promise<HookValidationResult> {
|
||||
const errors: HookValidationError[] = [];
|
||||
const warnings: HookValidationWarning[] = [];
|
||||
|
||||
// Guard against undefined or null hooks
|
||||
if (!hooks) {
|
||||
return { valid: true, errors, warnings };
|
||||
}
|
||||
|
||||
// Events with matchers
|
||||
const matcherEvents = ['PreToolUse', 'PostToolUse'] as const;
|
||||
|
||||
// Events without matchers
|
||||
const directEvents = ['Notification', 'Stop', 'SubagentStop'] as const;
|
||||
|
||||
// Validate events with matchers
|
||||
for (const event of matcherEvents) {
|
||||
const matchers = hooks[event];
|
||||
if (!matchers || !Array.isArray(matchers)) continue;
|
||||
|
||||
for (const matcher of matchers) {
|
||||
// Validate regex pattern if provided
|
||||
if (matcher.matcher) {
|
||||
try {
|
||||
new RegExp(matcher.matcher);
|
||||
} catch (e) {
|
||||
errors.push({
|
||||
event,
|
||||
matcher: matcher.matcher,
|
||||
message: `Invalid regex pattern: ${e instanceof Error ? e.message : 'Unknown error'}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate commands
|
||||
if (matcher.hooks && Array.isArray(matcher.hooks)) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (!hook.command || !hook.command.trim()) {
|
||||
errors.push({
|
||||
event,
|
||||
matcher: matcher.matcher,
|
||||
message: 'Empty command'
|
||||
});
|
||||
}
|
||||
|
||||
// Check for dangerous patterns
|
||||
const dangers = this.checkDangerousPatterns(hook.command || '');
|
||||
warnings.push(...dangers.map(d => ({
|
||||
event,
|
||||
matcher: matcher.matcher,
|
||||
command: hook.command || '',
|
||||
message: d
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate events without matchers
|
||||
for (const event of directEvents) {
|
||||
const directHooks = hooks[event];
|
||||
if (!directHooks || !Array.isArray(directHooks)) continue;
|
||||
|
||||
for (const hook of directHooks) {
|
||||
if (!hook.command || !hook.command.trim()) {
|
||||
errors.push({
|
||||
event,
|
||||
message: 'Empty command'
|
||||
});
|
||||
}
|
||||
|
||||
// Check for dangerous patterns
|
||||
const dangers = this.checkDangerousPatterns(hook.command || '');
|
||||
warnings.push(...dangers.map(d => ({
|
||||
event,
|
||||
command: hook.command || '',
|
||||
message: d
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for potentially dangerous command patterns
|
||||
*/
|
||||
public static checkDangerousPatterns(command: string): string[] {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Guard against undefined or null commands
|
||||
if (!command || typeof command !== 'string') {
|
||||
return warnings;
|
||||
}
|
||||
|
||||
const patterns = [
|
||||
{ pattern: /rm\s+-rf\s+\/(?:\s|$)/, message: 'Destructive command on root directory' },
|
||||
{ pattern: /rm\s+-rf\s+~/, message: 'Destructive command on home directory' },
|
||||
{ pattern: /:\s*\(\s*\)\s*\{.*\}\s*;/, message: 'Fork bomb pattern detected' },
|
||||
{ pattern: /curl.*\|\s*(?:bash|sh)/, message: 'Downloading and executing remote code' },
|
||||
{ pattern: /wget.*\|\s*(?:bash|sh)/, message: 'Downloading and executing remote code' },
|
||||
{ pattern: />\/dev\/sda/, message: 'Direct disk write operation' },
|
||||
{ pattern: /sudo\s+/, message: 'Elevated privileges required' },
|
||||
{ pattern: /dd\s+.*of=\/dev\//, message: 'Dangerous disk operation' },
|
||||
{ pattern: /mkfs\./, message: 'Filesystem formatting command' },
|
||||
{ pattern: /:(){ :|:& };:/, message: 'Fork bomb detected' },
|
||||
];
|
||||
|
||||
for (const { pattern, message } of patterns) {
|
||||
if (pattern.test(command)) {
|
||||
warnings.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unescaped variables that could lead to code injection
|
||||
if (command.includes('$') && !command.includes('"$')) {
|
||||
warnings.push('Unquoted shell variable detected - potential code injection risk');
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a command for safe shell execution
|
||||
*/
|
||||
static escapeCommand(command: string): string {
|
||||
// Basic shell escaping - in production, use a proper shell escaping library
|
||||
return command
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, '\\$')
|
||||
.replace(/`/g, '\\`');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for hooks/matchers/commands
|
||||
*/
|
||||
static generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user