Files
ccs/src/utils/websearch/profile-hook-injector.ts
T

271 lines
8.8 KiB
TypeScript

/**
* Profile Hook Injector
*
* Injects the legacy WebSearch compatibility hook into per-profile settings files.
* The first-class runtime now uses the CCS-managed MCP server; these hooks remain
* for compatibility and migration safety only.
*
* @module utils/websearch/profile-hook-injector
*/
import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../ui';
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';
import { getMigrationMarkerPath, installWebSearchHook } from './hook-installer';
// Valid profile name pattern (alphanumeric, dash, underscore only)
const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
function hasUsableHookBinary(): boolean {
try {
const hookPath = getHookPath();
const stat = fs.statSync(hookPath);
if (!stat.isFile()) {
return false;
}
fs.accessSync(hookPath, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
/**
* Check if CCS WebSearch hook exists in settings
*/
function hasCcsHook(settings: Record<string, unknown>): boolean {
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
if (!hooks?.PreToolUse) return false;
return hooks.PreToolUse.some((h: unknown) => {
return isCcsWebSearchHook(h as Record<string, unknown>);
});
}
/**
* Migrate CCS hook from global settings to profile settings (one-time)
*/
function migrateGlobalHook(): void {
const markerPath = getMigrationMarkerPath();
if (fs.existsSync(markerPath)) {
return; // Already migrated
}
try {
const removed = removeHookConfig();
if (removed && process.env.CCS_DEBUG) {
console.error(info('Migrated WebSearch hook from global settings'));
}
// Ensure CCS dir exists before creating marker
const ccsDir = getCcsDir();
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
// Create marker file atomically (wx = fail if exists, prevents race condition)
fs.writeFileSync(markerPath, new Date().toISOString(), { encoding: 'utf8', flag: 'wx' });
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Migration failed: ${(error as Error).message}`));
}
}
}
/**
* Ensure the legacy WebSearch compatibility hook is configured in a profile's
* settings file when that path is still needed.
*
* @param profileName - Name of the profile (e.g., 'agy', 'gemini', 'glm')
* @returns true if hook is configured (existing or newly added)
*/
export function ensureProfileHooks(profileName: string): boolean {
try {
// Validate profile name to prevent path traversal
if (!VALID_PROFILE_NAME.test(profileName)) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Invalid profile name: ${profileName}`));
}
return false;
}
const wsConfig = getWebSearchConfig();
// Skip if WebSearch is disabled
if (!wsConfig.enabled) {
return false;
}
// Get CCS directory (respects CCS_HOME for test isolation)
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profileName}.settings.json`);
// Read existing settings or create empty
let settings: Record<string, unknown> = {};
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf8');
settings = JSON.parse(content);
} catch (parseError) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Malformed ${profileName}.settings.json: ${(parseError as Error).message}`)
);
}
// Never overwrite malformed settings files; avoid destructive data loss.
return false;
}
}
// Keep the injected command target valid for all profile types, not just CLIProxy.
// The installer already skips byte-identical copies, so we can always attempt a refresh
// without rewriting unchanged hooks. Re-check existence after a failed install to tolerate
// concurrent first-run installs that may have completed in another process.
if (!installWebSearchHook() && !hasUsableHookBinary()) {
if (process.env.CCS_DEBUG) {
console.error(warn('WebSearch hook binary is missing and could not be installed'));
}
return false;
}
// One-time migration from global settings
migrateGlobalHook();
// Ensure CCS dir exists before writing settings updates.
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
// 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) {
// Re-read file to compare with modified settings (deduplicateCcsHooks mutates in-place)
const newContent = JSON.stringify(settings, null, 2);
const existingContent = fs.readFileSync(settingsPath, 'utf8');
// Only write if content actually changed
if (newContent !== existingContent) {
fs.writeFileSync(settingsPath, newContent, '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);
}
// Get hook config
const hookConfig = getWebSearchHookConfig();
// Ensure hooks structure exists
if (!settings.hooks) {
settings.hooks = {};
}
const settingsHooks = settings.hooks as Record<string, unknown[]>;
if (!settingsHooks.PreToolUse) {
settingsHooks.PreToolUse = [];
}
// Add CCS hook
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
settingsHooks.PreToolUse.push(...preToolUseHooks);
// Write updated settings
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info(`Added WebSearch hook to ${profileName}.settings.json`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to inject hook: ${(error as Error).message}`));
}
return false;
}
}
export function ensureProfileHooksOrThrow(profileName: string): void {
const wsConfig = getWebSearchConfig();
if (!wsConfig.enabled) {
return;
}
if (!ensureProfileHooks(profileName)) {
throw new Error(
`WebSearch is enabled, but CCS could not prepare the profile hook for "${profileName}".`
);
}
}
/**
* Update hook timeout if it differs from current config
*/
function updateHookTimeoutIfNeeded(
settings: Record<string, unknown>,
settingsPath: string
): boolean {
try {
const hooks = settings.hooks as Record<string, unknown[]>;
const hookConfig = getWebSearchHookConfig();
const expectedHookPath = getHookPath();
const expectedCommand = `node "${expectedHookPath}"`;
const expectedHooks = (hookConfig.PreToolUse as Array<Record<string, unknown>>)[0]
.hooks as Array<Record<string, unknown>>;
const expectedTimeout = expectedHooks[0].timeout as number;
let needsUpdate = false;
for (const h of hooks.PreToolUse) {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'WebSearch') continue;
const hookArray = hook.hooks as Array<Record<string, unknown>>;
if (!hookArray?.[0]?.command) continue;
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, '/') // Windows backslashes
.replace(/\/+/g, '/'); // Collapse multiple slashes
if (!normalizedCommand.includes('.ccs/hooks/websearch-transformer')) continue;
// Found CCS hook - check if needs update
if (hookArray[0].command !== expectedCommand) {
hookArray[0].command = expectedCommand;
needsUpdate = true;
}
if (hookArray[0].timeout !== expectedTimeout) {
hookArray[0].timeout = expectedTimeout;
needsUpdate = true;
}
}
if (needsUpdate) {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info('Updated WebSearch hook timeout in profile settings'));
}
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`updateHookTimeoutIfNeeded failed: ${(error as Error).message}`));
}
return false;
}
}