From 38eb74043c7f9e613e308392b2c159ebfc2a05c1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 17:28:21 -0500 Subject: [PATCH 01/13] feat(hooks): add block-image-read hook to prevent context overflow Add PreToolUse hook that intercepts Read tool calls on image files (.png, .jpg, .webp, etc.) and blocks them with helpful message. This prevents context exhaustion when image generation skills produce multiple files and the agent tries to read them (each image can consume 100K+ tokens). Configuration: - Enable via config.yaml: hooks.block_image_read.enabled: true - Or env var: CCS_BLOCK_IMAGE_READ=1 Hook integration: - lib/hooks/block-image-read.cjs - the hook script - src/utils/hooks/image-read-block-hook-env.ts - config loader - Integrated into all spawn locations (ccs.ts, shell-executor, cliproxy-executor) Closes #426 --- lib/hooks/block-image-read.cjs | 150 +++++++++++++++++++ src/ccs.ts | 5 + src/cliproxy/cliproxy-executor.ts | 3 + src/utils/hooks/image-read-block-hook-env.ts | 50 +++++++ src/utils/hooks/index.ts | 9 ++ src/utils/shell-executor.ts | 6 +- 6 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 lib/hooks/block-image-read.cjs create mode 100644 src/utils/hooks/image-read-block-hook-env.ts create mode 100644 src/utils/hooks/index.ts diff --git a/lib/hooks/block-image-read.cjs b/lib/hooks/block-image-read.cjs new file mode 100644 index 00000000..c4dceecd --- /dev/null +++ b/lib/hooks/block-image-read.cjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * CCS Image Read Blocking Hook + * + * Blocks Claude's Read tool from reading image files to prevent context overflow. + * Each image can consume 100K+ tokens, causing immediate context exhaustion. + * + * This is a PreToolUse hook that runs BEFORE the tool is executed. + * + * Usage: + * Configured in ~/.claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "Read", + * "hooks": [{ + * "type": "command", + * "command": "node ~/.ccs/hooks/block-image-read.cjs", + * "timeout": 5 + * }] + * }] + * } + * } + * + * Environment Variables: + * CCS_BLOCK_IMAGE_READ=1 - Enable blocking (required) + * CCS_BLOCK_IMAGE_READ=0 - Disable blocking (allow all reads) + * CCS_DEBUG=1 - Enable debug output + * + * Exit codes: + * 0 - Allow tool (pass-through) + * 2 - Block tool (deny with message) + * + * @module hooks/block-image-read + */ + +// Image file extensions to block +const IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|webp|gif|bmp|tiff|tif|ico|svg|heic|heif|avif)$/i; + +// 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); +}); + +/** + * Check if blocking is enabled via environment variable + */ +function isBlockingEnabled() { + // Must be explicitly enabled + return process.env.CCS_BLOCK_IMAGE_READ === '1'; +} + +/** + * Main hook processing logic + */ +function processHook() { + try { + // Skip if blocking not enabled + if (!isBlockingEnabled()) { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Image read blocking disabled (CCS_BLOCK_IMAGE_READ != 1)'); + } + 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 (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Read intercepted: ${filePath}`); + } + + // Check if file is an image + if (IMAGE_EXTENSIONS.test(filePath)) { + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Blocking image read: ${filePath}`); + } + outputBlock(filePath); + return; + } + + // Allow non-image files + process.exit(0); + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Parse error:', err.message); + } + // Don't block on parse errors + process.exit(0); + } +} + +/** + * Output block response and exit + */ +function outputBlock(filePath) { + // Extract just the filename for cleaner display + const fileName = filePath.split(/[/\\]/).pop() || filePath; + + const message = [ + '[Image Read Blocked - Context Protection]', + '', + `File: ${fileName}`, + `Path: ${filePath}`, + '', + 'Image files consume 100K+ tokens each and will exhaust context.', + '', + 'The image was generated successfully. To view it:', + ' - Open the file path above in your image viewer', + ' - Use your file manager to navigate to the location', + ' - On macOS: open "' + filePath + '"', + ' - On Linux: xdg-open "' + filePath + '"', + ' - On Windows: start "" "' + filePath + '"', + '', + 'If you need to analyze the image, use the ai-multimodal skill', + 'which processes images via Gemini API without loading into context.', + ].join('\n'); + + const output = { + decision: 'block', + reason: 'Image file blocked to prevent context overflow', + // User-facing message (shows in CLI output) + systemMessage: `[Image Read Blocked] ${fileName} - Open file directly to view.`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + // Claude reads this - explains what happened and alternatives + permissionDecisionReason: message, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} diff --git a/src/ccs.ts b/src/ccs.ts index 84a65178..6cf8140c 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -13,6 +13,7 @@ import { ensureProfileHooks, } from './utils/websearch-manager'; import { getGlobalEnvConfig } from './config/unified-config-loader'; +import { getImageReadBlockHookEnv } from './utils/hooks/image-read-block-hook-env'; import { fail, info } from './utils/ui'; // Import centralized error handling @@ -164,10 +165,12 @@ async function execClaudeWithProxy( const isWindows = process.platform === 'win32'; const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli); const webSearchEnv = getWebSearchHookEnv(); + const imageReadBlockEnv = getImageReadBlockHookEnv(); const env = { ...process.env, ...envVars, ...webSearchEnv, + ...imageReadBlockEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; @@ -612,6 +615,7 @@ async function main(): Promise { // Use --settings flag (backward compatible) const expandedSettingsPath = getSettingsPath(profileInfo.name); const webSearchEnv = getWebSearchHookEnv(); + const imageReadBlockEnv = getImageReadBlockHookEnv(); // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; @@ -633,6 +637,7 @@ async function main(): Promise { ...globalEnv, ...settingsEnv, // Explicitly inject all settings env vars ...webSearchEnv, + ...imageReadBlockEnv, CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider }; execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars); diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 0d824da4..ed98d8d8 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -37,6 +37,7 @@ import { DEFAULT_BACKEND } from './platform-detector'; import { configureProviderModel, getCurrentModel } from './model-config'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; +import { getImageReadBlockHookEnv } from '../utils/hooks/image-read-block-hook-env'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { CodexReasoningProxy } from './codex-reasoning-proxy'; import { ToolSanitizationProxy } from './tool-sanitization-proxy'; @@ -944,10 +945,12 @@ export async function execClaudeWithCLIProxy( ANTHROPIC_BASE_URL: finalBaseUrl, }; const webSearchEnv = getWebSearchHookEnv(); + const imageReadBlockEnv = getImageReadBlockHookEnv(); const env = { ...process.env, ...effectiveEnvVars, ...webSearchEnv, + ...imageReadBlockEnv, CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider }; diff --git a/src/utils/hooks/image-read-block-hook-env.ts b/src/utils/hooks/image-read-block-hook-env.ts new file mode 100644 index 00000000..ad2f3333 --- /dev/null +++ b/src/utils/hooks/image-read-block-hook-env.ts @@ -0,0 +1,50 @@ +/** + * Image Read Block Hook Environment Variables + * + * Provides environment variables for image read blocking hook configuration. + * Prevents context overflow when skills generate images and agent tries to read them. + * + * @module utils/hooks/image-read-block-hook-env + */ + +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + +/** + * Configuration for image read blocking. + */ +export interface ImageReadBlockConfig { + /** Whether blocking is enabled */ + enabled: boolean; +} + +/** + * Get image read block configuration from unified config. + * Defaults to disabled (opt-in feature). + */ +export function getImageReadBlockConfig(): ImageReadBlockConfig { + const config = loadOrCreateUnifiedConfig(); + // Access hooks config via type assertion since it's a new field not yet in UnifiedConfig type + const hooksConfig = ( + config as unknown as { hooks?: { block_image_read?: { enabled?: boolean } } } + ).hooks; + return { + // Default to false - must be explicitly enabled + enabled: hooksConfig?.block_image_read?.enabled ?? false, + }; +} + +/** + * Get environment variables for image read block hook configuration. + * + * @returns Record of environment variables to set before spawning Claude + */ +export function getImageReadBlockHookEnv(): Record { + const config = getImageReadBlockConfig(); + const env: Record = {}; + + if (config.enabled) { + env.CCS_BLOCK_IMAGE_READ = '1'; + } + + return env; +} diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts new file mode 100644 index 00000000..16449791 --- /dev/null +++ b/src/utils/hooks/index.ts @@ -0,0 +1,9 @@ +/** + * Hooks Utilities Index + * + * Centralized exports for all hook-related utilities. + * + * @module utils/hooks + */ + +export { getImageReadBlockHookEnv, getImageReadBlockConfig } from './image-read-block-hook-env'; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 56d1223b..ffea9d3c 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -7,6 +7,7 @@ import { spawn, ChildProcess } from 'child_process'; import { ErrorManager } from './error-manager'; import { getWebSearchHookEnv } from './websearch-manager'; +import { getImageReadBlockHookEnv } from './hooks/image-read-block-hook-env'; /** * Escape arguments for shell execution (Windows compatibility) @@ -28,11 +29,12 @@ export function execClaude( // Get WebSearch hook config env vars const webSearchEnv = getWebSearchHookEnv(); + const imageReadBlockEnv = getImageReadBlockHookEnv(); // Prepare environment (merge with process.env if envVars provided) const env = envVars - ? { ...process.env, ...envVars, ...webSearchEnv } - : { ...process.env, ...webSearchEnv }; + ? { ...process.env, ...envVars, ...webSearchEnv, ...imageReadBlockEnv } + : { ...process.env, ...webSearchEnv, ...imageReadBlockEnv }; let child: ChildProcess; if (needsShell) { From 9f3edc5dafb3aeb72f3d1cf2da80ccbda1690e48 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 19:00:46 -0500 Subject: [PATCH 02/13] fix(hooks): enable image-read blocking by default for third-party profiles Match WebSearch hook pattern: - ENABLED by default for settings/cliproxy profiles - DISABLED for native Claude accounts (account/default) - User can override via config: hooks.block_image_read.enabled: false This ensures CCS CLI users get context protection out-of-the-box while native Claude subscription users are unaffected. --- lib/hooks/block-image-read.cjs | 46 ++++++++++++++------ src/utils/hooks/image-read-block-hook-env.ts | 18 ++++++-- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/lib/hooks/block-image-read.cjs b/lib/hooks/block-image-read.cjs index c4dceecd..78d87e27 100644 --- a/lib/hooks/block-image-read.cjs +++ b/lib/hooks/block-image-read.cjs @@ -7,6 +7,11 @@ * * This is a PreToolUse hook that runs BEFORE the tool is executed. * + * Behavior (matches WebSearch pattern): + * - ENABLED by default for third-party profiles (settings, cliproxy) + * - DISABLED for native Claude accounts (account, default profiles) + * - User can override via config: hooks.block_image_read.enabled: false + * * Usage: * Configured in ~/.claude/settings.json: * { @@ -22,10 +27,11 @@ * } * } * - * Environment Variables: - * CCS_BLOCK_IMAGE_READ=1 - Enable blocking (required) - * CCS_BLOCK_IMAGE_READ=0 - Disable blocking (allow all reads) - * CCS_DEBUG=1 - Enable debug output + * Environment Variables (set by CCS): + * CCS_BLOCK_IMAGE_READ=1 - Enable blocking (default for third-party) + * CCS_BLOCK_IMAGE_READ=0 - Disable blocking + * CCS_PROFILE_TYPE - Profile type (account, default, settings, cliproxy) + * CCS_DEBUG=1 - Enable debug output * * Exit codes: * 0 - Allow tool (pass-through) @@ -53,11 +59,28 @@ process.stdin.on('error', () => { }); /** - * Check if blocking is enabled via environment variable + * Check if hook should skip (for native Claude accounts). + * Matches WebSearch hook pattern. */ -function isBlockingEnabled() { - // Must be explicitly enabled - return process.env.CCS_BLOCK_IMAGE_READ === '1'; +function shouldSkipHook() { + // Account/default profiles use native Claude - don't block + const profileType = process.env.CCS_PROFILE_TYPE; + if (profileType === 'account' || profileType === 'default') { + if (process.env.CCS_DEBUG) { + console.error(`[CCS Hook] Skipping image block for profile type: ${profileType}`); + } + return true; + } + + // Explicit disable via config + if (process.env.CCS_BLOCK_IMAGE_READ === '0') { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Image read blocking disabled by config'); + } + return true; + } + + return false; } /** @@ -65,11 +88,8 @@ function isBlockingEnabled() { */ function processHook() { try { - // Skip if blocking not enabled - if (!isBlockingEnabled()) { - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Image read blocking disabled (CCS_BLOCK_IMAGE_READ != 1)'); - } + // Skip for native accounts or explicit disable + if (shouldSkipHook()) { process.exit(0); } diff --git a/src/utils/hooks/image-read-block-hook-env.ts b/src/utils/hooks/image-read-block-hook-env.ts index ad2f3333..b2cc91fc 100644 --- a/src/utils/hooks/image-read-block-hook-env.ts +++ b/src/utils/hooks/image-read-block-hook-env.ts @@ -4,6 +4,9 @@ * Provides environment variables for image read blocking hook configuration. * Prevents context overflow when skills generate images and agent tries to read them. * + * Enabled by default for third-party profiles (settings, cliproxy). + * Disabled for native Claude accounts where context is managed server-side. + * * @module utils/hooks/image-read-block-hook-env */ @@ -13,13 +16,13 @@ import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; * Configuration for image read blocking. */ export interface ImageReadBlockConfig { - /** Whether blocking is enabled */ + /** Whether blocking is enabled (default: true) */ enabled: boolean; } /** * Get image read block configuration from unified config. - * Defaults to disabled (opt-in feature). + * Defaults to ENABLED (opt-out feature) - matches WebSearch pattern. */ export function getImageReadBlockConfig(): ImageReadBlockConfig { const config = loadOrCreateUnifiedConfig(); @@ -28,14 +31,18 @@ export function getImageReadBlockConfig(): ImageReadBlockConfig { config as unknown as { hooks?: { block_image_read?: { enabled?: boolean } } } ).hooks; return { - // Default to false - must be explicitly enabled - enabled: hooksConfig?.block_image_read?.enabled ?? false, + // Default to TRUE - enabled by default, user can opt-out + enabled: hooksConfig?.block_image_read?.enabled ?? true, }; } /** * Get environment variables for image read block hook configuration. * + * Like WebSearch, this respects CCS_PROFILE_TYPE: + * - 'account' or 'default' profiles: Skip blocking (native Claude) + * - 'settings' or 'cliproxy' profiles: Apply blocking + * * @returns Record of environment variables to set before spawning Claude */ export function getImageReadBlockHookEnv(): Record { @@ -44,6 +51,9 @@ export function getImageReadBlockHookEnv(): Record { if (config.enabled) { env.CCS_BLOCK_IMAGE_READ = '1'; + } else { + // Explicit disable signal + env.CCS_BLOCK_IMAGE_READ = '0'; } return env; From d5f2acaa6e9ee5d12a6035c2da1f975551b6a989 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 20:34:05 -0500 Subject: [PATCH 03/13] feat(hooks): add image/PDF analysis via CLIProxy transformer Intercept Read tool calls for image/PDF files and route through CLIProxy with gemini-2.5-flash for vision analysis. Returns text descriptions instead of blocking, enabling Claude to "see" images via proxy. Key changes: - Add image-analyzer-transformer.cjs hook script - Add ImageAnalysisConfig type and loader - Add hook installer and profile injector - Add prompt templates for analysis customization - Add e2e test suite (excluded from normal CI runs) - Configure test:e2e script for manual testing Environment variables: - CCS_IMAGE_ANALYSIS_ENABLED: Enable/disable (default: 1) - CCS_IMAGE_ANALYSIS_MODEL: Vision model (default: gemini-2.5-flash) - CCS_IMAGE_ANALYSIS_TIMEOUT: Timeout in seconds (default: 60) - CCS_CLIPROXY_API_KEY: API key for CLIProxy auth - CCS_CLIPROXY_PORT: CLIProxy port (default: 8317) Closes #426 --- bunfig.toml | 2 + lib/hooks/image-analyzer-transformer.cjs | 399 ++++++++++ lib/prompts/image-analysis-default.txt | 12 + lib/prompts/image-analysis-document.txt | 13 + lib/prompts/image-analysis-screenshot.txt | 13 + package.json | 3 +- src/config/unified-config-loader.ts | 24 + src/config/unified-config-types.ts | 29 + .../hooks/get-image-analysis-hook-env.ts | 33 + .../image-analyzer-hook-configuration.ts | 55 ++ .../hooks/image-analyzer-hook-installer.ts | 137 ++++ .../image-analyzer-profile-hook-injector.ts | 253 ++++++ src/utils/hooks/index.ts | 9 + src/utils/image-analysis/hook-installer.ts | 206 +++++ src/utils/image-analysis/index.ts | 15 + tests/e2e/image-analyzer-hook.e2e.test.ts | 751 ++++++++++++++++++ 16 files changed, 1953 insertions(+), 1 deletion(-) create mode 100755 lib/hooks/image-analyzer-transformer.cjs create mode 100644 lib/prompts/image-analysis-default.txt create mode 100644 lib/prompts/image-analysis-document.txt create mode 100644 lib/prompts/image-analysis-screenshot.txt create mode 100644 src/utils/hooks/get-image-analysis-hook-env.ts create mode 100644 src/utils/hooks/image-analyzer-hook-configuration.ts create mode 100644 src/utils/hooks/image-analyzer-hook-installer.ts create mode 100644 src/utils/hooks/image-analyzer-profile-hook-injector.ts create mode 100644 src/utils/image-analysis/hook-installer.ts create mode 100644 src/utils/image-analysis/index.ts create mode 100644 tests/e2e/image-analyzer-hook.e2e.test.ts 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'); + }); + }); +}); From 70caaa00a090fe6a1cfcff3ff47bcb355427ff42 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 20:58:05 -0500 Subject: [PATCH 04/13] fix(hooks): improve image analysis output format Match websearch transformer format: - Header: [Image Analysis via CLIProxy] - Metadata: File name, size in KB, model used - Separator lines for better readability - Footer instruction for Claude --- lib/hooks/image-analyzer-transformer.cjs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index ab7d831c..daa10782 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -209,29 +209,35 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { } /** - * Format analysis description for Claude + * Format analysis description for Claude (matches websearch format) */ -function formatDescription(filePath, description, model) { +function formatDescription(filePath, description, model, fileSize) { + const sizeKB = fileSize ? (fileSize / 1024).toFixed(1) : '?'; return [ - `## Image Analysis: ${path.basename(filePath)}`, + `[Image Analysis via CLIProxy]`, + '', + `File: ${path.basename(filePath)} (${sizeKB} KB)`, + `Model: ${model}`, + '', + '---', '', description, '', '---', - `*Analyzed via CLIProxy (${model})*`, + '*Use this description to understand the image content.*', ].join('\n'); } /** * Output success response and exit */ -function outputSuccess(filePath, description, model) { - const formattedDescription = formatDescription(filePath, description, model); +function outputSuccess(filePath, description, model, fileSize) { + const formattedDescription = formatDescription(filePath, description, model, fileSize); const output = { decision: 'block', reason: `Image analyzed: ${path.basename(filePath)}`, - systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy`, + systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy (${model})`, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', @@ -378,7 +384,7 @@ async function processHook() { const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); // Output success - outputSuccess(filePath, description, model); + outputSuccess(filePath, description, model, stats.size); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Error:', err.message); From 9662490a74297fe1c992e30beb212222abe77799 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 21:03:05 -0500 Subject: [PATCH 05/13] fix(hooks): add defensive validation for env var generation --- src/utils/hooks/get-image-analysis-hook-env.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 7cb88557..fc0e2102 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -25,8 +25,8 @@ export function getImageAnalysisHookEnv(profileName?: string): Record Date: Tue, 3 Feb 2026 21:32:19 -0500 Subject: [PATCH 06/13] refactor(hooks): use provider_models mapping for image analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes ImageAnalysisConfig from providers array to provider_models mapping for granular vision model control per CLIProxy provider. Breaking change: config.yaml image_analysis section now uses provider_models instead of providers/model fields. Provider-to-model mappings: - agy → gemini-2.5-flash - gemini → gemini-2.5-flash - codex → gpt-5.1-codex-mini - kiro → kiro-claude-haiku-4-5 - ghcp → claude-haiku-4.5 - claude → claude-haiku-4-5-20251001 Hook checks CCS_CURRENT_PROVIDER against provider_models and skips if no vision model configured for that provider. --- lib/hooks/image-analyzer-transformer.cjs | 45 +++++++++++++++--- src/cliproxy/cliproxy-executor.ts | 3 ++ src/config/unified-config-loader.ts | 8 ++-- src/config/unified-config-types.ts | 18 +++++--- .../hooks/get-image-analysis-hook-env.ts | 23 +++++++--- tests/e2e/image-analyzer-hook.e2e.test.ts | 46 +++++++++++++++++-- 6 files changed, 113 insertions(+), 30 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index daa10782..bea62c96 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -6,12 +6,13 @@ * 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 + * CCS_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely + * CCS_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1) + * CCS_IMAGE_ANALYSIS_PROVIDER_MODELS - Provider:model mapping (e.g., agy:gemini-2.5-flash,gemini:gemini-2.5-flash) + * CCS_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex) + * 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) @@ -65,6 +66,31 @@ Be comprehensive - this description replaces direct visual access.`; // HELPER FUNCTIONS // ============================================================================ +/** + * Parse provider_models env var to object + * Format: provider:model,provider:model + */ +function parseProviderModels(envValue) { + if (!envValue) return {}; + const result = {}; + envValue.split(',').forEach((pair) => { + const [provider, model] = pair.split(':'); + if (provider && model) { + result[provider.trim()] = model.trim(); + } + }); + return result; +} + +/** + * Get model for current provider from provider_models mapping + */ +function getModelForProvider() { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + return providerModels[currentProvider] || DEFAULT_MODEL; +} + /** * Check if file is an analyzable image or PDF */ @@ -295,6 +321,11 @@ function shouldSkipHook() { const profileType = process.env.CCS_PROFILE_TYPE; if (profileType === 'account' || profileType === 'default') return true; + // Check if current provider has a vision model configured + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + if (!providerModels[currentProvider]) return true; + return false; } @@ -368,7 +399,7 @@ async function processHook() { process.exit(0); } - const model = process.env.CCS_IMAGE_ANALYSIS_MODEL || DEFAULT_MODEL; + const model = getModelForProvider(); const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); const timeoutMs = timeout * 1000; diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index ed98d8d8..626e4519 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -38,6 +38,7 @@ import { configureProviderModel, getCurrentModel } from './model-config'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; import { getImageReadBlockHookEnv } from '../utils/hooks/image-read-block-hook-env'; +import { getImageAnalysisHookEnv } from '../utils/hooks/get-image-analysis-hook-env'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { CodexReasoningProxy } from './codex-reasoning-proxy'; import { ToolSanitizationProxy } from './tool-sanitization-proxy'; @@ -946,11 +947,13 @@ export async function execClaudeWithCLIProxy( }; const webSearchEnv = getWebSearchHookEnv(); const imageReadBlockEnv = getImageReadBlockHookEnv(); + const imageAnalysisEnv = getImageAnalysisHookEnv(provider); const env = { ...process.env, ...effectiveEnvVars, ...webSearchEnv, ...imageReadBlockEnv, + ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider }; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 1ad14459..86b3cdc0 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -298,9 +298,9 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { // 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, + provider_models: + partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, }, }; } @@ -740,8 +740,8 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig { 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, + provider_models: + config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1eab0270..c654d9ec 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -523,14 +523,12 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { * Routes image/PDF files through CLIProxy for vision analysis. */ export interface ImageAnalysisConfig { - /** Enable image analysis via CLIProxy (default: true for agy/gemini) */ + /** Enable image analysis via CLIProxy (default: true) */ 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[]; + /** Provider-to-model mapping for vision analysis */ + provider_models: Record; } /** @@ -539,9 +537,15 @@ export interface ImageAnalysisConfig { */ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { enabled: true, - model: 'gemini-2.5-flash', timeout: 60, - providers: ['agy', 'gemini'], + provider_models: { + agy: 'gemini-2.5-flash', + gemini: 'gemini-2.5-flash', + codex: 'gpt-5.1-codex-mini', + kiro: 'kiro-claude-haiku-4-5', + ghcp: 'claude-haiku-4.5', + claude: 'claude-haiku-4-5-20251001', + }, }; /** diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index fc0e2102..0dd9aa14 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -9,25 +9,34 @@ import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +/** + * Serialize provider_models map to env var format: provider:model,provider:model + */ +function serializeProviderModels(providerModels: Record): string { + return Object.entries(providerModels) + .map(([provider, model]) => `${provider}:${model}`) + .join(','); +} + /** * 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) + * @param provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex') * @returns Environment variables for image analysis hook */ -export function getImageAnalysisHookEnv(profileName?: string): Record { +export function getImageAnalysisHookEnv(provider?: 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; + // Check if current provider has a vision model configured + const hasVisionModel = provider && config.provider_models[provider]; + const skipImageAnalysis = !config.enabled || !hasVisionModel; return { CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0', - CCS_IMAGE_ANALYSIS_MODEL: config.model || 'gemini-2.5-flash', CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60), - CCS_IMAGE_ANALYSIS_PROVIDERS: config.providers.join(','), + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models), + CCS_CURRENT_PROVIDER: provider || '', CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', }; } diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index 630f7ae4..b3da61dd 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -31,6 +31,10 @@ 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'; +// Default provider models for testing (matches DEFAULT_IMAGE_ANALYSIS_CONFIG) +const DEFAULT_PROVIDER_MODELS = 'agy:gemini-2.5-flash,gemini:gemini-2.5-flash,codex:gpt-5.1-codex-mini,kiro:kiro-claude-haiku-4-5,ghcp:claude-haiku-4.5,claude:claude-haiku-4-5-20251001'; +const DEFAULT_PROVIDER = 'agy'; // Default test provider + // ============================================================================ // MOCK SERVER // ============================================================================ @@ -143,6 +147,9 @@ function invokeHook( ...process.env, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, CCS_CLIPROXY_PORT: String(MOCK_PORT), + // Default provider config for tests (can be overridden) + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS, + CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER, ...env, }, timeout: 10000, // 10 second timeout per test @@ -409,7 +416,13 @@ describe('Image Analyzer Hook', () => { input: 'not valid json', encoding: 'utf8', timeout: 5000, - env: { ...process.env, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY }, + env: { + ...process.env, + CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, + CCS_CLIPROXY_PORT: String(MOCK_PORT), + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS, + CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER, + }, }); // Should exit with error (code 2) @@ -546,7 +559,8 @@ describe('Image Analyzer Hook', () => { { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', - CCS_IMAGE_ANALYSIS_MODEL: 'gemini-2.5-flash', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', } ); @@ -646,7 +660,7 @@ describe('Image Analyzer Hook', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error'); }); - it('should use default model when CCS_IMAGE_ANALYSIS_MODEL is not set', () => { + it('should use model from provider_models mapping', () => { resetMockState(); invokeHook( @@ -654,11 +668,33 @@ describe('Image Analyzer Hook', () => { tool_name: 'Read', tool_input: { file_path: testPngPath }, }, - { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_CURRENT_PROVIDER: 'codex', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash', + } ); const body = lastRequest?.body as { model: string }; - expect(body.model).toBe('gemini-2.5-flash'); // Default model + expect(body.model).toBe('gpt-5.1-codex-mini'); // Model from provider_models + }); + + it('should skip when provider is not in provider_models', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_CURRENT_PROVIDER: 'unknown-provider', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + } + ); + + expect(result.code).toBe(0); // Skip - provider not in map }); }); From 0e7b9c91900c319a58ec698306be01bb1f665432 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 21:44:44 -0500 Subject: [PATCH 07/13] fix(hooks): add edge case validation in image analyzer - Empty model validation: check `model.trim()` before using - 10MB boundary: use `>=` instead of `>` for consistent messaging - Timeout clamping: ensure timeout is between 1-600 seconds - Response stream error: add error handler for network failures - Empty response validation: check before JSON.parse to prevent crashes --- lib/hooks/image-analyzer-transformer.cjs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index bea62c96..bd8e7387 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -75,7 +75,7 @@ function parseProviderModels(envValue) { const result = {}; envValue.split(',').forEach((pair) => { const [provider, model] = pair.split(':'); - if (provider && model) { + if (provider && model && model.trim()) { result[provider.trim()] = model.trim(); } }); @@ -200,12 +200,21 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { data += chunk; }); + res.on('error', (err) => { + reject(err); + }); + res.on('end', () => { if (res.statusCode !== 200) { reject(new Error(`CLIProxy returned status ${res.statusCode}: ${data}`)); return; } + if (!data || !data.trim()) { + reject(new Error('Empty response from CLIProxy')); + return; + } + try { const response = JSON.parse(data); const text = response.content?.[0]?.text; @@ -384,8 +393,8 @@ async function processHook() { // 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)`); + 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; } @@ -401,7 +410,7 @@ async function processHook() { const model = getModelForProvider(); const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); - const timeoutMs = timeout * 1000; + const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000; if (process.env.CCS_DEBUG) { console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`); From 2b0717ed53011dcb67cc03ad09a00cfabb682f1e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 22:14:52 -0500 Subject: [PATCH 08/13] feat(hooks): add UX improvements for image analysis hook Add comprehensive UX enhancements for the image analysis CLIProxy hook: - Add `ccs config image-analysis` CLI command for managing settings - Enable/disable toggle - Timeout configuration (10-600s) - Per-provider model configuration - Status display with provider models - Add specialized error handlers with actionable messages - File too large (with compression hints) - CLIProxy unavailable (with start instructions) - Auth failure (with re-auth commands) - Timeout (with increase timeout hint) - Rate limit (with retry guidance) - API error (with response body parsing) - Add comprehensive debug output (CCS_DEBUG=1) - Provider name and model - File size and media type - Timeout and endpoint - Skip reasons with context - Add help text updates - `ccs --help` includes Image Analysis section - `ccs config --help` lists image-analysis subcommand - Add doctor integration - Validates image_analysis config - Checks enabled status, providers, timeout - Warns if CLIProxy not running - Add unit tests for new config command --- lib/hooks/image-analyzer-transformer.cjs | 388 +++++++++++++++--- src/commands/config-command.ts | 15 + src/commands/config-image-analysis-command.ts | 203 +++++++++ src/commands/help-command.ts | 15 + src/commands/index.ts | 1 + src/config/unified-config-loader.ts | 24 ++ src/management/checks/image-analysis-check.ts | 159 +++++++ src/management/checks/index.ts | 3 + src/management/doctor.ts | 6 + .../config-image-analysis-command.test.ts | 187 +++++++++ 10 files changed, 953 insertions(+), 48 deletions(-) create mode 100644 src/commands/config-image-analysis-command.ts create mode 100644 src/management/checks/image-analysis-check.ts create mode 100644 tests/unit/commands/config-image-analysis-command.test.ts diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index bd8e7387..793a08e9 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -49,6 +49,21 @@ 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'; +// ============================================================================ +// ERROR CODES (for categorization) +// ============================================================================ + +const ERROR_CODES = { + FILE_TOO_LARGE: 'FILE_TOO_LARGE', + CLIPROXY_UNAVAILABLE: 'CLIPROXY_UNAVAILABLE', + AUTH_FAILED: 'AUTH_FAILED', + TIMEOUT: 'TIMEOUT', + RATE_LIMIT: 'RATE_LIMIT', + API_ERROR: 'API_ERROR', + PARSE_ERROR: 'PARSE_ERROR', + UNKNOWN: 'UNKNOWN', +}; + // Default analysis prompt const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description. @@ -66,6 +81,55 @@ Be comprehensive - this description replaces direct visual access.`; // HELPER FUNCTIONS // ============================================================================ +/** + * Output debug information to stderr + * Only outputs when CCS_DEBUG=1 + */ +function debugLog(message, data = {}) { + if (!process.env.CCS_DEBUG) return; + + const lines = [`[CCS Hook] ${message}`]; + + for (const [key, value] of Object.entries(data)) { + if (value !== undefined && value !== null) { + lines.push(` ${key}: ${value}`); + } + } + + console.error(lines.join('\n')); +} + +/** + * Get detailed debug context + */ +function getDebugContext(filePath, stats) { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown'; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const model = providerModels[currentProvider] || DEFAULT_MODEL; + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + const isDefaultModel = !providerModels[currentProvider]; + + return { + file: path.basename(filePath), + size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown', + provider: currentProvider, + model: model, + config: isDefaultModel ? 'default' : 'user-configured', + timeout: `${timeout}s`, + endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`, + }; +} + +/** + * Get current provider/model context for error messages + */ +function getProviderContext() { + const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown'; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const model = providerModels[provider] || DEFAULT_MODEL; + return { provider, model }; +} + /** * Parse provider_models env var to object * Format: provider:model,provider:model @@ -205,8 +269,20 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { }); res.on('end', () => { + // Categorize by status code + if (res.statusCode === 401 || res.statusCode === 403) { + reject(new Error(`AUTH_ERROR:${res.statusCode}`)); + return; + } + + if (res.statusCode === 429) { + const retryAfter = res.headers['retry-after']; + reject(new Error(`RATE_LIMIT:${retryAfter || ''}`)); + return; + } + if (res.statusCode !== 200) { - reject(new Error(`CLIProxy returned status ${res.statusCode}: ${data}`)); + reject(new Error(`API_ERROR:${res.statusCode}:${data}`)); return; } @@ -235,7 +311,7 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { req.on('error', (err) => reject(err)); req.on('timeout', () => { req.destroy(); - reject(new Error('Request timed out')); + reject(new Error('TIMEOUT')); }); req.write(requestBody); @@ -263,10 +339,213 @@ function formatDescription(filePath, description, model, fileSize) { ].join('\n'); } +// ============================================================================ +// SPECIALIZED ERROR HANDLERS +// ============================================================================ + +/** + * Format error output for Claude hook + */ +function formatErrorOutput(filePath, errorCode, message, troubleshooting) { + const { provider, model } = getProviderContext(); + + const lines = [ + `[Image Analysis - Error]`, + '', + `File: ${path.basename(filePath)}`, + `Provider: ${provider} | Model: ${model}`, + '', + `Error: ${message}`, + ]; + + if (troubleshooting && troubleshooting.length > 0) { + lines.push(''); + lines.push('Troubleshooting:'); + troubleshooting.forEach((step, i) => { + lines.push(` ${i + 1}. ${step}`); + }); + } + + lines.push(''); + lines.push('For help: ccs config image-analysis --help'); + + return { + decision: 'block', + reason: `Image analysis failed: ${errorCode}`, + systemMessage: `[Image Analysis] Failed: ${message}`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: lines.join('\n'), + }, + }; +} + +/** + * File too large error + */ +function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.FILE_TOO_LARGE, + `File too large (${actualSizeMB.toFixed(2)}MB > ${maxSizeMB}MB limit)`, + [ + 'Reduce image resolution or use compression', + 'For screenshots: use PNG optimizer (pngquant, optipng)', + 'For photos: resize to max 2048px width', + `Current limit: ${maxSizeMB}MB per file`, + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * CLIProxy unavailable error + */ +function outputCliProxyUnavailableError(filePath, endpoint) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.CLIPROXY_UNAVAILABLE, + `CLIProxy not available at ${endpoint}`, + [ + 'CLIProxy service may not be running', + 'Start with: ccs config (opens dashboard, starts CLIProxy)', + 'Or manually: ccs cliproxy start', + `Verify: curl ${endpoint}`, + 'Check status: ccs doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Authentication error + */ +function outputAuthError(filePath, statusCode) { + const { provider } = getProviderContext(); + const output = formatErrorOutput( + filePath, + ERROR_CODES.AUTH_FAILED, + `Authentication failed (HTTP ${statusCode})`, + [ + `Re-authenticate: ccs ${provider} --auth`, + `Check accounts: ccs ${provider} --accounts`, + 'Verify OAuth token is valid', + 'Check: ccs doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Timeout error + */ +function outputTimeoutError(filePath, timeoutSec) { + const { model } = getProviderContext(); + const output = formatErrorOutput( + filePath, + ERROR_CODES.TIMEOUT, + `Request timed out after ${timeoutSec}s`, + [ + 'Large files or complex images take longer', + `Increase timeout: ccs config image-analysis --timeout ${timeoutSec * 2}`, + 'Or via env: CCS_IMAGE_ANALYSIS_TIMEOUT=120', + `Current model (${model}) may be slow - try a faster variant`, + 'Check CLIProxy health: curl http://127.0.0.1:8317', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Rate limit error + */ +function outputRateLimitError(filePath, retryAfterSec) { + const { provider } = getProviderContext(); + const retryHint = retryAfterSec ? `Retry after ${retryAfterSec}s` : 'Wait a moment and retry'; + const output = formatErrorOutput( + filePath, + ERROR_CODES.RATE_LIMIT, + 'Rate limit exceeded', + [ + retryHint, + `Provider ${provider} has usage limits`, + 'Consider switching accounts: ccs ' + provider + ' --accounts', + 'Check quota: ccs cliproxy doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Generic API error + */ +function outputApiError(filePath, statusCode, responseBody) { + // Try to extract error message from response + let errorDetail = `HTTP ${statusCode}`; + try { + const parsed = JSON.parse(responseBody); + if (parsed.error?.message) { + errorDetail = parsed.error.message; + } else if (parsed.message) { + errorDetail = parsed.message; + } + } catch { + // Use raw body if not JSON (truncated) + if (responseBody && responseBody.length < 100) { + errorDetail = responseBody; + } + } + + const output = formatErrorOutput( + filePath, + ERROR_CODES.API_ERROR, + `API error: ${errorDetail}`, + [ + 'Check CLIProxy logs: ccs cleanup --show-logs', + 'Verify provider is authenticated: ccs doctor', + 'Try a different provider or model', + 'Report persistent issues: https://github.com/kaitranntt/ccs/issues', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Unknown/fallback error (replaces old outputError) + */ +function outputUnknownError(filePath, error) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.UNKNOWN, + error || 'Unknown error occurred', + [ + 'Check CLIProxy is running: curl http://127.0.0.1:8317', + 'Verify authentication: ccs doctor', + 'Check file is valid image/PDF', + 'Enable debug: CCS_DEBUG=1 ccs ', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + /** * Output success response and exit */ function outputSuccess(filePath, description, model, fileSize) { + debugLog('Returning analysis result', { + file: path.basename(filePath), + model: model, + descriptionLength: `${description.length} chars`, + }); + const formattedDescription = formatDescription(filePath, description, model, fileSize); const output = { @@ -285,55 +564,38 @@ function outputSuccess(filePath, description, model, fileSize) { } /** - * 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 + * Determine if hook should skip, with debug logging */ function shouldSkipHook() { // Explicit skip signal - if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') return true; + if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') { + debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP=1'); + return true; + } // Explicit disable - if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') return true; + if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') { + debugLog('Skipping: image analysis disabled (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; + if (profileType === 'account' || profileType === 'default') { + debugLog(`Skipping: profile type "${profileType}" uses native Read`); + return true; + } // Check if current provider has a vision model configured const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); - if (!providerModels[currentProvider]) return true; + + if (!providerModels[currentProvider]) { + debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, { + configured_providers: Object.keys(providerModels).join(', ') || 'none', + }); + return true; + } return false; } @@ -394,16 +656,17 @@ async function processHook() { // 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)`); + outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_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'); - } + debugLog('Skipping: CLIProxy not available', { + endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`, + action: 'passing through to native Read', + }); // Pass through to native Read process.exit(0); } @@ -412,17 +675,26 @@ async function processHook() { const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000; - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`); - } + // Get debug context before analysis + const debugContext = getDebugContext(filePath, stats); + debugLog('Starting image analysis', debugContext); // Encode file to base64 const base64Data = encodeFileToBase64(filePath); const mediaType = getMediaType(filePath); + debugLog('File encoded', { + mediaType: mediaType, + base64Length: `${(base64Data.length / 1024).toFixed(1)} KB`, + }); + // Analyze via CLIProxy const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + debugLog('Analysis complete', { + responseLength: `${description.length} chars`, + }); + // Output success outputSuccess(filePath, description, model, stats.size); } catch (err) { @@ -439,7 +711,27 @@ async function processHook() { // Ignore parse errors } - // Output error - outputError(filePath, err.message || 'Unknown error'); + // Categorize error by message pattern + const errMsg = err.message || ''; + + if (errMsg.startsWith('AUTH_ERROR:')) { + const statusCode = parseInt(errMsg.split(':')[1], 10); + outputAuthError(filePath, statusCode); + } else if (errMsg.startsWith('RATE_LIMIT:')) { + const retryAfter = errMsg.split(':')[1]; + outputRateLimitError(filePath, retryAfter ? parseInt(retryAfter, 10) : null); + } else if (errMsg.startsWith('API_ERROR:')) { + const parts = errMsg.split(':'); + const statusCode = parseInt(parts[1], 10); + const body = parts.slice(2).join(':'); + outputApiError(filePath, statusCode, body); + } else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) { + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + outputTimeoutError(filePath, timeout); + } else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) { + outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`); + } else { + outputUnknownError(filePath, errMsg); + } } } diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index e9132802..1db499e1 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -62,6 +62,12 @@ function showHelp(): void { console.log(' auth show Display current auth status'); console.log(' auth disable Disable authentication'); console.log(''); + console.log(' image-analysis Manage image analysis settings'); + console.log(' --enable Enable image analysis via CLIProxy'); + console.log(' --disable Disable image analysis'); + console.log(' --timeout Set analysis timeout (seconds)'); + console.log(' --set-model

