diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index bd8e7387..793a08e9 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -49,6 +49,21 @@ const CLIPROXY_PATH = '/v1/messages'; // API key passed via env from cliproxy-executor, defaults to CCS internal key const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed'; +// ============================================================================ +// ERROR CODES (for categorization) +// ============================================================================ + +const ERROR_CODES = { + FILE_TOO_LARGE: 'FILE_TOO_LARGE', + CLIPROXY_UNAVAILABLE: 'CLIPROXY_UNAVAILABLE', + AUTH_FAILED: 'AUTH_FAILED', + TIMEOUT: 'TIMEOUT', + RATE_LIMIT: 'RATE_LIMIT', + API_ERROR: 'API_ERROR', + PARSE_ERROR: 'PARSE_ERROR', + UNKNOWN: 'UNKNOWN', +}; + // Default analysis prompt const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description. @@ -66,6 +81,55 @@ Be comprehensive - this description replaces direct visual access.`; // HELPER FUNCTIONS // ============================================================================ +/** + * Output debug information to stderr + * Only outputs when CCS_DEBUG=1 + */ +function debugLog(message, data = {}) { + if (!process.env.CCS_DEBUG) return; + + const lines = [`[CCS Hook] ${message}`]; + + for (const [key, value] of Object.entries(data)) { + if (value !== undefined && value !== null) { + lines.push(` ${key}: ${value}`); + } + } + + console.error(lines.join('\n')); +} + +/** + * Get detailed debug context + */ +function getDebugContext(filePath, stats) { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown'; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const model = providerModels[currentProvider] || DEFAULT_MODEL; + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + const isDefaultModel = !providerModels[currentProvider]; + + return { + file: path.basename(filePath), + size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown', + provider: currentProvider, + model: model, + config: isDefaultModel ? 'default' : 'user-configured', + timeout: `${timeout}s`, + endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`, + }; +} + +/** + * Get current provider/model context for error messages + */ +function getProviderContext() { + const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown'; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const model = providerModels[provider] || DEFAULT_MODEL; + return { provider, model }; +} + /** * Parse provider_models env var to object * Format: provider:model,provider:model @@ -205,8 +269,20 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { }); res.on('end', () => { + // Categorize by status code + if (res.statusCode === 401 || res.statusCode === 403) { + reject(new Error(`AUTH_ERROR:${res.statusCode}`)); + return; + } + + if (res.statusCode === 429) { + const retryAfter = res.headers['retry-after']; + reject(new Error(`RATE_LIMIT:${retryAfter || ''}`)); + return; + } + if (res.statusCode !== 200) { - reject(new Error(`CLIProxy returned status ${res.statusCode}: ${data}`)); + reject(new Error(`API_ERROR:${res.statusCode}:${data}`)); return; } @@ -235,7 +311,7 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { req.on('error', (err) => reject(err)); req.on('timeout', () => { req.destroy(); - reject(new Error('Request timed out')); + reject(new Error('TIMEOUT')); }); req.write(requestBody); @@ -263,10 +339,213 @@ function formatDescription(filePath, description, model, fileSize) { ].join('\n'); } +// ============================================================================ +// SPECIALIZED ERROR HANDLERS +// ============================================================================ + +/** + * Format error output for Claude hook + */ +function formatErrorOutput(filePath, errorCode, message, troubleshooting) { + const { provider, model } = getProviderContext(); + + const lines = [ + `[Image Analysis - Error]`, + '', + `File: ${path.basename(filePath)}`, + `Provider: ${provider} | Model: ${model}`, + '', + `Error: ${message}`, + ]; + + if (troubleshooting && troubleshooting.length > 0) { + lines.push(''); + lines.push('Troubleshooting:'); + troubleshooting.forEach((step, i) => { + lines.push(` ${i + 1}. ${step}`); + }); + } + + lines.push(''); + lines.push('For help: ccs config image-analysis --help'); + + return { + decision: 'block', + reason: `Image analysis failed: ${errorCode}`, + systemMessage: `[Image Analysis] Failed: ${message}`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: lines.join('\n'), + }, + }; +} + +/** + * File too large error + */ +function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.FILE_TOO_LARGE, + `File too large (${actualSizeMB.toFixed(2)}MB > ${maxSizeMB}MB limit)`, + [ + 'Reduce image resolution or use compression', + 'For screenshots: use PNG optimizer (pngquant, optipng)', + 'For photos: resize to max 2048px width', + `Current limit: ${maxSizeMB}MB per file`, + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * CLIProxy unavailable error + */ +function outputCliProxyUnavailableError(filePath, endpoint) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.CLIPROXY_UNAVAILABLE, + `CLIProxy not available at ${endpoint}`, + [ + 'CLIProxy service may not be running', + 'Start with: ccs config (opens dashboard, starts CLIProxy)', + 'Or manually: ccs cliproxy start', + `Verify: curl ${endpoint}`, + 'Check status: ccs doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Authentication error + */ +function outputAuthError(filePath, statusCode) { + const { provider } = getProviderContext(); + const output = formatErrorOutput( + filePath, + ERROR_CODES.AUTH_FAILED, + `Authentication failed (HTTP ${statusCode})`, + [ + `Re-authenticate: ccs ${provider} --auth`, + `Check accounts: ccs ${provider} --accounts`, + 'Verify OAuth token is valid', + 'Check: ccs doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Timeout error + */ +function outputTimeoutError(filePath, timeoutSec) { + const { model } = getProviderContext(); + const output = formatErrorOutput( + filePath, + ERROR_CODES.TIMEOUT, + `Request timed out after ${timeoutSec}s`, + [ + 'Large files or complex images take longer', + `Increase timeout: ccs config image-analysis --timeout ${timeoutSec * 2}`, + 'Or via env: CCS_IMAGE_ANALYSIS_TIMEOUT=120', + `Current model (${model}) may be slow - try a faster variant`, + 'Check CLIProxy health: curl http://127.0.0.1:8317', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Rate limit error + */ +function outputRateLimitError(filePath, retryAfterSec) { + const { provider } = getProviderContext(); + const retryHint = retryAfterSec ? `Retry after ${retryAfterSec}s` : 'Wait a moment and retry'; + const output = formatErrorOutput( + filePath, + ERROR_CODES.RATE_LIMIT, + 'Rate limit exceeded', + [ + retryHint, + `Provider ${provider} has usage limits`, + 'Consider switching accounts: ccs ' + provider + ' --accounts', + 'Check quota: ccs cliproxy doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Generic API error + */ +function outputApiError(filePath, statusCode, responseBody) { + // Try to extract error message from response + let errorDetail = `HTTP ${statusCode}`; + try { + const parsed = JSON.parse(responseBody); + if (parsed.error?.message) { + errorDetail = parsed.error.message; + } else if (parsed.message) { + errorDetail = parsed.message; + } + } catch { + // Use raw body if not JSON (truncated) + if (responseBody && responseBody.length < 100) { + errorDetail = responseBody; + } + } + + const output = formatErrorOutput( + filePath, + ERROR_CODES.API_ERROR, + `API error: ${errorDetail}`, + [ + 'Check CLIProxy logs: ccs cleanup --show-logs', + 'Verify provider is authenticated: ccs doctor', + 'Try a different provider or model', + 'Report persistent issues: https://github.com/kaitranntt/ccs/issues', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Unknown/fallback error (replaces old outputError) + */ +function outputUnknownError(filePath, error) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.UNKNOWN, + error || 'Unknown error occurred', + [ + 'Check CLIProxy is running: curl http://127.0.0.1:8317', + 'Verify authentication: ccs doctor', + 'Check file is valid image/PDF', + 'Enable debug: CCS_DEBUG=1 ccs ', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + /** * Output success response and exit */ function outputSuccess(filePath, description, model, fileSize) { + debugLog('Returning analysis result', { + file: path.basename(filePath), + model: model, + descriptionLength: `${description.length} chars`, + }); + const formattedDescription = formatDescription(filePath, description, model, fileSize); const output = { @@ -285,55 +564,38 @@ function outputSuccess(filePath, description, model, fileSize) { } /** - * Output error message - */ -function outputError(filePath, error) { - const message = [ - `[Image Analysis - Error]`, - '', - `Failed to analyze: ${path.basename(filePath)}`, - '', - `Error: ${error}`, - '', - 'Troubleshooting:', - ' - Check CLIProxy is running: http://127.0.0.1:8317', - ' - Verify you are authenticated with agy or gemini', - ' - Check file size is under 10MB', - ].join('\n'); - - const output = { - decision: 'block', - reason: `Image analysis failed: ${error}`, - systemMessage: `[Image Analysis] Failed to analyze ${path.basename(filePath)}`, - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: message, - }, - }; - - console.log(JSON.stringify(output)); - process.exit(2); -} - -/** - * Determine if hook should skip + * Determine if hook should skip, with debug logging */ function shouldSkipHook() { // Explicit skip signal - if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') return true; + if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') { + debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP=1'); + return true; + } // Explicit disable - if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') return true; + if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') { + debugLog('Skipping: image analysis disabled (CCS_IMAGE_ANALYSIS_ENABLED=0)'); + return true; + } // Account/default profiles - use native Read const profileType = process.env.CCS_PROFILE_TYPE; - if (profileType === 'account' || profileType === 'default') return true; + if (profileType === 'account' || profileType === 'default') { + debugLog(`Skipping: profile type "${profileType}" uses native Read`); + return true; + } // Check if current provider has a vision model configured const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); - if (!providerModels[currentProvider]) return true; + + if (!providerModels[currentProvider]) { + debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, { + configured_providers: Object.keys(providerModels).join(', ') || 'none', + }); + return true; + } return false; } @@ -394,16 +656,17 @@ async function processHook() { // Check file size const stats = fs.statSync(filePath); if (stats.size >= MAX_FILE_SIZE_BYTES) { - outputError(filePath, `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB >= ${MAX_FILE_SIZE_MB}MB)`); + outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB); return; } // Check CLIProxy availability const cliProxyAvailable = await isCliProxyAvailable(); if (!cliProxyAvailable) { - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] CLIProxy not available, passing through'); - } + debugLog('Skipping: CLIProxy not available', { + endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`, + action: 'passing through to native Read', + }); // Pass through to native Read process.exit(0); } @@ -412,17 +675,26 @@ async function processHook() { const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000; - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`); - } + // Get debug context before analysis + const debugContext = getDebugContext(filePath, stats); + debugLog('Starting image analysis', debugContext); // Encode file to base64 const base64Data = encodeFileToBase64(filePath); const mediaType = getMediaType(filePath); + debugLog('File encoded', { + mediaType: mediaType, + base64Length: `${(base64Data.length / 1024).toFixed(1)} KB`, + }); + // Analyze via CLIProxy const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + debugLog('Analysis complete', { + responseLength: `${description.length} chars`, + }); + // Output success outputSuccess(filePath, description, model, stats.size); } catch (err) { @@ -439,7 +711,27 @@ async function processHook() { // Ignore parse errors } - // Output error - outputError(filePath, err.message || 'Unknown error'); + // Categorize error by message pattern + const errMsg = err.message || ''; + + if (errMsg.startsWith('AUTH_ERROR:')) { + const statusCode = parseInt(errMsg.split(':')[1], 10); + outputAuthError(filePath, statusCode); + } else if (errMsg.startsWith('RATE_LIMIT:')) { + const retryAfter = errMsg.split(':')[1]; + outputRateLimitError(filePath, retryAfter ? parseInt(retryAfter, 10) : null); + } else if (errMsg.startsWith('API_ERROR:')) { + const parts = errMsg.split(':'); + const statusCode = parseInt(parts[1], 10); + const body = parts.slice(2).join(':'); + outputApiError(filePath, statusCode, body); + } else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) { + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + outputTimeoutError(filePath, timeout); + } else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) { + outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`); + } else { + outputUnknownError(filePath, errMsg); + } } } diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index e9132802..1db499e1 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -62,6 +62,12 @@ function showHelp(): void { console.log(' auth show Display current auth status'); console.log(' auth disable Disable authentication'); console.log(''); + console.log(' image-analysis Manage image analysis settings'); + console.log(' --enable Enable image analysis via CLIProxy'); + console.log(' --disable Disable image analysis'); + console.log(' --timeout Set analysis timeout (seconds)'); + console.log(' --set-model

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

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

