feat(hooks): add UX improvements for image analysis hook

Add comprehensive UX enhancements for the image analysis CLIProxy hook:

- Add `ccs config image-analysis` CLI command for managing settings
  - Enable/disable toggle
  - Timeout configuration (10-600s)
  - Per-provider model configuration
  - Status display with provider models

- Add specialized error handlers with actionable messages
  - File too large (with compression hints)
  - CLIProxy unavailable (with start instructions)
  - Auth failure (with re-auth commands)
  - Timeout (with increase timeout hint)
  - Rate limit (with retry guidance)
  - API error (with response body parsing)

- Add comprehensive debug output (CCS_DEBUG=1)
  - Provider name and model
  - File size and media type
  - Timeout and endpoint
  - Skip reasons with context

- Add help text updates
  - `ccs --help` includes Image Analysis section
  - `ccs config --help` lists image-analysis subcommand

- Add doctor integration
  - Validates image_analysis config
  - Checks enabled status, providers, timeout
  - Warns if CLIProxy not running

- Add unit tests for new config command
This commit is contained in:
kaitranntt committed 2026-02-03 22:14:52 -05:00
1 parent 0e7b9c9190
commit 2b0717ed53
10 files changed
+953 -48

No files matched your search

+340 -48
View File
@@ -49,6 +49,21 @@ 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)
// ============================================================================
const ERROR_CODES = {
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
CLIPROXY_UNAVAILABLE: 'CLIPROXY_UNAVAILABLE',
AUTH_FAILED: 'AUTH_FAILED',
TIMEOUT: 'TIMEOUT',
RATE_LIMIT: 'RATE_LIMIT',
API_ERROR: 'API_ERROR',
PARSE_ERROR: 'PARSE_ERROR',
UNKNOWN: 'UNKNOWN',
};
// Default analysis prompt
const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description.
@@ -66,6 +81,55 @@ Be comprehensive - this description replaces direct visual access.`;
// HELPER FUNCTIONS
// ============================================================================
/**
* Output debug information to stderr
* Only outputs when CCS_DEBUG=1
*/
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) {
lines.push(` ${key}: ${value}`);
}
}
console.error(lines.join('\n'));
}
/**
* Get detailed debug context
*/
function getDebugContext(filePath, stats) {
const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const model = providerModels[currentProvider] || DEFAULT_MODEL;
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
const isDefaultModel = !providerModels[currentProvider];
return {
file: path.basename(filePath),
size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown',
provider: currentProvider,
model: model,
config: isDefaultModel ? 'default' : 'user-configured',
timeout: `${timeout}s`,
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`,
};
}
/**
* Get current provider/model context for error messages
*/
function getProviderContext() {
const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
const model = providerModels[provider] || DEFAULT_MODEL;
return { provider, model };
}
/**
* Parse provider_models env var to object
* Format: provider:model,provider:model
@@ -205,8 +269,20 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
});
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(`CLIProxy returned status ${res.statusCode}: ${data}`));
reject(new Error(`API_ERROR:${res.statusCode}:${data}`));
return;
}
@@ -235,7 +311,7 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) {
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
reject(new Error('TIMEOUT'));
});
req.write(requestBody);
@@ -263,10 +339,213 @@ function formatDescription(filePath, description, model, fileSize) {
].join('\n');
}
// ============================================================================
// SPECIALIZED ERROR HANDLERS
// ============================================================================
/**
* Format error output for Claude hook
*/
function formatErrorOutput(filePath, errorCode, message, troubleshooting) {
const { provider, model } = getProviderContext();
const lines = [
`[Image Analysis - Error]`,
'',
`File: ${path.basename(filePath)}`,
`Provider: ${provider} | Model: ${model}`,
'',
`Error: ${message}`,
];
if (troubleshooting && troubleshooting.length > 0) {
lines.push('');
lines.push('Troubleshooting:');
troubleshooting.forEach((step, i) => {
lines.push(` ${i + 1}. ${step}`);
});
}
lines.push('');
lines.push('For help: ccs config image-analysis --help');
return {
decision: 'block',
reason: `Image analysis failed: ${errorCode}`,
systemMessage: `[Image Analysis] Failed: ${message}`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: lines.join('\n'),
},
};
}
/**
* File too large error
*/
function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) {
const output = formatErrorOutput(
filePath,
ERROR_CODES.FILE_TOO_LARGE,
`File too large (${actualSizeMB.toFixed(2)}MB > ${maxSizeMB}MB limit)`,
[
'Reduce image resolution or use compression',
'For screenshots: use PNG optimizer (pngquant, optipng)',
'For photos: resize to max 2048px width',
`Current limit: ${maxSizeMB}MB per file`,
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* CLIProxy unavailable error
*/
function outputCliProxyUnavailableError(filePath, endpoint) {
const output = formatErrorOutput(
filePath,
ERROR_CODES.CLIPROXY_UNAVAILABLE,
`CLIProxy not available at ${endpoint}`,
[
'CLIProxy service may not be running',
'Start with: ccs config (opens dashboard, starts CLIProxy)',
'Or manually: ccs cliproxy start',
`Verify: curl ${endpoint}`,
'Check status: ccs doctor',
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Authentication error
*/
function outputAuthError(filePath, statusCode) {
const { provider } = getProviderContext();
const output = formatErrorOutput(
filePath,
ERROR_CODES.AUTH_FAILED,
`Authentication failed (HTTP ${statusCode})`,
[
`Re-authenticate: ccs ${provider} --auth`,
`Check accounts: ccs ${provider} --accounts`,
'Verify OAuth token is valid',
'Check: ccs doctor',
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Timeout error
*/
function outputTimeoutError(filePath, timeoutSec) {
const { model } = getProviderContext();
const output = formatErrorOutput(
filePath,
ERROR_CODES.TIMEOUT,
`Request timed out after ${timeoutSec}s`,
[
'Large files or complex images take longer',
`Increase timeout: ccs config image-analysis --timeout ${timeoutSec * 2}`,
'Or via env: CCS_IMAGE_ANALYSIS_TIMEOUT=120',
`Current model (${model}) may be slow - try a faster variant`,
'Check CLIProxy health: curl http://127.0.0.1:8317',
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Rate limit error
*/
function outputRateLimitError(filePath, retryAfterSec) {
const { provider } = getProviderContext();
const retryHint = retryAfterSec ? `Retry after ${retryAfterSec}s` : 'Wait a moment and retry';
const output = formatErrorOutput(
filePath,
ERROR_CODES.RATE_LIMIT,
'Rate limit exceeded',
[
retryHint,
`Provider ${provider} has usage limits`,
'Consider switching accounts: ccs ' + provider + ' --accounts',
'Check quota: ccs cliproxy doctor',
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Generic API error
*/
function outputApiError(filePath, statusCode, responseBody) {
// Try to extract error message from response
let errorDetail = `HTTP ${statusCode}`;
try {
const parsed = JSON.parse(responseBody);
if (parsed.error?.message) {
errorDetail = parsed.error.message;
} else if (parsed.message) {
errorDetail = parsed.message;
}
} catch {
// Use raw body if not JSON (truncated)
if (responseBody && responseBody.length < 100) {
errorDetail = responseBody;
}
}
const output = formatErrorOutput(
filePath,
ERROR_CODES.API_ERROR,
`API error: ${errorDetail}`,
[
'Check CLIProxy logs: ccs cleanup --show-logs',
'Verify provider is authenticated: ccs doctor',
'Try a different provider or model',
'Report persistent issues: https://github.com/kaitranntt/ccs/issues',
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Unknown/fallback error (replaces old outputError)
*/
function outputUnknownError(filePath, error) {
const output = formatErrorOutput(
filePath,
ERROR_CODES.UNKNOWN,
error || 'Unknown error occurred',
[
'Check CLIProxy is running: curl http://127.0.0.1:8317',
'Verify authentication: ccs doctor',
'Check file is valid image/PDF',
'Enable debug: CCS_DEBUG=1 ccs <provider>',
]
);
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Output success response and exit
*/
function outputSuccess(filePath, description, model, fileSize) {
debugLog('Returning analysis result', {
file: path.basename(filePath),
model: model,
descriptionLength: `${description.length} chars`,
});
const formattedDescription = formatDescription(filePath, description, model, fileSize);
const output = {
@@ -285,55 +564,38 @@ function outputSuccess(filePath, description, model, fileSize) {
}
/**
* Output error message
*/
function outputError(filePath, error) {
const message = [
`[Image Analysis - Error]`,
'',
`Failed to analyze: ${path.basename(filePath)}`,
'',
`Error: ${error}`,
'',
'Troubleshooting:',
' - Check CLIProxy is running: http://127.0.0.1:8317',
' - Verify you are authenticated with agy or gemini',
' - Check file size is under 10MB',
].join('\n');
const output = {
decision: 'block',
reason: `Image analysis failed: ${error}`,
systemMessage: `[Image Analysis] Failed to analyze ${path.basename(filePath)}`,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: message,
},
};
console.log(JSON.stringify(output));
process.exit(2);
}
/**
* Determine if hook should skip
* Determine if hook should skip, with debug logging
*/
function shouldSkipHook() {
// Explicit skip signal
if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') return true;
if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') {
debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP=1');
return true;
}
// Explicit disable
if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') return true;
if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') {
debugLog('Skipping: image analysis disabled (CCS_IMAGE_ANALYSIS_ENABLED=0)');
return true;
}
// Account/default profiles - use native Read
const profileType = process.env.CCS_PROFILE_TYPE;
if (profileType === 'account' || profileType === 'default') return true;
if (profileType === 'account' || profileType === 'default') {
debugLog(`Skipping: profile type "${profileType}" uses native Read`);
return true;
}
// Check if current provider has a vision model configured
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
if (!providerModels[currentProvider]) return true;
if (!providerModels[currentProvider]) {
debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, {
configured_providers: Object.keys(providerModels).join(', ') || 'none',
});
return true;
}
return false;
}
@@ -394,16 +656,17 @@ async function processHook() {
// Check file size
const stats = fs.statSync(filePath);
if (stats.size >= MAX_FILE_SIZE_BYTES) {
outputError(filePath, `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB >= ${MAX_FILE_SIZE_MB}MB)`);
outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB);
return;
}
// Check CLIProxy availability
const cliProxyAvailable = await isCliProxyAvailable();
if (!cliProxyAvailable) {
if (process.env.CCS_DEBUG) {
console.error('[CCS Hook] CLIProxy not available, passing through');
}
debugLog('Skipping: CLIProxy not available', {
endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`,
action: 'passing through to native Read',
});
// Pass through to native Read
process.exit(0);
}
@@ -412,17 +675,26 @@ async function processHook() {
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000;
if (process.env.CCS_DEBUG) {
console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`);
}
// 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
const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs);
debugLog('Analysis complete', {
responseLength: `${description.length} chars`,
});
// Output success
outputSuccess(filePath, description, model, stats.size);
} catch (err) {
@@ -439,7 +711,27 @@ async function processHook() {
// Ignore parse errors
}
// Output error
outputError(filePath, err.message || 'Unknown error');
// Categorize error by message pattern
const errMsg = err.message || '';
if (errMsg.startsWith('AUTH_ERROR:')) {
const statusCode = parseInt(errMsg.split(':')[1], 10);
outputAuthError(filePath, statusCode);
} else if (errMsg.startsWith('RATE_LIMIT:')) {
const retryAfter = errMsg.split(':')[1];
outputRateLimitError(filePath, retryAfter ? parseInt(retryAfter, 10) : null);
} else if (errMsg.startsWith('API_ERROR:')) {
const parts = errMsg.split(':');
const statusCode = parseInt(parts[1], 10);
const body = parts.slice(2).join(':');
outputApiError(filePath, statusCode, body);
} else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) {
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
outputTimeoutError(filePath, timeout);
} else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) {
outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`);
} else {
outputUnknownError(filePath, errMsg);
}
}
}