mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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
406 lines
11 KiB
JavaScript
Executable File
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');
|
|
}
|
|
}
|