mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
feat(hooks): add image/PDF analysis via CLIProxy transformer
Intercept Read tool calls for image/PDF files and route through CLIProxy with gemini-2.5-flash for vision analysis. Returns text descriptions instead of blocking, enabling Claude to "see" images via proxy. Key changes: - Add image-analyzer-transformer.cjs hook script - Add ImageAnalysisConfig type and loader - Add hook installer and profile injector - Add prompt templates for analysis customization - Add e2e test suite (excluded from normal CI runs) - Configure test:e2e script for manual testing Environment variables: - CCS_IMAGE_ANALYSIS_ENABLED: Enable/disable (default: 1) - CCS_IMAGE_ANALYSIS_MODEL: Vision model (default: gemini-2.5-flash) - CCS_IMAGE_ANALYSIS_TIMEOUT: Timeout in seconds (default: 60) - CCS_CLIPROXY_API_KEY: API key for CLIProxy auth - CCS_CLIPROXY_PORT: CLIProxy port (default: 8317) Closes #426
This commit is contained in:
@@ -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
|
||||
|
||||
Executable
+399
@@ -0,0 +1,399 @@
|
||||
#!/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_MODEL - Model to use (default: gemini-2.5-flash)
|
||||
* 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';
|
||||
|
||||
// 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
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 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('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`CLIProxy returned status ${res.statusCode}: ${data}`));
|
||||
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('Request timed out'));
|
||||
});
|
||||
|
||||
req.write(requestBody);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format analysis description for Claude
|
||||
*/
|
||||
function formatDescription(filePath, description, model) {
|
||||
return [
|
||||
`## Image Analysis: ${path.basename(filePath)}`,
|
||||
'',
|
||||
description,
|
||||
'',
|
||||
'---',
|
||||
`*Analyzed via CLIProxy (${model})*`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Output success response and exit
|
||||
*/
|
||||
function outputSuccess(filePath, description, model) {
|
||||
const formattedDescription = formatDescription(filePath, description, model);
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: `Image analyzed: ${path.basename(filePath)}`,
|
||||
systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: formattedDescription,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Output error message
|
||||
*/
|
||||
function outputError(filePath, error) {
|
||||
const message = [
|
||||
`[Image Analysis - Error]`,
|
||||
'',
|
||||
`Failed to analyze: ${path.basename(filePath)}`,
|
||||
'',
|
||||
`Error: ${error}`,
|
||||
'',
|
||||
'Troubleshooting:',
|
||||
' - Check CLIProxy is running: http://127.0.0.1:8317',
|
||||
' - Verify you are authenticated with agy or gemini',
|
||||
' - Check file size is under 10MB',
|
||||
].join('\n');
|
||||
|
||||
const output = {
|
||||
decision: 'block',
|
||||
reason: `Image analysis failed: ${error}`,
|
||||
systemMessage: `[Image Analysis] Failed to analyze ${path.basename(filePath)}`,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: message,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if hook should skip
|
||||
*/
|
||||
function shouldSkipHook() {
|
||||
// Explicit skip signal
|
||||
if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') return true;
|
||||
|
||||
// Explicit disable
|
||||
if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') return true;
|
||||
|
||||
// Account/default profiles - use native Read
|
||||
const profileType = process.env.CCS_PROFILE_TYPE;
|
||||
if (profileType === 'account' || profileType === 'default') return true;
|
||||
|
||||
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) {
|
||||
outputError(filePath, `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB > ${MAX_FILE_SIZE_MB}MB)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check CLIProxy availability
|
||||
const cliProxyAvailable = await isCliProxyAvailable();
|
||||
if (!cliProxyAvailable) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error('[CCS Hook] CLIProxy not available, passing through');
|
||||
}
|
||||
// Pass through to native Read
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const model = process.env.CCS_IMAGE_ANALYSIS_MODEL || DEFAULT_MODEL;
|
||||
const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10);
|
||||
const timeoutMs = timeout * 1000;
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`);
|
||||
}
|
||||
|
||||
// Encode file to base64
|
||||
const base64Data = encodeFileToBase64(filePath);
|
||||
const mediaType = getMediaType(filePath);
|
||||
|
||||
// Analyze via CLIProxy
|
||||
const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs);
|
||||
|
||||
// Output success
|
||||
outputSuccess(filePath, description, model);
|
||||
} 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
|
||||
}
|
||||
|
||||
// Output error
|
||||
outputError(filePath, err.message || 'Unknown error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
Analyze this image/document thoroughly and provide a detailed description.
|
||||
|
||||
Include:
|
||||
1. Overall content and purpose
|
||||
2. Text content (if any) - transcribe important text verbatim
|
||||
3. Visual elements (diagrams, charts, UI components, icons)
|
||||
4. Layout and structure (sections, hierarchy, flow)
|
||||
5. Colors, styling, notable design elements
|
||||
6. Any actionable information (buttons, links, code snippets)
|
||||
|
||||
Be comprehensive - this description replaces direct visual access.
|
||||
The AI assistant reading this cannot see the original image.
|
||||
@@ -0,0 +1,13 @@
|
||||
Analyze this document/PDF thoroughly for a developer.
|
||||
|
||||
Extract and provide:
|
||||
1. Document title, type, and structure
|
||||
2. All text content - transcribe in reading order
|
||||
3. Tables - format as markdown tables
|
||||
4. Lists and bullet points - preserve structure
|
||||
5. Code blocks or technical content
|
||||
6. Diagrams or flowcharts - describe in detail
|
||||
7. Headers and section organization
|
||||
8. Any important metadata visible
|
||||
|
||||
Accuracy in text extraction is critical.
|
||||
@@ -0,0 +1,13 @@
|
||||
Analyze this screenshot in detail for a developer who cannot see it.
|
||||
|
||||
Focus on:
|
||||
1. Application/website type and state
|
||||
2. UI elements visible (buttons, inputs, menus, modals)
|
||||
3. All text content - transcribe exactly
|
||||
4. Error messages or notifications (quote exactly)
|
||||
5. Layout and component hierarchy
|
||||
6. Interactive elements and their states
|
||||
7. Console output or logs if visible
|
||||
8. Any code snippets shown
|
||||
|
||||
Be precise - this enables the assistant to help debug or understand the UI.
|
||||
+2
-1
@@ -68,10 +68,11 @@
|
||||
"verify:bundle": "node scripts/verify-bundle.js",
|
||||
"test": "bun run build && bun run test:all",
|
||||
"test:ci": "bun run test:all",
|
||||
"test:all": "bun test",
|
||||
"test:all": "bun test tests/unit tests/integration tests/npm",
|
||||
"test:unit": "bun test tests/unit/",
|
||||
"test:npm": "bun test tests/npm/",
|
||||
"test:native": "bash tests/native/unix/edge-cases.sh",
|
||||
"test:e2e": "bun test tests/e2e/ --bail --timeout 60000",
|
||||
"dev": "bun run build:server && bun dist/ccs.js config --dev",
|
||||
"dev:symlink": "bash scripts/dev-symlink.sh",
|
||||
"dev:unlink": "bash scripts/dev-symlink.sh --restore",
|
||||
|
||||
@@ -20,9 +20,11 @@ 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';
|
||||
|
||||
@@ -293,6 +295,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,
|
||||
model: partial.image_analysis?.model ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.model,
|
||||
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
|
||||
providers: partial.image_analysis?.providers ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -721,3 +730,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,
|
||||
model: config.image_analysis?.model ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.model,
|
||||
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
|
||||
providers: config.image_analysis?.providers ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.providers,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -518,6 +518,32 @@ 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 for agy/gemini) */
|
||||
enabled: boolean;
|
||||
/** Model to use for analysis (default: gemini-2.5-flash) */
|
||||
model: string;
|
||||
/** Timeout in seconds (default: 60) */
|
||||
timeout: number;
|
||||
/** Providers to enable for (default: ['agy', 'gemini']) */
|
||||
providers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default image analysis configuration.
|
||||
* Enabled by default for CLIProxy providers with vision support.
|
||||
*/
|
||||
export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
|
||||
enabled: true,
|
||||
model: 'gemini-2.5-flash',
|
||||
timeout: 60,
|
||||
providers: ['agy', 'gemini'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Main unified configuration structure.
|
||||
* Stored in ~/.ccs/config.yaml
|
||||
@@ -551,6 +577,8 @@ export interface UnifiedConfig {
|
||||
thinking?: ThinkingConfig;
|
||||
/** Dashboard authentication configuration (optional) */
|
||||
dashboard_auth?: DashboardAuthConfig;
|
||||
/** Image analysis configuration (vision via CLIProxy) */
|
||||
image_analysis?: ImageAnalysisConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -644,6 +672,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,33 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Get image analysis hook environment variables.
|
||||
* These env vars control the hook's behavior via Claude Code hook system.
|
||||
*
|
||||
* @param profileName - Current profile name (to determine if native Claude)
|
||||
* @returns Environment variables for image analysis hook
|
||||
*/
|
||||
export function getImageAnalysisHookEnv(profileName?: string): Record<string, string> {
|
||||
const config = getImageAnalysisConfig();
|
||||
|
||||
// Native Claude profiles (no CLIProxy) should skip image analysis
|
||||
const isNativeProfile = !profileName || ['claude', 'anthropic'].includes(profileName);
|
||||
const skipImageAnalysis = isNativeProfile || !config.enabled;
|
||||
|
||||
return {
|
||||
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
|
||||
CCS_IMAGE_ANALYSIS_MODEL: config.model,
|
||||
CCS_IMAGE_ANALYSIS_TIMEOUT: config.timeout.toString(),
|
||||
CCS_IMAGE_ANALYSIS_PROVIDERS: config.providers.join(','),
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,12 @@
|
||||
*/
|
||||
|
||||
export { getImageReadBlockHookEnv, getImageReadBlockConfig } from './image-read-block-hook-env';
|
||||
export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env';
|
||||
export {
|
||||
getImageAnalyzerHookPath,
|
||||
getImageAnalyzerHookConfig,
|
||||
hasImageAnalyzerHook,
|
||||
installImageAnalyzerHook,
|
||||
uninstallImageAnalyzerHook,
|
||||
} from './image-analyzer-hook-installer';
|
||||
export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector';
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Image Analysis Hook Installer
|
||||
*
|
||||
* Manages installation of:
|
||||
* 1. block-image-read.cjs hook (blocks image reads to prevent context overflow)
|
||||
* 2. Prompt templates for image analysis (user-customizable)
|
||||
*
|
||||
* @module utils/image-analysis/hook-installer
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { info, warn } from '../ui';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
|
||||
// Hook file name
|
||||
const IMAGE_BLOCK_HOOK = 'block-image-read.cjs';
|
||||
|
||||
/**
|
||||
* Get path to installed hook script
|
||||
*/
|
||||
export function getHookPath(): string {
|
||||
return path.join(getCcsDir(), 'hooks', IMAGE_BLOCK_HOOK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CCS hooks directory
|
||||
*/
|
||||
export function getCcsHooksDir(): string {
|
||||
return path.join(getCcsDir(), 'hooks');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prompts directory for image analysis
|
||||
*/
|
||||
export function getPromptsDir(): string {
|
||||
return path.join(getCcsDir(), 'prompts', 'image-analysis');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if image block hook is installed
|
||||
*/
|
||||
export function hasImageBlockHook(): boolean {
|
||||
return fs.existsSync(getHookPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Install image block hook to ~/.ccs/hooks/
|
||||
*
|
||||
* This hook intercepts Read tool calls for image files and blocks them
|
||||
* to prevent context overflow (images consume 100K+ tokens each).
|
||||
*
|
||||
* @returns true if hook installed successfully
|
||||
*/
|
||||
export function installImageBlockHook(): boolean {
|
||||
try {
|
||||
// Ensure hooks directory exists
|
||||
const hooksDir = getCcsHooksDir();
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
const hookPath = getHookPath();
|
||||
|
||||
// Find the bundled hook script
|
||||
// In npm package: node_modules/ccs/lib/hooks/
|
||||
// In development: lib/hooks/
|
||||
const possiblePaths = [
|
||||
path.join(__dirname, '..', '..', '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK),
|
||||
path.join(__dirname, '..', '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK),
|
||||
path.join(__dirname, '..', 'lib', 'hooks', IMAGE_BLOCK_HOOK),
|
||||
];
|
||||
|
||||
let sourcePath: string | null = null;
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
sourcePath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourcePath) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Image block hook source not found: ${IMAGE_BLOCK_HOOK}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy hook to ~/.ccs/hooks/
|
||||
fs.copyFileSync(sourcePath, hookPath);
|
||||
fs.chmodSync(hookPath, 0o755);
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Installed image block hook: ${hookPath}`));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to install image block hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install prompt templates to ~/.ccs/prompts/image-analysis/
|
||||
* Only installs if directory doesn't exist (doesn't overwrite user edits)
|
||||
*
|
||||
* @returns true if prompts installed or already exist
|
||||
*/
|
||||
export function installImageAnalysisPrompts(): boolean {
|
||||
try {
|
||||
const promptsDir = getPromptsDir();
|
||||
|
||||
// Skip if already exists (preserve user customizations)
|
||||
if (fs.existsSync(promptsDir)) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
info('Image analysis prompts already installed - preserving user customizations')
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create directory
|
||||
fs.mkdirSync(promptsDir, { recursive: true, mode: 0o755 });
|
||||
|
||||
// Find bundled prompts
|
||||
const possibleBasePaths = [
|
||||
path.join(__dirname, '..', '..', '..', 'lib', 'prompts'),
|
||||
path.join(__dirname, '..', '..', 'lib', 'prompts'),
|
||||
path.join(__dirname, '..', 'lib', 'prompts'),
|
||||
];
|
||||
|
||||
let promptsBasePath: string | null = null;
|
||||
for (const p of possibleBasePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
promptsBasePath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!promptsBasePath) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn('Image analysis prompts source not found'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy prompt files
|
||||
const promptFiles = [
|
||||
'image-analysis-default.txt',
|
||||
'image-analysis-screenshot.txt',
|
||||
'image-analysis-document.txt',
|
||||
];
|
||||
|
||||
for (const file of promptFiles) {
|
||||
const sourcePath = path.join(promptsBasePath, file);
|
||||
const destPath = path.join(promptsDir, file.replace('image-analysis-', ''));
|
||||
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
fs.copyFileSync(sourcePath, destPath);
|
||||
fs.chmodSync(destPath, 0o644);
|
||||
} else if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Prompt template not found: ${file}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Installed image analysis prompts: ${promptsDir}`));
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to install image analysis prompts: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall image block hook from ~/.ccs/hooks/
|
||||
*
|
||||
* @returns true if hook uninstalled successfully
|
||||
*/
|
||||
export function uninstallImageBlockHook(): boolean {
|
||||
try {
|
||||
const hookPath = getHookPath();
|
||||
|
||||
if (fs.existsSync(hookPath)) {
|
||||
fs.unlinkSync(hookPath);
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(info(`Uninstalled image block hook: ${hookPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(warn(`Failed to uninstall image block hook: ${(error as Error).message}`));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Image Analysis Utilities
|
||||
*
|
||||
* Exports hook installer functions for image blocking and prompt management
|
||||
*/
|
||||
|
||||
export {
|
||||
getHookPath,
|
||||
getCcsHooksDir,
|
||||
getPromptsDir,
|
||||
hasImageBlockHook,
|
||||
installImageBlockHook,
|
||||
installImageAnalysisPrompts,
|
||||
uninstallImageBlockHook,
|
||||
} from './hook-installer';
|
||||
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// ============================================================================
|
||||
// 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),
|
||||
...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 },
|
||||
});
|
||||
|
||||
// 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_IMAGE_ANALYSIS_MODEL: '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 default model when CCS_IMAGE_ANALYSIS_MODEL is not set', () => {
|
||||
resetMockState();
|
||||
|
||||
invokeHook(
|
||||
{
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: testPngPath },
|
||||
},
|
||||
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
|
||||
);
|
||||
|
||||
const body = lastRequest?.body as { model: string };
|
||||
expect(body.model).toBe('gemini-2.5-flash'); // Default model
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user