Set model for provider'); + console.log(''); console.log('Options:'); console.log(' --port, -p PORT Specify server port (default: auto-detect)'); console.log(' --dev Development mode with Vite HMR'); @@ -72,6 +78,8 @@ function showHelp(): void { console.log(' ccs config --port 3000 Use specific port'); console.log(' ccs config --dev Development mode with hot reload'); console.log(' ccs config auth setup Configure dashboard login'); + console.log(' ccs config image-analysis Show image settings'); + console.log(' ccs config image-analysis --enable Enable feature'); console.log(''); } @@ -86,6 +94,13 @@ export async function handleConfigCommand(args: string[]): Promise { return; } + // Route image-analysis subcommand + if (args[0] === 'image-analysis') { + const { handleConfigImageAnalysisCommand } = await import('./config-image-analysis-command'); + await handleConfigImageAnalysisCommand(args.slice(1)); + return; + } + await initUI(); const options = parseArgs(args); diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts new file mode 100644 index 00000000..630f1ab9 --- /dev/null +++ b/src/commands/config-image-analysis-command.ts @@ -0,0 +1,203 @@ +/** + * Config Image Analysis Command Handler + * + * Manages image_analysis section of config.yaml via CLI. + * Usage: ccs config image-analysis [options] + */ + +import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui'; +import { + getImageAnalysisConfig, + updateUnifiedConfig, + loadOrCreateUnifiedConfig, +} from '../config/unified-config-loader'; +import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types'; + +interface ImageAnalysisCommandOptions { + enable?: boolean; + disable?: boolean; + timeout?: number; + setModel?: { provider: string; model: string }; + help?: boolean; +} + +function parseArgs(args: string[]): ImageAnalysisCommandOptions { + const options: ImageAnalysisCommandOptions = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--enable') { + options.enable = true; + } else if (arg === '--disable') { + options.disable = true; + } else if (arg === '--timeout' && args[i + 1]) { + const timeout = parseInt(args[++i], 10); + if (isNaN(timeout) || timeout < 10 || timeout > 600) { + console.error(fail('Timeout must be between 10 and 600 seconds')); + process.exit(1); + } + options.timeout = timeout; + } else if (arg === '--set-model' && args[i + 1] && args[i + 2]) { + options.setModel = { + provider: args[++i], + model: args[++i], + }; + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } + } + + return options; +} + +function showHelp(): void { + console.log(''); + console.log(header('ccs config image-analysis')); + console.log(''); + console.log(' Configure image analysis for CLIProxy providers.'); + console.log(' Images/PDFs are analyzed via vision models instead of direct Read.'); + console.log(''); + + console.log(subheader('Usage:')); + console.log(` ${color('ccs config image-analysis', 'command')} [options]`); + console.log(''); + + console.log(subheader('Options:')); + console.log(` ${color('--enable', 'command')} Enable image analysis`); + console.log(` ${color('--disable', 'command')} Disable image analysis`); + console.log(` ${color('--timeout ', 'command')} Set analysis timeout (10-600)`); + console.log(` ${color('--set-model

', 'command')} Set model for provider`); + console.log(` ${color('--help, -h', 'command')} Show this help`); + console.log(''); + + console.log(subheader('Provider Models:')); + console.log(` ${dim('Providers with vision support: agy, gemini, codex, kiro, ghcp, claude')}`); + console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`); + console.log(''); + + console.log(subheader('Examples:')); + console.log( + ` $ ${color('ccs config image-analysis', 'command')} ${dim('# Show status')}` + ); + console.log( + ` $ ${color('ccs config image-analysis --enable', 'command')} ${dim('# Enable feature')}` + ); + console.log( + ` $ ${color('ccs config image-analysis --timeout 120', 'command')} ${dim('# Set 2min timeout')}` + ); + console.log( + ` $ ${color('ccs config image-analysis --set-model agy gemini-2.5-pro', 'command')}` + ); + console.log(''); + + console.log(subheader('How it works:')); + console.log(` 1. When Claude's Read tool targets an image/PDF file`); + console.log(` 2. CCS hook intercepts and sends to CLIProxy vision API`); + console.log(` 3. Vision model analyzes and returns text description`); + console.log(` 4. Claude receives description instead of raw image data`); + console.log(''); + + console.log(subheader('Supported file types:')); + console.log(` ${dim('Images: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff')}`); + console.log(` ${dim('Documents: .pdf')}`); + console.log(''); +} + +function showStatus(forceReload = false): void { + // Force reload if config was just modified + const config = forceReload ? getImageAnalysisConfig() : getImageAnalysisConfig(); + + console.log(''); + console.log(header('Image Analysis Configuration')); + console.log(''); + + // Status + const statusText = config.enabled ? ok('Enabled') : warn('Disabled'); + console.log(` Status: ${statusText}`); + console.log(` Timeout: ${config.timeout}s`); + console.log(''); + + // Provider models + console.log(subheader('Provider Models:')); + const providers = Object.entries(config.provider_models); + if (providers.length === 0) { + console.log(` ${dim('No providers configured')}`); + } else { + for (const [provider, model] of providers) { + const isDefault = + DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models[ + provider as keyof typeof DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models + ] === model; + const suffix = isDefault ? dim(' (default)') : ''; + console.log(` ${color(provider.padEnd(10), 'command')} ${model}${suffix}`); + } + } + console.log(''); + + // Config location + console.log(subheader('Configuration:')); + console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`); + console.log(` Section: ${dim('image_analysis')}`); + console.log(''); + + // Troubleshooting hint if disabled + if (!config.enabled) { + console.log(info('To enable: ccs config image-analysis --enable')); + console.log(''); + } +} + +export async function handleConfigImageAnalysisCommand(args: string[]): Promise { + await initUI(); + + const options = parseArgs(args); + + if (options.help) { + showHelp(); + return; + } + + // Apply changes if any options provided + let hasChanges = false; + const config = loadOrCreateUnifiedConfig(); + const imageConfig = config.image_analysis ?? { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }; + + if (options.enable) { + imageConfig.enabled = true; + hasChanges = true; + } + + if (options.disable) { + imageConfig.enabled = false; + hasChanges = true; + } + + if (options.timeout !== undefined) { + imageConfig.timeout = options.timeout; + hasChanges = true; + } + + if (options.setModel) { + const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + if (!validProviders.includes(options.setModel.provider)) { + console.error(fail(`Invalid provider: ${options.setModel.provider}`)); + console.error(info(`Valid providers: ${validProviders.join(', ')}`)); + process.exit(1); + } + imageConfig.provider_models = { + ...imageConfig.provider_models, + [options.setModel.provider]: options.setModel.model, + }; + hasChanges = true; + } + + if (hasChanges) { + updateUnifiedConfig({ image_analysis: imageConfig }); + console.log(ok('Configuration updated')); + console.log(''); + } + + // Always show current status (reload if we made changes) + showStatus(hasChanges); +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index ae632fb9..6152ce76 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -242,6 +242,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config', 'Open web configuration dashboard'], ['ccs config auth setup', 'Configure dashboard login'], ['ccs config auth show', 'Show dashboard auth status'], + ['ccs config image-analysis', 'Show image analysis settings'], + ['ccs config image-analysis --enable', 'Enable image analysis'], ['ccs config --port 3000', 'Use specific port'], ['ccs persist ', 'Write profile env to ~/.claude/settings.json'], ['ccs persist --list-backups', 'List available settings.json backups'], @@ -306,6 +308,19 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['', 'before responding. Supported: agy, gemini (thinking models).'], ]); + // Image Analysis + printSubSection('Image Analysis (CLIProxy vision)', [ + ['ccs config image-analysis', 'Show current settings'], + ['ccs config image-analysis --enable', 'Enable for CLIProxy providers'], + ['ccs config image-analysis --disable', 'Disable (use native Read)'], + ['ccs config image-analysis --timeout 120', 'Set analysis timeout'], + ['ccs config image-analysis --set-model

