diff --git a/lib/hooks/image-analysis-runtime.cjs b/lib/hooks/image-analysis-runtime.cjs new file mode 100644 index 00000000..91188ce5 --- /dev/null +++ b/lib/hooks/image-analysis-runtime.cjs @@ -0,0 +1,458 @@ +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const path = require('path'); + +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 SCREENSHOT_NAME_REGEX = + /(screen[-_ ]?shot|screen[-_ ]?capture|screencap|snapshot|snip|clip|capture)/i; +const TEMPLATE_FILE_NAMES = { + default: 'default.txt', + screenshot: 'screenshot.txt', + document: 'document.txt', +}; +const FALLBACK_PROMPTS = { + default: `Analyze this image/document thoroughly and provide a detailed description. + +Include: +1. Overall content and purpose +2. Text content (if any) - transcribe important text verbatim +3. Visual elements (diagrams, charts, UI components, icons) +4. Layout and structure (sections, hierarchy, flow) +5. Colors, styling, notable design elements +6. Any actionable information (buttons, links, code snippets) + +Be comprehensive - this description replaces direct visual access. +The AI assistant reading this cannot see the original image.`, + screenshot: `Analyze this screenshot in detail for a developer who cannot see it. + +Focus on: +1. Application/website type and state +2. UI elements visible (buttons, inputs, menus, modals) +3. All text content - transcribe exactly +4. Error messages or notifications (quote exactly) +5. Layout and component hierarchy +6. Interactive elements and their states +7. Console output or logs if visible +8. Any code snippets shown + +Be precise - this enables the assistant to help debug or understand the UI.`, + document: `Analyze this document/PDF thoroughly for a developer. + +Extract and provide: +1. Document title, type, and structure +2. All text content - transcribe in reading order +3. Tables - format as markdown tables +4. Lists and bullet points - preserve structure +5. Code blocks or technical content +6. Diagrams or flowcharts - describe in detail +7. Headers and section organization +8. Any important metadata visible + +Accuracy in text extraction is critical.`, +}; + +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 && value !== '') { + lines.push(` ${key}: ${value}`); + } + } + console.error(lines.join('\n')); +} + +function parseProviderModels(envValue) { + if (!envValue) return {}; + return envValue.split(',').reduce((acc, pair) => { + const [provider, ...modelParts] = pair.split(':'); + const model = modelParts.join(':').trim(); + if (provider && model) { + acc[provider.trim()] = model; + } + return acc; + }, {}); +} + +function normalizeTemplateName(value) { + if (typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + return Object.prototype.hasOwnProperty.call(TEMPLATE_FILE_NAMES, normalized) ? normalized : null; +} + +function selectPromptTemplate(filePath, requestedTemplate) { + const explicitTemplate = normalizeTemplateName(requestedTemplate); + if (explicitTemplate) { + return explicitTemplate; + } + + const extension = path.extname(filePath).toLowerCase(); + if (PDF_EXTENSIONS.includes(extension)) { + return 'document'; + } + + return SCREENSHOT_NAME_REGEX.test(path.basename(filePath)) ? 'screenshot' : 'default'; +} + +function readPromptFile(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8').trim(); + return content.length > 0 ? content : null; + } catch { + return null; + } +} + +function loadPromptTemplate(filePath, requestedTemplate, focus) { + const template = selectPromptTemplate(filePath, requestedTemplate); + const promptsDir = process.env.CCS_IMAGE_ANALYSIS_PROMPTS_DIR || ''; + const promptPath = promptsDir + ? path.join(promptsDir, TEMPLATE_FILE_NAMES[template]) + : null; + const promptText = (promptPath && readPromptFile(promptPath)) || FALLBACK_PROMPTS[template]; + + if (!focus || !focus.trim()) { + return { + template, + promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback', + prompt: promptText, + }; + } + + return { + template, + promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback', + prompt: `${promptText}\n\nSpecific focus:\n${focus.trim()}`, + }; +} + +function getCurrentProvider() { + return process.env.CCS_CURRENT_PROVIDER || ''; +} + +function getConfiguredModel() { + const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL; + if (explicitModel && explicitModel.trim()) { + return explicitModel.trim(); + } + + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + return providerModels[getCurrentProvider()] || DEFAULT_MODEL; +} + +function getModelsToTry() { + const models = []; + const seen = new Set(); + + const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL; + if (explicitModel && explicitModel.trim()) { + models.push(explicitModel.trim()); + seen.add(explicitModel.trim()); + } + + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const providerModel = providerModels[getCurrentProvider()]; + if (providerModel && !seen.has(providerModel)) { + models.push(providerModel); + seen.add(providerModel); + } + + if (models.length === 0) { + models.push(DEFAULT_MODEL); + } + + return models; +} + +function getRuntimeBaseUrl() { + const runtimePath = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH || '') + .trim() + .replace(/\/+$/, ''); + const explicitBaseUrl = process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL; + if (explicitBaseUrl && explicitBaseUrl.trim()) { + const normalizedBaseUrl = explicitBaseUrl.trim().replace(/\/+$/, ''); + if (!runtimePath) { + return normalizedBaseUrl; + } + + try { + const parsed = new URL(normalizedBaseUrl); + const normalizedPath = parsed.pathname.replace(/\/+$/, ''); + if (normalizedPath === runtimePath) { + return normalizedBaseUrl; + } + + if (normalizedPath.length > 0 && normalizedPath !== '/') { + return normalizedBaseUrl; + } + + parsed.pathname = runtimePath; + return parsed.toString().replace(/\/+$/, ''); + } catch { + return `${normalizedBaseUrl}${runtimePath}`; + } + } + + const port = Number.parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10); + return `http://127.0.0.1:${port}${runtimePath}`; +} + +function getRuntimeEndpoint() { + return `${getRuntimeBaseUrl()}/v1/messages`; +} + +function getApiKey() { + if (Object.prototype.hasOwnProperty.call(process.env, 'CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY')) { + const explicitApiKey = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY || '').trim(); + return explicitApiKey || 'ccs-internal-managed'; + } + + return process.env.CCS_CLIPROXY_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || 'ccs-internal-managed'; +} + +function getTimeoutMs(timeoutMs) { + if (typeof timeoutMs === 'number' && timeoutMs > 0) { + return timeoutMs; + } + + const timeoutSec = Number.parseInt( + process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`, + 10 + ); + return Math.max(1, Math.min(600, timeoutSec)) * 1000; +} + +function isAnalyzableFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext); +} + +function getMediaType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return ( + { + '.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', + }[ext] || 'application/octet-stream' + ); +} + +function encodeFileToBase64(filePath) { + return fs.readFileSync(filePath).toString('base64'); +} + +function buildContentBlock(base64Data, mediaType) { + const source = { + type: 'base64', + media_type: mediaType, + data: base64Data, + }; + + if (mediaType === 'application/pdf') { + return { + type: 'document', + source, + }; + } + + return { + type: 'image', + source, + }; +} + +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()); + + return textBlocks.length > 0 ? textBlocks.join('\n\n') : null; +} + +function parseCliProxyResponse(data) { + const response = JSON.parse(data); + const text = extractTextContent(response); + if (!text) { + throw new Error('No text content in response'); + } + return text; +} + +function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) { + return new Promise((resolve, reject) => { + const endpoint = new URL(getRuntimeEndpoint()); + const transport = endpoint.protocol === 'https:' ? https : http; + const requestBody = JSON.stringify({ + model, + max_tokens: 4096, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + buildContentBlock(base64Data, mediaType), + ], + }, + ], + }); + + const req = transport.request( + { + protocol: endpoint.protocol, + hostname: endpoint.hostname, + port: endpoint.port, + path: `${endpoint.pathname}${endpoint.search}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody), + 'x-api-key': getApiKey(), + }, + timeout: timeoutMs, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 401 || res.statusCode === 403) { + reject(new Error(`AUTH_ERROR:${res.statusCode}`)); + return; + } + + if (res.statusCode === 429) { + reject(new Error(`RATE_LIMIT:${res.headers['retry-after'] || ''}`)); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`API_ERROR:${res.statusCode}:${data}`)); + return; + } + + try { + resolve(parseCliProxyResponse(data)); + } catch (error) { + reject(error); + } + }); + } + ); + + req.on('error', (error) => reject(error)); + req.on('timeout', () => { + req.destroy(); + reject(new Error('TIMEOUT')); + }); + req.write(requestBody); + req.end(); + }); +} + +async function analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs) { + const models = getModelsToTry(); + let lastError = null; + + for (const [index, model] of models.entries()) { + try { + debugLog(`Trying model ${index + 1}/${models.length}`, { model }); + const description = await analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs); + return { description, model }; + } catch (error) { + lastError = error; + const message = error.message || ''; + if ( + index === models.length - 1 || + ['AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', 'EACCES', 'EPERM', 'ECONNREFUSED'].some((token) => + message.includes(token) + ) + ) { + throw error; + } + } + } + + throw lastError || new Error('No models configured for image analysis'); +} + +async function analyzeFile(filePath, options = {}) { + const stats = fs.statSync(filePath); + if (stats.size >= MAX_FILE_SIZE_BYTES) { + throw new Error(`FILE_TOO_LARGE:${stats.size}`); + } + + const timeoutMs = getTimeoutMs(options.timeoutMs); + const { template, prompt, promptSource } = loadPromptTemplate( + filePath, + options.template, + options.focus + ); + const model = getConfiguredModel(); + + debugLog('Starting image analysis', { + file: path.basename(filePath), + size: `${(stats.size / 1024).toFixed(1)} KB`, + provider: getCurrentProvider() || 'unknown', + model, + modelsToTry: getModelsToTry().join(' -> '), + timeout: `${timeoutMs / 1000}s`, + endpoint: getRuntimeEndpoint(), + template, + promptSource, + }); + + const base64Data = encodeFileToBase64(filePath); + const mediaType = getMediaType(filePath); + debugLog('File encoded', { + mediaType, + base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`, + }); + + const result = await analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs); + debugLog('Analysis complete', { + responseLength: `${result.description.length} chars`, + model: result.model, + template, + }); + + return { + description: result.description, + model: result.model, + fileSize: stats.size, + mediaType, + template, + }; +} + +module.exports = { + DEFAULT_MODEL, + DEFAULT_TIMEOUT_SEC, + MAX_FILE_SIZE_BYTES, + analyzeFile, + getRuntimeEndpoint, + isAnalyzableFile, + parseProviderModels, + selectPromptTemplate, +}; diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index ba86f4d3..77f1ee11 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -24,7 +24,7 @@ const fs = require('fs'); const path = require('path'); -const http = require('http'); +const { analyzeFile, isAnalyzableFile, parseProviderModels } = require('./image-analysis-runtime.cjs'); // ============================================================================ // PLATFORM DETECTION @@ -36,19 +36,8 @@ 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) @@ -65,23 +54,6 @@ const ERROR_CODES = { 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 @@ -105,19 +77,19 @@ function debugLog(message, data = {}) { */ 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 model = + process.env.CCS_IMAGE_ANALYSIS_MODEL || + parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[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}`, + endpoint: process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL || '(runtime fallback)', }; } @@ -126,319 +98,12 @@ function getDebugContext(filePath, stats) { */ 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; + const model = + process.env.CCS_IMAGE_ANALYSIS_MODEL || + parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[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) */ @@ -520,29 +185,6 @@ function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) { 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( @@ -758,10 +400,11 @@ function shouldSkipHook() { } // Check if current provider has a vision model configured + const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL; const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); - if (!providerModels[currentProvider]) { + if (!explicitModel?.trim() && !providerModels[currentProvider]) { debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, { configured_providers: Object.keys(providerModels).join(', ') || 'none', }); @@ -832,57 +475,15 @@ async function processHook() { 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; - } + const debugContext = getDebugContext(filePath, null); + debugLog('Image analysis runtime prepared', debugContext); - // 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); + const result = await analyzeFile(filePath); + outputSuccess(filePath, result.description, result.model, result.fileSize); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Error:', err.message); @@ -893,7 +494,10 @@ async function processHook() { // Categorize error by message pattern const errMsg = err.message || ''; - if (errMsg.startsWith('AUTH_ERROR:')) { + if (errMsg.startsWith('FILE_TOO_LARGE:')) { + const fileSizeMb = Number.parseInt(errMsg.split(':')[1], 10) / 1024 / 1024; + outputFileTooLargeError(filePath, fileSizeMb, 10); + } else if (errMsg.startsWith('AUTH_ERROR:')) { const statusCode = parseInt(errMsg.split(':')[1], 10); outputAuthError(filePath, statusCode); } else if (errMsg.startsWith('RATE_LIMIT:')) { @@ -907,8 +511,13 @@ async function processHook() { } 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('ECONNREFUSED') || + errMsg.includes('ENOTFOUND') || + errMsg.includes('ENETUNREACH') || + errMsg.includes('EAI_AGAIN') + ) { + outputCliProxyUnavailableFallback(filePath); } else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) { outputFileAccessError(filePath, errMsg); } else { diff --git a/lib/mcp/ccs-image-analysis-server.cjs b/lib/mcp/ccs-image-analysis-server.cjs new file mode 100644 index 00000000..3616d540 --- /dev/null +++ b/lib/mcp/ccs-image-analysis-server.cjs @@ -0,0 +1,337 @@ +#!/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: + 'Absolute or workspace-relative path 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 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 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 = resolveFilePath(toolArgs); + if (!filePath) { + writeError(id, -32602, `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) { + const newlineIndex = inputBuffer.indexOf(0x0a); + if (newlineIndex === -1) { + return; + } + + const lineBuffer = inputBuffer.subarray(0, newlineIndex); + inputBuffer = inputBuffer.subarray(newlineIndex + 1); + const line = lineBuffer.toString('utf8').replace(/\r$/, '').trim(); + if (!line) { + continue; + } + + try { + const message = JSON.parse(line); + Promise.resolve(handleMessage(message)).catch((error) => { + 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); +}); diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 50f3b86b..28d43d11 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -11,6 +11,7 @@ import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { isSensitiveKey } from '../../utils/sensitive-keys'; import { isReservedName } from '../../config/reserved-names'; import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader'; @@ -219,6 +220,7 @@ export function registerApiProfileOrphans(options?: { try { if (orphan.validation.valid) { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false); result.registered.push(orphan.name); @@ -269,6 +271,7 @@ export function copyApiProfile( writeJsonObjectAtomically(destinationSettingsPath, sourceSettings); try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (hookError) { rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted); throw hookError; @@ -394,6 +397,7 @@ export function importApiProfileBundle( writeJsonObjectAtomically(settingsPath, settings); try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (hookError) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw hookError; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index efb70c32..a39fdce9 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -9,6 +9,7 @@ import { expandPath } from '../../utils/helpers'; import { validateApiName } from './validation-service'; import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; import { isReservedName } from '../../config/reserved-names'; @@ -127,6 +128,7 @@ function createSettingsFile( try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; @@ -216,6 +218,7 @@ function createApiProfileUnified( try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; diff --git a/src/ccs.ts b/src/ccs.ts index aafaee74..acc261b5 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -31,11 +31,17 @@ import { appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, } from './utils/websearch-manager'; +import { + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, + appendThirdPartyImageAnalysisToolArgs, +} from './utils/image-analysis'; import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { + applyImageAnalysisRuntimeOverrides, getImageAnalysisHookEnv, - installImageAnalyzerHook, + prepareImageAnalysisFallbackHook, resolveImageAnalysisRuntimeStatus, } from './utils/hooks'; import { fail, info, warn } from './utils/ui'; @@ -74,7 +80,11 @@ import { resolveDroidReasoningRuntime, } from './targets/droid-reasoning-runtime'; import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router'; -import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge'; +import { + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, +} from './api/services/cliproxy-profile-bridge'; +import { getEffectiveApiKey } from './cliproxy/auth-token-manager'; // Version and Update check utilities import { getVersion } from './utils/version'; @@ -685,7 +695,10 @@ async function main(): Promise { // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } + const imageAnalysisFallbackHookReady = + resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false; const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); // Inject Image Analyzer hook into profile settings before launch ensureImageAnalyzerHooks({ @@ -694,6 +707,7 @@ async function main(): Promise { cliproxyProvider: provider, isComposite: profileInfo.isComposite, settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles const variantPort = profileInfo.port; // variant-specific port for isolation @@ -848,11 +862,14 @@ async function main(): Promise { } else if (profileInfo.type === 'copilot') { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy ensureWebSearchMcpOrThrow(); - installImageAnalyzerHook(); + ensureImageAnalysisMcpOrThrow(); + const imageAnalysisFallbackHookReady = + resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false; // Inject Image Analyzer hook into profile settings before launch ensureImageAnalyzerHooks({ profileName: profileInfo.name, profileType: profileInfo.type, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); const { executeCopilotProfile } = await import('./copilot'); @@ -884,7 +901,7 @@ async function main(): Promise { // Settings-based profiles (glm, glmt) are third-party providers if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); - installImageAnalyzerHook(); + ensureImageAnalysisMcpOrThrow(); } // Display WebSearch status (single line, equilibrium UX) @@ -907,6 +924,9 @@ async function main(): Promise { } const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); + syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + const imageAnalysisFallbackHookReady = + resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false; const expandedSettingsPath = resolvedSettingsPath ?? (profileInfo.settingsPath @@ -920,6 +940,7 @@ async function main(): Promise { settingsPath: expandedSettingsPath, settings, cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); if (resolvedTarget !== 'claude') { const compatibility = evaluateTargetRuntimeCompatibility({ @@ -1022,13 +1043,24 @@ async function main(): Promise { profileType: profileInfo.type, settings, cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); + const currentBridgeAuthToken = cliproxyBridge + ? resolveCliproxyBridgeProfile(cliproxyBridge.provider).apiKey + : getEffectiveApiKey(); let imageAnalysisEnv = getImageAnalysisHookEnv({ profileName: profileInfo.name, profileType: profileInfo.type, settings, cliproxyBridge, }); + imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, { + backendId: imageAnalysisStatus.backendId, + model: imageAnalysisStatus.model, + runtimePath: imageAnalysisStatus.runtimePath, + baseUrl: cliproxyBridge?.currentBaseUrl || '', + apiKey: currentBridgeAuthToken, + }); const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; if ( @@ -1130,7 +1162,7 @@ async function main(): Promise { const launchArgs = [ '--settings', expandedSettingsPath, - ...appendThirdPartyWebSearchToolArgs(remainingArgs), + ...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(remainingArgs)), ]; const traceEnv = createWebSearchTraceContext({ launcher: 'ccs.settings-profile', diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 83a0294b..688bfd05 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -20,7 +20,10 @@ import { applyExtendedContextConfig } from '../config/extended-context-config'; import { CLIProxyProvider } from '../types'; import { CompositeTierConfig } from '../../config/unified-config-types'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; -import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env'; +import { + applyImageAnalysisRuntimeOverrides, + getImageAnalysisHookEnv, +} from '../../utils/hooks/get-image-analysis-hook-env'; import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; @@ -30,6 +33,7 @@ import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer'; import type { ProxyTarget } from '../proxy-target-resolver'; +import { getEffectiveApiKey } from '../auth-token-manager'; import { isSettings, type Settings } from '../../types/config'; export interface RemoteProxyConfig { @@ -76,6 +80,7 @@ interface CliproxyImageAnalysisDeps { hasImageAnalysisProfileHook: typeof hasImageAnalysisProfileHook; hasImageAnalyzerHook: typeof hasImageAnalyzerHook; resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus; + getLocalRuntimeApiKey: typeof getEffectiveApiKey; } interface ResolveCliproxyImageAnalysisEnvOptions { @@ -97,6 +102,7 @@ const defaultCliproxyImageAnalysisDeps: CliproxyImageAnalysisDeps = { hasImageAnalysisProfileHook, hasImageAnalyzerHook, resolveImageAnalysisRuntimeStatus, + getLocalRuntimeApiKey: getEffectiveApiKey, }; const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i; @@ -148,6 +154,14 @@ function loadImageAnalysisSettings(settingsPath?: string): Settings | undefined } } +function buildDirectProxyBaseUrl(target: ProxyTarget): string { + const isDefaultPort = + (target.protocol === 'https' && target.port === 443) || + (target.protocol === 'http' && target.port === 80); + const portSuffix = isDefaultPort ? '' : `:${target.port}`; + return `${target.protocol}://${target.host}${portSuffix}`; +} + export async function resolveCliproxyImageAnalysisEnv( options: ResolveCliproxyImageAnalysisEnvOptions, deps: Partial = {} @@ -190,7 +204,18 @@ export async function resolveCliproxyImageAnalysisEnv( }; } - return { env, warning: null }; + return { + env: applyImageAnalysisRuntimeOverrides(env, { + backendId: status.backendId, + model: status.model, + runtimePath: status.runtimePath, + baseUrl: buildDirectProxyBaseUrl(options.proxyTarget), + apiKey: options.proxyTarget.isRemote + ? options.proxyTarget.authToken || '' + : resolvedDeps.getLocalRuntimeApiKey(), + }), + warning: null, + }; } /** diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index b7b6cdeb..16ee372b 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -58,8 +58,12 @@ import { appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, } from '../../utils/websearch-manager'; +import { + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, + appendThirdPartyImageAnalysisToolArgs, +} from '../../utils/image-analysis'; import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; -import { installImageAnalyzerHook } from '../../utils/hooks'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types'; import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance'; @@ -205,11 +209,9 @@ export async function execClaudeWithCLIProxy( // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); displayWebSearchStatus(); - // Sync image analyzer hook from npm package to ~/.ccs/hooks/ - installImageAnalyzerHook(); - const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); warnOAuthBanRisk(provider); @@ -870,6 +872,8 @@ export async function execClaudeWithCLIProxy( } } + syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + // Build initial env vars to get ANTHROPIC_BASE_URL const initialEnvVars = buildClaudeEnvironment({ provider, @@ -1072,7 +1076,11 @@ export async function execClaudeWithCLIProxy( : getProviderSettingsPath(provider); let claude: ChildProcess; - const launchArgs = ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)]; + const launchArgs = [ + '--settings', + settingsPath, + ...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(claudeArgs)), + ]; const traceEnv = createWebSearchTraceContext({ launcher: 'cliproxy.executor', args: launchArgs, diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 924aa95d..2e3b89ec 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -15,7 +15,9 @@ import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIProxyProvider } from '../types'; import { CompositeTierConfig } from '../../config/unified-config-types'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; import { getEffectiveApiKey } from '../auth-token-manager'; import { warn } from '../../utils/ui'; import { normalizeModelIdForProvider } from '../model-id-normalizer'; @@ -155,10 +157,12 @@ export function createSettingsFile( try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); // Inject Image Analyzer hooks into variant settings ensureImageAnalyzerHooks({ @@ -166,6 +170,7 @@ export function createSettingsFile( profileType: 'cliproxy', cliproxyProvider: provider, settingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); return settingsPath; @@ -194,10 +199,12 @@ export function createSettingsFileUnified( try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); // Inject Image Analyzer hooks into variant settings ensureImageAnalyzerHooks({ @@ -205,6 +212,7 @@ export function createSettingsFileUnified( profileType: 'cliproxy', cliproxyProvider: provider, settingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); return settingsPath; @@ -297,16 +305,19 @@ export function createCompositeSettingsFile( if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) { try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); ensureImageAnalyzerHooks({ profileName: `composite-${name}`, profileType: 'cliproxy', cliproxyProvider: tiers[defaultTier].provider, isComposite: true, settingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); } diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index fb45f770..a829f00a 100644 --- a/src/commands/install-command.ts +++ b/src/commands/install-command.ts @@ -6,6 +6,8 @@ import { info, ok, color, box, initUI } from '../utils/ui'; import { uninstallWebSearchHook, uninstallWebSearchMcp } from '../utils/websearch'; +import { uninstallImageAnalysisMcp } from '../utils/image-analysis'; +import { uninstallImageAnalyzerHook } from '../utils/hooks'; import { ClaudeSymlinkManager } from '../utils/claude-symlink-manager'; /** @@ -49,12 +51,25 @@ export async function handleUninstallCommand(): Promise { removed += 1; } - // 3. Remove symlinks from ~/.claude/ + // 3. Remove Image Analysis hook fallback + managed MCP runtime + const imageHookRemoved = uninstallImageAnalyzerHook(); + if (imageHookRemoved) { + console.log(ok('Removed Image Analysis hook fallback')); + removed += 1; + } + + const imageMcpRemoved = uninstallImageAnalysisMcp(); + if (imageMcpRemoved) { + console.log(ok('Removed Image Analysis MCP runtime')); + removed += 1; + } + + // 4. Remove symlinks from ~/.claude/ const symlinkManager = new ClaudeSymlinkManager(); const symlinksRemoved = symlinkManager.uninstall(); removed += symlinksRemoved; // Add actual count of symlinks removed - // 4. Summary + // 5. Summary console.log(''); if (removed > 0) { console.log(ok('Uninstall complete!')); diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index e727fbb4..c78763d6 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -9,6 +9,7 @@ import { spawn } from 'child_process'; import { CopilotConfig } from '../config/unified-config-types'; import { getGlobalEnvConfig } from '../config/unified-config-loader'; import { ensureCliproxyService } from '../cliproxy'; +import { getEffectiveApiKey } from '../cliproxy/auth-token-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { isDaemonRunning, startDaemon } from './copilot-daemon'; @@ -22,13 +23,23 @@ import { createWebSearchTraceContext, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; -import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks'; +import { + appendThirdPartyImageAnalysisToolArgs, + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, +} from '../utils/image-analysis'; +import { + applyImageAnalysisRuntimeOverrides, + getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeStatus, +} from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; interface CopilotImageAnalysisDeps { ensureCliproxyService: typeof ensureCliproxyService; getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv; resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus; + getLocalRuntimeApiKey: typeof getEffectiveApiKey; } interface CopilotImageAnalysisResolution { @@ -96,6 +107,7 @@ export async function resolveCopilotImageAnalysisEnv( ensureCliproxyService, getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus, + getLocalRuntimeApiKey: getEffectiveApiKey, ...deps, }; @@ -141,7 +153,16 @@ export async function resolveCopilotImageAnalysisEnv( } } - return { env, warning: null }; + return { + env: applyImageAnalysisRuntimeOverrides(env, { + backendId: status.backendId, + model: status.model, + runtimePath: status.runtimePath, + baseUrl: `http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`, + apiKey: resolvedDeps.getLocalRuntimeApiKey(), + }), + warning: null, + }; } /** @@ -251,11 +272,15 @@ export async function executeCopilotProfile( } console.log(''); + ensureImageAnalysisMcpOrThrow(); syncWebSearchMcpToConfigDir(claudeConfigDir); + syncImageAnalysisMcpToConfigDir(claudeConfigDir); // Spawn Claude CLI return new Promise((resolve) => { - const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs); + const launchArgs = appendThirdPartyWebSearchToolArgs( + appendThirdPartyImageAnalysisToolArgs(claudeArgs) + ); const traceEnv = createWebSearchTraceContext({ launcher: 'copilot.executor', args: launchArgs, diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 074016b9..903d5a6e 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -15,10 +15,26 @@ import { ui, warn, info } from '../utils/ui'; import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types'; import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; import { buildExecutionResult } from './executor/result-aggregator'; -import { getCcsDir, getModelDisplayName } from '../utils/config-manager'; +import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager'; import { getProfileLookupCandidates } from '../utils/profile-compat'; import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; +import { + appendThirdPartyImageAnalysisToolArgs, + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, +} from '../utils/image-analysis'; +import { + applyImageAnalysisRuntimeOverrides, + getImageAnalysisHookEnv, + prepareImageAnalysisFallbackHook, + resolveImageAnalysisRuntimeStatus, +} from '../utils/hooks'; +import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../utils/hooks/image-analyzer-profile-hook-injector'; +import { resolveCliproxyBridgeMetadata, resolveCliproxyBridgeProfile } from '../api/services'; +import { ensureCliproxyService } from '../cliproxy'; +import { getEffectiveApiKey } from '../cliproxy/auth-token-manager'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { appendThirdPartyWebSearchToolArgs, appendWebSearchTrace, @@ -105,7 +121,80 @@ export class HeadlessExecutor { } ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); + syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + + const settings = loadSettings(settingsPath); + const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profile, + profileType: 'settings', + settingsPath, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ + profileName: profile, + profileType: 'settings', + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + const currentBridgeAuthToken = cliproxyBridge + ? resolveCliproxyBridgeProfile(cliproxyBridge.provider).apiKey + : getEffectiveApiKey(); + let imageAnalysisEnv = getImageAnalysisHookEnv({ + profileName: profile, + profileType: 'settings', + settings, + cliproxyBridge, + }); + imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, { + backendId: imageAnalysisStatus.backendId, + model: imageAnalysisStatus.model, + runtimePath: imageAnalysisStatus.runtimePath, + baseUrl: cliproxyBridge?.currentBaseUrl || '', + apiKey: currentBridgeAuthToken, + }); + + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; + if ( + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider && + imageAnalysisStatus.effectiveRuntimeMode === 'native-read' + ) { + console.error( + info( + `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } else if ( + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider && + imageAnalysisStatus.proxyReadiness === 'stopped' + ) { + const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, false); + if (!ensureServiceResult.started) { + console.error( + warn( + `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + } // Smart slash command detection and preservation const processedPrompt = this._processSlashCommand(enhancedPrompt); @@ -190,7 +279,9 @@ export class HeadlessExecutor { } } - const launchArgs = appendThirdPartyWebSearchToolArgs(args); + const launchArgs = appendThirdPartyWebSearchToolArgs( + appendThirdPartyImageAnalysisToolArgs(args) + ); const traceEnv = createWebSearchTraceContext({ launcher: 'delegation.headless-executor', args: launchArgs, @@ -217,6 +308,7 @@ export class HeadlessExecutor { sessionId, sessionMgr, claudeConfigDir: inheritedClaudeConfigDir, + imageAnalysisEnv, traceEnv, }); } @@ -235,6 +327,7 @@ export class HeadlessExecutor { sessionId: string | null; sessionMgr: SessionManager; claudeConfigDir?: string; + imageAnalysisEnv?: Record; traceEnv?: Record; } ): Promise { @@ -246,6 +339,7 @@ export class HeadlessExecutor { sessionId, sessionMgr, claudeConfigDir, + imageAnalysisEnv = {}, traceEnv = {}, } = ctx; @@ -265,6 +359,7 @@ export class HeadlessExecutor { ...process.env, ...getClaudeLaunchEnvOverrides(), ...getWebSearchHookEnv(), + ...imageAnalysisEnv, ...traceEnv, ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), CCS_PROFILE_TYPE: 'settings', diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 34d35394..7ef0645c 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -70,15 +70,17 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise if (!cliproxyAvailable) { results.details['Image Analysis'] = { status: 'WARN', - info: `Enabled but CLIProxy not running`, + info: 'Enabled; local CLIProxy will start on launch if needed', }; results.warnings.push({ name: 'Image Analysis', - message: 'CLIProxy not running - image analysis will fail', - fix: 'ccs config (starts CLIProxy)', + message: + 'CLIProxy not running yet - CCS will start it automatically when ImageAnalysis is used', + fix: 'Optional warm-up: ccs config', }); - console.log(` ${warn('CLIProxy:')} Not running at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`); - console.log(` ${dim('Note:')} Start with: ccs config`); + console.log( + ` ${warn('CLIProxy:')} Idle at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT} (auto-start on launch)` + ); return; } console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`); diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 776ebf98..b9729459 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -14,7 +14,7 @@ import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsDir, getCcsHome } from '../utils/config-manager'; -const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch']); +const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis']); /** Options for instance creation */ export interface InstanceOptions { diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 40835023..b68546eb 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -8,7 +8,9 @@ */ import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { resolveCliproxyBridgeProfile } from '../../api/services/cliproxy-profile-bridge'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { getPromptsDir } from '../image-analysis/hook-installer'; import { resolveImageAnalysisStatus, type ImageAnalysisResolutionContext, @@ -23,6 +25,14 @@ function serializeProviderModels(providerModels: Record): string .join(','); } +export interface ImageAnalysisRuntimeOverrides { + backendId?: string | null; + model?: string | null; + runtimePath?: string | null; + baseUrl?: string | null; + apiKey?: string | null; +} + /** * Get image analysis hook environment variables. * These env vars control the hook's behavior via Claude Code hook system. @@ -45,12 +55,66 @@ export function getImageAnalysisHookEnv( ? resolveImageAnalysisStatus(context, config) : resolveImageAnalysisStatus({ profileName: '' }, config); const skipImageAnalysis = !status.supported; + const runtimeApiKey = + typeof context === 'object' && context.cliproxyBridge + ? resolveCliproxyBridgeProfile(context.cliproxyBridge.provider).apiKey + : ''; return { CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0', CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60), CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models), CCS_CURRENT_PROVIDER: status.backendId || '', + CCS_IMAGE_ANALYSIS_BACKEND_ID: status.backendId || '', + CCS_IMAGE_ANALYSIS_MODEL: status.model || '', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: status.runtimePath || '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: + typeof context === 'object' ? context.cliproxyBridge?.currentBaseUrl || '' : '', + ...(runtimeApiKey ? { CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: runtimeApiKey } : {}), + CCS_IMAGE_ANALYSIS_PROMPTS_DIR: getPromptsDir(), CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', }; } + +/** + * Overlay execution-specific runtime values onto the baseline image-analysis env. + * Launch paths use this to pin analysis to the exact provider route and auth + * token selected for the current session rather than any stale saved values. + */ +export function applyImageAnalysisRuntimeOverrides( + env: Record, + overrides: ImageAnalysisRuntimeOverrides +): Record { + const nextEnv = { ...env }; + + const backendId = overrides.backendId?.trim(); + if (backendId) { + nextEnv.CCS_CURRENT_PROVIDER = backendId; + nextEnv.CCS_IMAGE_ANALYSIS_BACKEND_ID = backendId; + } + + const model = overrides.model?.trim(); + if (model) { + nextEnv.CCS_IMAGE_ANALYSIS_MODEL = model; + } + + const runtimePath = overrides.runtimePath?.trim(); + if (runtimePath) { + nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_PATH = runtimePath; + } + + if (overrides.baseUrl !== undefined) { + nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL = overrides.baseUrl?.trim() || ''; + } + + if (overrides.apiKey !== undefined) { + const apiKey = overrides.apiKey?.trim(); + if (apiKey) { + nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY = apiKey; + } else { + delete nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY; + } + } + + return nextEnv; +} diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index ec7921e4..2138893a 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -566,13 +566,13 @@ export function resolveImageAnalysisStatus( ? 'CLIProxy runtime readiness has not been verified yet.' : null, effectiveRuntimeMode: - config.enabled && resolution.backendId && model && status !== 'hook-missing' - ? 'cliproxy-image-analysis' - : 'native-read', + config.enabled && resolution.backendId && model ? 'cliproxy-image-analysis' : 'native-read', effectiveRuntimeReason: - status === 'hook-missing' || !config.enabled || !resolution.backendId || !model + !config.enabled || !resolution.backendId || !model ? reason - : null, + : status === 'attention' || status === 'hook-missing' + ? reason + : null, profileModel: nativeSupport.profileModel, nativeReadPreference: nativeSupport.nativeReadPreference, nativeImageCapable: nativeSupport.nativeImageCapable, diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts index 8fd910d2..7897b8c5 100644 --- a/src/utils/hooks/image-analysis-runtime-status.ts +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -119,13 +119,6 @@ function resolveEffectiveRuntime( }; } - if (status.status === 'hook-missing') { - return { - effectiveRuntimeMode: 'native-read', - effectiveRuntimeReason: status.reason, - }; - } - if (status.authReadiness === 'missing' || status.authReadiness === 'unknown') { return { effectiveRuntimeMode: 'native-read', @@ -142,7 +135,8 @@ function resolveEffectiveRuntime( return { effectiveRuntimeMode: 'cliproxy-image-analysis', - effectiveRuntimeReason: status.status === 'attention' ? status.reason : null, + effectiveRuntimeReason: + status.status === 'attention' || status.status === 'hook-missing' ? status.reason : null, }; } diff --git a/src/utils/hooks/image-analyzer-hook-installer.ts b/src/utils/hooks/image-analyzer-hook-installer.ts index 26f32224..e124c74a 100644 --- a/src/utils/hooks/image-analyzer-hook-installer.ts +++ b/src/utils/hooks/image-analyzer-hook-installer.ts @@ -14,6 +14,7 @@ import { getImageAnalyzerHookPath } from './image-analyzer-hook-configuration'; import { getCcsHooksDir } from '../config-manager'; import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { removeMigrationMarker } from './image-analyzer-profile-hook-injector'; +import { installImageAnalysisPrompts } from '../image-analysis/hook-installer'; // Re-export from hook-configuration for backward compatibility export { @@ -23,12 +24,65 @@ export { // Hook file name const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs'; +const IMAGE_ANALYSIS_RUNTIME = 'image-analysis-runtime.cjs'; + +function getImageAnalysisRuntimeHookPath(): string { + return path.join(getCcsHooksDir(), IMAGE_ANALYSIS_RUNTIME); +} + +function getHookArtifacts(): Array<{ fileName: string; destinationPath: string }> { + return [ + { fileName: IMAGE_ANALYZER_HOOK, destinationPath: getImageAnalyzerHookPath() }, + { + fileName: IMAGE_ANALYSIS_RUNTIME, + destinationPath: getImageAnalysisRuntimeHookPath(), + }, + ]; +} + +function resolveHookSourceBasePath( + artifacts: Array<{ fileName: string; destinationPath: string }> +): string | null { + const possibleBasePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'hooks'), + path.join(__dirname, '..', '..', 'lib', 'hooks'), + path.join(__dirname, '..', 'lib', 'hooks'), + ]; + + for (const basePath of possibleBasePaths) { + if (artifacts.every(({ fileName }) => fs.existsSync(path.join(basePath, fileName)))) { + return basePath; + } + } + + return null; +} + +function artifactsMatch(sourcePath: string, destinationPath: string): boolean { + try { + return fs.readFileSync(sourcePath).equals(fs.readFileSync(destinationPath)); + } catch { + return false; + } +} /** * Check if image analyzer hook is installed */ export function hasImageAnalyzerHook(): boolean { - return fs.existsSync(getImageAnalyzerHookPath()); + const artifacts = getHookArtifacts(); + if (!artifacts.every(({ destinationPath }) => fs.existsSync(destinationPath))) { + return false; + } + + const sourceBasePath = resolveHookSourceBasePath(artifacts); + if (!sourceBasePath) { + return true; + } + + return artifacts.every(({ fileName, destinationPath }) => + artifactsMatch(path.join(sourceBasePath, fileName), destinationPath) + ); } /** @@ -56,38 +110,25 @@ export function installImageAnalyzerHook(): boolean { fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 }); } - const hookPath = getImageAnalyzerHookPath(); + const artifacts = getHookArtifacts(); + const sourceBasePath = resolveHookSourceBasePath(artifacts); - // Find the bundled hook script - // In npm package: node_modules/ccs/lib/hooks/ - // In development: lib/hooks/ - const possiblePaths = [ - path.join(__dirname, '..', '..', '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK), - path.join(__dirname, '..', '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK), - path.join(__dirname, '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK), - ]; - - let sourcePath: string | null = null; - for (const p of possiblePaths) { - if (fs.existsSync(p)) { - sourcePath = p; - break; - } - } - - if (!sourcePath) { + if (!sourceBasePath) { if (process.env.CCS_DEBUG) { console.error(warn(`Image analyzer hook source not found: ${IMAGE_ANALYZER_HOOK}`)); } return false; } - // Copy hook to ~/.ccs/hooks/ - fs.copyFileSync(sourcePath, hookPath); - fs.chmodSync(hookPath, 0o755); + for (const { fileName, destinationPath } of artifacts) { + fs.copyFileSync(path.join(sourceBasePath, fileName), destinationPath); + fs.chmodSync(destinationPath, 0o755); + } + + installImageAnalysisPrompts(); if (process.env.CCS_DEBUG) { - console.error(info(`Installed image analyzer hook: ${hookPath}`)); + console.error(info(`Installed image analyzer hook runtime: ${hooksDir}`)); } // Note: Hook registration is handled by ensureProfileHooks() in image-analyzer-profile-injector.ts @@ -113,12 +154,14 @@ export function installImageAnalyzerHook(): boolean { */ export function uninstallImageAnalyzerHook(): boolean { try { - const hookPath = getImageAnalyzerHookPath(); + const artifactPaths = [getImageAnalyzerHookPath(), getImageAnalysisRuntimeHookPath()]; - if (fs.existsSync(hookPath)) { - fs.unlinkSync(hookPath); - if (process.env.CCS_DEBUG) { - console.error(info(`Uninstalled image analyzer hook: ${hookPath}`)); + for (const artifactPath of artifactPaths) { + if (fs.existsSync(artifactPath)) { + fs.unlinkSync(artifactPath); + if (process.env.CCS_DEBUG) { + console.error(info(`Uninstalled image analyzer artifact: ${artifactPath}`)); + } } } diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index a2b28978..7517dcf9 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -138,6 +138,10 @@ export function ensureProfileHooks(input: string | ImageAnalysisResolutionContex return false; } + if (context.sharedHookInstalled === false) { + return false; + } + // One-time migration marker migrateGlobalHook(); diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 68973870..913ff285 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -6,7 +6,16 @@ * @module utils/hooks */ -export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env'; +import { + hasImageAnalyzerHook as hasInstalledImageAnalyzerHook, + installImageAnalyzerHook as installSharedImageAnalyzerHook, +} from './image-analyzer-hook-installer'; + +export { + getImageAnalysisHookEnv, + applyImageAnalysisRuntimeOverrides, + type ImageAnalysisRuntimeOverrides, +} from './get-image-analysis-hook-env'; export { canonicalizeImageAnalysisConfig, resolveImageAnalysisStatus, @@ -26,3 +35,7 @@ export { uninstallImageAnalyzerHook, } from './image-analyzer-hook-installer'; export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector'; + +export function prepareImageAnalysisFallbackHook(): boolean { + return hasInstalledImageAnalyzerHook() || installSharedImageAnalyzerHook(); +} diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts new file mode 100644 index 00000000..f1d0cf0b --- /dev/null +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -0,0 +1,74 @@ +/** + * Claude launch argument helpers for first-class Image Analysis. + */ + +const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; +const IMAGE_ANALYSIS_STEERING_PROMPT = + 'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.'; + +function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } { + const terminatorIndex = args.indexOf('--'); + if (terminatorIndex === -1) { + return { optionArgs: args, trailingArgs: [] }; + } + + return { + optionArgs: args.slice(0, terminatorIndex), + trailingArgs: args.slice(terminatorIndex), + }; +} + +function getImmediateFlagValue(args: string[], index: number): string | null { + const value = args[index + 1]; + if (value === undefined || value === '--' || value.startsWith('--')) { + return null; + } + return value; +} + +function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === flag) { + const value = getImmediateFlagValue(args, index); + if (value === expectedValue) { + return true; + } + continue; + } + + if (arg === `${flag}=${expectedValue}`) { + return true; + } + + if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) { + return true; + } + } + + return false; +} + +function ensureImageAnalysisSteeringPrompt(args: string[]): string[] { + const { optionArgs, trailingArgs } = splitArgsAtTerminator(args); + + if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) { + return args; + } + + return [ + ...optionArgs, + APPEND_SYSTEM_PROMPT_FLAG, + IMAGE_ANALYSIS_STEERING_PROMPT, + ...trailingArgs, + ]; +} + +export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] { + return ensureImageAnalysisSteeringPrompt(args); +} + +export function getImageAnalysisSteeringPrompt(): string { + return IMAGE_ANALYSIS_STEERING_PROMPT; +} diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts index e239ae6d..89d5968d 100644 --- a/src/utils/image-analysis/index.ts +++ b/src/utils/image-analysis/index.ts @@ -5,3 +5,23 @@ */ export { getPromptsDir, installImageAnalysisPrompts } from './hook-installer'; +export { + getImageAnalysisMcpServerName, + getImageAnalysisMcpServerPath, + getImageAnalysisMcpRuntimePath, + installImageAnalysisMcpServer, + ensureImageAnalysisMcpConfig, + ensureImageAnalysisMcp, + uninstallImageAnalysisMcpServer, + removeImageAnalysisMcpConfig, + uninstallImageAnalysisMcp, + syncImageAnalysisMcpToConfigDir, + ensureImageAnalysisMcpOrThrow, +} from './mcp-installer'; +export { + appendThirdPartyImageAnalysisToolArgs, + getImageAnalysisSteeringPrompt, +} from './claude-tool-args'; + +export const IMAGE_ANALYSIS_PROMPT_TEMPLATES = ['default', 'screenshot', 'document'] as const; +export type ImageAnalysisPromptTemplate = (typeof IMAGE_ANALYSIS_PROMPT_TEMPLATES)[number]; diff --git a/src/utils/image-analysis/mcp-installer.ts b/src/utils/image-analysis/mcp-installer.ts new file mode 100644 index 00000000..11d53607 --- /dev/null +++ b/src/utils/image-analysis/mcp-installer.ts @@ -0,0 +1,393 @@ +/** + * Image Analysis MCP installer and ~/.claude.json provisioning. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { getCcsDir } from '../config-manager'; +import { getClaudeUserConfigPath } from '../claude-config-path'; +import { info, warn } from '../ui'; +import { InstanceManager } from '../../management/instance-manager'; +import { installImageAnalysisPrompts } from './hook-installer'; + +const IMAGE_ANALYSIS_MCP_SERVER = 'ccs-image-analysis-server.cjs'; +const IMAGE_ANALYSIS_MCP_RUNTIME = 'image-analysis-runtime.cjs'; +const IMAGE_ANALYSIS_MCP_SERVER_NAME = 'ccs-image-analysis'; + +interface ClaudeUserConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +interface ManagedImageAnalysisMcpConfig { + type: 'stdio'; + command: 'node'; + args: [string]; + env: Record; +} + +function getCcsMcpDir(): string { + return path.join(getCcsDir(), 'mcp'); +} + +export function getImageAnalysisMcpServerName(): string { + return IMAGE_ANALYSIS_MCP_SERVER_NAME; +} + +export function getImageAnalysisMcpServerPath(): string { + return path.join(getCcsMcpDir(), IMAGE_ANALYSIS_MCP_SERVER); +} + +export function getImageAnalysisMcpRuntimePath(): string { + return path.join(getCcsMcpDir(), IMAGE_ANALYSIS_MCP_RUNTIME); +} + +function hasMatchingContents(sourcePath: string, destinationPath: string): boolean { + if (!fs.existsSync(destinationPath)) { + return false; + } + + const source = fs.readFileSync(sourcePath); + try { + const destination = fs.readFileSync(destinationPath); + return source.equals(destination); + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Existing Image Analysis MCP server is unreadable: ${(error as Error).message}`) + ); + } + return false; + } +} + +function getTempPath(targetPath: string): string { + const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${targetPath}.${suffix}.tmp`; +} + +function resolveBundledArtifactSourcePath(fileName: string): string | null { + const possiblePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'mcp', fileName), + path.join(__dirname, '..', '..', 'lib', 'mcp', fileName), + path.join(__dirname, '..', 'lib', 'mcp', fileName), + path.join(__dirname, '..', '..', '..', 'lib', 'hooks', fileName), + path.join(__dirname, '..', '..', 'lib', 'hooks', fileName), + path.join(__dirname, '..', 'lib', 'hooks', fileName), + ]; + + for (const candidate of possiblePaths) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null { + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsed as ClaudeUserConfig; + } catch { + return null; + } +} + +function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean { + const tempPath = getTempPath(configPath); + const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600; + + try { + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.chmodSync(tempPath, fileMode); + fs.renameSync(tempPath, configPath); + return true; + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} + +function removeManagedServerConfig(configPath: string): boolean { + if (!fs.existsSync(configPath)) { + return false; + } + + const config = readClaudeUserConfig(configPath); + if (config === null) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)); + } + return false; + } + + const existingServers = + config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) + ? { ...(config.mcpServers as Record) } + : {}; + + if (!(IMAGE_ANALYSIS_MCP_SERVER_NAME in existingServers)) { + return false; + } + + delete existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; + + const nextConfig: ClaudeUserConfig = { ...config }; + if (Object.keys(existingServers).length === 0) { + delete nextConfig.mcpServers; + } else { + nextConfig.mcpServers = existingServers; + } + + try { + writeClaudeUserConfig(configPath, nextConfig); + if (process.env.CCS_DEBUG) { + console.error(info(`Removed Image Analysis MCP config from ${configPath}`)); + } + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn( + `Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}` + ) + ); + } + return false; + } +} + +export function installImageAnalysisMcpServer(): boolean { + const config = getImageAnalysisConfig(); + if (!config.enabled) { + return false; + } + + const artifacts = [ + { + fileName: IMAGE_ANALYSIS_MCP_SERVER, + sourcePath: resolveBundledArtifactSourcePath(IMAGE_ANALYSIS_MCP_SERVER), + destinationPath: getImageAnalysisMcpServerPath(), + }, + { + fileName: IMAGE_ANALYSIS_MCP_RUNTIME, + sourcePath: resolveBundledArtifactSourcePath(IMAGE_ANALYSIS_MCP_RUNTIME), + destinationPath: getImageAnalysisMcpRuntimePath(), + }, + ]; + + const missingArtifact = artifacts.find((artifact) => !artifact.sourcePath); + if (missingArtifact) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Image Analysis MCP runtime source not found: ${missingArtifact.fileName}`) + ); + } + return false; + } + + const mcpDir = getCcsMcpDir(); + if (!fs.existsSync(mcpDir)) { + fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 }); + } + + try { + for (const artifact of artifacts) { + const sourcePath = artifact.sourcePath; + if (!sourcePath) { + continue; + } + + if (hasMatchingContents(sourcePath, artifact.destinationPath)) { + continue; + } + + const tempPath = getTempPath(artifact.destinationPath); + + try { + fs.copyFileSync(sourcePath, tempPath); + fs.chmodSync(tempPath, 0o755); + try { + fs.renameSync(tempPath, artifact.destinationPath); + } catch (renameError) { + const errorCode = (renameError as NodeJS.ErrnoException).code; + if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') { + throw renameError; + } + + if (!hasMatchingContents(sourcePath, artifact.destinationPath)) { + fs.copyFileSync(tempPath, artifact.destinationPath); + fs.chmodSync(artifact.destinationPath, 0o755); + } + } + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } + } + + installImageAnalysisPrompts(); + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Failed to install Image Analysis MCP server: ${(error as Error).message}`) + ); + } + return false; + } +} + +export function ensureImageAnalysisMcpConfig(): boolean { + const imageConfig = getImageAnalysisConfig(); + if (!imageConfig.enabled) { + return false; + } + + const claudeUserConfigPath = getClaudeUserConfigPath(); + const claudeUserConfigDir = path.dirname(claudeUserConfigPath); + const config = readClaudeUserConfig(claudeUserConfigPath); + + if (config === null) { + if (process.env.CCS_DEBUG) { + console.error(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning')); + } + return false; + } + + if (!fs.existsSync(claudeUserConfigDir)) { + fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 }); + } + + const existingServers = + config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) + ? (config.mcpServers as Record) + : {}; + const desiredServerConfig: ManagedImageAnalysisMcpConfig = { + type: 'stdio', + command: 'node', + args: [getImageAnalysisMcpServerPath()], + env: {}, + }; + + const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; + if ( + typeof currentConfig === 'object' && + currentConfig !== null && + JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) + ) { + return true; + } + + const nextConfig: ClaudeUserConfig = { + ...config, + mcpServers: { + ...existingServers, + [IMAGE_ANALYSIS_MCP_SERVER_NAME]: desiredServerConfig, + }, + }; + + try { + writeClaudeUserConfig(claudeUserConfigPath, nextConfig); + if (process.env.CCS_DEBUG) { + console.error(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`)); + } + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)); + } + return false; + } +} + +export function ensureImageAnalysisMcp(): boolean { + const imageConfig = getImageAnalysisConfig(); + if (!imageConfig.enabled) { + return false; + } + + const installed = installImageAnalysisMcpServer(); + const configured = installed && ensureImageAnalysisMcpConfig(); + return installed && configured; +} + +export function syncImageAnalysisMcpToConfigDir(claudeConfigDir: string | undefined): boolean { + if (!claudeConfigDir) { + return false; + } + + return new InstanceManager().syncMcpServers(claudeConfigDir); +} + +export function uninstallImageAnalysisMcpServer(): boolean { + const artifactPaths = [getImageAnalysisMcpServerPath(), getImageAnalysisMcpRuntimePath()]; + if (!artifactPaths.some((artifactPath) => fs.existsSync(artifactPath))) { + return false; + } + + try { + let removed = false; + for (const artifactPath of artifactPaths) { + if (!fs.existsSync(artifactPath)) { + continue; + } + fs.unlinkSync(artifactPath); + removed = true; + } + return removed; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Failed to remove Image Analysis MCP server: ${(error as Error).message}`) + ); + } + return false; + } +} + +export function removeImageAnalysisMcpConfig(): boolean { + let removed = removeManagedServerConfig(getClaudeUserConfigPath()); + + const instanceManager = new InstanceManager(); + for (const instanceName of instanceManager.listInstances()) { + const instancePath = instanceManager.getInstancePath(instanceName); + const instanceClaudeConfigPath = path.join(instancePath, '.claude.json'); + removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed; + } + + return removed; +} + +export function uninstallImageAnalysisMcp(): boolean { + const removedConfig = removeImageAnalysisMcpConfig(); + const removedServer = uninstallImageAnalysisMcpServer(); + return removedConfig || removedServer; +} + +export function ensureImageAnalysisMcpOrThrow(): void { + const imageConfig = getImageAnalysisConfig(); + if (!imageConfig.enabled) { + return; + } + + if (!ensureImageAnalysisMcp()) { + console.error( + warn( + 'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.' + ) + ); + } +} diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index 3b98337e..06148c82 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -84,7 +84,6 @@ function resolveCurrentTargetMode( if (target !== 'claude') return 'bypassed'; if (status.nativeReadPreference) return 'native'; if (!status.backendId) return 'unresolved'; - if (status.status === 'hook-missing') return 'setup'; if (status.effectiveRuntimeMode === 'native-read') return 'fallback'; return 'active'; }