mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ function showHelp(): void {
|
||||
console.log(' auth show Display current auth status');
|
||||
console.log(' auth disable Disable authentication');
|
||||
console.log('');
|
||||
console.log(' image-analysis Manage image analysis settings');
|
||||
console.log(' --enable Enable image analysis via CLIProxy');
|
||||
console.log(' --disable Disable image analysis');
|
||||
console.log(' --timeout <s> Set analysis timeout (seconds)');
|
||||
console.log(' --set-model <p> <m> Set model for provider');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
|
||||
console.log(' --dev Development mode with Vite HMR');
|
||||
@@ -72,6 +78,8 @@ function showHelp(): void {
|
||||
console.log(' ccs config --port 3000 Use specific port');
|
||||
console.log(' ccs config --dev Development mode with hot reload');
|
||||
console.log(' ccs config auth setup Configure dashboard login');
|
||||
console.log(' ccs config image-analysis Show image settings');
|
||||
console.log(' ccs config image-analysis --enable Enable feature');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
@@ -86,6 +94,13 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Route image-analysis subcommand
|
||||
if (args[0] === 'image-analysis') {
|
||||
const { handleConfigImageAnalysisCommand } = await import('./config-image-analysis-command');
|
||||
await handleConfigImageAnalysisCommand(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
await initUI();
|
||||
|
||||
const options = parseArgs(args);
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Config Image Analysis Command Handler
|
||||
*
|
||||
* Manages image_analysis section of config.yaml via CLI.
|
||||
* Usage: ccs config image-analysis [options]
|
||||
*/
|
||||
|
||||
import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui';
|
||||
import {
|
||||
getImageAnalysisConfig,
|
||||
updateUnifiedConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
} from '../config/unified-config-loader';
|
||||
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types';
|
||||
|
||||
interface ImageAnalysisCommandOptions {
|
||||
enable?: boolean;
|
||||
disable?: boolean;
|
||||
timeout?: number;
|
||||
setModel?: { provider: string; model: string };
|
||||
help?: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): ImageAnalysisCommandOptions {
|
||||
const options: ImageAnalysisCommandOptions = {};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--enable') {
|
||||
options.enable = true;
|
||||
} else if (arg === '--disable') {
|
||||
options.disable = true;
|
||||
} else if (arg === '--timeout' && args[i + 1]) {
|
||||
const timeout = parseInt(args[++i], 10);
|
||||
if (isNaN(timeout) || timeout < 10 || timeout > 600) {
|
||||
console.error(fail('Timeout must be between 10 and 600 seconds'));
|
||||
process.exit(1);
|
||||
}
|
||||
options.timeout = timeout;
|
||||
} else if (arg === '--set-model' && args[i + 1] && args[i + 2]) {
|
||||
options.setModel = {
|
||||
provider: args[++i],
|
||||
model: args[++i],
|
||||
};
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function showHelp(): void {
|
||||
console.log('');
|
||||
console.log(header('ccs config image-analysis'));
|
||||
console.log('');
|
||||
console.log(' Configure image analysis for CLIProxy providers.');
|
||||
console.log(' Images/PDFs are analyzed via vision models instead of direct Read.');
|
||||
console.log('');
|
||||
|
||||
console.log(subheader('Usage:'));
|
||||
console.log(` ${color('ccs config image-analysis', 'command')} [options]`);
|
||||
console.log('');
|
||||
|
||||
console.log(subheader('Options:'));
|
||||
console.log(` ${color('--enable', 'command')} Enable image analysis`);
|
||||
console.log(` ${color('--disable', 'command')} Disable image analysis`);
|
||||
console.log(` ${color('--timeout <seconds>', 'command')} Set analysis timeout (10-600)`);
|
||||
console.log(` ${color('--set-model <p> <m>', 'command')} Set model for provider`);
|
||||
console.log(` ${color('--help, -h', 'command')} Show this help`);
|
||||
console.log('');
|
||||
|
||||
console.log(subheader('Provider Models:'));
|
||||
console.log(` ${dim('Providers with vision support: agy, gemini, codex, kiro, ghcp, claude')}`);
|
||||
console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`);
|
||||
console.log('');
|
||||
|
||||
console.log(subheader('Examples:'));
|
||||
console.log(
|
||||
` $ ${color('ccs config image-analysis', 'command')} ${dim('# Show status')}`
|
||||
);
|
||||
console.log(
|
||||
` $ ${color('ccs config image-analysis --enable', 'command')} ${dim('# Enable feature')}`
|
||||
);
|
||||
console.log(
|
||||
` $ ${color('ccs config image-analysis --timeout 120', 'command')} ${dim('# Set 2min timeout')}`
|
||||
);
|
||||
console.log(
|
||||
` $ ${color('ccs config image-analysis --set-model agy gemini-2.5-pro', 'command')}`
|
||||
);
|
||||
console.log('');
|
||||
|
||||
console.log(subheader('How it works:'));
|
||||
console.log(` 1. When Claude's Read tool targets an image/PDF file`);
|
||||
console.log(` 2. CCS hook intercepts and sends to CLIProxy vision API`);
|
||||
console.log(` 3. Vision model analyzes and returns text description`);
|
||||
console.log(` 4. Claude receives description instead of raw image data`);
|
||||
console.log('');
|
||||
|
||||
console.log(subheader('Supported file types:'));
|
||||
console.log(` ${dim('Images: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff')}`);
|
||||
console.log(` ${dim('Documents: .pdf')}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function showStatus(forceReload = false): void {
|
||||
// Force reload if config was just modified
|
||||
const config = forceReload ? getImageAnalysisConfig() : getImageAnalysisConfig();
|
||||
|
||||
console.log('');
|
||||
console.log(header('Image Analysis Configuration'));
|
||||
console.log('');
|
||||
|
||||
// Status
|
||||
const statusText = config.enabled ? ok('Enabled') : warn('Disabled');
|
||||
console.log(` Status: ${statusText}`);
|
||||
console.log(` Timeout: ${config.timeout}s`);
|
||||
console.log('');
|
||||
|
||||
// Provider models
|
||||
console.log(subheader('Provider Models:'));
|
||||
const providers = Object.entries(config.provider_models);
|
||||
if (providers.length === 0) {
|
||||
console.log(` ${dim('No providers configured')}`);
|
||||
} else {
|
||||
for (const [provider, model] of providers) {
|
||||
const isDefault =
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models[
|
||||
provider as keyof typeof DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models
|
||||
] === model;
|
||||
const suffix = isDefault ? dim(' (default)') : '';
|
||||
console.log(` ${color(provider.padEnd(10), 'command')} ${model}${suffix}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Config location
|
||||
console.log(subheader('Configuration:'));
|
||||
console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`);
|
||||
console.log(` Section: ${dim('image_analysis')}`);
|
||||
console.log('');
|
||||
|
||||
// Troubleshooting hint if disabled
|
||||
if (!config.enabled) {
|
||||
console.log(info('To enable: ccs config image-analysis --enable'));
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleConfigImageAnalysisCommand(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
const options = parseArgs(args);
|
||||
|
||||
if (options.help) {
|
||||
showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply changes if any options provided
|
||||
let hasChanges = false;
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const imageConfig = config.image_analysis ?? { ...DEFAULT_IMAGE_ANALYSIS_CONFIG };
|
||||
|
||||
if (options.enable) {
|
||||
imageConfig.enabled = true;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (options.disable) {
|
||||
imageConfig.enabled = false;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (options.timeout !== undefined) {
|
||||
imageConfig.timeout = options.timeout;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (options.setModel) {
|
||||
const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow'];
|
||||
if (!validProviders.includes(options.setModel.provider)) {
|
||||
console.error(fail(`Invalid provider: ${options.setModel.provider}`));
|
||||
console.error(info(`Valid providers: ${validProviders.join(', ')}`));
|
||||
process.exit(1);
|
||||
}
|
||||
imageConfig.provider_models = {
|
||||
...imageConfig.provider_models,
|
||||
[options.setModel.provider]: options.setModel.model,
|
||||
};
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
updateUnifiedConfig({ image_analysis: imageConfig });
|
||||
console.log(ok('Configuration updated'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Always show current status (reload if we made changes)
|
||||
showStatus(hasChanges);
|
||||
}
|
||||
@@ -242,6 +242,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs config', 'Open web configuration dashboard'],
|
||||
['ccs config auth setup', 'Configure dashboard login'],
|
||||
['ccs config auth show', 'Show dashboard auth status'],
|
||||
['ccs config image-analysis', 'Show image analysis settings'],
|
||||
['ccs config image-analysis --enable', 'Enable image analysis'],
|
||||
['ccs config --port 3000', 'Use specific port'],
|
||||
['ccs persist <profile>', 'Write profile env to ~/.claude/settings.json'],
|
||||
['ccs persist --list-backups', 'List available settings.json backups'],
|
||||
@@ -306,6 +308,19 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['', 'before responding. Supported: agy, gemini (thinking models).'],
|
||||
]);
|
||||
|
||||
// Image Analysis
|
||||
printSubSection('Image Analysis (CLIProxy vision)', [
|
||||
['ccs config image-analysis', 'Show current settings'],
|
||||
['ccs config image-analysis --enable', 'Enable for CLIProxy providers'],
|
||||
['ccs config image-analysis --disable', 'Disable (use native Read)'],
|
||||
['ccs config image-analysis --timeout 120', 'Set analysis timeout'],
|
||||
['ccs config image-analysis --set-model <p> <m>', 'Set provider model'],
|
||||
['', ''],
|
||||
['Note:', 'When enabled, images/PDFs are analyzed via vision models'],
|
||||
['', 'instead of passing raw data to Claude. Works with CLIProxy'],
|
||||
['', 'providers (agy, gemini, codex, kiro, ghcp).'],
|
||||
]);
|
||||
|
||||
// CLI Proxy env vars
|
||||
printSubSection('CLI Proxy Environment Variables', [
|
||||
['CCS_PROXY_HOST', 'Remote proxy hostname'],
|
||||
|
||||
@@ -6,6 +6,7 @@ export { handleApiCommand } from './api-command';
|
||||
export { handleCleanupCommand } from './cleanup-command';
|
||||
export { handleCliproxyCommand } from './cliproxy-command';
|
||||
export { handleConfigCommand } from './config-command';
|
||||
export { handleConfigImageAnalysisCommand } from './config-image-analysis-command';
|
||||
export { handleCopilotCommand } from './copilot-command';
|
||||
export { handleDoctorCommand } from './doctor-command';
|
||||
export { handleHelpCommand } from './help-command';
|
||||
|
||||
@@ -529,6 +529,30 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Image analysis section
|
||||
if (config.image_analysis) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push('# Image Analysis: Vision-based analysis for images and PDFs');
|
||||
lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.');
|
||||
lines.push('#');
|
||||
lines.push('# When enabled: Image files trigger vision analysis instead of raw file read');
|
||||
lines.push('# Provider models: Vision model used for each CLIProxy provider');
|
||||
lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)');
|
||||
lines.push('#');
|
||||
lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf');
|
||||
lines.push('# Configure via: ccs config image-analysis');
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
yaml
|
||||
.dump(
|
||||
{ image_analysis: config.image_analysis },
|
||||
{ indent: 2, lineWidth: -1, quotingType: '"' }
|
||||
)
|
||||
.trim()
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Image Analysis Config Check
|
||||
*
|
||||
* Validates image_analysis configuration in config.yaml.
|
||||
* Checks: enabled status, provider_models, timeout, CLIProxy availability.
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../../config/unified-config-types';
|
||||
import { ok, warn, dim } from '../../utils/ui';
|
||||
import type { HealthCheck } from './types';
|
||||
|
||||
/**
|
||||
* Check CLIProxy availability (simple HTTP check)
|
||||
*/
|
||||
async function isCliProxyAvailable(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: 8317,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
timeout: 2000,
|
||||
},
|
||||
(res) => {
|
||||
resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 500);
|
||||
}
|
||||
);
|
||||
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run image analysis configuration check
|
||||
*/
|
||||
export async function runImageAnalysisCheck(results: HealthCheck): Promise<void> {
|
||||
const config = getImageAnalysisConfig();
|
||||
|
||||
// Check 1: Feature status
|
||||
if (!config.enabled) {
|
||||
results.details['Image Analysis'] = {
|
||||
status: 'OK',
|
||||
info: 'Disabled (using native Read)',
|
||||
};
|
||||
console.log(` ${dim('Status:')} Disabled`);
|
||||
console.log(` ${dim('Tip:')} Enable with: ccs config image-analysis --enable`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Feature is enabled - run validation checks
|
||||
console.log(` ${ok('Status:')} Enabled`);
|
||||
|
||||
// Check 2: Provider models configured
|
||||
const providers = Object.keys(config.provider_models);
|
||||
if (providers.length === 0) {
|
||||
results.details['Image Analysis'] = {
|
||||
status: 'ERROR',
|
||||
info: 'No providers configured',
|
||||
};
|
||||
results.errors.push({
|
||||
name: 'Image Analysis',
|
||||
message: 'No provider models configured for image analysis',
|
||||
fix: 'ccs config image-analysis --set-model agy gemini-2.5-flash',
|
||||
});
|
||||
console.log(` ${warn('Providers:')} None configured`);
|
||||
return;
|
||||
}
|
||||
console.log(` ${ok('Providers:')} ${providers.join(', ')}`);
|
||||
|
||||
// Check 3: Timeout validation
|
||||
if (config.timeout < 10 || config.timeout > 600) {
|
||||
results.details['Image Analysis'] = {
|
||||
status: 'ERROR',
|
||||
info: `Invalid timeout: ${config.timeout}s`,
|
||||
};
|
||||
results.errors.push({
|
||||
name: 'Image Analysis',
|
||||
message: `Timeout ${config.timeout}s out of range (10-600)`,
|
||||
fix: 'ccs config image-analysis --timeout 60',
|
||||
});
|
||||
console.log(` ${warn('Timeout:')} ${config.timeout}s (invalid, must be 10-600)`);
|
||||
return;
|
||||
}
|
||||
console.log(` ${ok('Timeout:')} ${config.timeout}s`);
|
||||
|
||||
// Check 4: CLIProxy availability (only if enabled)
|
||||
const cliproxyAvailable = await isCliProxyAvailable();
|
||||
if (!cliproxyAvailable) {
|
||||
results.details['Image Analysis'] = {
|
||||
status: 'WARN',
|
||||
info: `Enabled but CLIProxy not running`,
|
||||
};
|
||||
results.warnings.push({
|
||||
name: 'Image Analysis',
|
||||
message: 'CLIProxy not running - image analysis will fail',
|
||||
fix: 'ccs config (starts CLIProxy)',
|
||||
});
|
||||
console.log(` ${warn('CLIProxy:')} Not running at http://127.0.0.1:8317`);
|
||||
console.log(` ${dim('Note:')} Start with: ccs config`);
|
||||
return;
|
||||
}
|
||||
console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:8317`);
|
||||
|
||||
// All checks passed
|
||||
results.details['Image Analysis'] = {
|
||||
status: 'OK',
|
||||
info: `Enabled (${providers.length} providers)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix image analysis configuration issues
|
||||
*/
|
||||
export async function fixImageAnalysisConfig(): Promise<boolean> {
|
||||
const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = await import(
|
||||
'../../config/unified-config-loader'
|
||||
);
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
let fixed = false;
|
||||
|
||||
// Fix missing provider_models
|
||||
if (
|
||||
!config.image_analysis?.provider_models ||
|
||||
Object.keys(config.image_analysis.provider_models).length === 0
|
||||
) {
|
||||
config.image_analysis = {
|
||||
...config.image_analysis,
|
||||
enabled: config.image_analysis?.enabled ?? true,
|
||||
timeout: config.image_analysis?.timeout ?? 60,
|
||||
provider_models: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models },
|
||||
};
|
||||
fixed = true;
|
||||
}
|
||||
|
||||
// Fix invalid timeout
|
||||
if (
|
||||
config.image_analysis &&
|
||||
(config.image_analysis.timeout < 10 || config.image_analysis.timeout > 600)
|
||||
) {
|
||||
config.image_analysis.timeout = 60;
|
||||
fixed = true;
|
||||
}
|
||||
|
||||
if (fixed) {
|
||||
updateUnifiedConfig({ image_analysis: config.image_analysis });
|
||||
}
|
||||
|
||||
return fixed;
|
||||
}
|
||||
@@ -49,3 +49,6 @@ export {
|
||||
|
||||
// OAuth checks
|
||||
export { OAuthPortsChecker, runOAuthChecks } from './oauth-check';
|
||||
|
||||
// Image Analysis checks
|
||||
export { runImageAnalysisCheck, fixImageAnalysisConfig } from './image-analysis-check';
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
runSymlinkChecks,
|
||||
runCLIProxyChecks,
|
||||
runOAuthChecks,
|
||||
runImageAnalysisCheck,
|
||||
} from './checks';
|
||||
import { runAutoRepair } from './repair';
|
||||
|
||||
@@ -76,6 +77,11 @@ class Doctor {
|
||||
await runOAuthChecks(this.results);
|
||||
console.log('');
|
||||
|
||||
// Group 8: Image Analysis Config
|
||||
console.log(header('IMAGE ANALYSIS'));
|
||||
await runImageAnalysisCheck(this.results);
|
||||
console.log('');
|
||||
|
||||
this.showReport();
|
||||
return this.results;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Config Image Analysis Command Tests
|
||||
*
|
||||
* Unit tests for ccs config image-analysis subcommand.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// Create temp directory for test isolation
|
||||
let testDir: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-image-test-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = testDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper to create config.yaml for tests
|
||||
function createConfigYaml(content: string): void {
|
||||
fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8');
|
||||
}
|
||||
|
||||
describe('config image-analysis command', () => {
|
||||
describe('config file parsing', () => {
|
||||
it('should parse enabled status from config.yaml', () => {
|
||||
createConfigYaml(`
|
||||
version: 2
|
||||
image_analysis:
|
||||
enabled: true
|
||||
timeout: 60
|
||||
provider_models:
|
||||
agy: gemini-2.5-flash
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
expect(content).toContain('enabled: true');
|
||||
expect(content).toContain('timeout: 60');
|
||||
expect(content).toContain('agy: gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('should parse disabled status from config.yaml', () => {
|
||||
createConfigYaml(`
|
||||
version: 2
|
||||
image_analysis:
|
||||
enabled: false
|
||||
timeout: 120
|
||||
provider_models: {}
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
expect(content).toContain('enabled: false');
|
||||
expect(content).toContain('timeout: 120');
|
||||
});
|
||||
|
||||
it('should parse multiple provider models', () => {
|
||||
createConfigYaml(`
|
||||
version: 2
|
||||
image_analysis:
|
||||
enabled: true
|
||||
timeout: 60
|
||||
provider_models:
|
||||
agy: gemini-2.5-flash
|
||||
gemini: gemini-2.5-pro
|
||||
codex: gpt-5.1-codex-mini
|
||||
kiro: kiro-claude-haiku-4-5
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
expect(content).toContain('agy: gemini-2.5-flash');
|
||||
expect(content).toContain('gemini: gemini-2.5-pro');
|
||||
expect(content).toContain('codex: gpt-5.1-codex-mini');
|
||||
expect(content).toContain('kiro: kiro-claude-haiku-4-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeout validation', () => {
|
||||
it('should accept valid timeout within range (10-600)', () => {
|
||||
const validTimeouts = [10, 60, 120, 300, 600];
|
||||
|
||||
for (const timeout of validTimeouts) {
|
||||
const isValid = timeout >= 10 && timeout <= 600;
|
||||
expect(isValid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject timeout below minimum (10)', () => {
|
||||
const invalidTimeouts = [0, 1, 5, 9];
|
||||
|
||||
for (const timeout of invalidTimeouts) {
|
||||
const isValid = timeout >= 10 && timeout <= 600;
|
||||
expect(isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject timeout above maximum (600)', () => {
|
||||
const invalidTimeouts = [601, 700, 1000, 3600];
|
||||
|
||||
for (const timeout of invalidTimeouts) {
|
||||
const isValid = timeout >= 10 && timeout <= 600;
|
||||
expect(isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider validation', () => {
|
||||
it('should accept valid providers', () => {
|
||||
const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow'];
|
||||
|
||||
for (const provider of validProviders) {
|
||||
expect(validProviders.includes(provider)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject invalid providers', () => {
|
||||
const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow'];
|
||||
const invalidProviders = ['unknown', 'custom', 'my-provider', 'test'];
|
||||
|
||||
for (const provider of invalidProviders) {
|
||||
expect(validProviders.includes(provider)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('default configuration', () => {
|
||||
it('should have correct default values', () => {
|
||||
// These are the expected defaults from unified-config-types.ts
|
||||
const defaultConfig = {
|
||||
enabled: true,
|
||||
timeout: 60,
|
||||
provider_models: {
|
||||
agy: 'gemini-2.5-flash',
|
||||
gemini: 'gemini-2.5-flash',
|
||||
codex: 'gpt-5.1-codex-mini',
|
||||
kiro: 'kiro-claude-haiku-4-5',
|
||||
ghcp: 'claude-haiku-4.5',
|
||||
claude: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
};
|
||||
|
||||
expect(defaultConfig.enabled).toBe(true);
|
||||
expect(defaultConfig.timeout).toBe(60);
|
||||
expect(Object.keys(defaultConfig.provider_models).length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config file structure', () => {
|
||||
it('should have image_analysis section', () => {
|
||||
createConfigYaml(`
|
||||
version: 2
|
||||
image_analysis:
|
||||
enabled: true
|
||||
timeout: 60
|
||||
provider_models:
|
||||
agy: gemini-2.5-flash
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
expect(content).toContain('image_analysis:');
|
||||
});
|
||||
|
||||
it('should support empty provider_models', () => {
|
||||
createConfigYaml(`
|
||||
version: 2
|
||||
image_analysis:
|
||||
enabled: false
|
||||
timeout: 60
|
||||
provider_models: {}
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
expect(content).toContain('provider_models: {}');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user