mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +00:00
feat(image-analysis): add provider-backed runtime
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const http = require('http');
|
const { analyzeFile, isAnalyzableFile, parseProviderModels } = require('./image-analysis-runtime.cjs');
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PLATFORM DETECTION
|
// PLATFORM DETECTION
|
||||||
@@ -36,19 +36,8 @@ const isWindows = process.platform === 'win32';
|
|||||||
// CONFIGURATION
|
// 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_MODEL = 'gemini-2.5-flash';
|
||||||
const DEFAULT_TIMEOUT_SEC = 60;
|
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)
|
// ERROR CODES (for categorization)
|
||||||
@@ -65,23 +54,6 @@ const ERROR_CODES = {
|
|||||||
UNKNOWN: 'UNKNOWN',
|
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
|
* Output debug information to stderr
|
||||||
* Only outputs when CCS_DEBUG=1
|
* Only outputs when CCS_DEBUG=1
|
||||||
@@ -105,19 +77,19 @@ function debugLog(message, data = {}) {
|
|||||||
*/
|
*/
|
||||||
function getDebugContext(filePath, stats) {
|
function getDebugContext(filePath, stats) {
|
||||||
const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
|
const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
|
||||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
const model =
|
||||||
const model = providerModels[currentProvider] || DEFAULT_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 timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||||
const modelsToTry = getModelsToTry();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
file: path.basename(filePath),
|
file: path.basename(filePath),
|
||||||
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
|
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
|
||||||
provider: currentProvider,
|
provider: currentProvider,
|
||||||
model: model,
|
model: model,
|
||||||
modelsToTry: modelsToTry.length > 1 ? modelsToTry.join(' -> ') : model,
|
|
||||||
timeout: `${timeout}s`,
|
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() {
|
function getProviderContext() {
|
||||||
const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
|
const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
|
||||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
const model =
|
||||||
const model = providerModels[provider] || DEFAULT_MODEL;
|
process.env.CCS_IMAGE_ANALYSIS_MODEL ||
|
||||||
|
parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[provider] ||
|
||||||
|
DEFAULT_MODEL;
|
||||||
return { provider, 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)
|
* Format analysis description for Claude (matches websearch format)
|
||||||
*/
|
*/
|
||||||
@@ -520,29 +185,6 @@ function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) {
|
|||||||
process.exit(2);
|
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) {
|
function outputAuthError(filePath, statusCode) {
|
||||||
const { provider } = getProviderContext();
|
const { provider } = getProviderContext();
|
||||||
const output = formatErrorOutput(
|
const output = formatErrorOutput(
|
||||||
@@ -758,10 +400,11 @@ function shouldSkipHook() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if current provider has a vision model configured
|
// 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 currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
|
||||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
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`, {
|
debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, {
|
||||||
configured_providers: Object.keys(providerModels).join(', ') || 'none',
|
configured_providers: Object.keys(providerModels).join(', ') || 'none',
|
||||||
});
|
});
|
||||||
@@ -832,57 +475,15 @@ async function processHook() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
// Let native Read handle the error
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file size
|
const debugContext = getDebugContext(filePath, null);
|
||||||
const stats = fs.statSync(filePath);
|
debugLog('Image analysis runtime prepared', debugContext);
|
||||||
if (stats.size >= MAX_FILE_SIZE_BYTES) {
|
|
||||||
outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check CLIProxy availability
|
const result = await analyzeFile(filePath);
|
||||||
const cliProxyAvailable = await isCliProxyAvailable();
|
outputSuccess(filePath, result.description, result.model, result.fileSize);
|
||||||
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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error('[CCS Hook] Error:', err.message);
|
console.error('[CCS Hook] Error:', err.message);
|
||||||
@@ -893,7 +494,10 @@ async function processHook() {
|
|||||||
// Categorize error by message pattern
|
// Categorize error by message pattern
|
||||||
const errMsg = err.message || '';
|
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);
|
const statusCode = parseInt(errMsg.split(':')[1], 10);
|
||||||
outputAuthError(filePath, statusCode);
|
outputAuthError(filePath, statusCode);
|
||||||
} else if (errMsg.startsWith('RATE_LIMIT:')) {
|
} else if (errMsg.startsWith('RATE_LIMIT:')) {
|
||||||
@@ -907,8 +511,13 @@ async function processHook() {
|
|||||||
} else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) {
|
} else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) {
|
||||||
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||||
outputTimeoutError(filePath, timeout);
|
outputTimeoutError(filePath, timeout);
|
||||||
} else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) {
|
} else if (
|
||||||
outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`);
|
errMsg.includes('ECONNREFUSED') ||
|
||||||
|
errMsg.includes('ENOTFOUND') ||
|
||||||
|
errMsg.includes('ENETUNREACH') ||
|
||||||
|
errMsg.includes('EAI_AGAIN')
|
||||||
|
) {
|
||||||
|
outputCliProxyUnavailableFallback(filePath);
|
||||||
} else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) {
|
} else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) {
|
||||||
outputFileAccessError(filePath, errMsg);
|
outputFileAccessError(filePath, errMsg);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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 { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
||||||
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
||||||
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
||||||
|
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
||||||
import { isSensitiveKey } from '../../utils/sensitive-keys';
|
import { isSensitiveKey } from '../../utils/sensitive-keys';
|
||||||
import { isReservedName } from '../../config/reserved-names';
|
import { isReservedName } from '../../config/reserved-names';
|
||||||
import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
@@ -219,6 +220,7 @@ export function registerApiProfileOrphans(options?: {
|
|||||||
try {
|
try {
|
||||||
if (orphan.validation.valid) {
|
if (orphan.validation.valid) {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
}
|
}
|
||||||
registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false);
|
registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false);
|
||||||
result.registered.push(orphan.name);
|
result.registered.push(orphan.name);
|
||||||
@@ -269,6 +271,7 @@ export function copyApiProfile(
|
|||||||
writeJsonObjectAtomically(destinationSettingsPath, sourceSettings);
|
writeJsonObjectAtomically(destinationSettingsPath, sourceSettings);
|
||||||
try {
|
try {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
} catch (hookError) {
|
} catch (hookError) {
|
||||||
rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted);
|
rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted);
|
||||||
throw hookError;
|
throw hookError;
|
||||||
@@ -394,6 +397,7 @@ export function importApiProfileBundle(
|
|||||||
writeJsonObjectAtomically(settingsPath, settings);
|
writeJsonObjectAtomically(settingsPath, settings);
|
||||||
try {
|
try {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
} catch (hookError) {
|
} catch (hookError) {
|
||||||
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
||||||
throw hookError;
|
throw hookError;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { expandPath } from '../../utils/helpers';
|
|||||||
import { validateApiName } from './validation-service';
|
import { validateApiName } from './validation-service';
|
||||||
import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
|
import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
|
||||||
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
||||||
|
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
||||||
import type { TargetType } from '../../targets/target-adapter';
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
import { resolveDroidProvider } from '../../targets/droid-provider';
|
import { resolveDroidProvider } from '../../targets/droid-provider';
|
||||||
import { isReservedName } from '../../config/reserved-names';
|
import { isReservedName } from '../../config/reserved-names';
|
||||||
@@ -127,6 +128,7 @@ function createSettingsFile(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -216,6 +218,7 @@ function createApiProfileUnified(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
+37
-5
@@ -31,11 +31,17 @@ import {
|
|||||||
appendThirdPartyWebSearchToolArgs,
|
appendThirdPartyWebSearchToolArgs,
|
||||||
createWebSearchTraceContext,
|
createWebSearchTraceContext,
|
||||||
} from './utils/websearch-manager';
|
} from './utils/websearch-manager';
|
||||||
|
import {
|
||||||
|
ensureImageAnalysisMcpOrThrow,
|
||||||
|
syncImageAnalysisMcpToConfigDir,
|
||||||
|
appendThirdPartyImageAnalysisToolArgs,
|
||||||
|
} from './utils/image-analysis';
|
||||||
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
||||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
|
import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector';
|
||||||
import {
|
import {
|
||||||
|
applyImageAnalysisRuntimeOverrides,
|
||||||
getImageAnalysisHookEnv,
|
getImageAnalysisHookEnv,
|
||||||
installImageAnalyzerHook,
|
prepareImageAnalysisFallbackHook,
|
||||||
resolveImageAnalysisRuntimeStatus,
|
resolveImageAnalysisRuntimeStatus,
|
||||||
} from './utils/hooks';
|
} from './utils/hooks';
|
||||||
import { fail, info, warn } from './utils/ui';
|
import { fail, info, warn } from './utils/ui';
|
||||||
@@ -74,7 +80,11 @@ import {
|
|||||||
resolveDroidReasoningRuntime,
|
resolveDroidReasoningRuntime,
|
||||||
} from './targets/droid-reasoning-runtime';
|
} from './targets/droid-reasoning-runtime';
|
||||||
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
|
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
|
// Version and Update check utilities
|
||||||
import { getVersion } from './utils/version';
|
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
|
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
|
||||||
if (resolvedTarget === 'claude') {
|
if (resolvedTarget === 'claude') {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
}
|
}
|
||||||
|
const imageAnalysisFallbackHookReady =
|
||||||
|
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
|
||||||
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
|
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
|
||||||
// Inject Image Analyzer hook into profile settings before launch
|
// Inject Image Analyzer hook into profile settings before launch
|
||||||
ensureImageAnalyzerHooks({
|
ensureImageAnalyzerHooks({
|
||||||
@@ -694,6 +707,7 @@ async function main(): Promise<void> {
|
|||||||
cliproxyProvider: provider,
|
cliproxyProvider: provider,
|
||||||
isComposite: profileInfo.isComposite,
|
isComposite: profileInfo.isComposite,
|
||||||
settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined,
|
settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined,
|
||||||
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||||
});
|
});
|
||||||
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
|
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
|
||||||
const variantPort = profileInfo.port; // variant-specific port for isolation
|
const variantPort = profileInfo.port; // variant-specific port for isolation
|
||||||
@@ -848,11 +862,14 @@ async function main(): Promise<void> {
|
|||||||
} else if (profileInfo.type === 'copilot') {
|
} else if (profileInfo.type === 'copilot') {
|
||||||
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
|
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
installImageAnalyzerHook();
|
ensureImageAnalysisMcpOrThrow();
|
||||||
|
const imageAnalysisFallbackHookReady =
|
||||||
|
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
|
||||||
// Inject Image Analyzer hook into profile settings before launch
|
// Inject Image Analyzer hook into profile settings before launch
|
||||||
ensureImageAnalyzerHooks({
|
ensureImageAnalyzerHooks({
|
||||||
profileName: profileInfo.name,
|
profileName: profileInfo.name,
|
||||||
profileType: profileInfo.type,
|
profileType: profileInfo.type,
|
||||||
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { executeCopilotProfile } = await import('./copilot');
|
const { executeCopilotProfile } = await import('./copilot');
|
||||||
@@ -884,7 +901,7 @@ async function main(): Promise<void> {
|
|||||||
// Settings-based profiles (glm, glmt) are third-party providers
|
// Settings-based profiles (glm, glmt) are third-party providers
|
||||||
if (resolvedTarget === 'claude') {
|
if (resolvedTarget === 'claude') {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
installImageAnalyzerHook();
|
ensureImageAnalysisMcpOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display WebSearch status (single line, equilibrium UX)
|
// Display WebSearch status (single line, equilibrium UX)
|
||||||
@@ -907,6 +924,9 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||||
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
||||||
|
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||||
|
const imageAnalysisFallbackHookReady =
|
||||||
|
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
|
||||||
const expandedSettingsPath =
|
const expandedSettingsPath =
|
||||||
resolvedSettingsPath ??
|
resolvedSettingsPath ??
|
||||||
(profileInfo.settingsPath
|
(profileInfo.settingsPath
|
||||||
@@ -920,6 +940,7 @@ async function main(): Promise<void> {
|
|||||||
settingsPath: expandedSettingsPath,
|
settingsPath: expandedSettingsPath,
|
||||||
settings,
|
settings,
|
||||||
cliproxyBridge,
|
cliproxyBridge,
|
||||||
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||||
});
|
});
|
||||||
if (resolvedTarget !== 'claude') {
|
if (resolvedTarget !== 'claude') {
|
||||||
const compatibility = evaluateTargetRuntimeCompatibility({
|
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||||
@@ -1022,13 +1043,24 @@ async function main(): Promise<void> {
|
|||||||
profileType: profileInfo.type,
|
profileType: profileInfo.type,
|
||||||
settings,
|
settings,
|
||||||
cliproxyBridge,
|
cliproxyBridge,
|
||||||
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||||
});
|
});
|
||||||
|
const currentBridgeAuthToken = cliproxyBridge
|
||||||
|
? resolveCliproxyBridgeProfile(cliproxyBridge.provider).apiKey
|
||||||
|
: getEffectiveApiKey();
|
||||||
let imageAnalysisEnv = getImageAnalysisHookEnv({
|
let imageAnalysisEnv = getImageAnalysisHookEnv({
|
||||||
profileName: profileInfo.name,
|
profileName: profileInfo.name,
|
||||||
profileType: profileInfo.type,
|
profileType: profileInfo.type,
|
||||||
settings,
|
settings,
|
||||||
cliproxyBridge,
|
cliproxyBridge,
|
||||||
});
|
});
|
||||||
|
imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, {
|
||||||
|
backendId: imageAnalysisStatus.backendId,
|
||||||
|
model: imageAnalysisStatus.model,
|
||||||
|
runtimePath: imageAnalysisStatus.runtimePath,
|
||||||
|
baseUrl: cliproxyBridge?.currentBaseUrl || '',
|
||||||
|
apiKey: currentBridgeAuthToken,
|
||||||
|
});
|
||||||
|
|
||||||
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
|
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
|
||||||
if (
|
if (
|
||||||
@@ -1130,7 +1162,7 @@ async function main(): Promise<void> {
|
|||||||
const launchArgs = [
|
const launchArgs = [
|
||||||
'--settings',
|
'--settings',
|
||||||
expandedSettingsPath,
|
expandedSettingsPath,
|
||||||
...appendThirdPartyWebSearchToolArgs(remainingArgs),
|
...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(remainingArgs)),
|
||||||
];
|
];
|
||||||
const traceEnv = createWebSearchTraceContext({
|
const traceEnv = createWebSearchTraceContext({
|
||||||
launcher: 'ccs.settings-profile',
|
launcher: 'ccs.settings-profile',
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import { applyExtendedContextConfig } from '../config/extended-context-config';
|
|||||||
import { CLIProxyProvider } from '../types';
|
import { CLIProxyProvider } from '../types';
|
||||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
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 { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status';
|
||||||
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
||||||
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
|
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 { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||||
import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer';
|
import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer';
|
||||||
import type { ProxyTarget } from '../proxy-target-resolver';
|
import type { ProxyTarget } from '../proxy-target-resolver';
|
||||||
|
import { getEffectiveApiKey } from '../auth-token-manager';
|
||||||
import { isSettings, type Settings } from '../../types/config';
|
import { isSettings, type Settings } from '../../types/config';
|
||||||
|
|
||||||
export interface RemoteProxyConfig {
|
export interface RemoteProxyConfig {
|
||||||
@@ -76,6 +80,7 @@ interface CliproxyImageAnalysisDeps {
|
|||||||
hasImageAnalysisProfileHook: typeof hasImageAnalysisProfileHook;
|
hasImageAnalysisProfileHook: typeof hasImageAnalysisProfileHook;
|
||||||
hasImageAnalyzerHook: typeof hasImageAnalyzerHook;
|
hasImageAnalyzerHook: typeof hasImageAnalyzerHook;
|
||||||
resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus;
|
resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus;
|
||||||
|
getLocalRuntimeApiKey: typeof getEffectiveApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolveCliproxyImageAnalysisEnvOptions {
|
interface ResolveCliproxyImageAnalysisEnvOptions {
|
||||||
@@ -97,6 +102,7 @@ const defaultCliproxyImageAnalysisDeps: CliproxyImageAnalysisDeps = {
|
|||||||
hasImageAnalysisProfileHook,
|
hasImageAnalysisProfileHook,
|
||||||
hasImageAnalyzerHook,
|
hasImageAnalyzerHook,
|
||||||
resolveImageAnalysisRuntimeStatus,
|
resolveImageAnalysisRuntimeStatus,
|
||||||
|
getLocalRuntimeApiKey: getEffectiveApiKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i;
|
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(
|
export async function resolveCliproxyImageAnalysisEnv(
|
||||||
options: ResolveCliproxyImageAnalysisEnvOptions,
|
options: ResolveCliproxyImageAnalysisEnvOptions,
|
||||||
deps: Partial<CliproxyImageAnalysisDeps> = {}
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ import {
|
|||||||
appendThirdPartyWebSearchToolArgs,
|
appendThirdPartyWebSearchToolArgs,
|
||||||
createWebSearchTraceContext,
|
createWebSearchTraceContext,
|
||||||
} from '../../utils/websearch-manager';
|
} from '../../utils/websearch-manager';
|
||||||
|
import {
|
||||||
|
ensureImageAnalysisMcpOrThrow,
|
||||||
|
syncImageAnalysisMcpToConfigDir,
|
||||||
|
appendThirdPartyImageAnalysisToolArgs,
|
||||||
|
} from '../../utils/image-analysis';
|
||||||
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
||||||
import { installImageAnalyzerHook } from '../../utils/hooks';
|
|
||||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||||
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
|
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
|
||||||
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance';
|
||||||
@@ -205,11 +209,9 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
// Setup first-class CCS WebSearch runtime
|
// Setup first-class CCS WebSearch runtime
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
displayWebSearchStatus();
|
displayWebSearchStatus();
|
||||||
|
|
||||||
// Sync image analyzer hook from npm package to ~/.ccs/hooks/
|
|
||||||
installImageAnalyzerHook();
|
|
||||||
|
|
||||||
const providerConfig = getProviderConfig(provider);
|
const providerConfig = getProviderConfig(provider);
|
||||||
log(`Provider: ${providerConfig.displayName}`);
|
log(`Provider: ${providerConfig.displayName}`);
|
||||||
warnOAuthBanRisk(provider);
|
warnOAuthBanRisk(provider);
|
||||||
@@ -870,6 +872,8 @@ export async function execClaudeWithCLIProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||||
|
|
||||||
// Build initial env vars to get ANTHROPIC_BASE_URL
|
// Build initial env vars to get ANTHROPIC_BASE_URL
|
||||||
const initialEnvVars = buildClaudeEnvironment({
|
const initialEnvVars = buildClaudeEnvironment({
|
||||||
provider,
|
provider,
|
||||||
@@ -1072,7 +1076,11 @@ export async function execClaudeWithCLIProxy(
|
|||||||
: getProviderSettingsPath(provider);
|
: getProviderSettingsPath(provider);
|
||||||
|
|
||||||
let claude: ChildProcess;
|
let claude: ChildProcess;
|
||||||
const launchArgs = ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)];
|
const launchArgs = [
|
||||||
|
'--settings',
|
||||||
|
settingsPath,
|
||||||
|
...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(claudeArgs)),
|
||||||
|
];
|
||||||
const traceEnv = createWebSearchTraceContext({
|
const traceEnv = createWebSearchTraceContext({
|
||||||
launcher: 'cliproxy.executor',
|
launcher: 'cliproxy.executor',
|
||||||
args: launchArgs,
|
args: launchArgs,
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
|
|||||||
import { CLIProxyProvider } from '../types';
|
import { CLIProxyProvider } from '../types';
|
||||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||||
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
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 { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
||||||
|
import { prepareImageAnalysisFallbackHook } from '../../utils/hooks';
|
||||||
import { getEffectiveApiKey } from '../auth-token-manager';
|
import { getEffectiveApiKey } from '../auth-token-manager';
|
||||||
import { warn } from '../../utils/ui';
|
import { warn } from '../../utils/ui';
|
||||||
import { normalizeModelIdForProvider } from '../model-id-normalizer';
|
import { normalizeModelIdForProvider } from '../model-id-normalizer';
|
||||||
@@ -155,10 +157,12 @@ export function createSettingsFile(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||||
|
|
||||||
// Inject Image Analyzer hooks into variant settings
|
// Inject Image Analyzer hooks into variant settings
|
||||||
ensureImageAnalyzerHooks({
|
ensureImageAnalyzerHooks({
|
||||||
@@ -166,6 +170,7 @@ export function createSettingsFile(
|
|||||||
profileType: 'cliproxy',
|
profileType: 'cliproxy',
|
||||||
cliproxyProvider: provider,
|
cliproxyProvider: provider,
|
||||||
settingsPath,
|
settingsPath,
|
||||||
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||||
});
|
});
|
||||||
|
|
||||||
return settingsPath;
|
return settingsPath;
|
||||||
@@ -194,10 +199,12 @@ export function createSettingsFileUnified(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||||
|
|
||||||
// Inject Image Analyzer hooks into variant settings
|
// Inject Image Analyzer hooks into variant settings
|
||||||
ensureImageAnalyzerHooks({
|
ensureImageAnalyzerHooks({
|
||||||
@@ -205,6 +212,7 @@ export function createSettingsFileUnified(
|
|||||||
profileType: 'cliproxy',
|
profileType: 'cliproxy',
|
||||||
cliproxyProvider: provider,
|
cliproxyProvider: provider,
|
||||||
settingsPath,
|
settingsPath,
|
||||||
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||||
});
|
});
|
||||||
|
|
||||||
return settingsPath;
|
return settingsPath;
|
||||||
@@ -297,16 +305,19 @@ export function createCompositeSettingsFile(
|
|||||||
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
|
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
|
||||||
try {
|
try {
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||||
ensureImageAnalyzerHooks({
|
ensureImageAnalyzerHooks({
|
||||||
profileName: `composite-${name}`,
|
profileName: `composite-${name}`,
|
||||||
profileType: 'cliproxy',
|
profileType: 'cliproxy',
|
||||||
cliproxyProvider: tiers[defaultTier].provider,
|
cliproxyProvider: tiers[defaultTier].provider,
|
||||||
isComposite: true,
|
isComposite: true,
|
||||||
settingsPath,
|
settingsPath,
|
||||||
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
import { info, ok, color, box, initUI } from '../utils/ui';
|
import { info, ok, color, box, initUI } from '../utils/ui';
|
||||||
import { uninstallWebSearchHook, uninstallWebSearchMcp } from '../utils/websearch';
|
import { uninstallWebSearchHook, uninstallWebSearchMcp } from '../utils/websearch';
|
||||||
|
import { uninstallImageAnalysisMcp } from '../utils/image-analysis';
|
||||||
|
import { uninstallImageAnalyzerHook } from '../utils/hooks';
|
||||||
import { ClaudeSymlinkManager } from '../utils/claude-symlink-manager';
|
import { ClaudeSymlinkManager } from '../utils/claude-symlink-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,12 +51,25 @@ export async function handleUninstallCommand(): Promise<void> {
|
|||||||
removed += 1;
|
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 symlinkManager = new ClaudeSymlinkManager();
|
||||||
const symlinksRemoved = symlinkManager.uninstall();
|
const symlinksRemoved = symlinkManager.uninstall();
|
||||||
removed += symlinksRemoved; // Add actual count of symlinks removed
|
removed += symlinksRemoved; // Add actual count of symlinks removed
|
||||||
|
|
||||||
// 4. Summary
|
// 5. Summary
|
||||||
console.log('');
|
console.log('');
|
||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
console.log(ok('Uninstall complete!'));
|
console.log(ok('Uninstall complete!'));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { spawn } from 'child_process';
|
|||||||
import { CopilotConfig } from '../config/unified-config-types';
|
import { CopilotConfig } from '../config/unified-config-types';
|
||||||
import { getGlobalEnvConfig } from '../config/unified-config-loader';
|
import { getGlobalEnvConfig } from '../config/unified-config-loader';
|
||||||
import { ensureCliproxyService } from '../cliproxy';
|
import { ensureCliproxyService } from '../cliproxy';
|
||||||
|
import { getEffectiveApiKey } from '../cliproxy/auth-token-manager';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||||
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
|
import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth';
|
||||||
import { isDaemonRunning, startDaemon } from './copilot-daemon';
|
import { isDaemonRunning, startDaemon } from './copilot-daemon';
|
||||||
@@ -22,13 +23,23 @@ import {
|
|||||||
createWebSearchTraceContext,
|
createWebSearchTraceContext,
|
||||||
syncWebSearchMcpToConfigDir,
|
syncWebSearchMcpToConfigDir,
|
||||||
} from '../utils/websearch-manager';
|
} 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';
|
import { stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||||
|
|
||||||
interface CopilotImageAnalysisDeps {
|
interface CopilotImageAnalysisDeps {
|
||||||
ensureCliproxyService: typeof ensureCliproxyService;
|
ensureCliproxyService: typeof ensureCliproxyService;
|
||||||
getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv;
|
getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv;
|
||||||
resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus;
|
resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus;
|
||||||
|
getLocalRuntimeApiKey: typeof getEffectiveApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CopilotImageAnalysisResolution {
|
interface CopilotImageAnalysisResolution {
|
||||||
@@ -96,6 +107,7 @@ export async function resolveCopilotImageAnalysisEnv(
|
|||||||
ensureCliproxyService,
|
ensureCliproxyService,
|
||||||
getImageAnalysisHookEnv,
|
getImageAnalysisHookEnv,
|
||||||
resolveImageAnalysisRuntimeStatus,
|
resolveImageAnalysisRuntimeStatus,
|
||||||
|
getLocalRuntimeApiKey: getEffectiveApiKey,
|
||||||
...deps,
|
...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('');
|
console.log('');
|
||||||
|
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
syncWebSearchMcpToConfigDir(claudeConfigDir);
|
syncWebSearchMcpToConfigDir(claudeConfigDir);
|
||||||
|
syncImageAnalysisMcpToConfigDir(claudeConfigDir);
|
||||||
|
|
||||||
// Spawn Claude CLI
|
// Spawn Claude CLI
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs);
|
const launchArgs = appendThirdPartyWebSearchToolArgs(
|
||||||
|
appendThirdPartyImageAnalysisToolArgs(claudeArgs)
|
||||||
|
);
|
||||||
const traceEnv = createWebSearchTraceContext({
|
const traceEnv = createWebSearchTraceContext({
|
||||||
launcher: 'copilot.executor',
|
launcher: 'copilot.executor',
|
||||||
args: launchArgs,
|
args: launchArgs,
|
||||||
|
|||||||
@@ -15,10 +15,26 @@ import { ui, warn, info } from '../utils/ui';
|
|||||||
import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types';
|
import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types';
|
||||||
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
|
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
|
||||||
import { buildExecutionResult } from './executor/result-aggregator';
|
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 { getProfileLookupCandidates } from '../utils/profile-compat';
|
||||||
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
|
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||||
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
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 {
|
import {
|
||||||
appendThirdPartyWebSearchToolArgs,
|
appendThirdPartyWebSearchToolArgs,
|
||||||
appendWebSearchTrace,
|
appendWebSearchTrace,
|
||||||
@@ -105,7 +121,80 @@ export class HeadlessExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureWebSearchMcpOrThrow();
|
ensureWebSearchMcpOrThrow();
|
||||||
|
ensureImageAnalysisMcpOrThrow();
|
||||||
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
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
|
// Smart slash command detection and preservation
|
||||||
const processedPrompt = this._processSlashCommand(enhancedPrompt);
|
const processedPrompt = this._processSlashCommand(enhancedPrompt);
|
||||||
@@ -190,7 +279,9 @@ export class HeadlessExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const launchArgs = appendThirdPartyWebSearchToolArgs(args);
|
const launchArgs = appendThirdPartyWebSearchToolArgs(
|
||||||
|
appendThirdPartyImageAnalysisToolArgs(args)
|
||||||
|
);
|
||||||
const traceEnv = createWebSearchTraceContext({
|
const traceEnv = createWebSearchTraceContext({
|
||||||
launcher: 'delegation.headless-executor',
|
launcher: 'delegation.headless-executor',
|
||||||
args: launchArgs,
|
args: launchArgs,
|
||||||
@@ -217,6 +308,7 @@ export class HeadlessExecutor {
|
|||||||
sessionId,
|
sessionId,
|
||||||
sessionMgr,
|
sessionMgr,
|
||||||
claudeConfigDir: inheritedClaudeConfigDir,
|
claudeConfigDir: inheritedClaudeConfigDir,
|
||||||
|
imageAnalysisEnv,
|
||||||
traceEnv,
|
traceEnv,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -235,6 +327,7 @@ export class HeadlessExecutor {
|
|||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
sessionMgr: SessionManager;
|
sessionMgr: SessionManager;
|
||||||
claudeConfigDir?: string;
|
claudeConfigDir?: string;
|
||||||
|
imageAnalysisEnv?: Record<string, string>;
|
||||||
traceEnv?: Record<string, string>;
|
traceEnv?: Record<string, string>;
|
||||||
}
|
}
|
||||||
): Promise<ExecutionResult> {
|
): Promise<ExecutionResult> {
|
||||||
@@ -246,6 +339,7 @@ export class HeadlessExecutor {
|
|||||||
sessionId,
|
sessionId,
|
||||||
sessionMgr,
|
sessionMgr,
|
||||||
claudeConfigDir,
|
claudeConfigDir,
|
||||||
|
imageAnalysisEnv = {},
|
||||||
traceEnv = {},
|
traceEnv = {},
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
@@ -265,6 +359,7 @@ export class HeadlessExecutor {
|
|||||||
...process.env,
|
...process.env,
|
||||||
...getClaudeLaunchEnvOverrides(),
|
...getClaudeLaunchEnvOverrides(),
|
||||||
...getWebSearchHookEnv(),
|
...getWebSearchHookEnv(),
|
||||||
|
...imageAnalysisEnv,
|
||||||
...traceEnv,
|
...traceEnv,
|
||||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||||
CCS_PROFILE_TYPE: 'settings',
|
CCS_PROFILE_TYPE: 'settings',
|
||||||
|
|||||||
@@ -70,15 +70,17 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise<void>
|
|||||||
if (!cliproxyAvailable) {
|
if (!cliproxyAvailable) {
|
||||||
results.details['Image Analysis'] = {
|
results.details['Image Analysis'] = {
|
||||||
status: 'WARN',
|
status: 'WARN',
|
||||||
info: `Enabled but CLIProxy not running`,
|
info: 'Enabled; local CLIProxy will start on launch if needed',
|
||||||
};
|
};
|
||||||
results.warnings.push({
|
results.warnings.push({
|
||||||
name: 'Image Analysis',
|
name: 'Image Analysis',
|
||||||
message: 'CLIProxy not running - image analysis will fail',
|
message:
|
||||||
fix: 'ccs config (starts CLIProxy)',
|
'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(
|
||||||
console.log(` ${dim('Note:')} Start with: ccs config`);
|
` ${warn('CLIProxy:')} Idle at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT} (auto-start on launch)`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`);
|
console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
|
|||||||
import type { AccountContextPolicy } from '../auth/account-context';
|
import type { AccountContextPolicy } from '../auth/account-context';
|
||||||
import { getCcsDir, getCcsHome } from '../utils/config-manager';
|
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 */
|
/** Options for instance creation */
|
||||||
export interface InstanceOptions {
|
export interface InstanceOptions {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||||
|
import { resolveCliproxyBridgeProfile } from '../../api/services/cliproxy-profile-bridge';
|
||||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||||
|
import { getPromptsDir } from '../image-analysis/hook-installer';
|
||||||
import {
|
import {
|
||||||
resolveImageAnalysisStatus,
|
resolveImageAnalysisStatus,
|
||||||
type ImageAnalysisResolutionContext,
|
type ImageAnalysisResolutionContext,
|
||||||
@@ -23,6 +25,14 @@ function serializeProviderModels(providerModels: Record<string, string>): string
|
|||||||
.join(',');
|
.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.
|
* Get image analysis hook environment variables.
|
||||||
* These env vars control the hook's behavior via Claude Code hook system.
|
* These env vars control the hook's behavior via Claude Code hook system.
|
||||||
@@ -45,12 +55,66 @@ export function getImageAnalysisHookEnv(
|
|||||||
? resolveImageAnalysisStatus(context, config)
|
? resolveImageAnalysisStatus(context, config)
|
||||||
: resolveImageAnalysisStatus({ profileName: '' }, config);
|
: resolveImageAnalysisStatus({ profileName: '' }, config);
|
||||||
const skipImageAnalysis = !status.supported;
|
const skipImageAnalysis = !status.supported;
|
||||||
|
const runtimeApiKey =
|
||||||
|
typeof context === 'object' && context.cliproxyBridge
|
||||||
|
? resolveCliproxyBridgeProfile(context.cliproxyBridge.provider).apiKey
|
||||||
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
|
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
|
||||||
CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60),
|
CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60),
|
||||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models),
|
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models),
|
||||||
CCS_CURRENT_PROVIDER: status.backendId || '',
|
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',
|
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.'
|
? 'CLIProxy runtime readiness has not been verified yet.'
|
||||||
: null,
|
: null,
|
||||||
effectiveRuntimeMode:
|
effectiveRuntimeMode:
|
||||||
config.enabled && resolution.backendId && model && status !== 'hook-missing'
|
config.enabled && resolution.backendId && model ? 'cliproxy-image-analysis' : 'native-read',
|
||||||
? 'cliproxy-image-analysis'
|
|
||||||
: 'native-read',
|
|
||||||
effectiveRuntimeReason:
|
effectiveRuntimeReason:
|
||||||
status === 'hook-missing' || !config.enabled || !resolution.backendId || !model
|
!config.enabled || !resolution.backendId || !model
|
||||||
? reason
|
? reason
|
||||||
: null,
|
: status === 'attention' || status === 'hook-missing'
|
||||||
|
? reason
|
||||||
|
: null,
|
||||||
profileModel: nativeSupport.profileModel,
|
profileModel: nativeSupport.profileModel,
|
||||||
nativeReadPreference: nativeSupport.nativeReadPreference,
|
nativeReadPreference: nativeSupport.nativeReadPreference,
|
||||||
nativeImageCapable: nativeSupport.nativeImageCapable,
|
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') {
|
if (status.authReadiness === 'missing' || status.authReadiness === 'unknown') {
|
||||||
return {
|
return {
|
||||||
effectiveRuntimeMode: 'native-read',
|
effectiveRuntimeMode: 'native-read',
|
||||||
@@ -142,7 +135,8 @@ function resolveEffectiveRuntime(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
effectiveRuntimeMode: 'cliproxy-image-analysis',
|
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 { getCcsHooksDir } from '../config-manager';
|
||||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||||
import { removeMigrationMarker } from './image-analyzer-profile-hook-injector';
|
import { removeMigrationMarker } from './image-analyzer-profile-hook-injector';
|
||||||
|
import { installImageAnalysisPrompts } from '../image-analysis/hook-installer';
|
||||||
|
|
||||||
// Re-export from hook-configuration for backward compatibility
|
// Re-export from hook-configuration for backward compatibility
|
||||||
export {
|
export {
|
||||||
@@ -23,12 +24,65 @@ export {
|
|||||||
|
|
||||||
// Hook file name
|
// Hook file name
|
||||||
const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs';
|
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
|
* Check if image analyzer hook is installed
|
||||||
*/
|
*/
|
||||||
export function hasImageAnalyzerHook(): boolean {
|
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 });
|
fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hookPath = getImageAnalyzerHookPath();
|
const artifacts = getHookArtifacts();
|
||||||
|
const sourceBasePath = resolveHookSourceBasePath(artifacts);
|
||||||
|
|
||||||
// Find the bundled hook script
|
if (!sourceBasePath) {
|
||||||
// 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 (process.env.CCS_DEBUG) {
|
if (process.env.CCS_DEBUG) {
|
||||||
console.error(warn(`Image analyzer hook source not found: ${IMAGE_ANALYZER_HOOK}`));
|
console.error(warn(`Image analyzer hook source not found: ${IMAGE_ANALYZER_HOOK}`));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy hook to ~/.ccs/hooks/
|
for (const { fileName, destinationPath } of artifacts) {
|
||||||
fs.copyFileSync(sourcePath, hookPath);
|
fs.copyFileSync(path.join(sourceBasePath, fileName), destinationPath);
|
||||||
fs.chmodSync(hookPath, 0o755);
|
fs.chmodSync(destinationPath, 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
installImageAnalysisPrompts();
|
||||||
|
|
||||||
if (process.env.CCS_DEBUG) {
|
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
|
// 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 {
|
export function uninstallImageAnalyzerHook(): boolean {
|
||||||
try {
|
try {
|
||||||
const hookPath = getImageAnalyzerHookPath();
|
const artifactPaths = [getImageAnalyzerHookPath(), getImageAnalysisRuntimeHookPath()];
|
||||||
|
|
||||||
if (fs.existsSync(hookPath)) {
|
for (const artifactPath of artifactPaths) {
|
||||||
fs.unlinkSync(hookPath);
|
if (fs.existsSync(artifactPath)) {
|
||||||
if (process.env.CCS_DEBUG) {
|
fs.unlinkSync(artifactPath);
|
||||||
console.error(info(`Uninstalled image analyzer hook: ${hookPath}`));
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.sharedHookInstalled === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// One-time migration marker
|
// One-time migration marker
|
||||||
migrateGlobalHook();
|
migrateGlobalHook();
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,16 @@
|
|||||||
* @module utils/hooks
|
* @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 {
|
export {
|
||||||
canonicalizeImageAnalysisConfig,
|
canonicalizeImageAnalysisConfig,
|
||||||
resolveImageAnalysisStatus,
|
resolveImageAnalysisStatus,
|
||||||
@@ -26,3 +35,7 @@ export {
|
|||||||
uninstallImageAnalyzerHook,
|
uninstallImageAnalyzerHook,
|
||||||
} from './image-analyzer-hook-installer';
|
} from './image-analyzer-hook-installer';
|
||||||
export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector';
|
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;
|
||||||
|
}
|
||||||
@@ -5,3 +5,23 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { getPromptsDir, installImageAnalysisPrompts } from './hook-installer';
|
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];
|
||||||
|
|||||||
@@ -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 (target !== 'claude') return 'bypassed';
|
||||||
if (status.nativeReadPreference) return 'native';
|
if (status.nativeReadPreference) return 'native';
|
||||||
if (!status.backendId) return 'unresolved';
|
if (!status.backendId) return 'unresolved';
|
||||||
if (status.status === 'hook-missing') return 'setup';
|
|
||||||
if (status.effectiveRuntimeMode === 'native-read') return 'fallback';
|
if (status.effectiveRuntimeMode === 'native-read') return 'fallback';
|
||||||
return 'active';
|
return 'active';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user