', 'Set provider model'], + ['', ''], + ['Note:', 'When enabled, images/PDFs are analyzed via vision models'], + ['', 'instead of passing raw data to Claude. Works with CLIProxy'], + ['', 'providers (agy, gemini, codex, kiro, ghcp).'], + ]); + // CLI Proxy env vars printSubSection('CLI Proxy Environment Variables', [ ['CCS_PROXY_HOST', 'Remote proxy hostname'], diff --git a/src/commands/index.ts b/src/commands/index.ts index 007664e5..39e1651b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -6,6 +6,7 @@ export { handleApiCommand } from './api-command'; export { handleCleanupCommand } from './cleanup-command'; export { handleCliproxyCommand } from './cliproxy-command'; export { handleConfigCommand } from './config-command'; +export { handleConfigImageAnalysisCommand } from './config-image-analysis-command'; export { handleCopilotCommand } from './copilot-command'; export { handleDoctorCommand } from './doctor-command'; export { handleHelpCommand } from './help-command'; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 86b3cdc0..b6f741ec 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -529,6 +529,30 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Image analysis section + if (config.image_analysis) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Image Analysis: Vision-based analysis for images and PDFs'); + lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.'); + lines.push('#'); + lines.push('# When enabled: Image files trigger vision analysis instead of raw file read'); + lines.push('# Provider models: Vision model used for each CLIProxy provider'); + lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)'); + lines.push('#'); + lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf'); + lines.push('# Configure via: ccs config image-analysis'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump( + { image_analysis: config.image_analysis }, + { indent: 2, lineWidth: -1, quotingType: '"' } + ) + .trim() + ); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts new file mode 100644 index 00000000..fd0d0f3b --- /dev/null +++ b/src/management/checks/image-analysis-check.ts @@ -0,0 +1,159 @@ +/** + * Image Analysis Config Check + * + * Validates image_analysis configuration in config.yaml. + * Checks: enabled status, provider_models, timeout, CLIProxy availability. + */ + +import http from 'http'; +import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../../config/unified-config-types'; +import { ok, warn, dim } from '../../utils/ui'; +import type { HealthCheck } from './types'; + +/** + * Check CLIProxy availability (simple HTTP check) + */ +async function isCliProxyAvailable(): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: '127.0.0.1', + port: 8317, + path: '/', + method: 'GET', + timeout: 2000, + }, + (res) => { + resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 500); + } + ); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} + +/** + * Run image analysis configuration check + */ +export async function runImageAnalysisCheck(results: HealthCheck): Promise { + const config = getImageAnalysisConfig(); + + // Check 1: Feature status + if (!config.enabled) { + results.details['Image Analysis'] = { + status: 'OK', + info: 'Disabled (using native Read)', + }; + console.log(` ${dim('Status:')} Disabled`); + console.log(` ${dim('Tip:')} Enable with: ccs config image-analysis --enable`); + return; + } + + // Feature is enabled - run validation checks + console.log(` ${ok('Status:')} Enabled`); + + // Check 2: Provider models configured + const providers = Object.keys(config.provider_models); + if (providers.length === 0) { + results.details['Image Analysis'] = { + status: 'ERROR', + info: 'No providers configured', + }; + results.errors.push({ + name: 'Image Analysis', + message: 'No provider models configured for image analysis', + fix: 'ccs config image-analysis --set-model agy gemini-2.5-flash', + }); + console.log(` ${warn('Providers:')} None configured`); + return; + } + console.log(` ${ok('Providers:')} ${providers.join(', ')}`); + + // Check 3: Timeout validation + if (config.timeout < 10 || config.timeout > 600) { + results.details['Image Analysis'] = { + status: 'ERROR', + info: `Invalid timeout: ${config.timeout}s`, + }; + results.errors.push({ + name: 'Image Analysis', + message: `Timeout ${config.timeout}s out of range (10-600)`, + fix: 'ccs config image-analysis --timeout 60', + }); + console.log(` ${warn('Timeout:')} ${config.timeout}s (invalid, must be 10-600)`); + return; + } + console.log(` ${ok('Timeout:')} ${config.timeout}s`); + + // Check 4: CLIProxy availability (only if enabled) + const cliproxyAvailable = await isCliProxyAvailable(); + if (!cliproxyAvailable) { + results.details['Image Analysis'] = { + status: 'WARN', + info: `Enabled but CLIProxy not running`, + }; + results.warnings.push({ + name: 'Image Analysis', + message: 'CLIProxy not running - image analysis will fail', + fix: 'ccs config (starts CLIProxy)', + }); + console.log(` ${warn('CLIProxy:')} Not running at http://127.0.0.1:8317`); + console.log(` ${dim('Note:')} Start with: ccs config`); + return; + } + console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:8317`); + + // All checks passed + results.details['Image Analysis'] = { + status: 'OK', + info: `Enabled (${providers.length} providers)`, + }; +} + +/** + * Fix image analysis configuration issues + */ +export async function fixImageAnalysisConfig(): Promise { + const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/unified-config-loader' + ); + + const config = loadOrCreateUnifiedConfig(); + let fixed = false; + + // Fix missing provider_models + if ( + !config.image_analysis?.provider_models || + Object.keys(config.image_analysis.provider_models).length === 0 + ) { + config.image_analysis = { + ...config.image_analysis, + enabled: config.image_analysis?.enabled ?? true, + timeout: config.image_analysis?.timeout ?? 60, + provider_models: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models }, + }; + fixed = true; + } + + // Fix invalid timeout + if ( + config.image_analysis && + (config.image_analysis.timeout < 10 || config.image_analysis.timeout > 600) + ) { + config.image_analysis.timeout = 60; + fixed = true; + } + + if (fixed) { + updateUnifiedConfig({ image_analysis: config.image_analysis }); + } + + return fixed; +} diff --git a/src/management/checks/index.ts b/src/management/checks/index.ts index 1ea17e5f..680c8f0c 100644 --- a/src/management/checks/index.ts +++ b/src/management/checks/index.ts @@ -49,3 +49,6 @@ export { // OAuth checks export { OAuthPortsChecker, runOAuthChecks } from './oauth-check'; + +// Image Analysis checks +export { runImageAnalysisCheck, fixImageAnalysisConfig } from './image-analysis-check'; diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 1ec0abd3..dcd9c0b8 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -13,6 +13,7 @@ import { runSymlinkChecks, runCLIProxyChecks, runOAuthChecks, + runImageAnalysisCheck, } from './checks'; import { runAutoRepair } from './repair'; @@ -76,6 +77,11 @@ class Doctor { await runOAuthChecks(this.results); console.log(''); + // Group 8: Image Analysis Config + console.log(header('IMAGE ANALYSIS')); + await runImageAnalysisCheck(this.results); + console.log(''); + this.showReport(); return this.results; } diff --git a/tests/unit/commands/config-image-analysis-command.test.ts b/tests/unit/commands/config-image-analysis-command.test.ts new file mode 100644 index 00000000..b31af9f2 --- /dev/null +++ b/tests/unit/commands/config-image-analysis-command.test.ts @@ -0,0 +1,187 @@ +/** + * Config Image Analysis Command Tests + * + * Unit tests for ccs config image-analysis subcommand. + */ + +import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Create temp directory for test isolation +let testDir: string; +let originalCcsHome: string | undefined; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-image-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = testDir; +}); + +afterEach(() => { + if (originalCcsHome) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +// Helper to create config.yaml for tests +function createConfigYaml(content: string): void { + fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8'); +} + +describe('config image-analysis command', () => { + describe('config file parsing', () => { + it('should parse enabled status from config.yaml', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: true + timeout: 60 + provider_models: + agy: gemini-2.5-flash +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('enabled: true'); + expect(content).toContain('timeout: 60'); + expect(content).toContain('agy: gemini-2.5-flash'); + }); + + it('should parse disabled status from config.yaml', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: false + timeout: 120 + provider_models: {} +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('enabled: false'); + expect(content).toContain('timeout: 120'); + }); + + it('should parse multiple provider models', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: true + timeout: 60 + provider_models: + agy: gemini-2.5-flash + gemini: gemini-2.5-pro + codex: gpt-5.1-codex-mini + kiro: kiro-claude-haiku-4-5 +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('agy: gemini-2.5-flash'); + expect(content).toContain('gemini: gemini-2.5-pro'); + expect(content).toContain('codex: gpt-5.1-codex-mini'); + expect(content).toContain('kiro: kiro-claude-haiku-4-5'); + }); + }); + + describe('timeout validation', () => { + it('should accept valid timeout within range (10-600)', () => { + const validTimeouts = [10, 60, 120, 300, 600]; + + for (const timeout of validTimeouts) { + const isValid = timeout >= 10 && timeout <= 600; + expect(isValid).toBe(true); + } + }); + + it('should reject timeout below minimum (10)', () => { + const invalidTimeouts = [0, 1, 5, 9]; + + for (const timeout of invalidTimeouts) { + const isValid = timeout >= 10 && timeout <= 600; + expect(isValid).toBe(false); + } + }); + + it('should reject timeout above maximum (600)', () => { + const invalidTimeouts = [601, 700, 1000, 3600]; + + for (const timeout of invalidTimeouts) { + const isValid = timeout >= 10 && timeout <= 600; + expect(isValid).toBe(false); + } + }); + }); + + describe('provider validation', () => { + it('should accept valid providers', () => { + const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + + for (const provider of validProviders) { + expect(validProviders.includes(provider)).toBe(true); + } + }); + + it('should reject invalid providers', () => { + const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + const invalidProviders = ['unknown', 'custom', 'my-provider', 'test']; + + for (const provider of invalidProviders) { + expect(validProviders.includes(provider)).toBe(false); + } + }); + }); + + describe('default configuration', () => { + it('should have correct default values', () => { + // These are the expected defaults from unified-config-types.ts + const defaultConfig = { + enabled: true, + timeout: 60, + provider_models: { + agy: 'gemini-2.5-flash', + gemini: 'gemini-2.5-flash', + codex: 'gpt-5.1-codex-mini', + kiro: 'kiro-claude-haiku-4-5', + ghcp: 'claude-haiku-4.5', + claude: 'claude-haiku-4-5-20251001', + }, + }; + + expect(defaultConfig.enabled).toBe(true); + expect(defaultConfig.timeout).toBe(60); + expect(Object.keys(defaultConfig.provider_models).length).toBe(6); + }); + }); + + describe('config file structure', () => { + it('should have image_analysis section', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: true + timeout: 60 + provider_models: + agy: gemini-2.5-flash +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('image_analysis:'); + }); + + it('should support empty provider_models', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: false + timeout: 60 + provider_models: {} +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('provider_models: {}'); + }); + }); +}); From cb8de2c8e8da2ac5e97b8e1893fc4586d0bf5c8c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 22:32:49 -0500 Subject: [PATCH 09/13] fix(hooks): improve error handling and edge cases for image analysis - Add empty model string validation in config command - Add --enable/--disable conflict detection - Truncate long model names in display (>40 chars) - Add EACCES/EPERM file permission error handler - Add filesystem error classification (ENOSPC, EROFS) - Log config upgrade save failures instead of silent catch --- lib/hooks/image-analyzer-transformer.cjs | 23 ++- src/commands/config-image-analysis-command.ts | 18 ++- src/config/unified-config-loader.ts | 151 +++++++++++++++--- 3 files changed, 164 insertions(+), 28 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index 793a08e9..d8bb71e1 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -517,6 +517,25 @@ function outputApiError(filePath, statusCode, responseBody) { process.exit(2); } +/** + * File permission error + */ +function outputFileAccessError(filePath, error) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.UNKNOWN, + `File access denied: ${error}`, + [ + 'Check file permissions: ls -l ' + filePath, + isWindows ? 'Run terminal as Administrator if needed' : 'Use sudo or adjust file ownership', + 'Verify file is readable by current user', + 'Move file to accessible location', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + /** * Unknown/fallback error (replaces old outputError) */ @@ -685,7 +704,7 @@ async function processHook() { debugLog('File encoded', { mediaType: mediaType, - base64Length: `${(base64Data.length / 1024).toFixed(1)} KB`, + base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`, }); // Analyze via CLIProxy @@ -730,6 +749,8 @@ async function processHook() { outputTimeoutError(filePath, timeout); } else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) { outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`); + } else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) { + outputFileAccessError(filePath, errMsg); } else { outputUnknownError(filePath, errMsg); } diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index 630f1ab9..401915fe 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -130,7 +130,9 @@ function showStatus(forceReload = false): void { provider as keyof typeof DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models ] === model; const suffix = isDefault ? dim(' (default)') : ''; - console.log(` ${color(provider.padEnd(10), 'command')} ${model}${suffix}`); + // Edge case #3: Long model name truncation + const truncatedModel = model.length > 40 ? model.slice(0, 37) + '...' : model; + console.log(` ${color(provider.padEnd(10), 'command')} ${truncatedModel}${suffix}`); } } console.log(''); @@ -158,6 +160,12 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< return; } + // Validate conflicting flags (Edge case #2: --enable + --disable conflict) + if (options.enable && options.disable) { + console.error(fail('Cannot use --enable and --disable together')); + process.exit(1); + } + // Apply changes if any options provided let hasChanges = false; const config = loadOrCreateUnifiedConfig(); @@ -185,9 +193,15 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< console.error(info(`Valid providers: ${validProviders.join(', ')}`)); process.exit(1); } + // Validate model name (Edge case #1: Empty model string validation) + const model = options.setModel.model; + if (!model || model.trim() === '') { + console.error(fail('Model name cannot be empty')); + process.exit(1); + } imageConfig.provider_models = { ...imageConfig.provider_models, - [options.setModel.provider]: options.setModel.model, + [options.setModel.provider]: model, }; hasChanges = true; } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index b6f741ec..1bb0e3c2 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -30,6 +30,8 @@ import { isUnifiedConfigEnabled } from './feature-flags'; const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; +const CONFIG_LOCK = 'config.yaml.lock'; +const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds /** * Get path to unified config.yaml @@ -45,6 +47,71 @@ export function getConfigJsonPath(): string { return path.join(getCcsDir(), CONFIG_JSON); } +/** + * Get path to config lockfile + */ +function getLockFilePath(): string { + return path.join(getCcsDir(), CONFIG_LOCK); +} + +/** + * Acquire lockfile for config write operations. + * Returns true if lock acquired, false if already locked by another process. + * Cleans up stale locks (older than LOCK_STALE_MS). + */ + +function acquireLock(): boolean { + const lockPath = getLockFilePath(); + const lockData = `${process.pid}\n${Date.now()}`; + + try { + // Check if lock exists + if (fs.existsSync(lockPath)) { + const content = fs.readFileSync(lockPath, 'utf8'); + const [pidStr, timestampStr] = content.trim().split('\n'); + const timestamp = parseInt(timestampStr, 10); + + // Check if lock is stale + if (Date.now() - timestamp > LOCK_STALE_MS) { + // Stale lock - remove and acquire + fs.unlinkSync(lockPath); + } else { + // Check if process still exists + try { + process.kill(parseInt(pidStr, 10), 0); // Signal 0 checks if process exists + // Process exists - lock is valid + return false; + } catch { + // Process doesn't exist - remove stale lock + fs.unlinkSync(lockPath); + } + } + } + + // Acquire lock + fs.writeFileSync(lockPath, lockData, { mode: 0o600 }); + return true; + } catch { + // Lock acquisition failed + return false; + } +} + +/** + * Release lockfile after config write operation. + */ + +function releaseLock(): void { + const lockPath = getLockFilePath(); + try { + if (fs.existsSync(lockPath)) { + fs.unlinkSync(lockPath); + } + } catch { + // Ignore cleanup errors + } +} + /** * Check if unified config.yaml exists */ @@ -105,8 +172,9 @@ export function loadUnifiedConfig(): UnifiedConfig | null { console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`); } return upgraded; - } catch { - // Ignore save errors during upgrade - config still works + } catch (saveError) { + console.error('[!] Config upgrade failed to save:', (saveError as Error).message); + // Continue using the upgraded version in-memory even if save fails } } @@ -559,39 +627,72 @@ function generateYamlWithComments(config: UnifiedConfig): string { /** * Save unified config to YAML file. * Uses atomic write (temp file + rename) to prevent corruption. + * Uses lockfile to prevent concurrent writes. */ export function saveUnifiedConfig(config: UnifiedConfig): void { const yamlPath = getConfigYamlPath(); const dir = path.dirname(yamlPath); - // Ensure directory exists - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + // Acquire lock (retry for up to 1 second) + const maxRetries = 10; + const retryDelayMs = 100; + let lockAcquired = false; + for (let i = 0; i < maxRetries; i++) { + if (acquireLock()) { + lockAcquired = true; + break; + } + // Wait before retry + const start = Date.now(); + while (Date.now() - start < retryDelayMs) { + // Busy wait + } } - // Ensure version is set - config.version = UNIFIED_CONFIG_VERSION; - - // Generate YAML with section comments - const yamlContent = generateYamlWithComments(config); - const content = generateYamlHeader() + yamlContent; - - // Atomic write: write to temp file, then rename - const tempPath = `${yamlPath}.tmp.${process.pid}`; + if (!lockAcquired) { + throw new Error('Config file is locked by another process. Wait a moment and try again.'); + } try { - fs.writeFileSync(tempPath, content, { mode: 0o600 }); - fs.renameSync(tempPath, yamlPath); - } catch (err) { - // Clean up temp file on error - if (fs.existsSync(tempPath)) { - try { - fs.unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } - throw err; + + // Ensure version is set + config.version = UNIFIED_CONFIG_VERSION; + + // Generate YAML with section comments + const yamlContent = generateYamlWithComments(config); + const content = generateYamlHeader() + yamlContent; + + // Atomic write: write to temp file, then rename + const tempPath = `${yamlPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, yamlPath); + } catch (error) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + // Classify filesystem errors + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOSPC') { + throw new Error('Disk full - cannot save config. Free up space and try again.'); + } else if (err.code === 'EROFS' || err.code === 'EACCES') { + throw new Error(`Cannot write config - check file permissions: ${err.message}`); + } + throw error; + } + } finally { + // Always release lock + releaseLock(); } } From 4d87a649de3873786926dad0f598d4f481b1b563 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 22:33:31 -0500 Subject: [PATCH 10/13] feat(ui): improve settings page UX and responsiveness - Add mobile-responsive layout for settings panels - Add empty state UI for websearch providers - Improve connection indicator with WebSocket status --- .../shared/connection-indicator.tsx | 22 ++++++++++++++----- ui/src/hooks/use-websocket.ts | 11 +++++++++- ui/src/pages/settings/index.tsx | 20 ++++++++++++++++- .../settings/sections/websearch/index.tsx | 17 +++++++++++++- 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/ui/src/components/shared/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx index eb00487d..84932a53 100644 --- a/ui/src/components/shared/connection-indicator.tsx +++ b/ui/src/components/shared/connection-indicator.tsx @@ -1,19 +1,29 @@ /** * Connection Indicator (Phase 04) * - * Shows WebSocket connection status in the header. + * Shows WebSocket connection status in the header with reconnection state. */ -import { Wifi, WifiOff } from 'lucide-react'; +import { Wifi, WifiOff, RefreshCw } from 'lucide-react'; import { useWebSocket } from '@/hooks/use-websocket'; export function ConnectionIndicator() { - const { status } = useWebSocket(); + const { status, isReconnecting } = useWebSocket(); const statusConfig = { connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' }, - connecting: { icon: Wifi, color: 'text-yellow-500', label: 'Connecting...' }, - disconnected: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' }, + connecting: { + icon: RefreshCw, + color: 'text-yellow-500', + label: 'Connecting...', + animate: true, + }, + disconnected: { + icon: isReconnecting ? RefreshCw : WifiOff, + color: isReconnecting ? 'text-amber-500' : 'text-red-500', + label: isReconnecting ? 'Reconnecting...' : 'Disconnected', + animate: isReconnecting, + }, }; const config = statusConfig[status]; @@ -21,7 +31,7 @@ export function ConnectionIndicator() { return (

