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/block-image-read.cjs b/lib/hooks/block-image-read.cjs new file mode 100644 index 00000000..78d87e27 --- /dev/null +++ b/lib/hooks/block-image-read.cjs @@ -0,0 +1,170 @@ +#!/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. + * + * 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: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "Read", + * "hooks": [{ + * "type": "command", + * "command": "node ~/.ccs/hooks/block-image-read.cjs", + * "timeout": 5 + * }] + * }] + * } + * } + * + * 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) + * 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 hook should skip (for native Claude accounts). + * Matches WebSearch hook pattern. + */ +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; +} + +/** + * Main hook processing logic + */ +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 (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/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs new file mode 100755 index 00000000..d8bb71e1 --- /dev/null +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -0,0 +1,758 @@ +#!/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_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) + * 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'; + +// ============================================================================ +// 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. + +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 +// ============================================================================ + +/** + * 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 + */ +function parseProviderModels(envValue) { + if (!envValue) return {}; + const result = {}; + envValue.split(',').forEach((pair) => { + const [provider, model] = pair.split(':'); + if (provider && model && model.trim()) { + 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 + */ +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('error', (err) => { + reject(err); + }); + + 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(`API_ERROR:${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; + + 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('TIMEOUT')); + }); + + req.write(requestBody); + req.end(); + }); +} + +/** + * Format analysis description for Claude (matches websearch format) + */ +function formatDescription(filePath, description, model, fileSize) { + const sizeKB = fileSize ? (fileSize / 1024).toFixed(1) : '?'; + return [ + `[Image Analysis via CLIProxy]`, + '', + `File: ${path.basename(filePath)} (${sizeKB} KB)`, + `Model: ${model}`, + '', + '---', + '', + description, + '', + '---', + '*Use this description to understand the image content.*', + ].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); +} + +/** + * 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) + */ +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 = { + decision: 'block', + reason: `Image analyzed: ${path.basename(filePath)}`, + systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy (${model})`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: formattedDescription, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Determine if hook should skip, with debug logging + */ +function shouldSkipHook() { + // Explicit skip signal + 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') { + 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') { + 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]) { + debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, { + configured_providers: Object.keys(providerModels).join(', ') || 'none', + }); + 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) { + outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB); + return; + } + + // Check CLIProxy availability + const cliProxyAvailable = await isCliProxyAvailable(); + if (!cliProxyAvailable) { + 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); + } + + const model = getModelForProvider(); + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000; + + // 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) { + 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 + } + + // 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 if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) { + outputFileAccessError(filePath, errMsg); + } else { + outputUnknownError(filePath, errMsg); + } + } +} 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 3b2c6409..8c0a5d61 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/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..808a1814 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -37,6 +37,8 @@ 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 { 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'; @@ -284,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; } } @@ -944,10 +969,14 @@ export async function execClaudeWithCLIProxy( ANTHROPIC_BASE_URL: finalBaseUrl, }; 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/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..401915fe --- /dev/null +++ b/src/commands/config-image-analysis-command.ts @@ -0,0 +1,217 @@ +/** + * 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)') : ''; + // 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(''); + + // 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; + } + + // 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(); + 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); + } + // 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]: 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 63a97fd1..a8e336de 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -20,14 +20,18 @@ 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'; 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 @@ -43,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 */ @@ -103,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 } } @@ -293,6 +363,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, + timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, + provider_models: + partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, + }, }; } @@ -520,45 +597,101 @@ 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'); } /** * 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; + } + // 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); } - // 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(); } } @@ -721,3 +854,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, + timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, + 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 cf7e0f1f..c654d9ec 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -518,6 +518,36 @@ 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) */ + enabled: boolean; + /** Timeout in seconds (default: 60) */ + timeout: number; + /** Provider-to-model mapping for vision analysis */ + provider_models: Record; +} + +/** + * Default image analysis configuration. + * Enabled by default for CLIProxy providers with vision support. + */ +export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { + 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', + }, +}; + /** * Main unified configuration structure. * Stored in ~/.ccs/config.yaml @@ -551,6 +581,8 @@ export interface UnifiedConfig { thinking?: ThinkingConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; + /** Image analysis configuration (vision via CLIProxy) */ + image_analysis?: ImageAnalysisConfig; } /** @@ -644,6 +676,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/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/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/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..0dd9aa14 --- /dev/null +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -0,0 +1,42 @@ +/** + * 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'; + +/** + * 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 provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex') + * @returns Environment variables for image analysis hook + */ +export function getImageAnalysisHookEnv(provider?: string): Record { + const config = getImageAnalysisConfig(); + + // 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_TIMEOUT: String(Number(config.timeout) || 60), + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models), + CCS_CURRENT_PROVIDER: provider || '', + 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/image-read-block-hook-env.ts b/src/utils/hooks/image-read-block-hook-env.ts new file mode 100644 index 00000000..b2cc91fc --- /dev/null +++ b/src/utils/hooks/image-read-block-hook-env.ts @@ -0,0 +1,60 @@ +/** + * 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. + * + * 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 + */ + +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; + +/** + * Configuration for image read blocking. + */ +export interface ImageReadBlockConfig { + /** Whether blocking is enabled (default: true) */ + enabled: boolean; +} + +/** + * Get image read block configuration from unified config. + * Defaults to ENABLED (opt-out feature) - matches WebSearch pattern. + */ +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 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 { + const config = getImageReadBlockConfig(); + const env: Record = {}; + + if (config.enabled) { + env.CCS_BLOCK_IMAGE_READ = '1'; + } else { + // Explicit disable signal + env.CCS_BLOCK_IMAGE_READ = '0'; + } + + return env; +} diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts new file mode 100644 index 00000000..d952a096 --- /dev/null +++ b/src/utils/hooks/index.ts @@ -0,0 +1,18 @@ +/** + * Hooks Utilities Index + * + * Centralized exports for all hook-related utilities. + * + * @module utils/hooks + */ + +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/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 56d1223b..d60e3574 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -7,12 +7,37 @@ 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) + * 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, '""') + '"'; + } } /** @@ -28,11 +53,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) { 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..b3da61dd --- /dev/null +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -0,0 +1,787 @@ +/** + * 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'; + +// 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 +// ============================================================================ + +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), + // 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 + }); + + 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, + 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) + 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_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy: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 model from provider_models mapping', () => { + resetMockState(); + + invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { + 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('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 + }); + }); + + // ========================================================================== + // 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'); + }); + }); +}); 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: {}'); + }); + }); +}); diff --git a/ui/src/components/shared/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx index eb00487d..3ce36825 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' }, + connected: { icon: Wifi, color: 'text-green-600', label: 'Connected', animate: false }, + 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 +

+ +
+ )} +