diff --git a/bunfig.toml b/bunfig.toml index 8bdd90e2..b40c7204 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,7 @@ [test] # Exclude UI tests - they use vitest and require jsdom environment # Run UI tests separately with: cd ui && bun run test +# Exclude e2e tests - they require manual setup and are slow +# Run e2e tests with: bun run test:e2e root = "./tests" timeout = 10000 diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs new file mode 100755 index 00000000..ab7d831c --- /dev/null +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -0,0 +1,399 @@ +#!/usr/bin/env node +/** + * CCS Image Analyzer Hook - Read Tool Interceptor + * + * Intercepts Claude's Read tool for image/PDF files and analyzes them via CLIProxy. + * Returns detailed text descriptions instead of allowing direct visual access. + * + * Environment Variables (set by CCS): + * CCS_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely + * CCS_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1) + * CCS_IMAGE_ANALYSIS_MODEL - Model to use (default: gemini-2.5-flash) + * CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60) + * CCS_PROFILE_TYPE - Profile type (account/default skip) + * CCS_DEBUG=1 - Enable debug output + * + * Exit codes: + * 0 - Allow tool (pass-through to native Read) + * 2 - Block tool (deny with analysis/message) + * + * @module hooks/image-analyzer-transformer + */ + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); + +// ============================================================================ +// PLATFORM DETECTION +// ============================================================================ + +const isWindows = process.platform === 'win32'; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff']; +const PDF_EXTENSIONS = ['.pdf']; + +const DEFAULT_MODEL = 'gemini-2.5-flash'; +const DEFAULT_TIMEOUT_SEC = 60; +const MAX_FILE_SIZE_MB = 10; +const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; + +const CLIPROXY_HOST = '127.0.0.1'; +const CLIPROXY_PORT = parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10); +const CLIPROXY_PATH = '/v1/messages'; +// API key passed via env from cliproxy-executor, defaults to CCS internal key +const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed'; + +// Default analysis prompt +const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description. + +Include: +1. Overall content and purpose +2. Text content (if any) - transcribe important text +3. Visual elements (diagrams, charts, UI components) +4. Layout and structure +5. Colors, styling, notable design elements +6. Any actionable information (buttons, links, code) + +Be comprehensive - this description replaces direct visual access.`; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Check if file is an analyzable image or PDF + */ +function isAnalyzableFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext); +} + +/** + * Get MIME type from file extension + */ +function getMediaType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', + '.pdf': 'application/pdf', + }; + return mimeTypes[ext] || 'application/octet-stream'; +} + +/** + * Encode file to base64 + */ +function encodeFileToBase64(filePath) { + const content = fs.readFileSync(filePath); + return content.toString('base64'); +} + +/** + * Check if CLIProxy is available + */ +function isCliProxyAvailable() { + return new Promise((resolve) => { + const req = http.request( + { + hostname: CLIPROXY_HOST, + port: CLIPROXY_PORT, + path: '/', + method: 'GET', + timeout: 2000, + }, + (res) => { + resolve(res.statusCode >= 200 && res.statusCode < 500); + } + ); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} + +/** + * Analyze file via CLIProxy vision API + */ +function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { + return new Promise((resolve, reject) => { + const requestBody = JSON.stringify({ + model: model, + max_tokens: 4096, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: DEFAULT_PROMPT }, + { + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: base64Data, + }, + }, + ], + }, + ], + }); + + const req = http.request( + { + hostname: CLIPROXY_HOST, + port: CLIPROXY_PORT, + path: CLIPROXY_PATH, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody), + 'x-api-key': CLIPROXY_API_KEY, + }, + timeout: timeoutMs, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode !== 200) { + reject(new Error(`CLIProxy returned status ${res.statusCode}: ${data}`)); + return; + } + + try { + const response = JSON.parse(data); + const text = response.content?.[0]?.text; + + if (!text) { + reject(new Error('No text content in response')); + return; + } + + resolve(text); + } catch (err) { + reject(new Error(`Failed to parse response: ${err.message}`)); + } + }); + } + ); + + req.on('error', (err) => reject(err)); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Request timed out')); + }); + + req.write(requestBody); + req.end(); + }); +} + +/** + * Format analysis description for Claude + */ +function formatDescription(filePath, description, model) { + return [ + `## Image Analysis: ${path.basename(filePath)}`, + '', + description, + '', + '---', + `*Analyzed via CLIProxy (${model})*`, + ].join('\n'); +} + +/** + * Output success response and exit + */ +function outputSuccess(filePath, description, model) { + const formattedDescription = formatDescription(filePath, description, model); + + const output = { + decision: 'block', + reason: `Image analyzed: ${path.basename(filePath)}`, + systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: formattedDescription, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Output error message + */ +function outputError(filePath, error) { + const message = [ + `[Image Analysis - Error]`, + '', + `Failed to analyze: ${path.basename(filePath)}`, + '', + `Error: ${error}`, + '', + 'Troubleshooting:', + ' - Check CLIProxy is running: http://127.0.0.1:8317', + ' - Verify you are authenticated with agy or gemini', + ' - Check file size is under 10MB', + ].join('\n'); + + const output = { + decision: 'block', + reason: `Image analysis failed: ${error}`, + systemMessage: `[Image Analysis] Failed to analyze ${path.basename(filePath)}`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: message, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Determine if hook should skip + */ +function shouldSkipHook() { + // Explicit skip signal + if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') return true; + + // Explicit disable + if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') return true; + + // Account/default profiles - use native Read + const profileType = process.env.CCS_PROFILE_TYPE; + if (profileType === 'account' || profileType === 'default') return true; + + return false; +} + +// ============================================================================ +// MAIN HOOK LOGIC +// ============================================================================ + +// Read input from stdin +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + processHook(); +}); + +// Handle stdin not being available +process.stdin.on('error', () => { + process.exit(0); +}); + +/** + * Main hook processing logic + */ +async function processHook() { + try { + // Skip for native accounts or explicit disable + if (shouldSkipHook()) { + process.exit(0); + } + + const data = JSON.parse(input); + + // Only handle Read tool + if (data.tool_name !== 'Read') { + process.exit(0); + } + + const filePath = data.tool_input?.file_path || ''; + + if (!filePath) { + process.exit(0); + } + + // Check if file exists + if (!fs.existsSync(filePath)) { + // Let native Read handle the error + process.exit(0); + } + + // Check if file is analyzable + if (!isAnalyzableFile(filePath)) { + process.exit(0); + } + + // Check file size + const stats = fs.statSync(filePath); + if (stats.size > MAX_FILE_SIZE_BYTES) { + outputError(filePath, `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB > ${MAX_FILE_SIZE_MB}MB)`); + return; + } + + // Check CLIProxy availability + const cliProxyAvailable = await isCliProxyAvailable(); + if (!cliProxyAvailable) { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] CLIProxy not available, passing through'); + } + // Pass through to native Read + process.exit(0); + } + + const model = process.env.CCS_IMAGE_ANALYSIS_MODEL || DEFAULT_MODEL; + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + const timeoutMs = timeout * 1000; + + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`); + } + + // Encode file to base64 + const base64Data = encodeFileToBase64(filePath); + const mediaType = getMediaType(filePath); + + // Analyze via CLIProxy + const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + + // Output success + outputSuccess(filePath, description, model); + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Error:', err.message); + } + + // Try to extract file path from parsed input + let filePath = 'unknown file'; + try { + const data = JSON.parse(input); + filePath = data.tool_input?.file_path || 'unknown file'; + } catch { + // Ignore parse errors + } + + // Output error + outputError(filePath, err.message || 'Unknown error'); + } +} diff --git a/lib/prompts/image-analysis-default.txt b/lib/prompts/image-analysis-default.txt new file mode 100644 index 00000000..ba1572cc --- /dev/null +++ b/lib/prompts/image-analysis-default.txt @@ -0,0 +1,12 @@ +Analyze this image/document thoroughly and provide a detailed description. + +Include: +1. Overall content and purpose +2. Text content (if any) - transcribe important text verbatim +3. Visual elements (diagrams, charts, UI components, icons) +4. Layout and structure (sections, hierarchy, flow) +5. Colors, styling, notable design elements +6. Any actionable information (buttons, links, code snippets) + +Be comprehensive - this description replaces direct visual access. +The AI assistant reading this cannot see the original image. diff --git a/lib/prompts/image-analysis-document.txt b/lib/prompts/image-analysis-document.txt new file mode 100644 index 00000000..655812ce --- /dev/null +++ b/lib/prompts/image-analysis-document.txt @@ -0,0 +1,13 @@ +Analyze this document/PDF thoroughly for a developer. + +Extract and provide: +1. Document title, type, and structure +2. All text content - transcribe in reading order +3. Tables - format as markdown tables +4. Lists and bullet points - preserve structure +5. Code blocks or technical content +6. Diagrams or flowcharts - describe in detail +7. Headers and section organization +8. Any important metadata visible + +Accuracy in text extraction is critical. diff --git a/lib/prompts/image-analysis-screenshot.txt b/lib/prompts/image-analysis-screenshot.txt new file mode 100644 index 00000000..0c053eb1 --- /dev/null +++ b/lib/prompts/image-analysis-screenshot.txt @@ -0,0 +1,13 @@ +Analyze this screenshot in detail for a developer who cannot see it. + +Focus on: +1. Application/website type and state +2. UI elements visible (buttons, inputs, menus, modals) +3. All text content - transcribe exactly +4. Error messages or notifications (quote exactly) +5. Layout and component hierarchy +6. Interactive elements and their states +7. Console output or logs if visible +8. Any code snippets shown + +Be precise - this enables the assistant to help debug or understand the UI. diff --git a/package.json b/package.json index db90e29d..95c1f141 100644 --- a/package.json +++ b/package.json @@ -68,10 +68,11 @@ "verify:bundle": "node scripts/verify-bundle.js", "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", - "test:all": "bun test", + "test:all": "bun test tests/unit tests/integration tests/npm", "test:unit": "bun test tests/unit/", "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", + "test:e2e": "bun test tests/e2e/ --bail --timeout 60000", "dev": "bun run build:server && bun dist/ccs.js config --dev", "dev:symlink": "bash scripts/dev-symlink.sh", "dev:unlink": "bash scripts/dev-symlink.sh --restore", diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 63a97fd1..1ad14459 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -20,9 +20,11 @@ import { DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, + DEFAULT_IMAGE_ANALYSIS_CONFIG, GlobalEnvConfig, ThinkingConfig, DashboardAuthConfig, + ImageAnalysisConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -293,6 +295,13 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { partial.dashboard_auth?.session_timeout_hours ?? DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours, }, + // Image analysis config - enabled by default for CLIProxy providers + image_analysis: { + enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, + model: partial.image_analysis?.model ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.model, + timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, + providers: partial.image_analysis?.providers ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.providers, + }, }; } @@ -721,3 +730,18 @@ export function getDashboardAuthConfig(): DashboardAuthConfig { session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24, }; } + +/** + * Get image_analysis configuration. + * Returns defaults if not configured. + */ +export function getImageAnalysisConfig(): ImageAnalysisConfig { + const config = loadOrCreateUnifiedConfig(); + + return { + enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, + model: config.image_analysis?.model ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.model, + timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, + providers: config.image_analysis?.providers ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.providers, + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index cf7e0f1f..1eab0270 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -518,6 +518,32 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { session_timeout_hours: 24, }; +/** + * Image analysis configuration. + * Routes image/PDF files through CLIProxy for vision analysis. + */ +export interface ImageAnalysisConfig { + /** Enable image analysis via CLIProxy (default: true for agy/gemini) */ + enabled: boolean; + /** Model to use for analysis (default: gemini-2.5-flash) */ + model: string; + /** Timeout in seconds (default: 60) */ + timeout: number; + /** Providers to enable for (default: ['agy', 'gemini']) */ + providers: string[]; +} + +/** + * Default image analysis configuration. + * Enabled by default for CLIProxy providers with vision support. + */ +export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { + enabled: true, + model: 'gemini-2.5-flash', + timeout: 60, + providers: ['agy', 'gemini'], +}; + /** * Main unified configuration structure. * Stored in ~/.ccs/config.yaml @@ -551,6 +577,8 @@ export interface UnifiedConfig { thinking?: ThinkingConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; + /** Image analysis configuration (vision via CLIProxy) */ + image_analysis?: ImageAnalysisConfig; } /** @@ -644,6 +672,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG }, thinking: { ...DEFAULT_THINKING_CONFIG }, dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG }, + image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }, }; } diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts new file mode 100644 index 00000000..7cb88557 --- /dev/null +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -0,0 +1,33 @@ +/** + * Image Analysis Hook Environment Variables + * + * Provides environment variables for image analysis hook configuration. + * Hook routes image/PDF files through CLIProxy for vision analysis. + * + * @module utils/hooks/image-analysis-hook-env + */ + +import { getImageAnalysisConfig } from '../../config/unified-config-loader'; + +/** + * Get image analysis hook environment variables. + * These env vars control the hook's behavior via Claude Code hook system. + * + * @param profileName - Current profile name (to determine if native Claude) + * @returns Environment variables for image analysis hook + */ +export function getImageAnalysisHookEnv(profileName?: string): Record { + const config = getImageAnalysisConfig(); + + // Native Claude profiles (no CLIProxy) should skip image analysis + const isNativeProfile = !profileName || ['claude', 'anthropic'].includes(profileName); + const skipImageAnalysis = isNativeProfile || !config.enabled; + + return { + CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0', + CCS_IMAGE_ANALYSIS_MODEL: config.model, + CCS_IMAGE_ANALYSIS_TIMEOUT: config.timeout.toString(), + CCS_IMAGE_ANALYSIS_PROVIDERS: config.providers.join(','), + CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', + }; +} diff --git a/src/utils/hooks/image-analyzer-hook-configuration.ts b/src/utils/hooks/image-analyzer-hook-configuration.ts new file mode 100644 index 00000000..a11f4239 --- /dev/null +++ b/src/utils/hooks/image-analyzer-hook-configuration.ts @@ -0,0 +1,55 @@ +/** + * Image Analyzer Hook Configuration + * + * Manages hook configuration for image analysis in Claude settings. + * + * @module utils/hooks/image-analyzer-hook-config + */ + +import * as path from 'path'; +import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { getCcsDir } from '../config-manager'; + +// Hook file name +const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs'; + +/** + * Get CCS hooks directory (respects CCS_HOME for test isolation) + */ +export function getCcsHooksDir(): string { + return path.join(getCcsDir(), 'hooks'); +} + +/** + * Get path to image analyzer hook + */ +export function getImageAnalyzerHookPath(): string { + return path.join(getCcsHooksDir(), IMAGE_ANALYZER_HOOK); +} + +/** + * Get hook config for settings.json injection + * Timeout includes buffer for CLI overhead + */ +export function getImageAnalyzerHookConfig(): Record { + const hookPath = getImageAnalyzerHookPath(); + const imageConfig = getImageAnalysisConfig(); + + // Add 5 second buffer to analysis timeout for hook execution overhead + const hookTimeout = imageConfig.timeout * 1000 + 5000; + + return { + PreToolUse: [ + { + matcher: 'Read', + hooks: [ + { + type: 'command', + command: `node "${hookPath}"`, + timeout: hookTimeout, + }, + ], + }, + ], + }; +} diff --git a/src/utils/hooks/image-analyzer-hook-installer.ts b/src/utils/hooks/image-analyzer-hook-installer.ts new file mode 100644 index 00000000..a4e92ecc --- /dev/null +++ b/src/utils/hooks/image-analyzer-hook-installer.ts @@ -0,0 +1,137 @@ +/** + * Image Analyzer Hook Installer + * + * Manages installation and uninstallation of the image analyzer hook. + * This hook intercepts Read tool calls and analyzes image files via CLIProxy. + * + * @module utils/hooks/image-analyzer-hook-installer + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { info, warn } from '../ui'; +import { getImageAnalyzerHookPath, getCcsHooksDir } from './image-analyzer-hook-configuration'; +import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { removeMigrationMarker } from './image-analyzer-profile-hook-injector'; + +// Re-export from hook-configuration for backward compatibility +export { + getImageAnalyzerHookPath, + getImageAnalyzerHookConfig, +} from './image-analyzer-hook-configuration'; + +// Hook file name +const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs'; + +/** + * Check if image analyzer hook is installed + */ +export function hasImageAnalyzerHook(): boolean { + return fs.existsSync(getImageAnalyzerHookPath()); +} + +/** + * Install image analyzer hook to ~/.ccs/hooks/ + * + * This hook intercepts Read calls and analyzes images via CLIProxy. + * + * @returns true if hook installed successfully + */ +export function installImageAnalyzerHook(): boolean { + try { + const imageConfig = getImageAnalysisConfig(); + + // Skip if disabled + if (!imageConfig.enabled) { + if (process.env.CCS_DEBUG) { + console.error(info('Image analysis disabled - skipping hook install')); + } + return false; + } + + // Ensure hooks directory exists + const hooksDir = getCcsHooksDir(); + if (!fs.existsSync(hooksDir)) { + fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 }); + } + + const hookPath = getImageAnalyzerHookPath(); + + // Find the bundled hook script + // In npm package: node_modules/ccs/lib/hooks/ + // In development: lib/hooks/ + const possiblePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK), + path.join(__dirname, '..', '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK), + path.join(__dirname, '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK), + ]; + + let sourcePath: string | null = null; + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + sourcePath = p; + break; + } + } + + if (!sourcePath) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Image analyzer hook source not found: ${IMAGE_ANALYZER_HOOK}`)); + } + return false; + } + + // Copy hook to ~/.ccs/hooks/ + fs.copyFileSync(sourcePath, hookPath); + fs.chmodSync(hookPath, 0o755); + + if (process.env.CCS_DEBUG) { + console.error(info(`Installed image analyzer hook: ${hookPath}`)); + } + + // Note: Hook registration is handled by ensureProfileHooks() in image-analyzer-profile-injector.ts + // which writes to per-profile settings (~/.ccs/.settings.json) + // Global settings (~/.claude/settings.json) are NOT modified here + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to install image analyzer hook: ${(error as Error).message}`)); + } + return false; + } +} + +/** + * Uninstall image analyzer hook from ~/.ccs/hooks/ + * + * Note: Does NOT touch global ~/.claude/settings.json. + * Profile-specific hooks are removed when ~/.ccs/ is deleted. + * + * @returns true if hook uninstalled successfully + */ +export function uninstallImageAnalyzerHook(): boolean { + try { + const hookPath = getImageAnalyzerHookPath(); + + if (fs.existsSync(hookPath)) { + fs.unlinkSync(hookPath); + if (process.env.CCS_DEBUG) { + console.error(info(`Uninstalled image analyzer hook: ${hookPath}`)); + } + } + + // Remove migration marker (so fresh install re-runs migration) + removeMigrationMarker(); + + // Note: Do NOT call removeHookConfig() - global settings should not be touched. + // Per-profile hooks in ~/.ccs/*.settings.json are cleaned up when ~/.ccs/ is deleted. + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to uninstall image analyzer hook: ${(error as Error).message}`)); + } + return false; + } +} diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts new file mode 100644 index 00000000..4d4e4f12 --- /dev/null +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -0,0 +1,253 @@ +/** + * Image Analyzer Profile Hook Injector + * + * Injects image analyzer hooks into per-profile settings files. + * This replaces the global ~/.claude/settings.json approach. + * + * Only injects for CLIProxy profiles (agy, gemini) that support vision analysis. + * + * @module utils/hooks/image-analyzer-profile-injector + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { info, warn } from '../ui'; +import { + getImageAnalyzerHookConfig, + getImageAnalyzerHookPath, +} from './image-analyzer-hook-configuration'; +import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { getCcsDir } from '../config-manager'; + +// Valid profile name pattern (alphanumeric, dash, underscore only) +const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/; + +/** + * Get migration marker path (respects CCS_HOME for test isolation) + */ +function getMigrationMarkerPath(): string { + return path.join(getCcsDir(), '.image-analyzer-hook-migrated'); +} + +/** + * Check if CCS image analyzer hook exists in settings + */ +function hasCcsHook(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + return hooks.PreToolUse.some((h: unknown) => { + const hook = h as Record; + if (hook.matcher !== 'Read') 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, '/') // Windows backslashes + .replace(/\/+/g, '/'); // Collapse multiple slashes + return normalized.includes('.ccs/hooks/image-analyzer-transformer'); + }); +} + +/** + * One-time migration marker management + */ +function migrateGlobalHook(): void { + const markerPath = getMigrationMarkerPath(); + if (fs.existsSync(markerPath)) { + return; // Already migrated + } + + try { + // No global hook to migrate (image analyzer is profile-only from the start) + // Just create marker to prevent future migration attempts + 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 image analyzer hook is configured in profile's settings file + * + * Only injects for CLIProxy profiles with vision support (agy, gemini). + * + * @param profileName - Name of the profile (e.g., 'agy', 'gemini') + * @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; + } + + // Only inject for CLIProxy profiles with vision support + const visionProfiles = ['agy', 'gemini']; + if (!visionProfiles.includes(profileName)) { + return false; + } + + const imageConfig = getImageAnalysisConfig(); + + // Skip if image analysis is disabled + if (!imageConfig.enabled) { + return false; + } + + // One-time migration marker + migrateGlobalHook(); + + // Get CCS directory (respects CCS_HOME for test isolation) + const ccsDir = getCcsDir(); + + // Ensure CCS dir exists + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 }); + } + + const settingsPath = path.join(ccsDir, `${profileName}.settings.json`); + + // Read existing settings or create empty + let settings: Record = {}; + 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}`) + ); + } + // Continue with empty settings, will add hooks + } + } + + // Check if CCS hook already present + if (hasCcsHook(settings)) { + // Update timeout if needed + return updateHookTimeoutIfNeeded(settings, settingsPath); + } + + // Get hook config + const hookConfig = getImageAnalyzerHookConfig(); + + // Ensure hooks structure exists + if (!settings.hooks) { + settings.hooks = {}; + } + + const settingsHooks = settings.hooks as Record; + 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 image analyzer 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; + } +} + +/** + * Update hook timeout if it differs from current config + */ +function updateHookTimeoutIfNeeded( + settings: Record, + settingsPath: string +): boolean { + try { + const hooks = settings.hooks as Record; + const hookConfig = getImageAnalyzerHookConfig(); + const expectedHookPath = getImageAnalyzerHookPath(); + const expectedCommand = `node "${expectedHookPath}"`; + const expectedHooks = (hookConfig.PreToolUse as Array>)[0] + .hooks as Array>; + const expectedTimeout = expectedHooks[0].timeout as number; + + let needsUpdate = false; + + for (const h of hooks.PreToolUse) { + const hook = h as Record; + if (hook.matcher !== 'Read') continue; + + const hookArray = hook.hooks as Array>; + 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/image-analyzer-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 image analyzer 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; + } +} + +/** + * Remove migration marker (called during uninstall) + */ +export function removeMigrationMarker(): void { + try { + const markerPath = getMigrationMarkerPath(); + if (fs.existsSync(markerPath)) { + fs.unlinkSync(markerPath); + } + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`removeMigrationMarker failed: ${(error as Error).message}`)); + } + } +} diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 16449791..d952a096 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -7,3 +7,12 @@ */ export { getImageReadBlockHookEnv, getImageReadBlockConfig } from './image-read-block-hook-env'; +export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env'; +export { + getImageAnalyzerHookPath, + getImageAnalyzerHookConfig, + hasImageAnalyzerHook, + installImageAnalyzerHook, + uninstallImageAnalyzerHook, +} from './image-analyzer-hook-installer'; +export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector'; diff --git a/src/utils/image-analysis/hook-installer.ts b/src/utils/image-analysis/hook-installer.ts new file mode 100644 index 00000000..99c4f624 --- /dev/null +++ b/src/utils/image-analysis/hook-installer.ts @@ -0,0 +1,206 @@ +/** + * Image Analysis Hook Installer + * + * Manages installation of: + * 1. block-image-read.cjs hook (blocks image reads to prevent context overflow) + * 2. Prompt templates for image analysis (user-customizable) + * + * @module utils/image-analysis/hook-installer + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { info, warn } from '../ui'; +import { getCcsDir } from '../config-manager'; + +// Hook file name +const IMAGE_BLOCK_HOOK = 'block-image-read.cjs'; + +/** + * Get path to installed hook script + */ +export function getHookPath(): string { + return path.join(getCcsDir(), 'hooks', IMAGE_BLOCK_HOOK); +} + +/** + * Get CCS hooks directory + */ +export function getCcsHooksDir(): string { + return path.join(getCcsDir(), 'hooks'); +} + +/** + * Get prompts directory for image analysis + */ +export function getPromptsDir(): string { + return path.join(getCcsDir(), 'prompts', 'image-analysis'); +} + +/** + * Check if image block hook is installed + */ +export function hasImageBlockHook(): boolean { + return fs.existsSync(getHookPath()); +} + +/** + * Install image block hook to ~/.ccs/hooks/ + * + * This hook intercepts Read tool calls for image files and blocks them + * to prevent context overflow (images consume 100K+ tokens each). + * + * @returns true if hook installed successfully + */ +export function installImageBlockHook(): boolean { + try { + // Ensure hooks directory exists + const hooksDir = getCcsHooksDir(); + if (!fs.existsSync(hooksDir)) { + fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 }); + } + + const hookPath = getHookPath(); + + // Find the bundled hook script + // In npm package: node_modules/ccs/lib/hooks/ + // In development: lib/hooks/ + const possiblePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK), + path.join(__dirname, '..', '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK), + path.join(__dirname, '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK), + ]; + + let sourcePath: string | null = null; + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + sourcePath = p; + break; + } + } + + if (!sourcePath) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Image block hook source not found: ${IMAGE_BLOCK_HOOK}`)); + } + return false; + } + + // Copy hook to ~/.ccs/hooks/ + fs.copyFileSync(sourcePath, hookPath); + fs.chmodSync(hookPath, 0o755); + + if (process.env.CCS_DEBUG) { + console.error(info(`Installed image block hook: ${hookPath}`)); + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to install image block hook: ${(error as Error).message}`)); + } + return false; + } +} + +/** + * Install prompt templates to ~/.ccs/prompts/image-analysis/ + * Only installs if directory doesn't exist (doesn't overwrite user edits) + * + * @returns true if prompts installed or already exist + */ +export function installImageAnalysisPrompts(): boolean { + try { + const promptsDir = getPromptsDir(); + + // Skip if already exists (preserve user customizations) + if (fs.existsSync(promptsDir)) { + if (process.env.CCS_DEBUG) { + console.error( + info('Image analysis prompts already installed - preserving user customizations') + ); + } + return true; + } + + // Create directory + fs.mkdirSync(promptsDir, { recursive: true, mode: 0o755 }); + + // Find bundled prompts + const possibleBasePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'prompts'), + path.join(__dirname, '..', '..', 'lib', 'prompts'), + path.join(__dirname, '..', 'lib', 'prompts'), + ]; + + let promptsBasePath: string | null = null; + for (const p of possibleBasePaths) { + if (fs.existsSync(p)) { + promptsBasePath = p; + break; + } + } + + if (!promptsBasePath) { + if (process.env.CCS_DEBUG) { + console.error(warn('Image analysis prompts source not found')); + } + return false; + } + + // Copy prompt files + const promptFiles = [ + 'image-analysis-default.txt', + 'image-analysis-screenshot.txt', + 'image-analysis-document.txt', + ]; + + for (const file of promptFiles) { + const sourcePath = path.join(promptsBasePath, file); + const destPath = path.join(promptsDir, file.replace('image-analysis-', '')); + + if (fs.existsSync(sourcePath)) { + fs.copyFileSync(sourcePath, destPath); + fs.chmodSync(destPath, 0o644); + } else if (process.env.CCS_DEBUG) { + console.error(warn(`Prompt template not found: ${file}`)); + } + } + + if (process.env.CCS_DEBUG) { + console.error(info(`Installed image analysis prompts: ${promptsDir}`)); + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to install image analysis prompts: ${(error as Error).message}`)); + } + return false; + } +} + +/** + * Uninstall image block hook from ~/.ccs/hooks/ + * + * @returns true if hook uninstalled successfully + */ +export function uninstallImageBlockHook(): boolean { + try { + const hookPath = getHookPath(); + + if (fs.existsSync(hookPath)) { + fs.unlinkSync(hookPath); + if (process.env.CCS_DEBUG) { + console.error(info(`Uninstalled image block hook: ${hookPath}`)); + } + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to uninstall image block hook: ${(error as Error).message}`)); + } + return false; + } +} diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts new file mode 100644 index 00000000..1fea2b08 --- /dev/null +++ b/src/utils/image-analysis/index.ts @@ -0,0 +1,15 @@ +/** + * Image Analysis Utilities + * + * Exports hook installer functions for image blocking and prompt management + */ + +export { + getHookPath, + getCcsHooksDir, + getPromptsDir, + hasImageBlockHook, + installImageBlockHook, + installImageAnalysisPrompts, + uninstallImageBlockHook, +} from './hook-installer'; diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts new file mode 100644 index 00000000..630f7ae4 --- /dev/null +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -0,0 +1,751 @@ +/** + * E2E Tests for Image Analyzer Hook + * + * ⚠️ NOT RUN IN NORMAL CI/CD - This is an E2E test file (.e2e.ts) + * + * Run manually with: bun test tests/integration/hooks/image-analyzer-hook.e2e.ts --bail + * + * Tests the image-analyzer-transformer.cjs hook with: + * - Generated test fixtures with predictable content + * - Mock CLIProxy server for reliable, fast tests + * - Direct hook invocation via stdin + * + * Uses a mock HTTP server that returns predictable responses to verify + * the hook correctly formats requests and parses responses. + * + * Use --bail flag to exit on first failure (recommended for long tests). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as http from 'http'; + +// ============================================================================ +// TEST CONFIGURATION +// ============================================================================ + +const HOOK_PATH = path.join(__dirname, '../../lib/hooks/image-analyzer-transformer.cjs'); +const TEST_DIR = '/tmp/ccs-hook-tests'; +const MOCK_PORT = 59876; // Use a unique port for mock server +const CLIPROXY_API_KEY = 'test-api-key-12345'; + +// ============================================================================ +// MOCK SERVER +// ============================================================================ + +interface MockServerRequest { + method: string; + path: string; + headers: Record; + body: unknown; +} + +let mockServer: http.Server | null = null; +let lastRequest: MockServerRequest | null = null; +let mockResponse: { content: string; statusCode: number } = { + content: 'This is a test image showing a red pixel.', + statusCode: 200, +}; + +/** + * Start mock CLIProxy server + */ +function startMockServer(): Promise { + return new Promise((resolve, reject) => { + mockServer = http.createServer((req, res) => { + // Health check endpoint - always return 200 + if (req.method === 'GET' && req.url === '/') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + // Capture request for verification + lastRequest = { + method: req.method || 'GET', + path: req.url || '/', + headers: req.headers, + body: body ? JSON.parse(body) : null, + }; + + // Return mock response in Anthropic format + if (mockResponse.statusCode !== 200) { + res.writeHead(mockResponse.statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'Mock error' } })); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + content: [{ type: 'text', text: mockResponse.content }], + }) + ); + }); + }); + + mockServer.on('error', reject); + mockServer.listen(MOCK_PORT, '127.0.0.1', () => { + resolve(); + }); + }); +} + +/** + * Stop mock CLIProxy server + */ +function stopMockServer(): Promise { + return new Promise((resolve) => { + if (mockServer) { + mockServer.close(() => { + mockServer = null; + resolve(); + }); + } else { + resolve(); + } + }); +} + +/** + * Reset mock server state between tests + */ +function resetMockState(): void { + lastRequest = null; + mockResponse = { + content: 'This is a test image showing a red pixel.', + statusCode: 200, + }; +} + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Invoke the hook with JSON input + */ +function invokeHook( + input: object, + env: Record = {} +): { code: number; stdout: string; stderr: string } { + const result = spawnSync('node', [HOOK_PATH], { + input: JSON.stringify(input), + encoding: 'utf8', + env: { + ...process.env, + CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, + CCS_CLIPROXY_PORT: String(MOCK_PORT), + ...env, + }, + timeout: 10000, // 10 second timeout per test + }); + + return { + code: result.status ?? -1, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +/** + * Create a minimal valid PNG file (1x1 red pixel) + */ +function createTestPng(filepath: string): void { + // 1x1 PNG with a red pixel (RGB: 255, 0, 0) + const png = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk (red pixel) + 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, + 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk + 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + fs.writeFileSync(filepath, png); +} + +/** + * Create a minimal valid JPEG file + */ +function createTestJpeg(filepath: string): void { + const jpeg = Buffer.from([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x48, + 0x00, 0x48, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, + 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0xff, 0xc4, 0x00, 0x14, + 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x7f, 0xff, 0xd9, + ]); + fs.writeFileSync(filepath, jpeg); +} + +/** + * Create a test text file + */ +function createTestTextFile(filepath: string, content: string): void { + fs.writeFileSync(filepath, content, 'utf8'); +} + +/** + * Create a large file exceeding 10MB + */ +function createLargeFile(filepath: string, sizeMB: number): void { + const bufferSize = 1024 * 1024; // 1MB + const totalBuffers = sizeMB; + const buffer = Buffer.alloc(bufferSize, 'A'); + const stream = fs.createWriteStream(filepath); + + for (let i = 0; i < totalBuffers; i++) { + stream.write(buffer); + } + stream.end(); +} + +// ============================================================================ +// TEST SUITE +// ============================================================================ + +describe('Image Analyzer Hook', () => { + let testPngPath: string; + let testJpegPath: string; + let testTextPath: string; + + beforeAll(async () => { + // Create test directory + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + + // Start mock server + await startMockServer(); + console.log(`[Test Setup] Mock CLIProxy started on port ${MOCK_PORT}`); + + // Create test files + testPngPath = path.join(TEST_DIR, 'test-image.png'); + testJpegPath = path.join(TEST_DIR, 'test-image.jpg'); + testTextPath = path.join(TEST_DIR, 'test-file.txt'); + + createTestPng(testPngPath); + createTestJpeg(testJpegPath); + createTestTextFile(testTextPath, 'This is a test file.'); + }); + + afterAll(async () => { + // Stop mock server + await stopMockServer(); + + // Clean up test files + const filesToClean = [testPngPath, testJpegPath, testTextPath]; + for (const f of filesToClean) { + if (f && fs.existsSync(f)) { + try { + fs.unlinkSync(f); + } catch { + // Ignore cleanup errors + } + } + } + if (fs.existsSync(TEST_DIR)) { + try { + fs.rmdirSync(TEST_DIR); + } catch { + // Ignore if not empty + } + } + }); + + // ========================================================================== + // GROUP A: FILE DETECTION AND FILTERING + // ========================================================================== + + describe('File Detection and Filtering', () => { + it('should pass through non-Read tools', () => { + const result = invokeHook({ + tool_name: 'Write', + tool_input: { file_path: testPngPath, content: 'test' }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through Read tool for non-image files (.txt)', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: testTextPath }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through Read tool for .ts files', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/test.ts' }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through Read tool for .md files', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/test.md' }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through Read tool for .json files', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/test.json' }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through files that do not exist', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/nonexistent-file-12345.png' }, + }); + + // Should pass through to let native Read handle the error + expect(result.code).toBe(0); + }); + }); + + // ========================================================================== + // GROUP B: ENVIRONMENT VARIABLE CONTROLS + // ========================================================================== + + describe('Environment Variable Controls', () => { + it('should skip when CCS_IMAGE_ANALYSIS_SKIP=1', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_SKIP: '1' } + ); + + expect(result.code).toBe(0); + }); + + it('should skip when CCS_IMAGE_ANALYSIS_ENABLED=0', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '0' } + ); + + expect(result.code).toBe(0); + }); + + it('should skip for account profile type', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_PROFILE_TYPE: 'account' } + ); + + expect(result.code).toBe(0); + }); + + it('should skip for default profile type', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_PROFILE_TYPE: 'default' } + ); + + expect(result.code).toBe(0); + }); + }); + + // ========================================================================== + // GROUP C: INPUT VALIDATION + // ========================================================================== + + describe('Input Validation', () => { + it('should handle missing file_path gracefully', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: {}, + }); + + expect(result.code).toBe(0); + }); + + it('should handle empty file_path', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '' }, + }); + + expect(result.code).toBe(0); + }); + + it('should handle malformed JSON input', () => { + const hookProcess = spawnSync('node', [HOOK_PATH], { + input: 'not valid json', + encoding: 'utf8', + timeout: 5000, + env: { ...process.env, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY }, + }); + + // Should exit with error (code 2) + expect(hookProcess.status).toBe(2); + }); + }); + + // ========================================================================== + // GROUP D: FILE SIZE LIMITS + // ========================================================================== + + describe('File Size Limits', () => { + it('should reject files larger than 10MB', () => { + // Create 11MB file + const largePath = path.join(TEST_DIR, 'large-test.png'); + createLargeFile(largePath, 11); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: largePath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // Should block with error + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('File too large'); + + // Cleanup + if (fs.existsSync(largePath)) fs.unlinkSync(largePath); + }); + }); + + // ========================================================================== + // GROUP E: MOCK CLIPROXY INTEGRATION (FAST, RELIABLE) + // ========================================================================== + + describe('CLIProxy Integration (Mock Server)', () => { + beforeAll(() => { + resetMockState(); + }); + + it('should pass through when CLIProxy is unavailable', async () => { + // Force hook to use a port that's definitely not running + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_CLIPROXY_PORT: '59999', // Non-existent port + CCS_DEBUG: '1', + } + ); + + // Should pass through (exit 0) when CLIProxy not available + expect(result.code).toBe(0); + expect(result.stderr).toContain('CLIProxy not available'); + }); + + it('should analyze PNG via mock CLIProxy and return analysis', () => { + resetMockState(); + mockResponse = { + content: 'This image shows a small red square, likely a single pixel or very minimal graphic.', + statusCode: 200, + }; + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // Should block with analysis (exit 2) + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('red square'); + }); + + it('should analyze JPEG via mock CLIProxy', () => { + resetMockState(); + mockResponse = { + content: 'A minimalist white image, possibly a blank canvas or placeholder.', + statusCode: 200, + }; + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testJpegPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('white image'); + }); + + it('should include API key in request header', () => { + resetMockState(); + + invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // Verify API key was sent + expect(lastRequest).not.toBeNull(); + expect(lastRequest?.headers['x-api-key']).toBe(CLIPROXY_API_KEY); + }); + + it('should send correct request format to CLIProxy', () => { + resetMockState(); + + invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_IMAGE_ANALYSIS_MODEL: 'gemini-2.5-flash', + } + ); + + // Verify request format + expect(lastRequest).not.toBeNull(); + expect(lastRequest?.method).toBe('POST'); + expect(lastRequest?.path).toBe('/v1/messages'); + + const body = lastRequest?.body as { + model: string; + max_tokens: number; + messages: Array<{ + role: string; + content: Array<{ + type: string; + text?: string; + source?: { type: string; media_type: string; data: string }; + }>; + }>; + }; + expect(body.model).toBe('gemini-2.5-flash'); + expect(body.max_tokens).toBe(4096); + expect(body.messages).toHaveLength(1); + expect(body.messages[0].role).toBe('user'); + + // Should have text prompt and image content + const content = body.messages[0].content; + expect(content.some((c) => c.type === 'text')).toBe(true); + expect(content.some((c) => c.type === 'image')).toBe(true); + + // Verify image is base64 encoded + const imageContent = content.find((c) => c.type === 'image'); + expect(imageContent?.source?.type).toBe('base64'); + expect(imageContent?.source?.media_type).toBe('image/png'); + expect(imageContent?.source?.data).toBeDefined(); + }); + + it('should use correct media type for JPEG', () => { + resetMockState(); + + invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testJpegPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + const body = lastRequest?.body as { + messages: Array<{ + content: Array<{ + type: string; + source?: { media_type: string }; + }>; + }>; + }; + const imageContent = body.messages[0].content.find((c) => c.type === 'image'); + expect(imageContent?.source?.media_type).toBe('image/jpeg'); + }); + + it('should respect debug mode and output debug messages', () => { + resetMockState(); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_DEBUG: '1' } + ); + + // Should output debug info to stderr + expect(result.stderr).toContain('[CCS Hook]'); + expect(result.stderr).toContain('Analyzing'); + }); + + it('should handle API error response gracefully (pass through)', () => { + resetMockState(); + mockResponse = { + content: '', + statusCode: 500, + }; + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // On API error, hook blocks with error message (exit 2) + // This ensures Claude knows the analysis failed rather than silently passing through + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error'); + }); + + it('should use default model when CCS_IMAGE_ANALYSIS_MODEL is not set', () => { + resetMockState(); + + invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + const body = lastRequest?.body as { model: string }; + expect(body.model).toBe('gemini-2.5-flash'); // Default model + }); + }); + + // ========================================================================== + // GROUP F: OUTPUT FORMAT VALIDATION + // ========================================================================== + + describe('Output Format Validation', () => { + it('should output valid JSON structure on success', () => { + resetMockState(); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + + // Validate structure + expect(output.decision).toBe('block'); + expect(output.reason).toBeDefined(); + expect(output.systemMessage).toBeDefined(); + expect(output.hookSpecificOutput).toBeDefined(); + expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput.permissionDecisionReason).toBeDefined(); + }); + + it('should include filename in output', () => { + resetMockState(); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + const output = JSON.parse(result.stdout); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('test-image.png'); + }); + + it('should include model name in output', () => { + resetMockState(); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + const output = JSON.parse(result.stdout); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('gemini-2.5-flash'); + }); + + it('should output valid JSON structure on file read error', () => { + // Create and immediately delete file to trigger error + const errorPath = path.join(TEST_DIR, 'error-test.png'); + createTestPng(errorPath); + + // Make file unreadable (simulate permission error) + fs.chmodSync(errorPath, 0o000); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: errorPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // Restore permissions and cleanup + fs.chmodSync(errorPath, 0o644); + fs.unlinkSync(errorPath); + + // Should output error in JSON format + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error'); + }); + }); +});