', 'Set provider model'], + ['', ''], + ['Note:', 'When enabled, images/PDFs are analyzed via vision models'], + ['', 'instead of passing raw data to Claude. Works with CLIProxy'], + ['', 'providers (agy, gemini, codex, kiro, ghcp).'], + ]); + // CLI Proxy env vars printSubSection('CLI Proxy Environment Variables', [ ['CCS_PROXY_HOST', 'Remote proxy hostname'], diff --git a/src/commands/index.ts b/src/commands/index.ts index 007664e5..39e1651b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -6,6 +6,7 @@ export { handleApiCommand } from './api-command'; export { handleCleanupCommand } from './cleanup-command'; export { handleCliproxyCommand } from './cliproxy-command'; export { handleConfigCommand } from './config-command'; +export { handleConfigImageAnalysisCommand } from './config-image-analysis-command'; export { handleCopilotCommand } from './copilot-command'; export { handleDoctorCommand } from './doctor-command'; export { handleHelpCommand } from './help-command'; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 86b3cdc0..b6f741ec 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -529,6 +529,30 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Image analysis section + if (config.image_analysis) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Image Analysis: Vision-based analysis for images and PDFs'); + lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.'); + lines.push('#'); + lines.push('# When enabled: Image files trigger vision analysis instead of raw file read'); + lines.push('# Provider models: Vision model used for each CLIProxy provider'); + lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)'); + lines.push('#'); + lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf'); + lines.push('# Configure via: ccs config image-analysis'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump( + { image_analysis: config.image_analysis }, + { indent: 2, lineWidth: -1, quotingType: '"' } + ) + .trim() + ); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts new file mode 100644 index 00000000..fd0d0f3b --- /dev/null +++ b/src/management/checks/image-analysis-check.ts @@ -0,0 +1,159 @@ +/** + * Image Analysis Config Check + * + * Validates image_analysis configuration in config.yaml. + * Checks: enabled status, provider_models, timeout, CLIProxy availability. + */ + +import http from 'http'; +import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../../config/unified-config-types'; +import { ok, warn, dim } from '../../utils/ui'; +import type { HealthCheck } from './types'; + +/** + * Check CLIProxy availability (simple HTTP check) + */ +async function isCliProxyAvailable(): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: '127.0.0.1', + port: 8317, + path: '/', + method: 'GET', + timeout: 2000, + }, + (res) => { + resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 500); + } + ); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} + +/** + * Run image analysis configuration check + */ +export async function runImageAnalysisCheck(results: HealthCheck): Promise { + const config = getImageAnalysisConfig(); + + // Check 1: Feature status + if (!config.enabled) { + results.details['Image Analysis'] = { + status: 'OK', + info: 'Disabled (using native Read)', + }; + console.log(` ${dim('Status:')} Disabled`); + console.log(` ${dim('Tip:')} Enable with: ccs config image-analysis --enable`); + return; + } + + // Feature is enabled - run validation checks + console.log(` ${ok('Status:')} Enabled`); + + // Check 2: Provider models configured + const providers = Object.keys(config.provider_models); + if (providers.length === 0) { + results.details['Image Analysis'] = { + status: 'ERROR', + info: 'No providers configured', + }; + results.errors.push({ + name: 'Image Analysis', + message: 'No provider models configured for image analysis', + fix: 'ccs config image-analysis --set-model agy gemini-2.5-flash', + }); + console.log(` ${warn('Providers:')} None configured`); + return; + } + console.log(` ${ok('Providers:')} ${providers.join(', ')}`); + + // Check 3: Timeout validation + if (config.timeout < 10 || config.timeout > 600) { + results.details['Image Analysis'] = { + status: 'ERROR', + info: `Invalid timeout: ${config.timeout}s`, + }; + results.errors.push({ + name: 'Image Analysis', + message: `Timeout ${config.timeout}s out of range (10-600)`, + fix: 'ccs config image-analysis --timeout 60', + }); + console.log(` ${warn('Timeout:')} ${config.timeout}s (invalid, must be 10-600)`); + return; + } + console.log(` ${ok('Timeout:')} ${config.timeout}s`); + + // Check 4: CLIProxy availability (only if enabled) + const cliproxyAvailable = await isCliProxyAvailable(); + if (!cliproxyAvailable) { + results.details['Image Analysis'] = { + status: 'WARN', + info: `Enabled but CLIProxy not running`, + }; + results.warnings.push({ + name: 'Image Analysis', + message: 'CLIProxy not running - image analysis will fail', + fix: 'ccs config (starts CLIProxy)', + }); + console.log(` ${warn('CLIProxy:')} Not running at http://127.0.0.1:8317`); + console.log(` ${dim('Note:')} Start with: ccs config`); + return; + } + console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:8317`); + + // All checks passed + results.details['Image Analysis'] = { + status: 'OK', + info: `Enabled (${providers.length} providers)`, + }; +} + +/** + * Fix image analysis configuration issues + */ +export async function fixImageAnalysisConfig(): Promise { + const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/unified-config-loader' + ); + + const config = loadOrCreateUnifiedConfig(); + let fixed = false; + + // Fix missing provider_models + if ( + !config.image_analysis?.provider_models || + Object.keys(config.image_analysis.provider_models).length === 0 + ) { + config.image_analysis = { + ...config.image_analysis, + enabled: config.image_analysis?.enabled ?? true, + timeout: config.image_analysis?.timeout ?? 60, + provider_models: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models }, + }; + fixed = true; + } + + // Fix invalid timeout + if ( + config.image_analysis && + (config.image_analysis.timeout < 10 || config.image_analysis.timeout > 600) + ) { + config.image_analysis.timeout = 60; + fixed = true; + } + + if (fixed) { + updateUnifiedConfig({ image_analysis: config.image_analysis }); + } + + return fixed; +} diff --git a/src/management/checks/index.ts b/src/management/checks/index.ts index 1ea17e5f..680c8f0c 100644 --- a/src/management/checks/index.ts +++ b/src/management/checks/index.ts @@ -49,3 +49,6 @@ export { // OAuth checks export { OAuthPortsChecker, runOAuthChecks } from './oauth-check'; + +// Image Analysis checks +export { runImageAnalysisCheck, fixImageAnalysisConfig } from './image-analysis-check'; diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 1ec0abd3..dcd9c0b8 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -13,6 +13,7 @@ import { runSymlinkChecks, runCLIProxyChecks, runOAuthChecks, + runImageAnalysisCheck, } from './checks'; import { runAutoRepair } from './repair'; @@ -76,6 +77,11 @@ class Doctor { await runOAuthChecks(this.results); console.log(''); + // Group 8: Image Analysis Config + console.log(header('IMAGE ANALYSIS')); + await runImageAnalysisCheck(this.results); + console.log(''); + this.showReport(); return this.results; } diff --git a/tests/unit/commands/config-image-analysis-command.test.ts b/tests/unit/commands/config-image-analysis-command.test.ts new file mode 100644 index 00000000..b31af9f2 --- /dev/null +++ b/tests/unit/commands/config-image-analysis-command.test.ts @@ -0,0 +1,187 @@ +/** + * Config Image Analysis Command Tests + * + * Unit tests for ccs config image-analysis subcommand. + */ + +import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Create temp directory for test isolation +let testDir: string; +let originalCcsHome: string | undefined; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-image-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = testDir; +}); + +afterEach(() => { + if (originalCcsHome) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +// Helper to create config.yaml for tests +function createConfigYaml(content: string): void { + fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8'); +} + +describe('config image-analysis command', () => { + describe('config file parsing', () => { + it('should parse enabled status from config.yaml', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: true + timeout: 60 + provider_models: + agy: gemini-2.5-flash +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('enabled: true'); + expect(content).toContain('timeout: 60'); + expect(content).toContain('agy: gemini-2.5-flash'); + }); + + it('should parse disabled status from config.yaml', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: false + timeout: 120 + provider_models: {} +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('enabled: false'); + expect(content).toContain('timeout: 120'); + }); + + it('should parse multiple provider models', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: true + timeout: 60 + provider_models: + agy: gemini-2.5-flash + gemini: gemini-2.5-pro + codex: gpt-5.1-codex-mini + kiro: kiro-claude-haiku-4-5 +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('agy: gemini-2.5-flash'); + expect(content).toContain('gemini: gemini-2.5-pro'); + expect(content).toContain('codex: gpt-5.1-codex-mini'); + expect(content).toContain('kiro: kiro-claude-haiku-4-5'); + }); + }); + + describe('timeout validation', () => { + it('should accept valid timeout within range (10-600)', () => { + const validTimeouts = [10, 60, 120, 300, 600]; + + for (const timeout of validTimeouts) { + const isValid = timeout >= 10 && timeout <= 600; + expect(isValid).toBe(true); + } + }); + + it('should reject timeout below minimum (10)', () => { + const invalidTimeouts = [0, 1, 5, 9]; + + for (const timeout of invalidTimeouts) { + const isValid = timeout >= 10 && timeout <= 600; + expect(isValid).toBe(false); + } + }); + + it('should reject timeout above maximum (600)', () => { + const invalidTimeouts = [601, 700, 1000, 3600]; + + for (const timeout of invalidTimeouts) { + const isValid = timeout >= 10 && timeout <= 600; + expect(isValid).toBe(false); + } + }); + }); + + describe('provider validation', () => { + it('should accept valid providers', () => { + const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + + for (const provider of validProviders) { + expect(validProviders.includes(provider)).toBe(true); + } + }); + + it('should reject invalid providers', () => { + const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + const invalidProviders = ['unknown', 'custom', 'my-provider', 'test']; + + for (const provider of invalidProviders) { + expect(validProviders.includes(provider)).toBe(false); + } + }); + }); + + describe('default configuration', () => { + it('should have correct default values', () => { + // These are the expected defaults from unified-config-types.ts + const defaultConfig = { + enabled: true, + timeout: 60, + provider_models: { + agy: 'gemini-2.5-flash', + gemini: 'gemini-2.5-flash', + codex: 'gpt-5.1-codex-mini', + kiro: 'kiro-claude-haiku-4-5', + ghcp: 'claude-haiku-4.5', + claude: 'claude-haiku-4-5-20251001', + }, + }; + + expect(defaultConfig.enabled).toBe(true); + expect(defaultConfig.timeout).toBe(60); + expect(Object.keys(defaultConfig.provider_models).length).toBe(6); + }); + }); + + describe('config file structure', () => { + it('should have image_analysis section', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: true + timeout: 60 + provider_models: + agy: gemini-2.5-flash +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('image_analysis:'); + }); + + it('should support empty provider_models', () => { + createConfigYaml(` +version: 2 +image_analysis: + enabled: false + timeout: 60 + provider_models: {} +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + expect(content).toContain('provider_models: {}'); + }); + }); +});