mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 02:16:27 +00:00
Merge pull request #441 from kaitranntt/kai/feat/426-block-image-read
feat(hooks): image analysis via CLIProxy with UX improvements
This commit is contained in:
34 files changed
+3492
-39
No files matched your search
@@ -1,5 +1,7 @@
|
||||
[test]
|
||||
# Exclude UI tests - they use vitest and require jsdom environment
|
||||
# Run UI tests separately with: cd ui && bun run test
|
||||
# Exclude e2e tests - they require manual setup and are slow
|
||||
# Run e2e tests with: bun run test:e2e
|
||||
root = "./tests"
|
||||
timeout = 10000
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CCS Image Read Blocking Hook
|
||||
*
|
||||
* Blocks Claude's Read tool from reading image files to prevent context overflow.
|
||||
* Each image can consume 100K+ tokens, causing immediate context exhaustion.
|
||||
*
|
||||
* This is a PreToolUse hook that runs BEFORE the tool is executed.
|
||||
*
|
||||
* Behavior (matches WebSearch pattern):
|
||||
* - ENABLED by default for third-party profiles (settings, cliproxy)
|
||||
* - DISABLED for native Claude accounts (account, default profiles)
|
||||
* - User can override via config: hooks.block_image_read.enabled: false
|
||||
*
|
||||
* Usage:
|
||||
* Configured in ~/.claude/settings.json:
|
||||
* {
|
||||
* "hooks": {
|
||||
* "PreToolUse": [{
|
||||
* "matcher": "Read",
|
||||
* "hooks": [{
|
||||
* "type": "command",
|
||||
* "command": "node ~/.ccs/hooks/block-image-read.cjs",
|
||||
* "timeout": 5
|
||||
* }]
|
||||
* }]
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Environment Variables (set by CCS):
|
||||
* CCS_BLOCK_IMAGE_READ=1 - Enable blocking (default for third-party)
|
||||
* CCS_BLOCK_IMAGE_READ=0 - Disable blocking
|
||||
* CCS_PROFILE_TYPE - Profile type (account, default, settings, cliproxy)
|
||||
* CCS_DEBUG=1 - Enable debug output
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - Allow tool (pass-through)
|
||||
* 2 - Block tool (deny with message)
|
||||
*
|
||||
* @module hooks/block-image-read
|
||||
*/
|
||||
|
||||
// Image file extensions to block
|
||||
const IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|webp|gif|bmp|tiff|tif|ico|svg|heic|heif|avif)$/i;
|
||||
|
||||
// Read input from stdin
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
processHook();
|
||||
});
|
||||
|
||||
// Handle stdin not being available
|
||||
process.stdin.on('error', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if hook should skip (for native Claude accounts).
|
||||
* Matches WebSearch hook pattern.
|
||||
*/
|
||||
function shouldSkipHook() {
|
||||
// Account/default profiles use native Claude - don't block
|
||||
const profileType = process.env.CCS_PROFILE_TYPE;
|
||||
if (profileType === 'account' || profileType === 'default') {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Skipping image block for profile type: ${profileType}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Explicit disable via config
|
||||
if (process.env.CCS_BLOCK_IMAGE_READ === '0') {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Image read blocking disabled by config');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook processing logic
|
||||
*/
|
||||
function processHook() {
|
||||
try {
|
||||
// Skip for native accounts or explicit disable
|
||||
if (shouldSkipHook()) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const data = JSON.parse(input);
|
||||
|
||||
// Only handle Read tool
|
||||
if (data.tool_name !== 'Read') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const filePath = data.tool_input?.file_path || '';
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Read intercepted: ${filePath}`);
|
||||
}
|
||||
|
||||
// Check if file is an image
|
||||
if (IMAGE_EXTENSIONS.test(filePath)) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Blocking image read: ${filePath}`);
|
||||
}
|
||||
outputBlock(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow non-image files
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Parse error:', err.message);
|
||||
}
|
||||
// Don't block on parse errors
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output block response and exit
|
||||
*/
|
||||
function outputBlock(filePath) {
|
||||
// Extract just the filename for cleaner display
|
||||
const fileName = filePath.split(/[/\\]/).pop() || filePath;
|
||||
|
||||
const message = [
|
||||
'[Image Read Blocked - Context Protection]',
|
||||
'',
|
||||
`File: ${fileName}`,
|
||||
`Path: ${filePath}`,
|
||||
'',
|
||||
'Image files consume 100K+ tokens each and will exhaust context.',
|
||||
'',
|
||||
'The image was generated successfully. To view it:',
|
||||
' - Open the file path above in your image viewer',
|
||||
' - Use your file manager to navigate to the location',
|
||||
' - On macOS: open "' + filePath + '"',
|
||||
' - On Linux: xdg-open "' + filePath + '"',
|
||||
' - On Windows: start "" "' + filePath + '"',
|
||||
'',
|
||||
'If you need to analyze the image, use the ai-multimodal skill',
|
||||
'which processes images via Gemini API without loading into context.',
|
||||
].join('\n');
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: 'Image file blocked to prevent context overflow',
|
||||
// User-facing message (shows in CLI output)
|
||||
systemMessage: `[Image Read Blocked] ${fileName} - Open file directly to view.`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
// Claude reads this - explains what happened and alternatives
|
||||
permissionDecisionReason: message,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
Executable
+758
@@ -0,0 +1,758 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CCS Image Analyzer Hook - Read Tool Interceptor
|
||||
*
|
||||
* Intercepts Claude's Read tool for image/PDF files and analyzes them via CLIProxy.
|
||||
* Returns detailed text descriptions instead of allowing direct visual access.
|
||||
*
|
||||
* Environment Variables (set by CCS):
|
||||
* CCS_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely
|
||||
* CCS_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1)
|
||||
* CCS_IMAGE_ANALYSIS_PROVIDER_MODELS - Provider:model mapping (e.g., agy:gemini-2.5-flash,gemini:gemini-2.5-flash)
|
||||
* CCS_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex)
|
||||
* CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60)
|
||||
* CCS_PROFILE_TYPE - Profile type (account/default skip)
|
||||
* CCS_DEBUG=1 - Enable debug output
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - Allow tool (pass-through to native Read)
|
||||
* 2 - Block tool (deny with analysis/message)
|
||||
*
|
||||
* @module hooks/image-analyzer-transformer
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
// ============================================================================
|
||||
// PLATFORM DETECTION
|
||||
// ============================================================================
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff'];
|
||||
const PDF_EXTENSIONS = ['.pdf'];
|
||||
|
||||
const DEFAULT_MODEL = 'gemini-2.5-flash';
|
||||
const DEFAULT_TIMEOUT_SEC = 60;
|
||||
const MAX_FILE_SIZE_MB = 10;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
|
||||
const CLIPROXY_HOST = '127.0.0.1';
|
||||
const CLIPROXY_PORT = parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10);
|
||||
const CLIPROXY_PATH = '/v1/messages';
|
||||
// API key passed via env from cliproxy-executor, defaults to CCS internal key
|
||||
const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed';
|
||||
|
||||
// ============================================================================
|
||||
// ERROR CODES (for categorization)
|
||||
// ============================================================================
|
||||
|
||||
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.
|
||||
|
||||
Include:
|
||||
1. Overall content and purpose
|
||||
2. Text content (if any) - transcribe important text
|
||||
3. Visual elements (diagrams, charts, UI components)
|
||||
4. Layout and structure
|
||||
5. Colors, styling, notable design elements
|
||||
6. Any actionable information (buttons, links, code)
|
||||
|
||||
Be comprehensive - this description replaces direct visual access.`;
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Output debug information to stderr
|
||||
* Only outputs when CCS_DEBUG=1
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model for current provider from provider_models mapping
|
||||
*/
|
||||
function getModelForProvider() {
|
||||
const currentProvider = process.env.CCS_CURRENT_PROVIDER || '';
|
||||
const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS);
|
||||
return providerModels[currentProvider] || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 response = JSON.parse(data);
|
||||
const text = response.content?.[0]?.text;
|
||||
|
||||
if (!text) {
|
||||
reject(new Error('No text content in response'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(text);
|
||||
} catch (err) {
|
||||
reject(new Error(`Failed to parse response: ${err.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
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)
|
||||
*/
|
||||
function formatDescription(filePath, description, model, fileSize) {
|
||||
const sizeKB = fileSize ? (fileSize / 1024).toFixed(1) : '?';
|
||||
return [
|
||||
`[Image Analysis via CLIProxy]`,
|
||||
'',
|
||||
`File: ${path.basename(filePath)} (${sizeKB} KB)`,
|
||||
`Model: ${model}`,
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
description,
|
||||
'',
|
||||
'---',
|
||||
'*Use this description to understand the image content.*',
|
||||
].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);
|
||||
}
|
||||
|
||||
/**
|
||||
* File permission error
|
||||
*/
|
||||
function outputFileAccessError(filePath, error) {
|
||||
const output = formatErrorOutput(
|
||||
filePath,
|
||||
ERROR_CODES.UNKNOWN,
|
||||
`File access denied: ${error}`,
|
||||
[
|
||||
'Check file permissions: ls -l ' + filePath,
|
||||
isWindows ? 'Run terminal as Administrator if needed' : 'Use sudo or adjust file ownership',
|
||||
'Verify file is readable by current user',
|
||||
'Move file to accessible location',
|
||||
]
|
||||
);
|
||||
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 = {
|
||||
decision: 'block',
|
||||
reason: `Image analyzed: ${path.basename(filePath)}`,
|
||||
systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy (${model})`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: formattedDescription,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if hook should skip, with debug logging
|
||||
*/
|
||||
function shouldSkipHook() {
|
||||
// Explicit skip signal
|
||||
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') {
|
||||
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') {
|
||||
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]) {
|
||||
debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, {
|
||||
configured_providers: Object.keys(providerModels).join(', ') || 'none',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN HOOK LOGIC
|
||||
// ============================================================================
|
||||
|
||||
// Read input from stdin
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
processHook();
|
||||
});
|
||||
|
||||
// Handle stdin not being available
|
||||
process.stdin.on('error', () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Main hook processing logic
|
||||
*/
|
||||
async function processHook() {
|
||||
try {
|
||||
// Skip for native accounts or explicit disable
|
||||
if (shouldSkipHook()) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const data = JSON.parse(input);
|
||||
|
||||
// Only handle Read tool
|
||||
if (data.tool_name !== 'Read') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const filePath = data.tool_input?.file_path || '';
|
||||
|
||||
if (!filePath) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
// Let native Read handle the error
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check if file is analyzable
|
||||
if (!isAnalyzableFile(filePath)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check file size
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.size >= MAX_FILE_SIZE_BYTES) {
|
||||
outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check CLIProxy availability
|
||||
const cliProxyAvailable = await isCliProxyAvailable();
|
||||
if (!cliProxyAvailable) {
|
||||
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);
|
||||
}
|
||||
|
||||
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
|
||||
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) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] Error:', err.message);
|
||||
}
|
||||
|
||||
// Try to extract file path from parsed input
|
||||
let filePath = 'unknown file';
|
||||
try {
|
||||
const data = JSON.parse(input);
|
||||
filePath = data.tool_input?.file_path || 'unknown file';
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
// 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 if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) {
|
||||
outputFileAccessError(filePath, errMsg);
|
||||
} else {
|
||||
outputUnknownError(filePath, errMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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.
|
||||
@@ -0,0 +1,13 @@
|
||||
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.
|
||||
@@ -0,0 +1,13 @@
|
||||
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.
|
||||
+2
-1
@@ -68,10 +68,11 @@
|
||||
"verify:bundle": "node scripts/verify-bundle.js",
|
||||
"test": "bun run build && bun run test:all",
|
||||
"test:ci": "bun run test:all",
|
||||
"test:all": "bun test",
|
||||
"test:all": "bun test tests/unit tests/integration tests/npm",
|
||||
"test:unit": "bun test tests/unit/",
|
||||
"test:npm": "bun test tests/npm/",
|
||||
"test:native": "bash tests/native/unix/edge-cases.sh",
|
||||
"test:e2e": "bun test tests/e2e/ --bail --timeout 60000",
|
||||
"dev": "bun run build:server && bun dist/ccs.js config --dev",
|
||||
"dev:symlink": "bash scripts/dev-symlink.sh",
|
||||
"dev:unlink": "bash scripts/dev-symlink.sh --restore",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ensureProfileHooks,
|
||||
} from './utils/websearch-manager';
|
||||
import { getGlobalEnvConfig } from './config/unified-config-loader';
|
||||
import { getImageReadBlockHookEnv } from './utils/hooks/image-read-block-hook-env';
|
||||
import { fail, info } from './utils/ui';
|
||||
|
||||
// Import centralized error handling
|
||||
@@ -164,10 +165,12 @@ async function execClaudeWithProxy(
|
||||
const isWindows = process.platform === 'win32';
|
||||
const needsShell = isWindows && /\.(cmd|bat|ps1)$/i.test(claudeCli);
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const imageReadBlockEnv = getImageReadBlockHookEnv();
|
||||
const env = {
|
||||
...process.env,
|
||||
...envVars,
|
||||
...webSearchEnv,
|
||||
...imageReadBlockEnv,
|
||||
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
|
||||
};
|
||||
|
||||
@@ -612,6 +615,7 @@ async function main(): Promise<void> {
|
||||
// Use --settings flag (backward compatible)
|
||||
const expandedSettingsPath = getSettingsPath(profileInfo.name);
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const imageReadBlockEnv = getImageReadBlockHookEnv();
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
|
||||
const globalEnvConfig = getGlobalEnvConfig();
|
||||
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||
@@ -633,6 +637,7 @@ async function main(): Promise<void> {
|
||||
...globalEnv,
|
||||
...settingsEnv, // Explicitly inject all settings env vars
|
||||
...webSearchEnv,
|
||||
...imageReadBlockEnv,
|
||||
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
|
||||
};
|
||||
execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars);
|
||||
|
||||
@@ -37,6 +37,8 @@ import { DEFAULT_BACKEND } from './platform-detector';
|
||||
import { configureProviderModel, getCurrentModel } from './model-config';
|
||||
import { resolveProxyConfig, PROXY_CLI_FLAGS } from './proxy-config-resolver';
|
||||
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||
import { getImageReadBlockHookEnv } from '../utils/hooks/image-read-block-hook-env';
|
||||
import { getImageAnalysisHookEnv } from '../utils/hooks/get-image-analysis-hook-env';
|
||||
import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog';
|
||||
import { CodexReasoningProxy } from './codex-reasoning-proxy';
|
||||
import { ToolSanitizationProxy } from './tool-sanitization-proxy';
|
||||
@@ -284,6 +286,29 @@ export async function execClaudeWithCLIProxy(
|
||||
spinner.succeed('CLIProxy binary ready');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to prepare CLIProxy');
|
||||
const err = error as Error;
|
||||
|
||||
// Check if network offline (DNS, connection, or timeout failure)
|
||||
const networkErrors = [
|
||||
'getaddrinfo',
|
||||
'ENOTFOUND',
|
||||
'ETIMEDOUT',
|
||||
'ECONNREFUSED',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
];
|
||||
const isNetworkError = networkErrors.some((errCode) => err.message.includes(errCode));
|
||||
|
||||
if (isNetworkError) {
|
||||
console.error('');
|
||||
console.error(fail('No network connection detected'));
|
||||
console.error('');
|
||||
console.error('CLIProxy binary download requires internet access.');
|
||||
console.error('Please check your network connection and try again.');
|
||||
console.error('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -944,10 +969,14 @@ export async function execClaudeWithCLIProxy(
|
||||
ANTHROPIC_BASE_URL: finalBaseUrl,
|
||||
};
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const imageReadBlockEnv = getImageReadBlockHookEnv();
|
||||
const imageAnalysisEnv = getImageAnalysisHookEnv(provider);
|
||||
const env = {
|
||||
...process.env,
|
||||
...effectiveEnvVars,
|
||||
...webSearchEnv,
|
||||
...imageReadBlockEnv,
|
||||
...imageAnalysisEnv,
|
||||
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
|
||||
};
|
||||
|
||||
|
||||
@@ -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,217 @@
|
||||
/**
|
||||
* 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)') : '';
|
||||
// Edge case #3: Long model name truncation
|
||||
const truncatedModel = model.length > 40 ? model.slice(0, 37) + '...' : model;
|
||||
console.log(` ${color(provider.padEnd(10), 'command')} ${truncatedModel}${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;
|
||||
}
|
||||
|
||||
// Validate conflicting flags (Edge case #2: --enable + --disable conflict)
|
||||
if (options.enable && options.disable) {
|
||||
console.error(fail('Cannot use --enable and --disable together'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Validate model name (Edge case #1: Empty model string validation)
|
||||
const model = options.setModel.model;
|
||||
if (!model || model.trim() === '') {
|
||||
console.error(fail('Model name cannot be empty'));
|
||||
process.exit(1);
|
||||
}
|
||||
imageConfig.provider_models = {
|
||||
...imageConfig.provider_models,
|
||||
[options.setModel.provider]: 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';
|
||||
|
||||
@@ -20,14 +20,18 @@ import {
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
|
||||
DEFAULT_THINKING_CONFIG,
|
||||
DEFAULT_DASHBOARD_AUTH_CONFIG,
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
GlobalEnvConfig,
|
||||
ThinkingConfig,
|
||||
DashboardAuthConfig,
|
||||
ImageAnalysisConfig,
|
||||
} from './unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
|
||||
const CONFIG_YAML = 'config.yaml';
|
||||
const CONFIG_JSON = 'config.json';
|
||||
const CONFIG_LOCK = 'config.yaml.lock';
|
||||
const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds
|
||||
|
||||
/**
|
||||
* Get path to unified config.yaml
|
||||
@@ -43,6 +47,71 @@ export function getConfigJsonPath(): string {
|
||||
return path.join(getCcsDir(), CONFIG_JSON);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to config lockfile
|
||||
*/
|
||||
function getLockFilePath(): string {
|
||||
return path.join(getCcsDir(), CONFIG_LOCK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire lockfile for config write operations.
|
||||
* Returns true if lock acquired, false if already locked by another process.
|
||||
* Cleans up stale locks (older than LOCK_STALE_MS).
|
||||
*/
|
||||
|
||||
function acquireLock(): boolean {
|
||||
const lockPath = getLockFilePath();
|
||||
const lockData = `${process.pid}\n${Date.now()}`;
|
||||
|
||||
try {
|
||||
// Check if lock exists
|
||||
if (fs.existsSync(lockPath)) {
|
||||
const content = fs.readFileSync(lockPath, 'utf8');
|
||||
const [pidStr, timestampStr] = content.trim().split('\n');
|
||||
const timestamp = parseInt(timestampStr, 10);
|
||||
|
||||
// Check if lock is stale
|
||||
if (Date.now() - timestamp > LOCK_STALE_MS) {
|
||||
// Stale lock - remove and acquire
|
||||
fs.unlinkSync(lockPath);
|
||||
} else {
|
||||
// Check if process still exists
|
||||
try {
|
||||
process.kill(parseInt(pidStr, 10), 0); // Signal 0 checks if process exists
|
||||
// Process exists - lock is valid
|
||||
return false;
|
||||
} catch {
|
||||
// Process doesn't exist - remove stale lock
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire lock
|
||||
fs.writeFileSync(lockPath, lockData, { mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
// Lock acquisition failed
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release lockfile after config write operation.
|
||||
*/
|
||||
|
||||
function releaseLock(): void {
|
||||
const lockPath = getLockFilePath();
|
||||
try {
|
||||
if (fs.existsSync(lockPath)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if unified config.yaml exists
|
||||
*/
|
||||
@@ -103,8 +172,9 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
|
||||
console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`);
|
||||
}
|
||||
return upgraded;
|
||||
} catch {
|
||||
// Ignore save errors during upgrade - config still works
|
||||
} catch (saveError) {
|
||||
console.error('[!] Config upgrade failed to save:', (saveError as Error).message);
|
||||
// Continue using the upgraded version in-memory even if save fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +363,13 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
partial.dashboard_auth?.session_timeout_hours ??
|
||||
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
|
||||
},
|
||||
// Image analysis config - enabled by default for CLIProxy providers
|
||||
image_analysis: {
|
||||
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
||||
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
|
||||
provider_models:
|
||||
partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -520,45 +597,101 @@ 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save unified config to YAML file.
|
||||
* Uses atomic write (temp file + rename) to prevent corruption.
|
||||
* Uses lockfile to prevent concurrent writes.
|
||||
*/
|
||||
export function saveUnifiedConfig(config: UnifiedConfig): void {
|
||||
const yamlPath = getConfigYamlPath();
|
||||
const dir = path.dirname(yamlPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// Acquire lock (retry for up to 1 second)
|
||||
const maxRetries = 10;
|
||||
const retryDelayMs = 100;
|
||||
let lockAcquired = false;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (acquireLock()) {
|
||||
lockAcquired = true;
|
||||
break;
|
||||
}
|
||||
// Synchronous sleep without CPU-intensive busy-wait
|
||||
// Uses Atomics.wait which properly sleeps the thread
|
||||
// Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs);
|
||||
}
|
||||
|
||||
// Ensure version is set
|
||||
config.version = UNIFIED_CONFIG_VERSION;
|
||||
|
||||
// Generate YAML with section comments
|
||||
const yamlContent = generateYamlWithComments(config);
|
||||
const content = generateYamlHeader() + yamlContent;
|
||||
|
||||
// Atomic write: write to temp file, then rename
|
||||
const tempPath = `${yamlPath}.tmp.${process.pid}`;
|
||||
if (!lockAcquired) {
|
||||
throw new Error('Config file is locked by another process. Wait a moment and try again.');
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tempPath, content, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, yamlPath);
|
||||
} catch (err) {
|
||||
// Clean up temp file on error
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
throw err;
|
||||
|
||||
// Ensure version is set
|
||||
config.version = UNIFIED_CONFIG_VERSION;
|
||||
|
||||
// Generate YAML with section comments
|
||||
const yamlContent = generateYamlWithComments(config);
|
||||
const content = generateYamlHeader() + yamlContent;
|
||||
|
||||
// Atomic write: write to temp file, then rename
|
||||
const tempPath = `${yamlPath}.tmp.${process.pid}`;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tempPath, content, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, yamlPath);
|
||||
} catch (error) {
|
||||
// Clean up temp file on error
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
// Classify filesystem errors
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code === 'ENOSPC') {
|
||||
throw new Error('Disk full - cannot save config. Free up space and try again.');
|
||||
} else if (err.code === 'EROFS' || err.code === 'EACCES') {
|
||||
throw new Error(`Cannot write config - check file permissions: ${err.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
// Always release lock
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,3 +854,18 @@ export function getDashboardAuthConfig(): DashboardAuthConfig {
|
||||
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image_analysis configuration.
|
||||
* Returns defaults if not configured.
|
||||
*/
|
||||
export function getImageAnalysisConfig(): ImageAnalysisConfig {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
|
||||
return {
|
||||
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
||||
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
|
||||
provider_models:
|
||||
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
|
||||
};
|
||||
}
|
||||
@@ -518,6 +518,36 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
|
||||
session_timeout_hours: 24,
|
||||
};
|
||||
|
||||
/**
|
||||
* Image analysis configuration.
|
||||
* Routes image/PDF files through CLIProxy for vision analysis.
|
||||
*/
|
||||
export interface ImageAnalysisConfig {
|
||||
/** Enable image analysis via CLIProxy (default: true) */
|
||||
enabled: boolean;
|
||||
/** Timeout in seconds (default: 60) */
|
||||
timeout: number;
|
||||
/** Provider-to-model mapping for vision analysis */
|
||||
provider_models: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default image analysis configuration.
|
||||
* Enabled by default for CLIProxy providers with vision support.
|
||||
*/
|
||||
export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Main unified configuration structure.
|
||||
* Stored in ~/.ccs/config.yaml
|
||||
@@ -551,6 +581,8 @@ export interface UnifiedConfig {
|
||||
thinking?: ThinkingConfig;
|
||||
/** Dashboard authentication configuration (optional) */
|
||||
dashboard_auth?: DashboardAuthConfig;
|
||||
/** Image analysis configuration (vision via CLIProxy) */
|
||||
image_analysis?: ImageAnalysisConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -644,6 +676,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG },
|
||||
thinking: { ...DEFAULT_THINKING_CONFIG },
|
||||
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
|
||||
image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils';
|
||||
import { killProcessOnPort, getPlatformName } from '../../utils/platform-commands';
|
||||
import { createSpinner } from '../checks/types';
|
||||
import { fixImageAnalysisConfig } from '../checks/image-analysis-check';
|
||||
|
||||
const ora = createSpinner();
|
||||
|
||||
@@ -132,6 +133,20 @@ export async function runAutoRepair(): Promise<void> {
|
||||
symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Fix 5: Image analysis config validation
|
||||
const imageSpinner = ora('Checking image analysis config').start();
|
||||
try {
|
||||
const imageFixed = await fixImageAnalysisConfig();
|
||||
if (imageFixed) {
|
||||
imageSpinner.succeed(`${ok('Fixed')} Repaired image analysis configuration`);
|
||||
fixed++;
|
||||
} else {
|
||||
imageSpinner.succeed(`${ok('OK')} Image analysis config is valid`);
|
||||
}
|
||||
} catch (err) {
|
||||
imageSpinner.fail(`${fail('Error')} Could not fix image config: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('');
|
||||
if (fixed > 0) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Image Analysis Hook Environment Variables
|
||||
*
|
||||
* Provides environment variables for image analysis hook configuration.
|
||||
* Hook routes image/PDF files through CLIProxy for vision analysis.
|
||||
*
|
||||
* @module utils/hooks/image-analysis-hook-env
|
||||
*/
|
||||
|
||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||
|
||||
/**
|
||||
* Serialize provider_models map to env var format: provider:model,provider:model
|
||||
*/
|
||||
function serializeProviderModels(providerModels: Record<string, string>): string {
|
||||
return Object.entries(providerModels)
|
||||
.map(([provider, model]) => `${provider}:${model}`)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image analysis hook environment variables.
|
||||
* These env vars control the hook's behavior via Claude Code hook system.
|
||||
*
|
||||
* @param provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex')
|
||||
* @returns Environment variables for image analysis hook
|
||||
*/
|
||||
export function getImageAnalysisHookEnv(provider?: string): Record<string, string> {
|
||||
const config = getImageAnalysisConfig();
|
||||
|
||||
// Check if current provider has a vision model configured
|
||||
const hasVisionModel = provider && config.provider_models[provider];
|
||||
const skipImageAnalysis = !config.enabled || !hasVisionModel;
|
||||
|
||||
return {
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
|
||||
CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60),
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models),
|
||||
CCS_CURRENT_PROVIDER: provider || '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Image Analyzer Hook Configuration
|
||||
*
|
||||
* Manages hook configuration for image analysis in Claude settings.
|
||||
*
|
||||
* @module utils/hooks/image-analyzer-hook-config
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
|
||||
// Hook file name
|
||||
const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs';
|
||||
|
||||
/**
|
||||
* Get CCS hooks directory (respects CCS_HOME for test isolation)
|
||||
*/
|
||||
export function getCcsHooksDir(): string {
|
||||
return path.join(getCcsDir(), 'hooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to image analyzer hook
|
||||
*/
|
||||
export function getImageAnalyzerHookPath(): string {
|
||||
return path.join(getCcsHooksDir(), IMAGE_ANALYZER_HOOK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hook config for settings.json injection
|
||||
* Timeout includes buffer for CLI overhead
|
||||
*/
|
||||
export function getImageAnalyzerHookConfig(): Record<string, unknown> {
|
||||
const hookPath = getImageAnalyzerHookPath();
|
||||
const imageConfig = getImageAnalysisConfig();
|
||||
|
||||
// Add 5 second buffer to analysis timeout for hook execution overhead
|
||||
const hookTimeout = imageConfig.timeout * 1000 + 5000;
|
||||
|
||||
return {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `node "${hookPath}"`,
|
||||
timeout: hookTimeout,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Image Analyzer Hook Installer
|
||||
*
|
||||
* Manages installation and uninstallation of the image analyzer hook.
|
||||
* This hook intercepts Read tool calls and analyzes image files via CLIProxy.
|
||||
*
|
||||
* @module utils/hooks/image-analyzer-hook-installer
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { info, warn } from '../ui';
|
||||
import { getImageAnalyzerHookPath, getCcsHooksDir } from './image-analyzer-hook-configuration';
|
||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||
import { removeMigrationMarker } from './image-analyzer-profile-hook-injector';
|
||||
|
||||
// Re-export from hook-configuration for backward compatibility
|
||||
export {
|
||||
getImageAnalyzerHookPath,
|
||||
getImageAnalyzerHookConfig,
|
||||
} from './image-analyzer-hook-configuration';
|
||||
|
||||
// Hook file name
|
||||
const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs';
|
||||
|
||||
/**
|
||||
* Check if image analyzer hook is installed
|
||||
*/
|
||||
export function hasImageAnalyzerHook(): boolean {
|
||||
return fs.existsSync(getImageAnalyzerHookPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Install image analyzer hook to ~/.ccs/hooks/
|
||||
*
|
||||
* This hook intercepts Read calls and analyzes images via CLIProxy.
|
||||
*
|
||||
* @returns true if hook installed successfully
|
||||
*/
|
||||
export function installImageAnalyzerHook(): boolean {
|
||||
try {
|
||||
const imageConfig = getImageAnalysisConfig();
|
||||
|
||||
// Skip if disabled
|
||||
if (!imageConfig.enabled) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info('Image analysis disabled - skipping hook install'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure hooks directory exists
|
||||
const hooksDir = getCcsHooksDir();
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const hookPath = getImageAnalyzerHookPath();
|
||||
|
||||
// Find the bundled hook script
|
||||
// In npm package: node_modules/ccs/lib/hooks/
|
||||
// In development: lib/hooks/
|
||||
const possiblePaths = [
|
||||
path.join(__dirname, '..', '..', '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK),
|
||||
path.join(__dirname, '..', '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK),
|
||||
path.join(__dirname, '..', 'lib', 'hooks', IMAGE_ANALYZER_HOOK),
|
||||
];
|
||||
|
||||
let sourcePath: string | null = null;
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
sourcePath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourcePath) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Image analyzer hook source not found: ${IMAGE_ANALYZER_HOOK}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy hook to ~/.ccs/hooks/
|
||||
fs.copyFileSync(sourcePath, hookPath);
|
||||
fs.chmodSync(hookPath, 0o755);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Installed image analyzer hook: ${hookPath}`));
|
||||
}
|
||||
|
||||
// Note: Hook registration is handled by ensureProfileHooks() in image-analyzer-profile-injector.ts
|
||||
// which writes to per-profile settings (~/.ccs/<profile>.settings.json)
|
||||
// Global settings (~/.claude/settings.json) are NOT modified here
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to install image analyzer hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall image analyzer hook from ~/.ccs/hooks/
|
||||
*
|
||||
* Note: Does NOT touch global ~/.claude/settings.json.
|
||||
* Profile-specific hooks are removed when ~/.ccs/ is deleted.
|
||||
*
|
||||
* @returns true if hook uninstalled successfully
|
||||
*/
|
||||
export function uninstallImageAnalyzerHook(): boolean {
|
||||
try {
|
||||
const hookPath = getImageAnalyzerHookPath();
|
||||
|
||||
if (fs.existsSync(hookPath)) {
|
||||
fs.unlinkSync(hookPath);
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Uninstalled image analyzer hook: ${hookPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove migration marker (so fresh install re-runs migration)
|
||||
removeMigrationMarker();
|
||||
|
||||
// Note: Do NOT call removeHookConfig() - global settings should not be touched.
|
||||
// Per-profile hooks in ~/.ccs/*.settings.json are cleaned up when ~/.ccs/ is deleted.
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to uninstall image analyzer hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Image Analyzer Profile Hook Injector
|
||||
*
|
||||
* Injects image analyzer hooks into per-profile settings files.
|
||||
* This replaces the global ~/.claude/settings.json approach.
|
||||
*
|
||||
* Only injects for CLIProxy profiles (agy, gemini) that support vision analysis.
|
||||
*
|
||||
* @module utils/hooks/image-analyzer-profile-injector
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { info, warn } from '../ui';
|
||||
import {
|
||||
getImageAnalyzerHookConfig,
|
||||
getImageAnalyzerHookPath,
|
||||
} from './image-analyzer-hook-configuration';
|
||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
|
||||
// Valid profile name pattern (alphanumeric, dash, underscore only)
|
||||
const VALID_PROFILE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/**
|
||||
* Get migration marker path (respects CCS_HOME for test isolation)
|
||||
*/
|
||||
function getMigrationMarkerPath(): string {
|
||||
return path.join(getCcsDir(), '.image-analyzer-hook-migrated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CCS image analyzer hook exists in settings
|
||||
*/
|
||||
function hasCcsHook(settings: Record<string, unknown>): boolean {
|
||||
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
||||
if (!hooks?.PreToolUse) return false;
|
||||
|
||||
return hooks.PreToolUse.some((h: unknown) => {
|
||||
const hook = h as Record<string, unknown>;
|
||||
if (hook.matcher !== 'Read') return false;
|
||||
|
||||
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
|
||||
const command = hookArray?.[0]?.command;
|
||||
if (typeof command !== 'string') return false;
|
||||
|
||||
const normalized = command
|
||||
.replace(/\\/g, '/') // Windows backslashes
|
||||
.replace(/\/+/g, '/'); // Collapse multiple slashes
|
||||
return normalized.includes('.ccs/hooks/image-analyzer-transformer');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration marker management
|
||||
*/
|
||||
function migrateGlobalHook(): void {
|
||||
const markerPath = getMigrationMarkerPath();
|
||||
if (fs.existsSync(markerPath)) {
|
||||
return; // Already migrated
|
||||
}
|
||||
|
||||
try {
|
||||
// No global hook to migrate (image analyzer is profile-only from the start)
|
||||
// Just create marker to prevent future migration attempts
|
||||
const ccsDir = getCcsDir();
|
||||
if (!fs.existsSync(ccsDir)) {
|
||||
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
// Create marker file atomically (wx = fail if exists, prevents race condition)
|
||||
fs.writeFileSync(markerPath, new Date().toISOString(), { encoding: 'utf8', flag: 'wx' });
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Migration failed: ${(error as Error).message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure image analyzer hook is configured in profile's settings file
|
||||
*
|
||||
* Only injects for CLIProxy profiles with vision support (agy, gemini).
|
||||
*
|
||||
* @param profileName - Name of the profile (e.g., 'agy', 'gemini')
|
||||
* @returns true if hook is configured (existing or newly added)
|
||||
*/
|
||||
export function ensureProfileHooks(profileName: string): boolean {
|
||||
try {
|
||||
// Validate profile name to prevent path traversal
|
||||
if (!VALID_PROFILE_NAME.test(profileName)) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Invalid profile name: ${profileName}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only inject for CLIProxy profiles with vision support
|
||||
const visionProfiles = ['agy', 'gemini'];
|
||||
if (!visionProfiles.includes(profileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageConfig = getImageAnalysisConfig();
|
||||
|
||||
// Skip if image analysis is disabled
|
||||
if (!imageConfig.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// One-time migration marker
|
||||
migrateGlobalHook();
|
||||
|
||||
// Get CCS directory (respects CCS_HOME for test isolation)
|
||||
const ccsDir = getCcsDir();
|
||||
|
||||
// Ensure CCS dir exists
|
||||
if (!fs.existsSync(ccsDir)) {
|
||||
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const settingsPath = path.join(ccsDir, `${profileName}.settings.json`);
|
||||
|
||||
// Read existing settings or create empty
|
||||
let settings: Record<string, unknown> = {};
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf8');
|
||||
settings = JSON.parse(content);
|
||||
} catch (parseError) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
warn(`Malformed ${profileName}.settings.json: ${(parseError as Error).message}`)
|
||||
);
|
||||
}
|
||||
// Continue with empty settings, will add hooks
|
||||
}
|
||||
}
|
||||
|
||||
// Check if CCS hook already present
|
||||
if (hasCcsHook(settings)) {
|
||||
// Update timeout if needed
|
||||
return updateHookTimeoutIfNeeded(settings, settingsPath);
|
||||
}
|
||||
|
||||
// Get hook config
|
||||
const hookConfig = getImageAnalyzerHookConfig();
|
||||
|
||||
// Ensure hooks structure exists
|
||||
if (!settings.hooks) {
|
||||
settings.hooks = {};
|
||||
}
|
||||
|
||||
const settingsHooks = settings.hooks as Record<string, unknown[]>;
|
||||
if (!settingsHooks.PreToolUse) {
|
||||
settingsHooks.PreToolUse = [];
|
||||
}
|
||||
|
||||
// Add CCS hook
|
||||
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
|
||||
settingsHooks.PreToolUse.push(...preToolUseHooks);
|
||||
|
||||
// Write updated settings
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Added image analyzer hook to ${profileName}.settings.json`));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to inject hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update hook timeout if it differs from current config
|
||||
*/
|
||||
function updateHookTimeoutIfNeeded(
|
||||
settings: Record<string, unknown>,
|
||||
settingsPath: string
|
||||
): boolean {
|
||||
try {
|
||||
const hooks = settings.hooks as Record<string, unknown[]>;
|
||||
const hookConfig = getImageAnalyzerHookConfig();
|
||||
const expectedHookPath = getImageAnalyzerHookPath();
|
||||
const expectedCommand = `node "${expectedHookPath}"`;
|
||||
const expectedHooks = (hookConfig.PreToolUse as Array<Record<string, unknown>>)[0]
|
||||
.hooks as Array<Record<string, unknown>>;
|
||||
const expectedTimeout = expectedHooks[0].timeout as number;
|
||||
|
||||
let needsUpdate = false;
|
||||
|
||||
for (const h of hooks.PreToolUse) {
|
||||
const hook = h as Record<string, unknown>;
|
||||
if (hook.matcher !== 'Read') continue;
|
||||
|
||||
const hookArray = hook.hooks as Array<Record<string, unknown>>;
|
||||
if (!hookArray?.[0]?.command) continue;
|
||||
|
||||
const command = hookArray[0].command;
|
||||
if (typeof command !== 'string') continue;
|
||||
// Normalize path separators for cross-platform matching (Windows uses backslashes)
|
||||
const normalizedCommand = command
|
||||
.replace(/\\/g, '/') // Windows backslashes
|
||||
.replace(/\/+/g, '/'); // Collapse multiple slashes
|
||||
if (!normalizedCommand.includes('.ccs/hooks/image-analyzer-transformer')) continue;
|
||||
|
||||
// Found CCS hook - check if needs update
|
||||
if (hookArray[0].command !== expectedCommand) {
|
||||
hookArray[0].command = expectedCommand;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (hookArray[0].timeout !== expectedTimeout) {
|
||||
hookArray[0].timeout = expectedTimeout;
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info('Updated image analyzer hook timeout in profile settings'));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`updateHookTimeoutIfNeeded failed: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove migration marker (called during uninstall)
|
||||
*/
|
||||
export function removeMigrationMarker(): void {
|
||||
try {
|
||||
const markerPath = getMigrationMarkerPath();
|
||||
if (fs.existsSync(markerPath)) {
|
||||
fs.unlinkSync(markerPath);
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`removeMigrationMarker failed: ${(error as Error).message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Image Read Block Hook Environment Variables
|
||||
*
|
||||
* Provides environment variables for image read blocking hook configuration.
|
||||
* Prevents context overflow when skills generate images and agent tries to read them.
|
||||
*
|
||||
* Enabled by default for third-party profiles (settings, cliproxy).
|
||||
* Disabled for native Claude accounts where context is managed server-side.
|
||||
*
|
||||
* @module utils/hooks/image-read-block-hook-env
|
||||
*/
|
||||
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
|
||||
/**
|
||||
* Configuration for image read blocking.
|
||||
*/
|
||||
export interface ImageReadBlockConfig {
|
||||
/** Whether blocking is enabled (default: true) */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image read block configuration from unified config.
|
||||
* Defaults to ENABLED (opt-out feature) - matches WebSearch pattern.
|
||||
*/
|
||||
export function getImageReadBlockConfig(): ImageReadBlockConfig {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
// Access hooks config via type assertion since it's a new field not yet in UnifiedConfig type
|
||||
const hooksConfig = (
|
||||
config as unknown as { hooks?: { block_image_read?: { enabled?: boolean } } }
|
||||
).hooks;
|
||||
return {
|
||||
// Default to TRUE - enabled by default, user can opt-out
|
||||
enabled: hooksConfig?.block_image_read?.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for image read block hook configuration.
|
||||
*
|
||||
* Like WebSearch, this respects CCS_PROFILE_TYPE:
|
||||
* - 'account' or 'default' profiles: Skip blocking (native Claude)
|
||||
* - 'settings' or 'cliproxy' profiles: Apply blocking
|
||||
*
|
||||
* @returns Record of environment variables to set before spawning Claude
|
||||
*/
|
||||
export function getImageReadBlockHookEnv(): Record<string, string> {
|
||||
const config = getImageReadBlockConfig();
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
if (config.enabled) {
|
||||
env.CCS_BLOCK_IMAGE_READ = '1';
|
||||
} else {
|
||||
// Explicit disable signal
|
||||
env.CCS_BLOCK_IMAGE_READ = '0';
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Hooks Utilities Index
|
||||
*
|
||||
* Centralized exports for all hook-related utilities.
|
||||
*
|
||||
* @module utils/hooks
|
||||
*/
|
||||
|
||||
export { getImageReadBlockHookEnv, getImageReadBlockConfig } from './image-read-block-hook-env';
|
||||
export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env';
|
||||
export {
|
||||
getImageAnalyzerHookPath,
|
||||
getImageAnalyzerHookConfig,
|
||||
hasImageAnalyzerHook,
|
||||
installImageAnalyzerHook,
|
||||
uninstallImageAnalyzerHook,
|
||||
} from './image-analyzer-hook-installer';
|
||||
export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector';
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Image Analysis Hook Installer
|
||||
*
|
||||
* Manages installation of:
|
||||
* 1. block-image-read.cjs hook (blocks image reads to prevent context overflow)
|
||||
* 2. Prompt templates for image analysis (user-customizable)
|
||||
*
|
||||
* @module utils/image-analysis/hook-installer
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { info, warn } from '../ui';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
|
||||
// Hook file name
|
||||
const IMAGE_BLOCK_HOOK = 'block-image-read.cjs';
|
||||
|
||||
/**
|
||||
* Get path to installed hook script
|
||||
*/
|
||||
export function getHookPath(): string {
|
||||
return path.join(getCcsDir(), 'hooks', IMAGE_BLOCK_HOOK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CCS hooks directory
|
||||
*/
|
||||
export function getCcsHooksDir(): string {
|
||||
return path.join(getCcsDir(), 'hooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prompts directory for image analysis
|
||||
*/
|
||||
export function getPromptsDir(): string {
|
||||
return path.join(getCcsDir(), 'prompts', 'image-analysis');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image block hook is installed
|
||||
*/
|
||||
export function hasImageBlockHook(): boolean {
|
||||
return fs.existsSync(getHookPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Install image block hook to ~/.ccs/hooks/
|
||||
*
|
||||
* This hook intercepts Read tool calls for image files and blocks them
|
||||
* to prevent context overflow (images consume 100K+ tokens each).
|
||||
*
|
||||
* @returns true if hook installed successfully
|
||||
*/
|
||||
export function installImageBlockHook(): boolean {
|
||||
try {
|
||||
// Ensure hooks directory exists
|
||||
const hooksDir = getCcsHooksDir();
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const hookPath = getHookPath();
|
||||
|
||||
// Find the bundled hook script
|
||||
// In npm package: node_modules/ccs/lib/hooks/
|
||||
// In development: lib/hooks/
|
||||
const possiblePaths = [
|
||||
path.join(__dirname, '..', '..', '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK),
|
||||
path.join(__dirname, '..', '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK),
|
||||
path.join(__dirname, '..', 'lib', 'hooks', IMAGE_BLOCK_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) {
|
||||
console.error(warn(`Image block hook source not found: ${IMAGE_BLOCK_HOOK}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy hook to ~/.ccs/hooks/
|
||||
fs.copyFileSync(sourcePath, hookPath);
|
||||
fs.chmodSync(hookPath, 0o755);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Installed image block hook: ${hookPath}`));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to install image block hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install prompt templates to ~/.ccs/prompts/image-analysis/
|
||||
* Only installs if directory doesn't exist (doesn't overwrite user edits)
|
||||
*
|
||||
* @returns true if prompts installed or already exist
|
||||
*/
|
||||
export function installImageAnalysisPrompts(): boolean {
|
||||
try {
|
||||
const promptsDir = getPromptsDir();
|
||||
|
||||
// Skip if already exists (preserve user customizations)
|
||||
if (fs.existsSync(promptsDir)) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
info('Image analysis prompts already installed - preserving user customizations')
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create directory
|
||||
fs.mkdirSync(promptsDir, { recursive: true, mode: 0o755 });
|
||||
|
||||
// Find bundled prompts
|
||||
const possibleBasePaths = [
|
||||
path.join(__dirname, '..', '..', '..', 'lib', 'prompts'),
|
||||
path.join(__dirname, '..', '..', 'lib', 'prompts'),
|
||||
path.join(__dirname, '..', 'lib', 'prompts'),
|
||||
];
|
||||
|
||||
let promptsBasePath: string | null = null;
|
||||
for (const p of possibleBasePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
promptsBasePath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!promptsBasePath) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn('Image analysis prompts source not found'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy prompt files
|
||||
const promptFiles = [
|
||||
'image-analysis-default.txt',
|
||||
'image-analysis-screenshot.txt',
|
||||
'image-analysis-document.txt',
|
||||
];
|
||||
|
||||
for (const file of promptFiles) {
|
||||
const sourcePath = path.join(promptsBasePath, file);
|
||||
const destPath = path.join(promptsDir, file.replace('image-analysis-', ''));
|
||||
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
fs.copyFileSync(sourcePath, destPath);
|
||||
fs.chmodSync(destPath, 0o644);
|
||||
} else if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Prompt template not found: ${file}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Installed image analysis prompts: ${promptsDir}`));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to install image analysis prompts: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall image block hook from ~/.ccs/hooks/
|
||||
*
|
||||
* @returns true if hook uninstalled successfully
|
||||
*/
|
||||
export function uninstallImageBlockHook(): boolean {
|
||||
try {
|
||||
const hookPath = getHookPath();
|
||||
|
||||
if (fs.existsSync(hookPath)) {
|
||||
fs.unlinkSync(hookPath);
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Uninstalled image block hook: ${hookPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to uninstall image block hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Image Analysis Utilities
|
||||
*
|
||||
* Exports hook installer functions for image blocking and prompt management
|
||||
*/
|
||||
|
||||
export {
|
||||
getHookPath,
|
||||
getCcsHooksDir,
|
||||
getPromptsDir,
|
||||
hasImageBlockHook,
|
||||
installImageBlockHook,
|
||||
installImageAnalysisPrompts,
|
||||
uninstallImageBlockHook,
|
||||
} from './hook-installer';
|
||||
@@ -7,12 +7,37 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { ErrorManager } from './error-manager';
|
||||
import { getWebSearchHookEnv } from './websearch-manager';
|
||||
import { getImageReadBlockHookEnv } from './hooks/image-read-block-hook-env';
|
||||
|
||||
/**
|
||||
* Escape arguments for shell execution (Windows compatibility)
|
||||
* Handles PowerShell special characters: backticks, $variables, double quotes
|
||||
*/
|
||||
export function escapeShellArg(arg: string): string {
|
||||
return '"' + String(arg).replace(/"/g, '""') + '"';
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
if (isWindows) {
|
||||
// PowerShell: Use single quotes for literal strings to prevent variable expansion
|
||||
// Escape single quotes by doubling them (PowerShell syntax)
|
||||
// Fallback to double quotes with escapes if single quotes present
|
||||
if (arg.includes("'")) {
|
||||
// Contains single quote - use double quotes with escape sequences
|
||||
return (
|
||||
'"' +
|
||||
String(arg)
|
||||
.replace(/\$/g, '`$') // Escape $ to prevent variable expansion
|
||||
.replace(/`/g, '``') // Escape backticks
|
||||
.replace(/"/g, '`"') + // Escape double quotes
|
||||
'"'
|
||||
);
|
||||
} else {
|
||||
// No single quotes - use single quotes for literal string (safest)
|
||||
return "'" + String(arg) + "'";
|
||||
}
|
||||
} else {
|
||||
// Unix/macOS: Double quotes with escaped inner quotes
|
||||
return '"' + String(arg).replace(/"/g, '""') + '"';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,11 +53,12 @@ export function execClaude(
|
||||
|
||||
// Get WebSearch hook config env vars
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const imageReadBlockEnv = getImageReadBlockHookEnv();
|
||||
|
||||
// Prepare environment (merge with process.env if envVars provided)
|
||||
const env = envVars
|
||||
? { ...process.env, ...envVars, ...webSearchEnv }
|
||||
: { ...process.env, ...webSearchEnv };
|
||||
? { ...process.env, ...envVars, ...webSearchEnv, ...imageReadBlockEnv }
|
||||
: { ...process.env, ...webSearchEnv, ...imageReadBlockEnv };
|
||||
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
|
||||
@@ -0,0 +1,787 @@
|
||||
/**
|
||||
* E2E Tests for Image Analyzer Hook
|
||||
*
|
||||
* ⚠️ NOT RUN IN NORMAL CI/CD - This is an E2E test file (.e2e.ts)
|
||||
*
|
||||
* Run manually with: bun test tests/integration/hooks/image-analyzer-hook.e2e.ts --bail
|
||||
*
|
||||
* Tests the image-analyzer-transformer.cjs hook with:
|
||||
* - Generated test fixtures with predictable content
|
||||
* - Mock CLIProxy server for reliable, fast tests
|
||||
* - Direct hook invocation via stdin
|
||||
*
|
||||
* Uses a mock HTTP server that returns predictable responses to verify
|
||||
* the hook correctly formats requests and parses responses.
|
||||
*
|
||||
* Use --bail flag to exit on first failure (recommended for long tests).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
|
||||
// ============================================================================
|
||||
// TEST CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const HOOK_PATH = path.join(__dirname, '../../lib/hooks/image-analyzer-transformer.cjs');
|
||||
const TEST_DIR = '/tmp/ccs-hook-tests';
|
||||
const MOCK_PORT = 59876; // Use a unique port for mock server
|
||||
const CLIPROXY_API_KEY = 'test-api-key-12345';
|
||||
|
||||
// Default provider models for testing (matches DEFAULT_IMAGE_ANALYSIS_CONFIG)
|
||||
const DEFAULT_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';
|
||||
const DEFAULT_PROVIDER = 'agy'; // Default test provider
|
||||
|
||||
// ============================================================================
|
||||
// MOCK SERVER
|
||||
// ============================================================================
|
||||
|
||||
interface MockServerRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
let mockServer: http.Server | null = null;
|
||||
let lastRequest: MockServerRequest | null = null;
|
||||
let mockResponse: { content: string; statusCode: number } = {
|
||||
content: 'This is a test image showing a red pixel.',
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
/**
|
||||
* Start mock CLIProxy server
|
||||
*/
|
||||
function startMockServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
mockServer = http.createServer((req, res) => {
|
||||
// Health check endpoint - always return 200
|
||||
if (req.method === 'GET' && req.url === '/') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
// Capture request for verification
|
||||
lastRequest = {
|
||||
method: req.method || 'GET',
|
||||
path: req.url || '/',
|
||||
headers: req.headers,
|
||||
body: body ? JSON.parse(body) : null,
|
||||
};
|
||||
|
||||
// Return mock response in Anthropic format
|
||||
if (mockResponse.statusCode !== 200) {
|
||||
res.writeHead(mockResponse.statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: { message: 'Mock error' } }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
content: [{ type: 'text', text: mockResponse.content }],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
mockServer.on('error', reject);
|
||||
mockServer.listen(MOCK_PORT, '127.0.0.1', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop mock CLIProxy server
|
||||
*/
|
||||
function stopMockServer(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (mockServer) {
|
||||
mockServer.close(() => {
|
||||
mockServer = null;
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset mock server state between tests
|
||||
*/
|
||||
function resetMockState(): void {
|
||||
lastRequest = null;
|
||||
mockResponse = {
|
||||
content: 'This is a test image showing a red pixel.',
|
||||
statusCode: 200,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Invoke the hook with JSON input
|
||||
*/
|
||||
function invokeHook(
|
||||
input: object,
|
||||
env: Record<string, string> = {}
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
const result = spawnSync('node', [HOOK_PATH], {
|
||||
input: JSON.stringify(input),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
||||
CCS_CLIPROXY_PORT: String(MOCK_PORT),
|
||||
// Default provider config for tests (can be overridden)
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS,
|
||||
CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER,
|
||||
...env,
|
||||
},
|
||||
timeout: 10000, // 10 second timeout per test
|
||||
});
|
||||
|
||||
return {
|
||||
code: result.status ?? -1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal valid PNG file (1x1 red pixel)
|
||||
*/
|
||||
function createTestPng(filepath: string): void {
|
||||
// 1x1 PNG with a red pixel (RGB: 255, 0, 0)
|
||||
const png = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||||
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk (red pixel)
|
||||
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
|
||||
0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk
|
||||
0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
fs.writeFileSync(filepath, png);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal valid JPEG file
|
||||
*/
|
||||
function createTestJpeg(filepath: string): void {
|
||||
const jpeg = Buffer.from([
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x48,
|
||||
0x00, 0x48, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,
|
||||
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0xff, 0xc4, 0x00, 0x14,
|
||||
0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x7f, 0xff, 0xd9,
|
||||
]);
|
||||
fs.writeFileSync(filepath, jpeg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test text file
|
||||
*/
|
||||
function createTestTextFile(filepath: string, content: string): void {
|
||||
fs.writeFileSync(filepath, content, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a large file exceeding 10MB
|
||||
*/
|
||||
function createLargeFile(filepath: string, sizeMB: number): void {
|
||||
const bufferSize = 1024 * 1024; // 1MB
|
||||
const totalBuffers = sizeMB;
|
||||
const buffer = Buffer.alloc(bufferSize, 'A');
|
||||
const stream = fs.createWriteStream(filepath);
|
||||
|
||||
for (let i = 0; i < totalBuffers; i++) {
|
||||
stream.write(buffer);
|
||||
}
|
||||
stream.end();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEST SUITE
|
||||
// ============================================================================
|
||||
|
||||
describe('Image Analyzer Hook', () => {
|
||||
let testPngPath: string;
|
||||
let testJpegPath: string;
|
||||
let testTextPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create test directory
|
||||
if (!fs.existsSync(TEST_DIR)) {
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Start mock server
|
||||
await startMockServer();
|
||||
console.log(`[Test Setup] Mock CLIProxy started on port ${MOCK_PORT}`);
|
||||
|
||||
// Create test files
|
||||
testPngPath = path.join(TEST_DIR, 'test-image.png');
|
||||
testJpegPath = path.join(TEST_DIR, 'test-image.jpg');
|
||||
testTextPath = path.join(TEST_DIR, 'test-file.txt');
|
||||
|
||||
createTestPng(testPngPath);
|
||||
createTestJpeg(testJpegPath);
|
||||
createTestTextFile(testTextPath, 'This is a test file.');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Stop mock server
|
||||
await stopMockServer();
|
||||
|
||||
// Clean up test files
|
||||
const filesToClean = [testPngPath, testJpegPath, testTextPath];
|
||||
for (const f of filesToClean) {
|
||||
if (f && fs.existsSync(f)) {
|
||||
try {
|
||||
fs.unlinkSync(f);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(TEST_DIR)) {
|
||||
try {
|
||||
fs.rmdirSync(TEST_DIR);
|
||||
} catch {
|
||||
// Ignore if not empty
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GROUP A: FILE DETECTION AND FILTERING
|
||||
// ==========================================================================
|
||||
|
||||
describe('File Detection and Filtering', () => {
|
||||
it('should pass through non-Read tools', () => {
|
||||
const result = invokeHook({
|
||||
tool_name: 'Write',
|
||||
tool_input: { file_path: testPngPath, content: 'test' },
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should pass through Read tool for non-image files (.txt)', () => {
|
||||
const result = invokeHook({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testTextPath },
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should pass through Read tool for .ts files', () => {
|
||||
const result = invokeHook({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/tmp/test.ts' },
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should pass through Read tool for .md files', () => {
|
||||
const result = invokeHook({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/tmp/test.md' },
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should pass through Read tool for .json files', () => {
|
||||
const result = invokeHook({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/tmp/test.json' },
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should pass through files that do not exist', () => {
|
||||
const result = invokeHook({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/tmp/nonexistent-file-12345.png' },
|
||||
});
|
||||
|
||||
// Should pass through to let native Read handle the error
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GROUP B: ENVIRONMENT VARIABLE CONTROLS
|
||||
// ==========================================================================
|
||||
|
||||
describe('Environment Variable Controls', () => {
|
||||
it('should skip when CCS_IMAGE_ANALYSIS_SKIP=1', () => {
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_SKIP: '1' }
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip when CCS_IMAGE_ANALYSIS_ENABLED=0', () => {
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '0' }
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip for account profile type', () => {
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_PROFILE_TYPE: 'account' }
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip for default profile type', () => {
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_PROFILE_TYPE: 'default' }
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GROUP C: INPUT VALIDATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('Input Validation', () => {
|
||||
it('should handle missing file_path gracefully', () => {
|
||||
const result = invokeHook({
|
||||
tool_name: 'Read',
|
||||
tool_input: {},
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty file_path', () => {
|
||||
const result = invokeHook({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '' },
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle malformed JSON input', () => {
|
||||
const hookProcess = spawnSync('node', [HOOK_PATH], {
|
||||
input: 'not valid json',
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
|
||||
CCS_CLIPROXY_PORT: String(MOCK_PORT),
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS,
|
||||
CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER,
|
||||
},
|
||||
});
|
||||
|
||||
// Should exit with error (code 2)
|
||||
expect(hookProcess.status).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GROUP D: FILE SIZE LIMITS
|
||||
// ==========================================================================
|
||||
|
||||
describe('File Size Limits', () => {
|
||||
it('should reject files larger than 10MB', () => {
|
||||
// Create 11MB file
|
||||
const largePath = path.join(TEST_DIR, 'large-test.png');
|
||||
createLargeFile(largePath, 11);
|
||||
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: largePath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
// Should block with error
|
||||
expect(result.code).toBe(2);
|
||||
const output = JSON.parse(result.stdout);
|
||||
expect(output.decision).toBe('block');
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('File too large');
|
||||
|
||||
// Cleanup
|
||||
if (fs.existsSync(largePath)) fs.unlinkSync(largePath);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GROUP E: MOCK CLIPROXY INTEGRATION (FAST, RELIABLE)
|
||||
// ==========================================================================
|
||||
|
||||
describe('CLIProxy Integration (Mock Server)', () => {
|
||||
beforeAll(() => {
|
||||
resetMockState();
|
||||
});
|
||||
|
||||
it('should pass through when CLIProxy is unavailable', async () => {
|
||||
// Force hook to use a port that's definitely not running
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_PROFILE_TYPE: 'cliproxy',
|
||||
CCS_CLIPROXY_PORT: '59999', // Non-existent port
|
||||
CCS_DEBUG: '1',
|
||||
}
|
||||
);
|
||||
|
||||
// Should pass through (exit 0) when CLIProxy not available
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toContain('CLIProxy not available');
|
||||
});
|
||||
|
||||
it('should analyze PNG via mock CLIProxy and return analysis', () => {
|
||||
resetMockState();
|
||||
mockResponse = {
|
||||
content: 'This image shows a small red square, likely a single pixel or very minimal graphic.',
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
// Should block with analysis (exit 2)
|
||||
expect(result.code).toBe(2);
|
||||
const output = JSON.parse(result.stdout);
|
||||
expect(output.decision).toBe('block');
|
||||
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('red square');
|
||||
});
|
||||
|
||||
it('should analyze JPEG via mock CLIProxy', () => {
|
||||
resetMockState();
|
||||
mockResponse = {
|
||||
content: 'A minimalist white image, possibly a blank canvas or placeholder.',
|
||||
statusCode: 200,
|
||||
};
|
||||
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testJpegPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
expect(result.code).toBe(2);
|
||||
const output = JSON.parse(result.stdout);
|
||||
expect(output.decision).toBe('block');
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('white image');
|
||||
});
|
||||
|
||||
it('should include API key in request header', () => {
|
||||
resetMockState();
|
||||
|
||||
invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
// Verify API key was sent
|
||||
expect(lastRequest).not.toBeNull();
|
||||
expect(lastRequest?.headers['x-api-key']).toBe(CLIPROXY_API_KEY);
|
||||
});
|
||||
|
||||
it('should send correct request format to CLIProxy', () => {
|
||||
resetMockState();
|
||||
|
||||
invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_PROFILE_TYPE: 'cliproxy',
|
||||
CCS_CURRENT_PROVIDER: 'agy',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash',
|
||||
}
|
||||
);
|
||||
|
||||
// Verify request format
|
||||
expect(lastRequest).not.toBeNull();
|
||||
expect(lastRequest?.method).toBe('POST');
|
||||
expect(lastRequest?.path).toBe('/v1/messages');
|
||||
|
||||
const body = lastRequest?.body as {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: { type: string; media_type: string; data: string };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
expect(body.model).toBe('gemini-2.5-flash');
|
||||
expect(body.max_tokens).toBe(4096);
|
||||
expect(body.messages).toHaveLength(1);
|
||||
expect(body.messages[0].role).toBe('user');
|
||||
|
||||
// Should have text prompt and image content
|
||||
const content = body.messages[0].content;
|
||||
expect(content.some((c) => c.type === 'text')).toBe(true);
|
||||
expect(content.some((c) => c.type === 'image')).toBe(true);
|
||||
|
||||
// Verify image is base64 encoded
|
||||
const imageContent = content.find((c) => c.type === 'image');
|
||||
expect(imageContent?.source?.type).toBe('base64');
|
||||
expect(imageContent?.source?.media_type).toBe('image/png');
|
||||
expect(imageContent?.source?.data).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use correct media type for JPEG', () => {
|
||||
resetMockState();
|
||||
|
||||
invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testJpegPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
const body = lastRequest?.body as {
|
||||
messages: Array<{
|
||||
content: Array<{
|
||||
type: string;
|
||||
source?: { media_type: string };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
const imageContent = body.messages[0].content.find((c) => c.type === 'image');
|
||||
expect(imageContent?.source?.media_type).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should respect debug mode and output debug messages', () => {
|
||||
resetMockState();
|
||||
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_DEBUG: '1' }
|
||||
);
|
||||
|
||||
// Should output debug info to stderr
|
||||
expect(result.stderr).toContain('[CCS Hook]');
|
||||
expect(result.stderr).toContain('Analyzing');
|
||||
});
|
||||
|
||||
it('should handle API error response gracefully (pass through)', () => {
|
||||
resetMockState();
|
||||
mockResponse = {
|
||||
content: '',
|
||||
statusCode: 500,
|
||||
};
|
||||
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
// On API error, hook blocks with error message (exit 2)
|
||||
// This ensures Claude knows the analysis failed rather than silently passing through
|
||||
expect(result.code).toBe(2);
|
||||
const output = JSON.parse(result.stdout);
|
||||
expect(output.decision).toBe('block');
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error');
|
||||
});
|
||||
|
||||
it('should use model from provider_models mapping', () => {
|
||||
resetMockState();
|
||||
|
||||
invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_PROFILE_TYPE: 'cliproxy',
|
||||
CCS_CURRENT_PROVIDER: 'codex',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash',
|
||||
}
|
||||
);
|
||||
|
||||
const body = lastRequest?.body as { model: string };
|
||||
expect(body.model).toBe('gpt-5.1-codex-mini'); // Model from provider_models
|
||||
});
|
||||
|
||||
it('should skip when provider is not in provider_models', () => {
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: '1',
|
||||
CCS_PROFILE_TYPE: 'cliproxy',
|
||||
CCS_CURRENT_PROVIDER: 'unknown-provider',
|
||||
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash',
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.code).toBe(0); // Skip - provider not in map
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// GROUP F: OUTPUT FORMAT VALIDATION
|
||||
// ==========================================================================
|
||||
|
||||
describe('Output Format Validation', () => {
|
||||
it('should output valid JSON structure on success', () => {
|
||||
resetMockState();
|
||||
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
expect(result.code).toBe(2);
|
||||
const output = JSON.parse(result.stdout);
|
||||
|
||||
// Validate structure
|
||||
expect(output.decision).toBe('block');
|
||||
expect(output.reason).toBeDefined();
|
||||
expect(output.systemMessage).toBeDefined();
|
||||
expect(output.hookSpecificOutput).toBeDefined();
|
||||
expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse');
|
||||
expect(output.hookSpecificOutput.permissionDecision).toBe('deny');
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include filename in output', () => {
|
||||
resetMockState();
|
||||
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
const output = JSON.parse(result.stdout);
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('test-image.png');
|
||||
});
|
||||
|
||||
it('should include model name in output', () => {
|
||||
resetMockState();
|
||||
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
const output = JSON.parse(result.stdout);
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('should output valid JSON structure on file read error', () => {
|
||||
// Create and immediately delete file to trigger error
|
||||
const errorPath = path.join(TEST_DIR, 'error-test.png');
|
||||
createTestPng(errorPath);
|
||||
|
||||
// Make file unreadable (simulate permission error)
|
||||
fs.chmodSync(errorPath, 0o000);
|
||||
|
||||
const result = invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: errorPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
// Restore permissions and cleanup
|
||||
fs.chmodSync(errorPath, 0o644);
|
||||
fs.unlinkSync(errorPath);
|
||||
|
||||
// Should output error in JSON format
|
||||
expect(result.code).toBe(2);
|
||||
const output = JSON.parse(result.stdout);
|
||||
expect(output.decision).toBe('block');
|
||||
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {}');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,29 @@
|
||||
/**
|
||||
* Connection Indicator (Phase 04)
|
||||
*
|
||||
* Shows WebSocket connection status in the header.
|
||||
* Shows WebSocket connection status in the header with reconnection state.
|
||||
*/
|
||||
|
||||
import { Wifi, WifiOff } from 'lucide-react';
|
||||
import { Wifi, WifiOff, RefreshCw } from 'lucide-react';
|
||||
import { useWebSocket } from '@/hooks/use-websocket';
|
||||
|
||||
export function ConnectionIndicator() {
|
||||
const { status } = useWebSocket();
|
||||
const { status, isReconnecting } = useWebSocket();
|
||||
|
||||
const statusConfig = {
|
||||
connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' },
|
||||
connecting: { icon: Wifi, color: 'text-yellow-500', label: 'Connecting...' },
|
||||
disconnected: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' },
|
||||
connected: { icon: Wifi, color: 'text-green-600', label: 'Connected', animate: false },
|
||||
connecting: {
|
||||
icon: RefreshCw,
|
||||
color: 'text-yellow-500',
|
||||
label: 'Connecting...',
|
||||
animate: true,
|
||||
},
|
||||
disconnected: {
|
||||
icon: isReconnecting ? RefreshCw : WifiOff,
|
||||
color: isReconnecting ? 'text-amber-500' : 'text-red-500',
|
||||
label: isReconnecting ? 'Reconnecting...' : 'Disconnected',
|
||||
animate: isReconnecting,
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
@@ -21,7 +31,7 @@ export function ConnectionIndicator() {
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 text-sm ${config.color}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
<Icon className={`w-4 h-4 ${config.animate ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden sm:inline">{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
export function useWebSocket() {
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const reconnectAttempts = useRef(0);
|
||||
@@ -75,6 +76,7 @@ export function useWebSocket() {
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('connected');
|
||||
setIsReconnecting(false);
|
||||
reconnectAttempts.current = 0;
|
||||
console.log('[WS] Connected');
|
||||
};
|
||||
@@ -97,6 +99,7 @@ export function useWebSocket() {
|
||||
|
||||
// Attempt reconnect with exponential backoff
|
||||
if (reconnectAttempts.current < maxReconnectAttempts) {
|
||||
setIsReconnecting(true);
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
|
||||
reconnectAttempts.current++;
|
||||
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`);
|
||||
@@ -104,6 +107,8 @@ export function useWebSocket() {
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connectRef.current();
|
||||
}, delay);
|
||||
} else {
|
||||
setIsReconnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,6 +122,7 @@ export function useWebSocket() {
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect
|
||||
setIsReconnecting(false);
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
@@ -142,5 +148,8 @@ export function useWebSocket() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({ status, connect, disconnect }), [status, connect, disconnect]);
|
||||
return useMemo(
|
||||
() => ({ status, isReconnecting, connect, disconnect }),
|
||||
[status, isReconnecting, connect, disconnect]
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,25 @@ function SettingsPageInner() {
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)]">
|
||||
<PanelGroup direction="horizontal" className="h-full">
|
||||
{/* Mobile View - Stacked vertically */}
|
||||
<div className="md:hidden h-full flex flex-col">
|
||||
<div className="border-b bg-background p-4">
|
||||
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</div>
|
||||
<SectionErrorBoundary>
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
{activeTab === 'websearch' && <WebSearchSection />}
|
||||
{activeTab === 'globalenv' && <GlobalEnvSection />}
|
||||
{activeTab === 'thinking' && <ThinkingSection />}
|
||||
{activeTab === 'proxy' && <ProxySection />}
|
||||
{activeTab === 'auth' && <AuthSection />}
|
||||
{activeTab === 'backups' && <BackupsSection />}
|
||||
</Suspense>
|
||||
</SectionErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Desktop View - Side-by-side panels */}
|
||||
<PanelGroup direction="horizontal" className="h-full hidden md:flex">
|
||||
{/* Left Panel - Settings Controls */}
|
||||
<Panel defaultSize={40} minSize={30} maxSize={55}>
|
||||
<div className="h-full border-r flex flex-col bg-muted/30 relative">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react';
|
||||
import { useWebSearchConfig, useRawConfig } from '../../hooks';
|
||||
import { ProviderCard } from './provider-card';
|
||||
|
||||
@@ -141,6 +141,21 @@ export default function WebSearchSection() {
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-medium">Providers</h3>
|
||||
|
||||
{/* Empty state when no providers available */}
|
||||
{!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
|
||||
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg text-center bg-muted/30">
|
||||
<Package className="w-12 h-12 text-muted-foreground mb-3 opacity-30" />
|
||||
<p className="font-medium text-foreground mb-1">No providers configured</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Install CLI tools to enable web search providers
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchStatus}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Check for providers
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProviderCard
|
||||
name="gemini"
|
||||
label="Google Gemini CLI (1000 req/day free)"
|
||||
|
||||
Reference in new issue
Block a user