- + {config.label}
); diff --git a/ui/src/hooks/use-websocket.ts b/ui/src/hooks/use-websocket.ts index 198b10ca..81164214 100644 --- a/ui/src/hooks/use-websocket.ts +++ b/ui/src/hooks/use-websocket.ts @@ -18,6 +18,7 @@ type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'; export function useWebSocket() { const [status, setStatus] = useState('disconnected'); + const [isReconnecting, setIsReconnecting] = useState(false); const wsRef = useRef(null); const queryClient = useQueryClient(); const reconnectAttempts = useRef(0); @@ -75,6 +76,7 @@ export function useWebSocket() { ws.onopen = () => { setStatus('connected'); + setIsReconnecting(false); reconnectAttempts.current = 0; console.log('[WS] Connected'); }; @@ -97,6 +99,7 @@ export function useWebSocket() { // Attempt reconnect with exponential backoff if (reconnectAttempts.current < maxReconnectAttempts) { + setIsReconnecting(true); const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000); reconnectAttempts.current++; console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`); @@ -104,6 +107,8 @@ export function useWebSocket() { reconnectTimeoutRef.current = setTimeout(() => { connectRef.current(); }, delay); + } else { + setIsReconnecting(false); } }; @@ -117,6 +122,7 @@ export function useWebSocket() { const disconnect = useCallback(() => { reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect + setIsReconnecting(false); if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; @@ -142,5 +148,8 @@ export function useWebSocket() { return () => clearInterval(interval); }, []); - return useMemo(() => ({ status, connect, disconnect }), [status, connect, disconnect]); + return useMemo( + () => ({ status, isReconnecting, connect, disconnect }), + [status, isReconnecting, connect, disconnect] + ); } diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index b7d447af..1571fdde 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -115,7 +115,25 @@ function SettingsPageInner() { return (
- + {/* Mobile View - Stacked vertically */} +
+
+ +
+ + }> + {activeTab === 'websearch' && } + {activeTab === 'globalenv' && } + {activeTab === 'thinking' && } + {activeTab === 'proxy' && } + {activeTab === 'auth' && } + {activeTab === 'backups' && } + + +
+ + {/* Desktop View - Side-by-side panels */} + {/* Left Panel - Settings Controls */}
diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 5891f6d2..7b061fd8 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -7,7 +7,7 @@ import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react'; +import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react'; import { useWebSearchConfig, useRawConfig } from '../../hooks'; import { ProviderCard } from './provider-card'; @@ -141,6 +141,21 @@ export default function WebSearchSection() {

Providers

+ {/* Empty state when no providers available */} + {!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && ( +
+ +

No providers configured

+

+ Install CLI tools to enable web search providers +

+ +
+ )} + Date: Tue, 3 Feb 2026 22:34:15 -0500 Subject: [PATCH 11/13] fix(cli): improve network handling and shell escaping - Add network connectivity check before CLIProxy operations - Enhance PowerShell argument escaping for special characters --- src/cliproxy/cliproxy-executor.ts | 23 +++++++++++++++++++++++ src/utils/shell-executor.ts | 26 +++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 626e4519..808a1814 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -286,6 +286,29 @@ export async function execClaudeWithCLIProxy( spinner.succeed('CLIProxy binary ready'); } catch (error) { spinner.fail('Failed to prepare CLIProxy'); + const err = error as Error; + + // Check if network offline (DNS, connection, or timeout failure) + const networkErrors = [ + 'getaddrinfo', + 'ENOTFOUND', + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ENETUNREACH', + 'EAI_AGAIN', + ]; + const isNetworkError = networkErrors.some((errCode) => err.message.includes(errCode)); + + if (isNetworkError) { + console.error(''); + console.error(fail('No network connection detected')); + console.error(''); + console.error('CLIProxy binary download requires internet access.'); + console.error('Please check your network connection and try again.'); + console.error(''); + process.exit(1); + } + throw error; } } diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index ffea9d3c..d60e3574 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -11,9 +11,33 @@ import { getImageReadBlockHookEnv } from './hooks/image-read-block-hook-env'; /** * Escape arguments for shell execution (Windows compatibility) + * Handles PowerShell special characters: backticks, $variables, double quotes */ export function escapeShellArg(arg: string): string { - return '"' + String(arg).replace(/"/g, '""') + '"'; + const isWindows = process.platform === 'win32'; + + if (isWindows) { + // PowerShell: Use single quotes for literal strings to prevent variable expansion + // Escape single quotes by doubling them (PowerShell syntax) + // Fallback to double quotes with escapes if single quotes present + if (arg.includes("'")) { + // Contains single quote - use double quotes with escape sequences + return ( + '"' + + String(arg) + .replace(/\$/g, '`$') // Escape $ to prevent variable expansion + .replace(/`/g, '``') // Escape backticks + .replace(/"/g, '`"') + // Escape double quotes + '"' + ); + } else { + // No single quotes - use single quotes for literal string (safest) + return "'" + String(arg) + "'"; + } + } else { + // Unix/macOS: Double quotes with escaped inner quotes + return '"' + String(arg).replace(/"/g, '""') + '"'; + } } /** From 57f7a70d67cb7bc854fa3aa4c0a932134dc1278c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 23:54:29 -0500 Subject: [PATCH 12/13] fix(ui): add missing animate property to connection indicator - Add animate: false to connected state config to fix TypeScript error - Integrate fixImageAnalysisConfig() into auto-repair for Fix 5 --- src/management/repair/auto-repair.ts | 15 +++++++++++++++ ui/src/components/shared/connection-indicator.tsx | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/management/repair/auto-repair.ts b/src/management/repair/auto-repair.ts index 882bb3b1..b769bbc7 100644 --- a/src/management/repair/auto-repair.ts +++ b/src/management/repair/auto-repair.ts @@ -15,6 +15,7 @@ import { import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils'; import { killProcessOnPort, getPlatformName } from '../../utils/platform-commands'; import { createSpinner } from '../checks/types'; +import { fixImageAnalysisConfig } from '../checks/image-analysis-check'; const ora = createSpinner(); @@ -132,6 +133,20 @@ export async function runAutoRepair(): Promise { symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`); } + // Fix 5: Image analysis config validation + const imageSpinner = ora('Checking image analysis config').start(); + try { + const imageFixed = await fixImageAnalysisConfig(); + if (imageFixed) { + imageSpinner.succeed(`${ok('Fixed')} Repaired image analysis configuration`); + fixed++; + } else { + imageSpinner.succeed(`${ok('OK')} Image analysis config is valid`); + } + } catch (err) { + imageSpinner.fail(`${fail('Error')} Could not fix image config: ${(err as Error).message}`); + } + // Summary console.log(''); if (fixed > 0) { diff --git a/ui/src/components/shared/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx index 84932a53..3ce36825 100644 --- a/ui/src/components/shared/connection-indicator.tsx +++ b/ui/src/components/shared/connection-indicator.tsx @@ -11,7 +11,7 @@ export function ConnectionIndicator() { const { status, isReconnecting } = useWebSocket(); const statusConfig = { - connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' }, + connected: { icon: Wifi, color: 'text-green-600', label: 'Connected', animate: false }, connecting: { icon: RefreshCw, color: 'text-yellow-500', From ec4e1ae31c882e8422e8defca6c10a9c79addc5d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 23:57:55 -0500 Subject: [PATCH 13/13] perf(config): replace busy-wait with Atomics.wait in lock retry Use Atomics.wait for synchronous sleep instead of CPU-intensive busy-wait loop. This properly sleeps the thread without wasting cycles. Addresses code review feedback on PR #441. --- src/config/unified-config-loader.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 1bb0e3c2..a8e336de 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -642,11 +642,10 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { lockAcquired = true; break; } - // Wait before retry - const start = Date.now(); - while (Date.now() - start < retryDelayMs) { - // Busy wait - } + // Synchronous sleep without CPU-intensive busy-wait + // Uses Atomics.wait which properly sleeps the thread + // Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); } if (!lockAcquired) {