Files
ccs/lib/hooks/image-analyzer-transformer.cjs
T
kaitranntt 70caaa00a0 fix(hooks): improve image analysis output format
Match websearch transformer format:
- Header: [Image Analysis via CLIProxy]
- Metadata: File name, size in KB, model used
- Separator lines for better readability
- Footer instruction for Claude
2026-02-03 20:58:05 -05:00

406 lines
11 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_MODEL - Model to use (default: gemini-2.5-flash)
* CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60)
* CCS_PROFILE_TYPE - Profile type (account/default skip)
* CCS_DEBUG=1 - Enable debug output
*
* Exit codes:
* 0 - Allow tool (pass-through to native Read)
* 2 - Block tool (deny with analysis/message)
*
* @module hooks/image-analyzer-transformer
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
// ============================================================================
// PLATFORM DETECTION
// ============================================================================
const isWindows = process.platform === 'win32';
// ============================================================================
// CONFIGURATION
// ============================================================================
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff'];
const PDF_EXTENSIONS = ['.pdf'];
const DEFAULT_MODEL = 'gemini-2.5-flash';
const DEFAULT_TIMEOUT_SEC = 60;
const MAX_FILE_SIZE_MB = 10;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const CLIPROXY_HOST = '127.0.0.1';
const CLIPROXY_PORT = parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10);
const CLIPROXY_PATH = '/v1/messages';
// API key passed via env from cliproxy-executor, defaults to CCS internal key
const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed';
// Default analysis prompt
const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description.
Include:
1. Overall content and purpose
2. Text content (if any) - transcribe important text
3. Visual elements (diagrams, charts, UI components)
4. Layout and structure
5. Colors, styling, notable design elements
6. Any actionable information (buttons, links, code)
Be comprehensive - this description replaces direct visual access.`;
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Check if file is an analyzable image or PDF
*/
function isAnalyzableFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext);
}
/**
* Get MIME type from file extension
*/
function getMediaType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.heic': 'image/heic',
'.bmp': 'image/bmp',
'.tiff': 'image/tiff',
'.pdf': 'application/pdf',
};
return mimeTypes[ext] || 'application/octet-stream';
}
/**
* Encode file to base64
*/
function encodeFileToBase64(filePath) {
const content = fs.readFileSync(filePath);
return content.toString('base64');
}
/**
* Check if CLIProxy is available
*/
function isCliProxyAvailable() {
return new Promise((resolve) => {
const req = http.request(
{
hostname: CLIPROXY_HOST,
port: CLIPROXY_PORT,
path: '/',
method: 'GET',
timeout: 2000,
},
(res) => {
resolve(res.statusCode >= 200 && res.statusCode < 500);
}
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
}
/**
* Analyze file via CLIProxy vision API
*/
function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
return new Promise((resolve, reject) => {
const requestBody = JSON.stringify({
model: model,
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: DEFAULT_PROMPT },
{
type: 'image',
source: {
type: 'base64',
media_type: mediaType,
data: base64Data,
},
},
],
},
],
});
const req = http.request(
{
hostname: CLIPROXY_HOST,
port: CLIPROXY_PORT,
path: CLIPROXY_PATH,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
'x-api-key': CLIPROXY_API_KEY,
},
timeout: timeoutMs,
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`CLIProxy returned status ${res.statusCode}: ${data}`));
return;
}
try {
const response = JSON.parse(data);
const text = response.content?.[0]?.text;
if (!text) {
reject(new Error('No text content in response'));
return;
}
resolve(text);
} catch (err) {
reject(new Error(`Failed to parse response: ${err.message}`));
}
});
}
);
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
req.write(requestBody);
req.end();
});
}
/**
* Format analysis description for Claude (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');
}
/**
* Output success response and exit
*/
function outputSuccess(filePath, description, model, fileSize) {
const formattedDescription = formatDescription(filePath, description, model, fileSize);
const output = {
decision: 'block',
reason: `Image analyzed: ${path.basename(filePath)}`,
systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy (${model})`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: formattedDescription,
},
};
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Output error message
*/
function outputError(filePath, error) {
const message = [
`[Image Analysis - Error]`,
'',
`Failed to analyze: ${path.basename(filePath)}`,
'',
`Error: ${error}`,
'',
'Troubleshooting:',
' - Check CLIProxy is running: http://127.0.0.1:8317',
' - Verify you are authenticated with agy or gemini',
' - Check file size is under 10MB',
].join('\n');
const output = {
decision: 'block',
reason: `Image analysis failed: ${error}`,
systemMessage: `[Image Analysis] Failed to analyze ${path.basename(filePath)}`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Determine if hook should skip
*/
function shouldSkipHook() {
// Explicit skip signal
if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') return true;
// Explicit disable
if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') return true;
// Account/default profiles - use native Read
const profileType = process.env.CCS_PROFILE_TYPE;
if (profileType === 'account' || profileType === 'default') return true;
return false;
}
// ============================================================================
// MAIN HOOK LOGIC
// ============================================================================
// Read input from stdin
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', () => {
processHook();
});
// Handle stdin not being available
process.stdin.on('error', () => {
process.exit(0);
});
/**
* Main hook processing logic
*/
async function processHook() {
try {
// Skip for native accounts or explicit disable
if (shouldSkipHook()) {
process.exit(0);
}
const data = JSON.parse(input);
// Only handle Read tool
if (data.tool_name !== 'Read') {
process.exit(0);
}
const filePath = data.tool_input?.file_path || '';
if (!filePath) {
process.exit(0);
}
// Check if file exists
if (!fs.existsSync(filePath)) {
// Let native Read handle the error
process.exit(0);
}
// Check if file is analyzable
if (!isAnalyzableFile(filePath)) {
process.exit(0);
}
// Check file size
const stats = fs.statSync(filePath);
if (stats.size > MAX_FILE_SIZE_BYTES) {
outputError(filePath, `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB > ${MAX_FILE_SIZE_MB}MB)`);
return;
}
// Check CLIProxy availability
const cliProxyAvailable = await isCliProxyAvailable();
if (!cliProxyAvailable) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] CLIProxy not available, passing through');
}
// Pass through to native Read
process.exit(0);
}
const model = process.env.CCS_IMAGE_ANALYSIS_MODEL || DEFAULT_MODEL;
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
const timeoutMs = timeout * 1000;
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`);
}
// Encode file to base64
const base64Data = encodeFileToBase64(filePath);
const mediaType = getMediaType(filePath);
// Analyze via CLIProxy
const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs);
// Output success
outputSuccess(filePath, description, model, 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
}
// Output error
outputError(filePath, err.message || 'Unknown error');
}
}