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:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-04 00:01:25 -05:00
commit 1ad1372068
34 files changed
+3492 -39

No files matched your search

+2
View File
@@ -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
+170
View File
@@ -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);
}
+758
View File
@@ -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);
}
}
}
+12
View File
@@ -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.
+13
View File
@@ -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.
+13
View File
@@ -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
View File
@@ -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",
+5
View File
@@ -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);
+29
View File
@@ -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
};
+15
View File
@@ -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);
}
+15
View File
@@ -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'],
+1
View File
@@ -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';
+173 -25
View File
@@ -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,
};
}
+33
View File
@@ -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;
}
+3
View File
@@ -49,3 +49,6 @@ export {
// OAuth checks
export { OAuthPortsChecker, runOAuthChecks } from './oauth-check';
// Image Analysis checks
export { runImageAnalysisCheck, fixImageAnalysisConfig } from './image-analysis-check';
+6
View File
@@ -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
View File
@@ -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;
}
+18
View File
@@ -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';
+206
View File
@@ -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;
}
}
+15
View File
@@ -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';
+29 -3
View File
@@ -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) {
+787
View File
@@ -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>
);
+10 -1
View File
@@ -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]
);
}
+19 -1
View File
@@ -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)"