From d61c940a087d4e9134fa0a9ae32dc8d79d42648d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 11:18:20 -0500 Subject: [PATCH 1/3] fix(websearch): normalize Windows path separators in hook detection On Windows, path.join() produces backslash paths (C:\Users\.ccs\hooks\...) but detection used forward slashes, causing: - Failed hook detection on Windows - Duplicate hooks added on every CCS invocation Changes: - Normalize path separators (\ -> /) before matching - Add deduplicateCcsHooks() to auto-cleanup accumulated duplicates - Fix affects hasCcsHook(), isCcsWebSearchHook(), removeHookConfig() --- src/utils/websearch/hook-config.ts | 4 +- src/utils/websearch/profile-hook-injector.ts | 58 +++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index d05003c0..21a0c765 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -229,7 +229,9 @@ export function removeHookConfig(): boolean { if (!hookArray?.[0]?.command) return true; // Keep malformed entries const command = hookArray[0].command as string; - return !command.includes('.ccs/hooks/websearch-transformer'); // Remove if CCS hook + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command.replace(/\\/g, '/'); + return !normalizedCommand.includes('.ccs/hooks/websearch-transformer'); // Remove if CCS hook }); if (hooks.PreToolUse.length === originalLength) { diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 214184da..56a9c477 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -40,10 +40,52 @@ function hasCcsHook(settings: Record): boolean { if (!hookArray?.[0]?.command) return false; const command = hookArray[0].command as string; - return command.includes('.ccs/hooks/websearch-transformer'); + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command.replace(/\\/g, '/'); + return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); }); } +/** + * Check if a hook entry is a CCS WebSearch hook + */ +function isCcsWebSearchHook(hook: Record): boolean { + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + if (!hookArray?.[0]?.command) return false; + + const command = hookArray[0].command as string; + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command.replace(/\\/g, '/'); + return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); +} + +/** + * Remove duplicate CCS WebSearch hooks from settings, keeping only the first one + * Returns true if duplicates were removed + */ +function deduplicateCcsHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + let foundFirst = false; + const originalLength = hooks.PreToolUse.length; + + hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks + + if (!foundFirst) { + foundFirst = true; + return true; // Keep first CCS hook + } + return false; // Remove subsequent duplicates + }); + + return hooks.PreToolUse.length < originalLength; +} + /** * Migrate CCS hook from global settings to profile settings (one-time) */ @@ -126,6 +168,16 @@ export function ensureProfileHooks(profileName: string): boolean { // Check if CCS hook already present if (hasCcsHook(settings)) { + // Clean up any duplicates that may have accumulated (Windows path bug fix) + const hadDuplicates = deduplicateCcsHooks(settings); + if (hadDuplicates) { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); + if (process.env.CCS_DEBUG) { + console.error( + info(`Removed duplicate WebSearch hooks from ${profileName}.settings.json`) + ); + } + } // Update timeout if needed return updateHookTimeoutIfNeeded(settings, settingsPath); } @@ -189,7 +241,9 @@ function updateHookTimeoutIfNeeded( if (!hookArray?.[0]?.command) continue; const command = hookArray[0].command as string; - if (!command.includes('.ccs/hooks/websearch-transformer')) continue; + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command.replace(/\\/g, '/'); + if (!normalizedCommand.includes('.ccs/hooks/websearch-transformer')) continue; // Found CCS hook - check if needs update if (hookArray[0].command !== expectedCommand) { From 847aad00fee1fd920ddf8ea3a4b0e85aa1f3dfa4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 11:36:48 -0500 Subject: [PATCH 2/3] fix(websearch): add type guards and deduplication for global settings - Add typeof check before .replace() to prevent crash on non-string commands - Add isCcsWebSearchHook() and deduplicateCcsHooks() to hook-config.ts - Fix ensureHookConfig() detection to verify path, not just matcher - Prevent overwriting user's custom WebSearch hooks --- src/utils/websearch/hook-config.ts | 67 +++++++++++++++++++- src/utils/websearch/profile-hook-injector.ts | 9 ++- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index 21a0c765..fe01adc0 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -51,6 +51,47 @@ export function getHookPath(): string { return path.join(getCcsHooksDir(), WEBSEARCH_HOOK); } +/** + * Check if a hook entry is a CCS WebSearch hook + */ +function isCcsWebSearchHook(hook: Record): boolean { + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + if (!hookArray?.[0]?.command) return false; + + const command = hookArray[0].command; + if (typeof command !== 'string') return false; + // Normalize path separators for cross-platform matching (Windows uses backslashes) + const normalizedCommand = command.replace(/\\/g, '/'); + return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); +} + +/** + * Remove duplicate CCS WebSearch hooks from settings, keeping only the first one + * Returns true if duplicates were removed + */ +function deduplicateCcsHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + let foundFirst = false; + const originalLength = hooks.PreToolUse.length; + + hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks + + if (!foundFirst) { + foundFirst = true; + return true; // Keep first CCS hook + } + return false; // Remove subsequent duplicates + }); + + return hooks.PreToolUse.length < originalLength; +} + /** * Get WebSearch hook configuration for settings.json * Timeout is computed from max provider timeout in config.yaml + buffer @@ -123,11 +164,27 @@ export function ensureHookConfig(): boolean { if (hooks?.PreToolUse) { const webSearchHookIndex = hooks.PreToolUse.findIndex((h: unknown) => { const hook = h as Record; - return hook.matcher === 'WebSearch'; + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + const command = hookArray?.[0]?.command; + if (typeof command !== 'string') return false; + + const normalized = command.replace(/\\/g, '/'); + return normalized.includes('.ccs/hooks/websearch-transformer'); }); if (webSearchHookIndex !== -1) { - // Hook exists - check if it needs updating + // Hook exists - first clean up any duplicates + const hadDuplicates = deduplicateCcsHooks(settings); + if (hadDuplicates) { + fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(settings, null, 2), 'utf8'); + if (process.env.CCS_DEBUG) { + console.error(info('Removed duplicate WebSearch hooks from settings.json')); + } + } + + // Then check if it needs updating const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; const existingHooks = existingHook.hooks as Array>; const currentHookConfig = getWebSearchHookConfig(); @@ -170,6 +227,12 @@ export function ensureHookConfig(): boolean { settingsHooks.PreToolUse = []; } + // Remove any existing CCS hooks first to prevent duplicates + settingsHooks.PreToolUse = settingsHooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + return !isCcsWebSearchHook(hook); + }); + // Add our hook config const preToolUseHooks = hookConfig.PreToolUse as unknown[]; settingsHooks.PreToolUse.push(...preToolUseHooks); diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 56a9c477..4ef27c4b 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -39,7 +39,8 @@ function hasCcsHook(settings: Record): boolean { const hookArray = hook.hooks as Array> | undefined; if (!hookArray?.[0]?.command) return false; - const command = hookArray[0].command as string; + const command = hookArray[0].command; + if (typeof command !== 'string') return false; // Normalize path separators for cross-platform matching (Windows uses backslashes) const normalizedCommand = command.replace(/\\/g, '/'); return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); @@ -55,7 +56,8 @@ function isCcsWebSearchHook(hook: Record): boolean { const hookArray = hook.hooks as Array> | undefined; if (!hookArray?.[0]?.command) return false; - const command = hookArray[0].command as string; + const command = hookArray[0].command; + if (typeof command !== 'string') return false; // Normalize path separators for cross-platform matching (Windows uses backslashes) const normalizedCommand = command.replace(/\\/g, '/'); return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); @@ -240,7 +242,8 @@ function updateHookTimeoutIfNeeded( const hookArray = hook.hooks as Array>; if (!hookArray?.[0]?.command) continue; - const command = hookArray[0].command as string; + const command = hookArray[0].command; + if (typeof command !== 'string') continue; // Normalize path separators for cross-platform matching (Windows uses backslashes) const normalizedCommand = command.replace(/\\/g, '/'); if (!normalizedCommand.includes('.ccs/hooks/websearch-transformer')) continue; From 1f8d9b82d5ad89cefe73966e9c1ef57692dd9284 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 11:42:39 -0500 Subject: [PATCH 3/3] refactor(websearch): extract shared hook utils to DRY module - Create hook-utils.ts with isCcsWebSearchHook() and deduplicateCcsHooks() - Update profile-hook-injector.ts and hook-config.ts to use shared module - Combine double writeFileSync into single write in ensureHookConfig() Addresses code review feedback on PR #420 --- src/utils/websearch/hook-config.ts | 55 +++----------------- src/utils/websearch/hook-utils.ts | 50 ++++++++++++++++++ src/utils/websearch/profile-hook-injector.ts | 54 +------------------ 3 files changed, 58 insertions(+), 101 deletions(-) create mode 100644 src/utils/websearch/hook-utils.ts diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index fe01adc0..4b80e088 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -12,6 +12,7 @@ import * as os from 'os'; import { info, warn } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getCcsDir } from '../config-manager'; +import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; // Hook file name const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; @@ -51,47 +52,6 @@ export function getHookPath(): string { return path.join(getCcsHooksDir(), WEBSEARCH_HOOK); } -/** - * Check if a hook entry is a CCS WebSearch hook - */ -function isCcsWebSearchHook(hook: Record): boolean { - if (hook.matcher !== 'WebSearch') return false; - - const hookArray = hook.hooks as Array> | undefined; - if (!hookArray?.[0]?.command) return false; - - const command = hookArray[0].command; - if (typeof command !== 'string') return false; - // Normalize path separators for cross-platform matching (Windows uses backslashes) - const normalizedCommand = command.replace(/\\/g, '/'); - return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); -} - -/** - * Remove duplicate CCS WebSearch hooks from settings, keeping only the first one - * Returns true if duplicates were removed - */ -function deduplicateCcsHooks(settings: Record): boolean { - const hooks = settings.hooks as Record | undefined; - if (!hooks?.PreToolUse) return false; - - let foundFirst = false; - const originalLength = hooks.PreToolUse.length; - - hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { - const hook = h as Record; - if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks - - if (!foundFirst) { - foundFirst = true; - return true; // Keep first CCS hook - } - return false; // Remove subsequent duplicates - }); - - return hooks.PreToolUse.length < originalLength; -} - /** * Get WebSearch hook configuration for settings.json * Timeout is computed from max provider timeout in config.yaml + buffer @@ -177,12 +137,6 @@ export function ensureHookConfig(): boolean { if (webSearchHookIndex !== -1) { // Hook exists - first clean up any duplicates const hadDuplicates = deduplicateCcsHooks(settings); - if (hadDuplicates) { - fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(settings, null, 2), 'utf8'); - if (process.env.CCS_DEBUG) { - console.error(info('Removed duplicate WebSearch hooks from settings.json')); - } - } // Then check if it needs updating const existingHook = hooks.PreToolUse[webSearchHookIndex] as Record; @@ -204,10 +158,13 @@ export function ensureHookConfig(): boolean { needsUpdate = true; } - if (needsUpdate) { + // Combine into single write if either changed + if (hadDuplicates || needsUpdate) { fs.writeFileSync(getClaudeSettingsPath(), JSON.stringify(settings, null, 2), 'utf8'); if (process.env.CCS_DEBUG) { - console.error(info('Updated WebSearch hook config in settings.json')); + if (hadDuplicates) + console.error(info('Removed duplicate WebSearch hooks from settings.json')); + if (needsUpdate) console.error(info('Updated WebSearch hook config in settings.json')); } } return true; diff --git a/src/utils/websearch/hook-utils.ts b/src/utils/websearch/hook-utils.ts new file mode 100644 index 00000000..85618d64 --- /dev/null +++ b/src/utils/websearch/hook-utils.ts @@ -0,0 +1,50 @@ +/** + * WebSearch Hook Utilities + * + * Shared helper functions for WebSearch hook detection and deduplication. + * + * @module utils/websearch/hook-utils + */ + +/** + * Check if a hook entry is a CCS WebSearch hook + * Normalizes path separators for cross-platform matching (Windows uses backslashes) + */ +export function isCcsWebSearchHook(hook: Record): boolean { + if (hook.matcher !== 'WebSearch') return false; + + const hookArray = hook.hooks as Array> | undefined; + if (!hookArray?.[0]?.command) return false; + + const command = hookArray[0].command; + if (typeof command !== 'string') return false; + + // Normalize path separators for cross-platform matching + const normalizedCommand = command.replace(/\\/g, '/'); + return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); +} + +/** + * Remove duplicate CCS WebSearch hooks from settings, keeping only the first one + * Returns true if duplicates were removed + */ +export function deduplicateCcsHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + let foundFirst = false; + const originalLength = hooks.PreToolUse.length; + + hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { + const hook = h as Record; + if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks + + if (!foundFirst) { + foundFirst = true; + return true; // Keep first CCS hook + } + return false; // Remove subsequent duplicates + }); + + return hooks.PreToolUse.length < originalLength; +} diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 4ef27c4b..c4fe01de 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -14,6 +14,7 @@ import { getWebSearchHookConfig, getHookPath } from './hook-config'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { removeHookConfig } from './hook-config'; import { getCcsDir } from '../config-manager'; +import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; // Valid profile name pattern (alphanumeric, dash, underscore only) const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/; @@ -33,61 +34,10 @@ function hasCcsHook(settings: Record): boolean { if (!hooks?.PreToolUse) return false; return hooks.PreToolUse.some((h: unknown) => { - const hook = h as Record; - if (hook.matcher !== 'WebSearch') return false; - - const hookArray = hook.hooks as Array> | undefined; - if (!hookArray?.[0]?.command) return false; - - const command = hookArray[0].command; - if (typeof command !== 'string') return false; - // Normalize path separators for cross-platform matching (Windows uses backslashes) - const normalizedCommand = command.replace(/\\/g, '/'); - return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); + return isCcsWebSearchHook(h as Record); }); } -/** - * Check if a hook entry is a CCS WebSearch hook - */ -function isCcsWebSearchHook(hook: Record): boolean { - if (hook.matcher !== 'WebSearch') return false; - - const hookArray = hook.hooks as Array> | undefined; - if (!hookArray?.[0]?.command) return false; - - const command = hookArray[0].command; - if (typeof command !== 'string') return false; - // Normalize path separators for cross-platform matching (Windows uses backslashes) - const normalizedCommand = command.replace(/\\/g, '/'); - return normalizedCommand.includes('.ccs/hooks/websearch-transformer'); -} - -/** - * Remove duplicate CCS WebSearch hooks from settings, keeping only the first one - * Returns true if duplicates were removed - */ -function deduplicateCcsHooks(settings: Record): boolean { - const hooks = settings.hooks as Record | undefined; - if (!hooks?.PreToolUse) return false; - - let foundFirst = false; - const originalLength = hooks.PreToolUse.length; - - hooks.PreToolUse = hooks.PreToolUse.filter((h: unknown) => { - const hook = h as Record; - if (!isCcsWebSearchHook(hook)) return true; // Keep non-CCS hooks - - if (!foundFirst) { - foundFirst = true; - return true; // Keep first CCS hook - } - return false; // Remove subsequent duplicates - }); - - return hooks.PreToolUse.length < originalLength; -} - /** * Migrate CCS hook from global settings to profile settings (one-time) */