#!/usr/bin/env node const fs = require('fs'); const path = require('path'); function loadRuntimeModule() { const candidates = [ path.join(__dirname, 'image-analysis-runtime.cjs'), path.join(__dirname, '../hooks/image-analysis-runtime.cjs'), ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { return require(candidate); } } throw new Error( `ccs-image-analysis runtime not found. Checked: ${candidates.map((candidate) => path.basename(candidate)).join(', ')}` ); } const { analyzeFile, isAnalyzableFile } = loadRuntimeModule(); const PROTOCOL_VERSION = '2024-11-05'; const SERVER_NAME = 'ccs-image-analysis'; const SERVER_VERSION = '1.0.0'; const TOOL_NAME = 'ImageAnalysis'; const TOOL_ALIASES = ['AnalyzeImage', 'ReadImage']; const TEMPLATE_NAMES = ['default', 'screenshot', 'document']; const TOOL_DESCRIPTION = 'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.'; function isSupportedToolName(name) { return name === TOOL_NAME || TOOL_ALIASES.includes(name); } function shouldExposeTools() { return ( process.env.CCS_IMAGE_ANALYSIS_ENABLED === '1' && process.env.CCS_IMAGE_ANALYSIS_SKIP !== '1' && Boolean(process.env.CCS_CURRENT_PROVIDER || process.env.CCS_IMAGE_ANALYSIS_MODEL) ); } function getTools() { if (!shouldExposeTools()) { return []; } return [ { name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.', }, focus: { type: 'string', description: 'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".', }, template: { type: 'string', enum: TEMPLATE_NAMES, description: 'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.', }, }, required: ['filePath'], additionalProperties: false, }, }, ]; } function writeMessage(message) { process.stdout.write(`${JSON.stringify(message)}\n`); } function writeResponse(id, result) { writeMessage({ jsonrpc: '2.0', id, result, }); } function writeError(id, code, message) { writeMessage({ jsonrpc: '2.0', id, error: { code, message, }, }); } function formatResult(filePath, result, focus) { const lines = [ '[Image Analysis via CCS]', '', `File: ${path.basename(filePath)} (${(result.fileSize / 1024).toFixed(1)} KB)`, `Model: ${result.model}`, `Template: ${result.template}`, ]; if (focus && focus.trim()) { lines.push(`Focus: ${focus.trim()}`); } lines.push('', '---', '', result.description); return lines.join('\n'); } function normalizeTemplate(value) { if (typeof value !== 'string') { return undefined; } const normalized = value.trim().toLowerCase(); return TEMPLATE_NAMES.includes(normalized) ? normalized : undefined; } function normalizePathForComparison(value) { return process.platform === 'win32' ? value.toLowerCase() : value; } function isPathWithinWorkspace(workspaceRoot, candidatePath) { const relativePath = path.relative(workspaceRoot, candidatePath); return ( relativePath === '' || (!relativePath.startsWith('..') && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath)) ); } function resolveFilePath(toolArgs) { if (!toolArgs || typeof toolArgs !== 'object') { return ''; } const candidates = [toolArgs.filePath, toolArgs.file_path, toolArgs.path]; for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.trim().length > 0) { return candidate.trim(); } } return ''; } function resolveWorkspaceFilePath(toolArgs) { const requestedPath = resolveFilePath(toolArgs); if (!requestedPath) { return { filePath: '', error: null }; } const workspaceRoot = (() => { try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); } })(); const absolutePath = path.resolve(process.cwd(), requestedPath); const comparisonPath = (() => { if (fs.existsSync(absolutePath)) { return fs.realpathSync(absolutePath); } const suffixSegments = []; let currentPath = absolutePath; while (!fs.existsSync(currentPath)) { const parentPath = path.dirname(currentPath); if (parentPath === currentPath) { break; } suffixSegments.unshift(path.basename(currentPath)); currentPath = parentPath; } const resolvedExistingPath = fs.existsSync(currentPath) ? fs.realpathSync(currentPath) : path.resolve(currentPath); return path.join(resolvedExistingPath, ...suffixSegments); })(); if ( !isPathWithinWorkspace( normalizePathForComparison(workspaceRoot), normalizePathForComparison(comparisonPath) ) ) { return { filePath: '', error: 'ImageAnalysis only allows files inside the current workspace.', }; } return { filePath: absolutePath, error: null, }; } function resolveFocus(toolArgs) { return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0 ? toolArgs.focus.trim() : undefined; } function formatErrorDetail(filePath, error) { const message = error instanceof Error ? error.message : String(error); if (message.startsWith('FILE_TOO_LARGE:')) { const fileSizeBytes = Number.parseInt(message.split(':')[1], 10); const sizeMb = Number.isFinite(fileSizeBytes) ? (fileSizeBytes / 1024 / 1024).toFixed(1) : '?'; return `ImageAnalysis cannot process ${path.basename(filePath)} because it is too large (${sizeMb} MB). The limit is 10 MB.`; } if (message.startsWith('AUTH_ERROR:')) { return `ImageAnalysis failed because CCS vision auth for this provider is unavailable (${message.split(':')[1]}).`; } if (message.startsWith('RATE_LIMIT:')) { return `ImageAnalysis hit a provider rate limit while analyzing ${path.basename(filePath)}.`; } if (message.startsWith('API_ERROR:')) { return `ImageAnalysis failed at the CCS provider route while analyzing ${path.basename(filePath)}.`; } if ( message === 'TIMEOUT' || message.includes('timed out') || message.includes('timeout') || message.includes('ECONNREFUSED') || message.includes('ENOTFOUND') || message.includes('ENETUNREACH') || message.includes('EAI_AGAIN') ) { return `ImageAnalysis could not reach the configured CCS provider route for ${path.basename(filePath)}.`; } if (message.includes('EACCES') || message.includes('EPERM')) { return `ImageAnalysis could not read ${filePath} because access was denied.`; } return `ImageAnalysis failed for ${path.basename(filePath)}: ${message}`; } async function handleToolCall(message) { const id = message.id; const params = message.params || {}; const toolArgs = params.arguments || {}; const toolName = params.name || ''; if (!isSupportedToolName(toolName)) { writeError(id, -32602, `Unknown tool: ${toolName}`); return; } if (!shouldExposeTools()) { writeResponse(id, { content: [ { type: 'text', text: 'CCS ImageAnalysis is unavailable for this profile or no provider-backed vision route is ready.', }, ], isError: true, }); return; } const { filePath, error: filePathError } = resolveWorkspaceFilePath(toolArgs); if (!filePath) { writeError( id, -32602, filePathError || `Tool "${TOOL_NAME}" requires a non-empty filePath.` ); return; } if (!fs.existsSync(filePath)) { writeResponse(id, { content: [{ type: 'text', text: `ImageAnalysis could not find file: ${filePath}` }], isError: true, }); return; } if (!isAnalyzableFile(filePath)) { writeResponse(id, { content: [ { type: 'text', text: `ImageAnalysis only supports image and PDF files. Use Read for ${path.basename(filePath)} instead.`, }, ], isError: true, }); return; } const focus = resolveFocus(toolArgs); const template = normalizeTemplate(toolArgs.template); try { const result = await analyzeFile(filePath, { focus, template }); writeResponse(id, { content: [{ type: 'text', text: formatResult(filePath, result, focus) }], }); } catch (error) { writeResponse(id, { content: [{ type: 'text', text: formatErrorDetail(filePath, error) }], isError: true, }); } } async function handleMessage(message) { if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { return; } switch (message.method) { case 'initialize': writeResponse(message.id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {}, }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION, }, }); return; case 'notifications/initialized': return; case 'ping': writeResponse(message.id, {}); return; case 'tools/list': writeResponse(message.id, { tools: getTools() }); return; case 'tools/call': await handleToolCall(message); return; default: if (message.id !== undefined) { writeError(message.id, -32601, `Method not found: ${message.method}`); } } } let inputBuffer = Buffer.alloc(0); function processIncomingBuffer() { while (true) { let body; const startsWithLegacyHeaders = inputBuffer .slice(0, Math.min(inputBuffer.length, 32)) .toString('utf8') .toLowerCase() .startsWith('content-length:'); if (startsWithLegacyHeaders) { const headerEnd = inputBuffer.indexOf('\r\n\r\n'); if (headerEnd === -1) { return; } const headerText = inputBuffer.slice(0, headerEnd).toString('utf8'); const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i); if (!contentLengthMatch) { inputBuffer = Buffer.alloc(0); return; } const contentLength = Number.parseInt(contentLengthMatch[1], 10); const messageEnd = headerEnd + 4 + contentLength; if (inputBuffer.length < messageEnd) { return; } body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8'); inputBuffer = inputBuffer.slice(messageEnd); } else { const newlineIndex = inputBuffer.indexOf('\n'); if (newlineIndex === -1) { return; } body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); inputBuffer = inputBuffer.slice(newlineIndex + 1); if (!body) { continue; } } try { const message = JSON.parse(body); Promise.resolve(handleMessage(message)).catch((error) => { if (message && message.id !== undefined) { writeError(message.id, -32603, (error && error.message) || 'Internal error'); } else if (process.env.CCS_DEBUG) { console.error(`[ccs-image-analysis] ${error instanceof Error ? error.stack : error}`); } }); } catch (error) { if (process.env.CCS_DEBUG) { console.error( `[ccs-image-analysis] Failed to parse JSON-RPC message: ${ error instanceof Error ? error.message : error }` ); } } } } process.stdin.on('data', (chunk) => { inputBuffer = Buffer.concat([inputBuffer, chunk]); processIncomingBuffer(); }); process.stdin.on('end', () => { process.exit(0); });