feat(image-analysis): add provider-backed runtime

This commit is contained in:
Tam Nhu Tran
2026-04-02 15:44:13 -04:00
parent 2c006afbb9
commit 813b2dd4d0
24 changed files with 1715 additions and 487 deletions
+458
View File
@@ -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,
};
+27 -418
View File
@@ -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 {
+337
View File
@@ -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 || '<missing>';
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);
});
@@ -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;
+3
View File
@@ -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;
+37 -5
View File
@@ -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<void> {
// 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<void> {
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<void> {
} 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<void> {
// 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<void> {
}
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<void> {
settingsPath: expandedSettingsPath,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
if (resolvedTarget !== 'claude') {
const compatibility = evaluateTargetRuntimeCompatibility({
@@ -1022,13 +1043,24 @@ async function main(): Promise<void> {
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<void> {
const launchArgs = [
'--settings',
expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(remainingArgs),
...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(remainingArgs)),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile',
+27 -2
View File
@@ -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<CliproxyImageAnalysisDeps> = {}
@@ -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,
};
}
/**
+13 -5
View File
@@ -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,
+11
View File
@@ -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,
});
}
+17 -2
View File
@@ -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<void> {
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!'));
+28 -3
View File
@@ -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,
+97 -2
View File
@@ -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<string, string>;
traceEnv?: Record<string, string>;
}
): Promise<ExecutionResult> {
@@ -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',
@@ -70,15 +70,17 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise<void>
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}`);
+1 -1
View File
@@ -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 {
@@ -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, string>): 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<string, string>,
overrides: ImageAnalysisRuntimeOverrides
): Record<string, string> {
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;
}
@@ -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,
@@ -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,
};
}
@@ -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}`));
}
}
}
@@ -138,6 +138,10 @@ export function ensureProfileHooks(input: string | ImageAnalysisResolutionContex
return false;
}
if (context.sharedHookInstalled === false) {
return false;
}
// One-time migration marker
migrateGlobalHook();
+14 -1
View File
@@ -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();
}
@@ -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;
}
+20
View File
@@ -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];
+393
View File
@@ -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<string, unknown>;
[key: string]: unknown;
}
interface ManagedImageAnalysisMcpConfig {
type: 'stdio';
command: 'node';
args: [string];
env: Record<string, string>;
}
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<string, unknown>) }
: {};
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<string, unknown>)
: {};
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.'
)
);
}
}
@@ -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';
}