Files
ccs/lib/hooks/image-analyzer-transformer.cjs
T

919 lines
27 KiB
JavaScript
Executable File

#!/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)
* ANTHROPIC_MODEL - Chat model env (not used for image analysis fallback)
* 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 modelsToTry = getModelsToTry();
return {
file: path.basename(filePath),
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
provider: currentProvider,
model: model,
modelsToTry: modelsToTry.length > 1 ? modelsToTry.join(' -> ') : model,
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;
}
/**
* Extract concatenated text content from CLIProxy response blocks.
* Skips thinking and other non-text blocks.
*/
function extractTextContent(response) {
if (!response || !Array.isArray(response.content)) {
return null;
}
const textBlocks = response.content
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.filter((text) => text.trim());
if (textBlocks.length === 0) {
return null;
}
return textBlocks.join('\n\n');
}
/**
* Parse raw CLIProxy response body and extract text content.
*/
function parseCliProxyResponse(data) {
let response;
try {
response = JSON.parse(data);
} catch (err) {
throw new Error(`Failed to parse response: ${err.message}`);
}
const text = extractTextContent(response);
if (!text) {
throw new Error('No text content in response');
}
return text;
}
/**
* Get model for current provider from provider_models mapping
* Returns primary model only (for display/logging)
*/
function getModelForProvider() {
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
return providerModels[currentProvider] || DEFAULT_MODEL;
}
/**
* Get list of models to try in order:
* 1. provider_models[current_provider] (if exists)
* 2. DEFAULT_MODEL when no provider-specific vision model is configured
*
* ANTHROPIC_MODEL is intentionally ignored here because the chat model may
* not be vision-capable on the current provider route.
*/
function getModelsToTry() {
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const models = [];
const seen = new Set();
// 1. Provider-specific model
if (providerModels[currentProvider]) {
models.push(providerModels[currentProvider]);
seen.add(providerModels[currentProvider]);
}
// 2. Default model — only when no provider-specific model is configured,
// since DEFAULT_MODEL (gemini-2.5-flash) may not be routable on the
// current provider's CLIProxy endpoint (e.g. codex, claude)
if (models.length === 0 && !seen.has(DEFAULT_MODEL)) {
models.push(DEFAULT_MODEL);
seen.add(DEFAULT_MODEL);
}
return models;
}
/**
* Analyze with retry logic - tries models in order until one succeeds
*/
async function analyzeWithRetry(base64Data, mediaType, timeoutMs) {
const models = getModelsToTry();
// Defensive check - should never happen but provides clear error
if (models.length === 0) {
throw new Error('No models configured for image analysis');
}
let lastError = null;
for (let i = 0; i < models.length; i++) {
const model = models[i];
try {
debugLog(`Trying model ${i + 1}/${models.length}`, { model });
const result = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs);
if (i > 0) {
debugLog('Retry succeeded', { model, attempt: i + 1 });
}
return { description: result, model };
} catch (err) {
lastError = err;
const isLastModel = i === models.length - 1;
// Don't retry on certain errors (auth, rate limit, timeout, file access, network)
const errMsg = err.message || '';
const noRetryPatterns = [
'AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT',
'EACCES', 'EPERM', 'ECONNREFUSED',
'ENOTFOUND', 'ENETUNREACH', 'EAI_AGAIN' // Network errors - no point retrying
];
const shouldNotRetry = noRetryPatterns.some(p => errMsg.includes(p));
if (shouldNotRetry || isLastModel) {
debugLog('Analysis failed, no more retries', {
model,
error: errMsg,
reason: shouldNotRetry ? 'non-retryable error' : 'last model'
});
throw err;
}
debugLog('Model failed, trying next', {
model,
error: errMsg.substring(0, 100),
nextModel: models[i + 1]
});
}
}
throw lastError || new Error('No models available');
}
/**
* 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 text = parseCliProxyResponse(data);
resolve(text);
} catch (err) {
reject(err);
}
});
}
);
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 <provider>',
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* CLIProxy unavailable fallback - blocks Read to prevent context overflow
* When CLIProxy is not running, we cannot analyze the image.
* Blocking prevents the image from loading into Claude's context (100K+ tokens).
*/
function outputCliProxyUnavailableFallback(filePath) {
const fileName = filePath.split(/[/\\]/).pop() || filePath;
// Keep message minimal to avoid context pollution and hallucination
const message = [
'[Image Read Blocked]',
'',
`File: ${fileName}`,
'',
'CLIProxy unavailable. Image blocked to prevent context overflow.',
].join('\n');
const output = {
decision: 'block',
reason: 'CLIProxy unavailable - image blocked to prevent context overflow',
systemMessage: `[Image Blocked] ${fileName} - CLIProxy unavailable. Start: ccs config`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
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
*
* Two-phase design: Phase 1 filters non-image Read calls silently (exit 0).
* Phase 2 only runs for confirmed image/PDF files, so error messages are
* always relevant and never confuse users reading code or text files.
*/
async function processHook() {
// Phase 1: Fast bail-out for non-image files
// Any failure here → pass through silently to native Read
let filePath;
try {
const data = JSON.parse(input);
// Only handle Read tool
if (data.tool_name !== 'Read') {
process.exit(0);
}
filePath = data.tool_input?.file_path || '';
if (!filePath) {
process.exit(0);
}
// Check file extension BEFORE any other processing — this is the key gate
// that ensures non-image Read calls never see hook errors
if (!isAnalyzableFile(filePath)) {
process.exit(0);
}
} catch {
// stdin parse failure or unexpected error → pass through silently
process.exit(0);
}
// Phase 2: Image/PDF file processing — errors here are relevant to the user
try {
// Skip for native accounts or explicit disable
if (shouldSkipHook()) {
process.exit(0);
}
// Check if file exists
if (!fs.existsSync(filePath)) {
// Let native Read handle the error
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('Blocking: CLIProxy not available', {
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`,
action: 'blocking to prevent context overflow',
});
outputCliProxyUnavailableFallback(filePath);
return;
}
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 with retry logic
const { description, model: usedModel } = await analyzeWithRetry(base64Data, mediaType, timeoutMs);
debugLog('Analysis complete', {
responseLength: `${description.length} chars`,
model: usedModel,
});
// Output success
outputSuccess(filePath, description, usedModel, stats.size);
} catch (err) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] Error:', err.message);
}
// filePath is guaranteed set by Phase 1 — only image files reach here
// 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);
}
}
}