From 38eb74043c7f9e613e308392b2c159ebfc2a05c1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 17:28:21 -0500 Subject: [PATCH 01/34] feat(hooks): add block-image-read hook to prevent context overflow Add PreToolUse hook that intercepts Read tool calls on image files (.png, .jpg, .webp, etc.) and blocks them with helpful message. This prevents context exhaustion when image generation skills produce multiple files and the agent tries to read them (each image can consume 100K+ tokens). Configuration: - Enable via config.yaml: hooks.block_image_read.enabled: true - Or env var: CCS_BLOCK_IMAGE_READ=1 Hook integration: - lib/hooks/block-image-read.cjs - the hook script - src/utils/hooks/image-read-block-hook-env.ts - config loader - Integrated into all spawn locations (ccs.ts, shell-executor, cliproxy-executor) Closes #426 --- lib/hooks/block-image-read.cjs | 150 +++++++++++++++++++ src/ccs.ts | 5 + src/cliproxy/cliproxy-executor.ts | 3 + src/utils/hooks/image-read-block-hook-env.ts | 50 +++++++ src/utils/hooks/index.ts | 9 ++ src/utils/shell-executor.ts | 6 +- 6 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 lib/hooks/block-image-read.cjs create mode 100644 src/utils/hooks/image-read-block-hook-env.ts create mode 100644 src/utils/hooks/index.ts diff --git a/lib/hooks/block-image-read.cjs b/lib/hooks/block-image-read.cjs new file mode 100644 index 00000000..c4dceecd --- /dev/null +++ b/lib/hooks/block-image-read.cjs @@ -0,0 +1,150 @@ +#!/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. + * + * 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: + * CCS_BLOCK_IMAGE_READ=1 - Enable blocking (required) + * CCS_BLOCK_IMAGE_READ=0 - Disable blocking (allow all reads) + * 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 blocking is enabled via environment variable + */ +function isBlockingEnabled() { + // Must be explicitly enabled + return process.env.CCS_BLOCK_IMAGE_READ === '1'; +} + +/** + * Main hook processing logic + */ +function processHook() { + try { + // Skip if blocking not enabled + if (!isBlockingEnabled()) { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Image read blocking disabled (CCS_BLOCK_IMAGE_READ != 1)'); + } + 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); +} diff --git a/src/ccs.ts b/src/ccs.ts index 84a65178..6cf8140c 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 { // 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 { ...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); diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 0d824da4..ed98d8d8 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -37,6 +37,7 @@ 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 { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { CodexReasoningProxy } from './codex-reasoning-proxy'; import { ToolSanitizationProxy } from './tool-sanitization-proxy'; @@ -944,10 +945,12 @@ export async function execClaudeWithCLIProxy( ANTHROPIC_BASE_URL: finalBaseUrl, }; const webSearchEnv = getWebSearchHookEnv(); + const imageReadBlockEnv = getImageReadBlockHookEnv(); const env = { ...process.env, ...effectiveEnvVars, ...webSearchEnv, + ...imageReadBlockEnv, CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider }; diff --git a/src/utils/hooks/image-read-block-hook-env.ts b/src/utils/hooks/image-read-block-hook-env.ts new file mode 100644 index 00000000..ad2f3333 --- /dev/null +++ b/src/utils/hooks/image-read-block-hook-env.ts @@ -0,0 +1,50 @@ +/** + * 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. + * + * @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 */ + enabled: boolean; +} + +/** + * Get image read block configuration from unified config. + * Defaults to disabled (opt-in feature). + */ +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 false - must be explicitly enabled + enabled: hooksConfig?.block_image_read?.enabled ?? false, + }; +} + +/** + * Get environment variables for image read block hook configuration. + * + * @returns Record of environment variables to set before spawning Claude + */ +export function getImageReadBlockHookEnv(): Record { + const config = getImageReadBlockConfig(); + const env: Record = {}; + + if (config.enabled) { + env.CCS_BLOCK_IMAGE_READ = '1'; + } + + return env; +} diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts new file mode 100644 index 00000000..16449791 --- /dev/null +++ b/src/utils/hooks/index.ts @@ -0,0 +1,9 @@ +/** + * Hooks Utilities Index + * + * Centralized exports for all hook-related utilities. + * + * @module utils/hooks + */ + +export { getImageReadBlockHookEnv, getImageReadBlockConfig } from './image-read-block-hook-env'; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 56d1223b..ffea9d3c 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -7,6 +7,7 @@ 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) @@ -28,11 +29,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) { From 9f3edc5dafb3aeb72f3d1cf2da80ccbda1690e48 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 2 Feb 2026 19:00:46 -0500 Subject: [PATCH 02/34] fix(hooks): enable image-read blocking by default for third-party profiles Match WebSearch hook pattern: - ENABLED by default for settings/cliproxy profiles - DISABLED for native Claude accounts (account/default) - User can override via config: hooks.block_image_read.enabled: false This ensures CCS CLI users get context protection out-of-the-box while native Claude subscription users are unaffected. --- lib/hooks/block-image-read.cjs | 46 ++++++++++++++------ src/utils/hooks/image-read-block-hook-env.ts | 18 ++++++-- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/lib/hooks/block-image-read.cjs b/lib/hooks/block-image-read.cjs index c4dceecd..78d87e27 100644 --- a/lib/hooks/block-image-read.cjs +++ b/lib/hooks/block-image-read.cjs @@ -7,6 +7,11 @@ * * 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: * { @@ -22,10 +27,11 @@ * } * } * - * Environment Variables: - * CCS_BLOCK_IMAGE_READ=1 - Enable blocking (required) - * CCS_BLOCK_IMAGE_READ=0 - Disable blocking (allow all reads) - * CCS_DEBUG=1 - Enable debug output + * 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) @@ -53,11 +59,28 @@ process.stdin.on('error', () => { }); /** - * Check if blocking is enabled via environment variable + * Check if hook should skip (for native Claude accounts). + * Matches WebSearch hook pattern. */ -function isBlockingEnabled() { - // Must be explicitly enabled - return process.env.CCS_BLOCK_IMAGE_READ === '1'; +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; } /** @@ -65,11 +88,8 @@ function isBlockingEnabled() { */ function processHook() { try { - // Skip if blocking not enabled - if (!isBlockingEnabled()) { - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] Image read blocking disabled (CCS_BLOCK_IMAGE_READ != 1)'); - } + // Skip for native accounts or explicit disable + if (shouldSkipHook()) { process.exit(0); } diff --git a/src/utils/hooks/image-read-block-hook-env.ts b/src/utils/hooks/image-read-block-hook-env.ts index ad2f3333..b2cc91fc 100644 --- a/src/utils/hooks/image-read-block-hook-env.ts +++ b/src/utils/hooks/image-read-block-hook-env.ts @@ -4,6 +4,9 @@ * 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 */ @@ -13,13 +16,13 @@ import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; * Configuration for image read blocking. */ export interface ImageReadBlockConfig { - /** Whether blocking is enabled */ + /** Whether blocking is enabled (default: true) */ enabled: boolean; } /** * Get image read block configuration from unified config. - * Defaults to disabled (opt-in feature). + * Defaults to ENABLED (opt-out feature) - matches WebSearch pattern. */ export function getImageReadBlockConfig(): ImageReadBlockConfig { const config = loadOrCreateUnifiedConfig(); @@ -28,14 +31,18 @@ export function getImageReadBlockConfig(): ImageReadBlockConfig { config as unknown as { hooks?: { block_image_read?: { enabled?: boolean } } } ).hooks; return { - // Default to false - must be explicitly enabled - enabled: hooksConfig?.block_image_read?.enabled ?? false, + // 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 { @@ -44,6 +51,9 @@ export function getImageReadBlockHookEnv(): Record { if (config.enabled) { env.CCS_BLOCK_IMAGE_READ = '1'; + } else { + // Explicit disable signal + env.CCS_BLOCK_IMAGE_READ = '0'; } return env; From f6b7045023e5de52f57fa79445a29de5bfe5a5ff Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 00:01:30 -0500 Subject: [PATCH 03/34] fix(delegation): dynamic model display from settings - Read ANTHROPIC_MODEL from profile settings instead of hardcoding - Display model name in full uppercase (GLM-4.7, not Glm-4.7) - Add null/undefined guard to getModelDisplayName - Remove hardcoded GLM-4.6/GLM-4.6 (Thinking) display names Closes #431 --- src/delegation/headless-executor.ts | 5 ++- src/delegation/result-formatter.ts | 23 +++---------- src/utils/config-manager.ts | 34 +++++++++++++++++++ .../unit/delegation/result-formatter.test.js | 11 +++--- 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index b282936a..1a8be770 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -14,7 +14,7 @@ import { ui, warn, info } from '../utils/ui'; import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types'; import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; import { buildExecutionResult } from './executor/result-aggregator'; -import { getCcsDir } from '../utils/config-manager'; +import { getCcsDir, getModelDisplayName } from '../utils/config-manager'; // Re-export types for consumers export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types'; @@ -196,8 +196,7 @@ export class HeadlessExecutor { const streamBuffer = new StreamBuffer(); if (showProgress) { - const modelName = - profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase(); + const modelName = getModelDisplayName(profile); console.error(ui.info(`Delegating to ${modelName}...`)); } diff --git a/src/delegation/result-formatter.ts b/src/delegation/result-formatter.ts index 4eae4f9f..abe32638 100644 --- a/src/delegation/result-formatter.ts +++ b/src/delegation/result-formatter.ts @@ -9,6 +9,7 @@ import * as path from 'path'; import { execSync } from 'child_process'; import * as fs from 'fs'; import { ui } from '../utils/ui'; +import { getModelDisplayName } from '../utils/config-manager'; import type { ExecutionResult, ExecutionError, PermissionDenial } from './executor/types'; // Alias for backward compatibility @@ -58,7 +59,7 @@ class ResultFormatter { let output = ''; // Header box - const modelName = this.getModelDisplayName(profile); + const modelName = getModelDisplayName(profile); const headerIcon = success ? '[i]' : '[X]'; output += ui.box(`${headerIcon} Delegated to ${modelName} (ccs:${profile})`, { borderStyle: 'round', @@ -225,7 +226,7 @@ class ResultFormatter { */ private static formatInfoTable(result: ExecutionResult): string { const { cwd, profile, duration, exitCode, sessionId, totalCost, numTurns } = result; - const modelName = this.getModelDisplayName(profile); + const modelName = getModelDisplayName(profile); const durationSec = (duration / 1000).toFixed(1); const rows: string[][] = [ @@ -253,20 +254,6 @@ class ResultFormatter { }); } - /** - * Get display name for model profile - */ - private static getModelDisplayName(profile: string): string { - const displayNames: Record = { - glm: 'GLM-4.6', - glmt: 'GLM-4.6 (Thinking)', - kimi: 'Kimi', - default: 'Claude', - }; - - return displayNames[profile] || profile.toUpperCase(); - } - /** * Truncate string to max length */ @@ -283,7 +270,7 @@ class ResultFormatter { static async formatMinimal(result: ExecutionResult): Promise { await ui.init(); const { profile, success, duration } = result; - const modelName = this.getModelDisplayName(profile); + const modelName = getModelDisplayName(profile); const icon = success ? ui.ok('') : ui.fail(''); const durationSec = (duration / 1000).toFixed(1); @@ -326,7 +313,7 @@ class ResultFormatter { await ui.init(); const { profile, duration, sessionId, totalCost, permissionDenials } = result; - const modelName = this.getModelDisplayName(profile); + const modelName = getModelDisplayName(profile); const timeoutMin = (duration / 60000).toFixed(1); let output = ''; diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index e1169137..adb8bf01 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -246,3 +246,37 @@ export function getSettingsPath(profile: string): string { return expandedPath; } + +/** + * Get display name for a profile by reading ANTHROPIC_MODEL from settings + * @param profile - Profile name (glm, glmt, kimi, custom, etc.) + * @returns Formatted display name (e.g., 'GLM-4.7', 'Kimi', 'Custom-Model') + */ +export function getModelDisplayName(profile: string): string { + if (!profile) { + return ''; + } + + const settingsPath = path.join(getCcsDir(), `${profile}.settings.json`); + + try { + if (fs.existsSync(settingsPath)) { + const content = fs.readFileSync(settingsPath, 'utf8'); + const settings = JSON.parse(content) as { env?: { ANTHROPIC_MODEL?: string } }; + const model = settings.env?.ANTHROPIC_MODEL; + + if (model) { + // Format: 'glm-4.7' -> 'GLM-4.7' (uppercase letters, preserve numbers) + return model + .split('-') + .map((part) => part.toUpperCase()) + .join('-'); + } + } + } catch { + // Fall through to default + } + + // Fallback: profile name uppercase + return profile.toUpperCase(); +} diff --git a/tests/unit/delegation/result-formatter.test.js b/tests/unit/delegation/result-formatter.test.js index 48e2c449..c791f15b 100644 --- a/tests/unit/delegation/result-formatter.test.js +++ b/tests/unit/delegation/result-formatter.test.js @@ -16,7 +16,7 @@ describe('ResultFormatter', () => { const formatted = await ResultFormatter.format(result); - assert.ok(formatted.includes('Delegated to GLM-4.6')); + assert.ok(formatted.toLowerCase().includes('delegated to glm')); assert.ok(formatted.includes('ccs:glm')); assert.ok(formatted.includes('/home/user/project')); assert.ok(formatted.includes('2.3s')); @@ -179,11 +179,13 @@ describe('ResultFormatter', () => { }; const glmFormatted = await ResultFormatter.format(glmResult); - assert.ok(glmFormatted.includes('GLM-4.6')); + // Model display reads from settings or falls back to profile uppercase + // Use case-insensitive check since format may vary (GLM, Glm-4.7, etc.) + assert.ok(glmFormatted.toLowerCase().includes('glm')); const kimiResult = { ...glmResult, profile: 'kimi' }; const kimiFormatted = await ResultFormatter.format(kimiResult); - assert.ok(kimiFormatted.includes('Kimi')); + assert.ok(kimiFormatted.toLowerCase().includes('kimi')); }); }); @@ -220,7 +222,8 @@ describe('ResultFormatter', () => { const minimal = await ResultFormatter.formatMinimal(result); assert.ok(minimal.includes('[OK]')); - assert.ok(minimal.includes('GLM-4.6')); + // Model display reads from settings or falls back to profile uppercase + assert.ok(minimal.toLowerCase().includes('glm')); assert.ok(minimal.includes('1.5s')); assert.ok(minimal.split('\n').length <= 3); }); From b833b149e0fe43141d3eb32bbe0bd33b460875cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 3 Feb 2026 05:14:46 +0000 Subject: [PATCH 04/34] chore(release): 7.34.1-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 47d5075e..d2c47f32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.1", + "version": "7.34.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From c324e92eb442669656b53a8f685030f5cb15ce3d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 15:49:10 -0500 Subject: [PATCH 05/34] fix(backup): create backups only when settings content changes Previously, PUT /api/settings/:profile created a backup on every request regardless of whether the content actually changed. This led to hundreds of identical backup files accumulating in ~/.ccs/backups/. Changes: - Compare existing content with new content before creating backup - Reuse computed newContent for atomic write (DRY) - Make hook injection idempotent by checking content before write Fixes #433 --- src/utils/websearch/profile-hook-injector.ts | 16 ++++++++++----- src/web-server/routes/settings-routes.ts | 21 ++++++++++++-------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index 5a98b00c..8b2916de 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -123,11 +123,17 @@ export function ensureProfileHooks(profileName: string): boolean { // Clean up any duplicates that may have accumulated (Windows path bug fix) const hadDuplicates = deduplicateCcsHooks(settings); if (hadDuplicates) { - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8'); - if (process.env.CCS_DEBUG) { - console.error( - info(`Removed duplicate WebSearch hooks from ${profileName}.settings.json`) - ); + // Re-read file to compare with modified settings (deduplicateCcsHooks mutates in-place) + const newContent = JSON.stringify(settings, null, 2); + const existingContent = fs.readFileSync(settingsPath, 'utf8'); + // Only write if content actually changed + if (newContent !== existingContent) { + fs.writeFileSync(settingsPath, newContent, 'utf8'); + if (process.env.CCS_DEBUG) { + console.error( + info(`Removed duplicate WebSearch hooks from ${profileName}.settings.json`) + ); + } } } // Update timeout if needed diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 57b61aad..baaa73a9 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -156,16 +156,21 @@ router.put('/:profile', (req: Request, res: Response): void => { } } - // Create backup only if file exists + // Create backup only if file exists AND content actually changed let backupPath: string | undefined; + const newContent = JSON.stringify(settings, null, 2) + '\n'; if (fileExists) { - const backupDir = path.join(ccsDir, 'backups'); - if (!fs.existsSync(backupDir)) { - fs.mkdirSync(backupDir, { recursive: true }); + const existingContent = fs.readFileSync(settingsPath, 'utf8'); + // Only create backup if content differs + if (existingContent !== newContent) { + const backupDir = path.join(ccsDir, 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); + fs.copyFileSync(settingsPath, backupPath); } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`); - fs.copyFileSync(settingsPath, backupPath); } // Ensure directory exists for new files @@ -175,7 +180,7 @@ router.put('/:profile', (req: Request, res: Response): void => { // Write new settings atomically const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.writeFileSync(tempPath, newContent); fs.renameSync(tempPath, settingsPath); const newStat = fs.statSync(settingsPath); From 38d2db930433aa0fa3bad47886474d625e08585e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 01:00:01 +0000 Subject: [PATCH 06/34] chore(release): 7.34.1-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d2c47f32..b79c36f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.1-dev.1", + "version": "7.34.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From d5f2acaa6e9ee5d12a6035c2da1f975551b6a989 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 20:34:05 -0500 Subject: [PATCH 07/34] 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 --- bunfig.toml | 2 + lib/hooks/image-analyzer-transformer.cjs | 399 ++++++++++ lib/prompts/image-analysis-default.txt | 12 + lib/prompts/image-analysis-document.txt | 13 + lib/prompts/image-analysis-screenshot.txt | 13 + package.json | 3 +- src/config/unified-config-loader.ts | 24 + src/config/unified-config-types.ts | 29 + .../hooks/get-image-analysis-hook-env.ts | 33 + .../image-analyzer-hook-configuration.ts | 55 ++ .../hooks/image-analyzer-hook-installer.ts | 137 ++++ .../image-analyzer-profile-hook-injector.ts | 253 ++++++ src/utils/hooks/index.ts | 9 + src/utils/image-analysis/hook-installer.ts | 206 +++++ src/utils/image-analysis/index.ts | 15 + tests/e2e/image-analyzer-hook.e2e.test.ts | 751 ++++++++++++++++++ 16 files changed, 1953 insertions(+), 1 deletion(-) create mode 100755 lib/hooks/image-analyzer-transformer.cjs create mode 100644 lib/prompts/image-analysis-default.txt create mode 100644 lib/prompts/image-analysis-document.txt create mode 100644 lib/prompts/image-analysis-screenshot.txt create mode 100644 src/utils/hooks/get-image-analysis-hook-env.ts create mode 100644 src/utils/hooks/image-analyzer-hook-configuration.ts create mode 100644 src/utils/hooks/image-analyzer-hook-installer.ts create mode 100644 src/utils/hooks/image-analyzer-profile-hook-injector.ts create mode 100644 src/utils/image-analysis/hook-installer.ts create mode 100644 src/utils/image-analysis/index.ts create mode 100644 tests/e2e/image-analyzer-hook.e2e.test.ts diff --git a/bunfig.toml b/bunfig.toml index 8bdd90e2..b40c7204 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -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 diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs new file mode 100755 index 00000000..ab7d831c --- /dev/null +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -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'); + } +} diff --git a/lib/prompts/image-analysis-default.txt b/lib/prompts/image-analysis-default.txt new file mode 100644 index 00000000..ba1572cc --- /dev/null +++ b/lib/prompts/image-analysis-default.txt @@ -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. diff --git a/lib/prompts/image-analysis-document.txt b/lib/prompts/image-analysis-document.txt new file mode 100644 index 00000000..655812ce --- /dev/null +++ b/lib/prompts/image-analysis-document.txt @@ -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. diff --git a/lib/prompts/image-analysis-screenshot.txt b/lib/prompts/image-analysis-screenshot.txt new file mode 100644 index 00000000..0c053eb1 --- /dev/null +++ b/lib/prompts/image-analysis-screenshot.txt @@ -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. diff --git a/package.json b/package.json index db90e29d..95c1f141 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 63a97fd1..1ad14459 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -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 { 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, + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index cf7e0f1f..1eab0270 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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 }, }; } diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts new file mode 100644 index 00000000..7cb88557 --- /dev/null +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -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 { + 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', + }; +} diff --git a/src/utils/hooks/image-analyzer-hook-configuration.ts b/src/utils/hooks/image-analyzer-hook-configuration.ts new file mode 100644 index 00000000..a11f4239 --- /dev/null +++ b/src/utils/hooks/image-analyzer-hook-configuration.ts @@ -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 { + 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, + }, + ], + }, + ], + }; +} diff --git a/src/utils/hooks/image-analyzer-hook-installer.ts b/src/utils/hooks/image-analyzer-hook-installer.ts new file mode 100644 index 00000000..a4e92ecc --- /dev/null +++ b/src/utils/hooks/image-analyzer-hook-installer.ts @@ -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/.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; + } +} diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts new file mode 100644 index 00000000..4d4e4f12 --- /dev/null +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -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): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks?.PreToolUse) return false; + + return hooks.PreToolUse.some((h: unknown) => { + const hook = h as Record; + if (hook.matcher !== 'Read') return false; + + const hookArray = hook.hooks as Array> | 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 = {}; + 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; + 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, + settingsPath: string +): boolean { + try { + const hooks = settings.hooks as Record; + const hookConfig = getImageAnalyzerHookConfig(); + const expectedHookPath = getImageAnalyzerHookPath(); + const expectedCommand = `node "${expectedHookPath}"`; + const expectedHooks = (hookConfig.PreToolUse as Array>)[0] + .hooks as Array>; + const expectedTimeout = expectedHooks[0].timeout as number; + + let needsUpdate = false; + + for (const h of hooks.PreToolUse) { + const hook = h as Record; + if (hook.matcher !== 'Read') continue; + + const hookArray = hook.hooks as Array>; + 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}`)); + } + } +} diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 16449791..d952a096 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -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'; diff --git a/src/utils/image-analysis/hook-installer.ts b/src/utils/image-analysis/hook-installer.ts new file mode 100644 index 00000000..99c4f624 --- /dev/null +++ b/src/utils/image-analysis/hook-installer.ts @@ -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; + } +} diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts new file mode 100644 index 00000000..1fea2b08 --- /dev/null +++ b/src/utils/image-analysis/index.ts @@ -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'; diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts new file mode 100644 index 00000000..630f7ae4 --- /dev/null +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -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; + 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 { + 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 { + 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 = {} +): { 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'); + }); + }); +}); From 70caaa00a090fe6a1cfcff3ff47bcb355427ff42 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 20:58:05 -0500 Subject: [PATCH 08/34] fix(hooks): improve image analysis output format Match websearch transformer format: - Header: [Image Analysis via CLIProxy] - Metadata: File name, size in KB, model used - Separator lines for better readability - Footer instruction for Claude --- lib/hooks/image-analyzer-transformer.cjs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index ab7d831c..daa10782 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -209,29 +209,35 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { } /** - * Format analysis description for Claude + * Format analysis description for Claude (matches websearch format) */ -function formatDescription(filePath, description, model) { +function formatDescription(filePath, description, model, fileSize) { + const sizeKB = fileSize ? (fileSize / 1024).toFixed(1) : '?'; return [ - `## Image Analysis: ${path.basename(filePath)}`, + `[Image Analysis via CLIProxy]`, + '', + `File: ${path.basename(filePath)} (${sizeKB} KB)`, + `Model: ${model}`, + '', + '---', '', description, '', '---', - `*Analyzed via CLIProxy (${model})*`, + '*Use this description to understand the image content.*', ].join('\n'); } /** * Output success response and exit */ -function outputSuccess(filePath, description, model) { - const formattedDescription = formatDescription(filePath, description, model); +function outputSuccess(filePath, description, model, fileSize) { + 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`, + systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy (${model})`, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', @@ -378,7 +384,7 @@ async function processHook() { const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); // Output success - outputSuccess(filePath, description, model); + outputSuccess(filePath, description, model, stats.size); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Error:', err.message); From 9662490a74297fe1c992e30beb212222abe77799 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 21:03:05 -0500 Subject: [PATCH 09/34] fix(hooks): add defensive validation for env var generation --- src/utils/hooks/get-image-analysis-hook-env.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 7cb88557..fc0e2102 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -25,8 +25,8 @@ export function getImageAnalysisHookEnv(profileName?: string): Record Date: Tue, 3 Feb 2026 21:09:18 -0500 Subject: [PATCH 10/34] fix(glmt): gate retry rate limit logs behind verbose flag Rate limit retry messages now only appear when verbose mode is enabled, keeping test output clean while preserving debug capability. --- src/glmt/glmt-proxy.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index 85d8c22f..f52d55a9 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -447,9 +447,11 @@ export class GlmtProxy { lastError = err; const delay = this.calculateRetryDelay(attempt, retryAfter); - console.error( - `[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms` - ); + if (this.verbose) { + console.error( + `[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms` + ); + } await this.sleep(delay); } @@ -507,9 +509,11 @@ export class GlmtProxy { lastError = err; const delay = this.calculateRetryDelay(attempt, retryAfter); - console.error( - `[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms` - ); + if (this.verbose) { + console.error( + `[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms` + ); + } await this.sleep(delay); } From 40caff13ad5e8eaca71bddb05368d2218ce94453 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 21:32:19 -0500 Subject: [PATCH 11/34] refactor(hooks): use provider_models mapping for image analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes ImageAnalysisConfig from providers array to provider_models mapping for granular vision model control per CLIProxy provider. Breaking change: config.yaml image_analysis section now uses provider_models instead of providers/model fields. Provider-to-model mappings: - 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 Hook checks CCS_CURRENT_PROVIDER against provider_models and skips if no vision model configured for that provider. --- lib/hooks/image-analyzer-transformer.cjs | 45 +++++++++++++++--- src/cliproxy/cliproxy-executor.ts | 3 ++ src/config/unified-config-loader.ts | 8 ++-- src/config/unified-config-types.ts | 18 +++++--- .../hooks/get-image-analysis-hook-env.ts | 23 +++++++--- tests/e2e/image-analyzer-hook.e2e.test.ts | 46 +++++++++++++++++-- 6 files changed, 113 insertions(+), 30 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index daa10782..bea62c96 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -6,12 +6,13 @@ * 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 + * 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) @@ -65,6 +66,31 @@ Be comprehensive - this description replaces direct visual access.`; // HELPER FUNCTIONS // ============================================================================ +/** + * 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) { + 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 */ @@ -295,6 +321,11 @@ function shouldSkipHook() { const profileType = process.env.CCS_PROFILE_TYPE; if (profileType === 'account' || profileType === 'default') return true; + // Check if current provider has a vision model configured + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + if (!providerModels[currentProvider]) return true; + return false; } @@ -368,7 +399,7 @@ async function processHook() { process.exit(0); } - const model = process.env.CCS_IMAGE_ANALYSIS_MODEL || DEFAULT_MODEL; + const model = getModelForProvider(); const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); const timeoutMs = timeout * 1000; diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index ed98d8d8..626e4519 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -38,6 +38,7 @@ 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'; @@ -946,11 +947,13 @@ export async function execClaudeWithCLIProxy( }; 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 }; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 1ad14459..86b3cdc0 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -298,9 +298,9 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { // 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, + provider_models: + partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, }, }; } @@ -740,8 +740,8 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig { 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, + provider_models: + config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, }; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1eab0270..c654d9ec 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -523,14 +523,12 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { * Routes image/PDF files through CLIProxy for vision analysis. */ export interface ImageAnalysisConfig { - /** Enable image analysis via CLIProxy (default: true for agy/gemini) */ + /** Enable image analysis via CLIProxy (default: true) */ 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[]; + /** Provider-to-model mapping for vision analysis */ + provider_models: Record; } /** @@ -539,9 +537,15 @@ export interface ImageAnalysisConfig { */ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { enabled: true, - model: 'gemini-2.5-flash', timeout: 60, - providers: ['agy', 'gemini'], + 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', + }, }; /** diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index fc0e2102..0dd9aa14 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -9,25 +9,34 @@ import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +/** + * Serialize provider_models map to env var format: provider:model,provider:model + */ +function serializeProviderModels(providerModels: Record): 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 profileName - Current profile name (to determine if native Claude) + * @param provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex') * @returns Environment variables for image analysis hook */ -export function getImageAnalysisHookEnv(profileName?: string): Record { +export function getImageAnalysisHookEnv(provider?: string): Record { const config = getImageAnalysisConfig(); - // Native Claude profiles (no CLIProxy) should skip image analysis - const isNativeProfile = !profileName || ['claude', 'anthropic'].includes(profileName); - const skipImageAnalysis = isNativeProfile || !config.enabled; + // 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_MODEL: config.model || 'gemini-2.5-flash', CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60), - CCS_IMAGE_ANALYSIS_PROVIDERS: config.providers.join(','), + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models), + CCS_CURRENT_PROVIDER: provider || '', CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', }; } diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index 630f7ae4..b3da61dd 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -31,6 +31,10 @@ 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 // ============================================================================ @@ -143,6 +147,9 @@ function invokeHook( ...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 @@ -409,7 +416,13 @@ describe('Image Analyzer Hook', () => { input: 'not valid json', encoding: 'utf8', timeout: 5000, - env: { ...process.env, CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY }, + 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) @@ -546,7 +559,8 @@ describe('Image Analyzer Hook', () => { { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', - CCS_IMAGE_ANALYSIS_MODEL: 'gemini-2.5-flash', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', } ); @@ -646,7 +660,7 @@ describe('Image Analyzer Hook', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error'); }); - it('should use default model when CCS_IMAGE_ANALYSIS_MODEL is not set', () => { + it('should use model from provider_models mapping', () => { resetMockState(); invokeHook( @@ -654,11 +668,33 @@ describe('Image Analyzer Hook', () => { tool_name: 'Read', tool_input: { file_path: testPngPath }, }, - { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + { + 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('gemini-2.5-flash'); // Default model + 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 }); }); From e821a3bee6662f47f6f439d47c6a4c3a331f3780 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 02:35:51 +0000 Subject: [PATCH 12/34] chore(release): 7.34.1-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b79c36f0..3c2b8427 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.1-dev.2", + "version": "7.34.1-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From a55e0af8ef440d2cbfa7d9a487727fb1ee874bc6 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 21:41:56 -0500 Subject: [PATCH 13/34] fix(update): pre-remove package on Windows bun before reinstall On Windows, bun's global binary symlink may not update properly when reinstalling the same package. Pre-remove the existing installation to ensure a clean update, mirroring the dev-install.sh behavior. Closes #435 --- src/commands/update-command.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index b9f3d1bc..c78329c9 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -189,8 +189,10 @@ async function performNpmUpdate( case 'bun': updateCommand = 'bun'; updateArgs = ['add', '-g', `@kaitranntt/ccs@${targetTag}`]; - cacheCommand = null; - cacheArgs = null; + // On Windows, bun's global bin symlink may not update properly without removal first + // Pre-remove to ensure clean reinstall (mirrors dev-install.sh behavior) + cacheCommand = process.platform === 'win32' ? 'bun' : null; + cacheArgs = process.platform === 'win32' ? ['remove', '-g', '@kaitranntt/ccs'] : null; break; default: updateCommand = 'npm'; @@ -271,7 +273,16 @@ async function performNpmUpdate( }; if (cacheCommand && cacheArgs) { - console.log(info('Clearing package cache...')); + // For bun on Windows, we pre-remove instead of cache clear + const isBunPreRemove = packageManager === 'bun' && cacheArgs.includes('remove'); + const stepMessage = isBunPreRemove + ? 'Removing existing installation...' + : 'Clearing package cache...'; + const failMessage = isBunPreRemove + ? 'Pre-removal failed, proceeding anyway...' + : 'Cache clearing failed, proceeding anyway...'; + + console.log(info(stepMessage)); // On Windows, use shell with full command string to avoid deprecation warning const cacheChild = isWindows ? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { @@ -283,13 +294,13 @@ async function performNpmUpdate( cacheChild.on('exit', (code) => { if (code !== 0) { - console.log(warn('Cache clearing failed, proceeding anyway...')); + console.log(warn(failMessage)); } performUpdate(); }); cacheChild.on('error', () => { - console.log(warn('Cache clearing failed, proceeding anyway...')); + console.log(warn(failMessage)); performUpdate(); }); } else { From 0e7b9c91900c319a58ec698306be01bb1f665432 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 21:44:44 -0500 Subject: [PATCH 14/34] fix(hooks): add edge case validation in image analyzer - Empty model validation: check `model.trim()` before using - 10MB boundary: use `>=` instead of `>` for consistent messaging - Timeout clamping: ensure timeout is between 1-600 seconds - Response stream error: add error handler for network failures - Empty response validation: check before JSON.parse to prevent crashes --- lib/hooks/image-analyzer-transformer.cjs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index bea62c96..bd8e7387 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -75,7 +75,7 @@ function parseProviderModels(envValue) { const result = {}; envValue.split(',').forEach((pair) => { const [provider, model] = pair.split(':'); - if (provider && model) { + if (provider && model && model.trim()) { result[provider.trim()] = model.trim(); } }); @@ -200,12 +200,21 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { data += chunk; }); + res.on('error', (err) => { + reject(err); + }); + res.on('end', () => { if (res.statusCode !== 200) { reject(new Error(`CLIProxy returned status ${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; @@ -384,8 +393,8 @@ async function processHook() { // Check file size const stats = fs.statSync(filePath); - if (stats.size > MAX_FILE_SIZE_BYTES) { - outputError(filePath, `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB > ${MAX_FILE_SIZE_MB}MB)`); + 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; } @@ -401,7 +410,7 @@ async function processHook() { const model = getModelForProvider(); const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); - const timeoutMs = timeout * 1000; + const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000; if (process.env.CCS_DEBUG) { console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`); From 272be161fa4c5b4ff1dcae56ae00888506e1ff07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 02:51:16 +0000 Subject: [PATCH 15/34] chore(release): 7.34.1-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3c2b8427..3b2c6409 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.1-dev.3", + "version": "7.34.1-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 2b0717ed53011dcb67cc03ad09a00cfabb682f1e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 22:14:52 -0500 Subject: [PATCH 16/34] feat(hooks): add UX improvements for image analysis hook Add comprehensive UX enhancements for the image analysis CLIProxy hook: - Add `ccs config image-analysis` CLI command for managing settings - Enable/disable toggle - Timeout configuration (10-600s) - Per-provider model configuration - Status display with provider models - Add specialized error handlers with actionable messages - File too large (with compression hints) - CLIProxy unavailable (with start instructions) - Auth failure (with re-auth commands) - Timeout (with increase timeout hint) - Rate limit (with retry guidance) - API error (with response body parsing) - Add comprehensive debug output (CCS_DEBUG=1) - Provider name and model - File size and media type - Timeout and endpoint - Skip reasons with context - Add help text updates - `ccs --help` includes Image Analysis section - `ccs config --help` lists image-analysis subcommand - Add doctor integration - Validates image_analysis config - Checks enabled status, providers, timeout - Warns if CLIProxy not running - Add unit tests for new config command --- lib/hooks/image-analyzer-transformer.cjs | 388 +++++++++++++++--- src/commands/config-command.ts | 15 + src/commands/config-image-analysis-command.ts | 203 +++++++++ src/commands/help-command.ts | 15 + src/commands/index.ts | 1 + src/config/unified-config-loader.ts | 24 ++ src/management/checks/image-analysis-check.ts | 159 +++++++ src/management/checks/index.ts | 3 + src/management/doctor.ts | 6 + .../config-image-analysis-command.test.ts | 187 +++++++++ 10 files changed, 953 insertions(+), 48 deletions(-) create mode 100644 src/commands/config-image-analysis-command.ts create mode 100644 src/management/checks/image-analysis-check.ts create mode 100644 tests/unit/commands/config-image-analysis-command.test.ts diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index bd8e7387..793a08e9 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -49,6 +49,21 @@ const CLIPROXY_PATH = '/v1/messages'; // API key passed via env from cliproxy-executor, defaults to CCS internal key const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed'; +// ============================================================================ +// ERROR CODES (for categorization) +// ============================================================================ + +const ERROR_CODES = { + FILE_TOO_LARGE: 'FILE_TOO_LARGE', + CLIPROXY_UNAVAILABLE: 'CLIPROXY_UNAVAILABLE', + AUTH_FAILED: 'AUTH_FAILED', + TIMEOUT: 'TIMEOUT', + RATE_LIMIT: 'RATE_LIMIT', + API_ERROR: 'API_ERROR', + PARSE_ERROR: 'PARSE_ERROR', + UNKNOWN: 'UNKNOWN', +}; + // Default analysis prompt const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description. @@ -66,6 +81,55 @@ Be comprehensive - this description replaces direct visual access.`; // HELPER FUNCTIONS // ============================================================================ +/** + * Output debug information to stderr + * Only outputs when CCS_DEBUG=1 + */ +function debugLog(message, data = {}) { + if (!process.env.CCS_DEBUG) return; + + const lines = [`[CCS Hook] ${message}`]; + + for (const [key, value] of Object.entries(data)) { + if (value !== undefined && value !== null) { + lines.push(` ${key}: ${value}`); + } + } + + console.error(lines.join('\n')); +} + +/** + * Get detailed debug context + */ +function getDebugContext(filePath, stats) { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown'; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const model = providerModels[currentProvider] || DEFAULT_MODEL; + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + const isDefaultModel = !providerModels[currentProvider]; + + return { + file: path.basename(filePath), + size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown', + provider: currentProvider, + model: model, + config: isDefaultModel ? 'default' : 'user-configured', + timeout: `${timeout}s`, + endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`, + }; +} + +/** + * Get current provider/model context for error messages + */ +function getProviderContext() { + const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown'; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const model = providerModels[provider] || DEFAULT_MODEL; + return { provider, model }; +} + /** * Parse provider_models env var to object * Format: provider:model,provider:model @@ -205,8 +269,20 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { }); res.on('end', () => { + // Categorize by status code + if (res.statusCode === 401 || res.statusCode === 403) { + reject(new Error(`AUTH_ERROR:${res.statusCode}`)); + return; + } + + if (res.statusCode === 429) { + const retryAfter = res.headers['retry-after']; + reject(new Error(`RATE_LIMIT:${retryAfter || ''}`)); + return; + } + if (res.statusCode !== 200) { - reject(new Error(`CLIProxy returned status ${res.statusCode}: ${data}`)); + reject(new Error(`API_ERROR:${res.statusCode}:${data}`)); return; } @@ -235,7 +311,7 @@ function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { req.on('error', (err) => reject(err)); req.on('timeout', () => { req.destroy(); - reject(new Error('Request timed out')); + reject(new Error('TIMEOUT')); }); req.write(requestBody); @@ -263,10 +339,213 @@ function formatDescription(filePath, description, model, fileSize) { ].join('\n'); } +// ============================================================================ +// SPECIALIZED ERROR HANDLERS +// ============================================================================ + +/** + * Format error output for Claude hook + */ +function formatErrorOutput(filePath, errorCode, message, troubleshooting) { + const { provider, model } = getProviderContext(); + + const lines = [ + `[Image Analysis - Error]`, + '', + `File: ${path.basename(filePath)}`, + `Provider: ${provider} | Model: ${model}`, + '', + `Error: ${message}`, + ]; + + if (troubleshooting && troubleshooting.length > 0) { + lines.push(''); + lines.push('Troubleshooting:'); + troubleshooting.forEach((step, i) => { + lines.push(` ${i + 1}. ${step}`); + }); + } + + lines.push(''); + lines.push('For help: ccs config image-analysis --help'); + + return { + decision: 'block', + reason: `Image analysis failed: ${errorCode}`, + systemMessage: `[Image Analysis] Failed: ${message}`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: lines.join('\n'), + }, + }; +} + +/** + * File too large error + */ +function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.FILE_TOO_LARGE, + `File too large (${actualSizeMB.toFixed(2)}MB > ${maxSizeMB}MB limit)`, + [ + 'Reduce image resolution or use compression', + 'For screenshots: use PNG optimizer (pngquant, optipng)', + 'For photos: resize to max 2048px width', + `Current limit: ${maxSizeMB}MB per file`, + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * CLIProxy unavailable error + */ +function outputCliProxyUnavailableError(filePath, endpoint) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.CLIPROXY_UNAVAILABLE, + `CLIProxy not available at ${endpoint}`, + [ + 'CLIProxy service may not be running', + 'Start with: ccs config (opens dashboard, starts CLIProxy)', + 'Or manually: ccs cliproxy start', + `Verify: curl ${endpoint}`, + 'Check status: ccs doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Authentication error + */ +function outputAuthError(filePath, statusCode) { + const { provider } = getProviderContext(); + const output = formatErrorOutput( + filePath, + ERROR_CODES.AUTH_FAILED, + `Authentication failed (HTTP ${statusCode})`, + [ + `Re-authenticate: ccs ${provider} --auth`, + `Check accounts: ccs ${provider} --accounts`, + 'Verify OAuth token is valid', + 'Check: ccs doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Timeout error + */ +function outputTimeoutError(filePath, timeoutSec) { + const { model } = getProviderContext(); + const output = formatErrorOutput( + filePath, + ERROR_CODES.TIMEOUT, + `Request timed out after ${timeoutSec}s`, + [ + 'Large files or complex images take longer', + `Increase timeout: ccs config image-analysis --timeout ${timeoutSec * 2}`, + 'Or via env: CCS_IMAGE_ANALYSIS_TIMEOUT=120', + `Current model (${model}) may be slow - try a faster variant`, + 'Check CLIProxy health: curl http://127.0.0.1:8317', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Rate limit error + */ +function outputRateLimitError(filePath, retryAfterSec) { + const { provider } = getProviderContext(); + const retryHint = retryAfterSec ? `Retry after ${retryAfterSec}s` : 'Wait a moment and retry'; + const output = formatErrorOutput( + filePath, + ERROR_CODES.RATE_LIMIT, + 'Rate limit exceeded', + [ + retryHint, + `Provider ${provider} has usage limits`, + 'Consider switching accounts: ccs ' + provider + ' --accounts', + 'Check quota: ccs cliproxy doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Generic API error + */ +function outputApiError(filePath, statusCode, responseBody) { + // Try to extract error message from response + let errorDetail = `HTTP ${statusCode}`; + try { + const parsed = JSON.parse(responseBody); + if (parsed.error?.message) { + errorDetail = parsed.error.message; + } else if (parsed.message) { + errorDetail = parsed.message; + } + } catch { + // Use raw body if not JSON (truncated) + if (responseBody && responseBody.length < 100) { + errorDetail = responseBody; + } + } + + const output = formatErrorOutput( + filePath, + ERROR_CODES.API_ERROR, + `API error: ${errorDetail}`, + [ + 'Check CLIProxy logs: ccs cleanup --show-logs', + 'Verify provider is authenticated: ccs doctor', + 'Try a different provider or model', + 'Report persistent issues: https://github.com/kaitranntt/ccs/issues', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Unknown/fallback error (replaces old outputError) + */ +function outputUnknownError(filePath, error) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.UNKNOWN, + error || 'Unknown error occurred', + [ + 'Check CLIProxy is running: curl http://127.0.0.1:8317', + 'Verify authentication: ccs doctor', + 'Check file is valid image/PDF', + 'Enable debug: CCS_DEBUG=1 ccs ', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + /** * Output success response and exit */ function outputSuccess(filePath, description, model, fileSize) { + debugLog('Returning analysis result', { + file: path.basename(filePath), + model: model, + descriptionLength: `${description.length} chars`, + }); + const formattedDescription = formatDescription(filePath, description, model, fileSize); const output = { @@ -285,55 +564,38 @@ function outputSuccess(filePath, description, model, fileSize) { } /** - * Output error message - */ -function outputError(filePath, error) { - const message = [ - `[Image Analysis - Error]`, - '', - `Failed to analyze: ${path.basename(filePath)}`, - '', - `Error: ${error}`, - '', - 'Troubleshooting:', - ' - Check CLIProxy is running: http://127.0.0.1:8317', - ' - Verify you are authenticated with agy or gemini', - ' - Check file size is under 10MB', - ].join('\n'); - - const output = { - decision: 'block', - reason: `Image analysis failed: ${error}`, - systemMessage: `[Image Analysis] Failed to analyze ${path.basename(filePath)}`, - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: message, - }, - }; - - console.log(JSON.stringify(output)); - process.exit(2); -} - -/** - * Determine if hook should skip + * Determine if hook should skip, with debug logging */ function shouldSkipHook() { // Explicit skip signal - if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') return true; + if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') { + debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP=1'); + return true; + } // Explicit disable - if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') return true; + if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') { + debugLog('Skipping: image analysis disabled (CCS_IMAGE_ANALYSIS_ENABLED=0)'); + return true; + } // Account/default profiles - use native Read const profileType = process.env.CCS_PROFILE_TYPE; - if (profileType === 'account' || profileType === 'default') return true; + if (profileType === 'account' || profileType === 'default') { + debugLog(`Skipping: profile type "${profileType}" uses native Read`); + return true; + } // Check if current provider has a vision model configured const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); - if (!providerModels[currentProvider]) return true; + + if (!providerModels[currentProvider]) { + debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, { + configured_providers: Object.keys(providerModels).join(', ') || 'none', + }); + return true; + } return false; } @@ -394,16 +656,17 @@ async function processHook() { // Check file size const stats = fs.statSync(filePath); if (stats.size >= MAX_FILE_SIZE_BYTES) { - outputError(filePath, `File too large (${(stats.size / 1024 / 1024).toFixed(2)}MB >= ${MAX_FILE_SIZE_MB}MB)`); + outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB); return; } // Check CLIProxy availability const cliProxyAvailable = await isCliProxyAvailable(); if (!cliProxyAvailable) { - if (process.env.CCS_DEBUG) { - console.error('[CCS Hook] CLIProxy not available, passing through'); - } + debugLog('Skipping: CLIProxy not available', { + endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`, + action: 'passing through to native Read', + }); // Pass through to native Read process.exit(0); } @@ -412,17 +675,26 @@ async function processHook() { const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000; - if (process.env.CCS_DEBUG) { - console.error(`[CCS Hook] Analyzing ${path.basename(filePath)} via CLIProxy (${model})`); - } + // Get debug context before analysis + const debugContext = getDebugContext(filePath, stats); + debugLog('Starting image analysis', debugContext); // Encode file to base64 const base64Data = encodeFileToBase64(filePath); const mediaType = getMediaType(filePath); + debugLog('File encoded', { + mediaType: mediaType, + base64Length: `${(base64Data.length / 1024).toFixed(1)} KB`, + }); + // Analyze via CLIProxy const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + debugLog('Analysis complete', { + responseLength: `${description.length} chars`, + }); + // Output success outputSuccess(filePath, description, model, stats.size); } catch (err) { @@ -439,7 +711,27 @@ async function processHook() { // Ignore parse errors } - // Output error - outputError(filePath, err.message || 'Unknown error'); + // Categorize error by message pattern + const errMsg = err.message || ''; + + if (errMsg.startsWith('AUTH_ERROR:')) { + const statusCode = parseInt(errMsg.split(':')[1], 10); + outputAuthError(filePath, statusCode); + } else if (errMsg.startsWith('RATE_LIMIT:')) { + const retryAfter = errMsg.split(':')[1]; + outputRateLimitError(filePath, retryAfter ? parseInt(retryAfter, 10) : null); + } else if (errMsg.startsWith('API_ERROR:')) { + const parts = errMsg.split(':'); + const statusCode = parseInt(parts[1], 10); + const body = parts.slice(2).join(':'); + outputApiError(filePath, statusCode, body); + } else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) { + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + outputTimeoutError(filePath, timeout); + } else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) { + outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`); + } else { + outputUnknownError(filePath, errMsg); + } } } diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index e9132802..1db499e1 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -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 Set analysis timeout (seconds)'); + console.log(' --set-model

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 { 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); diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts new file mode 100644 index 00000000..630f1ab9 --- /dev/null +++ b/src/commands/config-image-analysis-command.ts @@ -0,0 +1,203 @@ +/** + * Config Image Analysis Command Handler + * + * Manages image_analysis section of config.yaml via CLI. + * Usage: ccs config image-analysis [options] + */ + +import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui'; +import { + getImageAnalysisConfig, + updateUnifiedConfig, + loadOrCreateUnifiedConfig, +} from '../config/unified-config-loader'; +import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types'; + +interface ImageAnalysisCommandOptions { + enable?: boolean; + disable?: boolean; + timeout?: number; + setModel?: { provider: string; model: string }; + help?: boolean; +} + +function parseArgs(args: string[]): ImageAnalysisCommandOptions { + const options: ImageAnalysisCommandOptions = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--enable') { + options.enable = true; + } else if (arg === '--disable') { + options.disable = true; + } else if (arg === '--timeout' && args[i + 1]) { + const timeout = parseInt(args[++i], 10); + if (isNaN(timeout) || timeout < 10 || timeout > 600) { + console.error(fail('Timeout must be between 10 and 600 seconds')); + process.exit(1); + } + options.timeout = timeout; + } else if (arg === '--set-model' && args[i + 1] && args[i + 2]) { + options.setModel = { + provider: args[++i], + model: args[++i], + }; + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } + } + + return options; +} + +function showHelp(): void { + console.log(''); + console.log(header('ccs config image-analysis')); + console.log(''); + console.log(' Configure image analysis for CLIProxy providers.'); + console.log(' Images/PDFs are analyzed via vision models instead of direct Read.'); + console.log(''); + + console.log(subheader('Usage:')); + console.log(` ${color('ccs config image-analysis', 'command')} [options]`); + console.log(''); + + console.log(subheader('Options:')); + console.log(` ${color('--enable', 'command')} Enable image analysis`); + console.log(` ${color('--disable', 'command')} Disable image analysis`); + console.log(` ${color('--timeout ', 'command')} Set analysis timeout (10-600)`); + console.log(` ${color('--set-model

', 'command')} Set model for provider`); + console.log(` ${color('--help, -h', 'command')} Show this help`); + console.log(''); + + console.log(subheader('Provider Models:')); + console.log(` ${dim('Providers with vision support: agy, gemini, codex, kiro, ghcp, claude')}`); + console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`); + console.log(''); + + console.log(subheader('Examples:')); + console.log( + ` $ ${color('ccs config image-analysis', 'command')} ${dim('# Show status')}` + ); + console.log( + ` $ ${color('ccs config image-analysis --enable', 'command')} ${dim('# Enable feature')}` + ); + console.log( + ` $ ${color('ccs config image-analysis --timeout 120', 'command')} ${dim('# Set 2min timeout')}` + ); + console.log( + ` $ ${color('ccs config image-analysis --set-model agy gemini-2.5-pro', 'command')}` + ); + console.log(''); + + console.log(subheader('How it works:')); + console.log(` 1. When Claude's Read tool targets an image/PDF file`); + console.log(` 2. CCS hook intercepts and sends to CLIProxy vision API`); + console.log(` 3. Vision model analyzes and returns text description`); + console.log(` 4. Claude receives description instead of raw image data`); + console.log(''); + + console.log(subheader('Supported file types:')); + console.log(` ${dim('Images: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff')}`); + console.log(` ${dim('Documents: .pdf')}`); + console.log(''); +} + +function showStatus(forceReload = false): void { + // Force reload if config was just modified + const config = forceReload ? getImageAnalysisConfig() : getImageAnalysisConfig(); + + console.log(''); + console.log(header('Image Analysis Configuration')); + console.log(''); + + // Status + const statusText = config.enabled ? ok('Enabled') : warn('Disabled'); + console.log(` Status: ${statusText}`); + console.log(` Timeout: ${config.timeout}s`); + console.log(''); + + // Provider models + console.log(subheader('Provider Models:')); + const providers = Object.entries(config.provider_models); + if (providers.length === 0) { + console.log(` ${dim('No providers configured')}`); + } else { + for (const [provider, model] of providers) { + const isDefault = + DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models[ + provider as keyof typeof DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models + ] === model; + const suffix = isDefault ? dim(' (default)') : ''; + console.log(` ${color(provider.padEnd(10), 'command')} ${model}${suffix}`); + } + } + console.log(''); + + // Config location + console.log(subheader('Configuration:')); + console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`); + console.log(` Section: ${dim('image_analysis')}`); + console.log(''); + + // Troubleshooting hint if disabled + if (!config.enabled) { + console.log(info('To enable: ccs config image-analysis --enable')); + console.log(''); + } +} + +export async function handleConfigImageAnalysisCommand(args: string[]): Promise { + await initUI(); + + const options = parseArgs(args); + + if (options.help) { + showHelp(); + return; + } + + // Apply changes if any options provided + let hasChanges = false; + const config = loadOrCreateUnifiedConfig(); + const imageConfig = config.image_analysis ?? { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }; + + if (options.enable) { + imageConfig.enabled = true; + hasChanges = true; + } + + if (options.disable) { + imageConfig.enabled = false; + hasChanges = true; + } + + if (options.timeout !== undefined) { + imageConfig.timeout = options.timeout; + hasChanges = true; + } + + if (options.setModel) { + const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + if (!validProviders.includes(options.setModel.provider)) { + console.error(fail(`Invalid provider: ${options.setModel.provider}`)); + console.error(info(`Valid providers: ${validProviders.join(', ')}`)); + process.exit(1); + } + imageConfig.provider_models = { + ...imageConfig.provider_models, + [options.setModel.provider]: options.setModel.model, + }; + hasChanges = true; + } + + if (hasChanges) { + updateUnifiedConfig({ image_analysis: imageConfig }); + console.log(ok('Configuration updated')); + console.log(''); + } + + // Always show current status (reload if we made changes) + showStatus(hasChanges); +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index ae632fb9..6152ce76 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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 ', '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

', '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'], diff --git a/src/commands/index.ts b/src/commands/index.ts index 007664e5..39e1651b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -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'; diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 86b3cdc0..b6f741ec 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -529,6 +529,30 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Image analysis section + if (config.image_analysis) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Image Analysis: Vision-based analysis for images and PDFs'); + lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.'); + lines.push('#'); + lines.push('# When enabled: Image files trigger vision analysis instead of raw file read'); + lines.push('# Provider models: Vision model used for each CLIProxy provider'); + lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)'); + lines.push('#'); + lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf'); + lines.push('# Configure via: ccs config image-analysis'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump( + { image_analysis: config.image_analysis }, + { indent: 2, lineWidth: -1, quotingType: '"' } + ) + .trim() + ); + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts new file mode 100644 index 00000000..fd0d0f3b --- /dev/null +++ b/src/management/checks/image-analysis-check.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/src/management/checks/index.ts b/src/management/checks/index.ts index 1ea17e5f..680c8f0c 100644 --- a/src/management/checks/index.ts +++ b/src/management/checks/index.ts @@ -49,3 +49,6 @@ export { // OAuth checks export { OAuthPortsChecker, runOAuthChecks } from './oauth-check'; + +// Image Analysis checks +export { runImageAnalysisCheck, fixImageAnalysisConfig } from './image-analysis-check'; diff --git a/src/management/doctor.ts b/src/management/doctor.ts index 1ec0abd3..dcd9c0b8 100644 --- a/src/management/doctor.ts +++ b/src/management/doctor.ts @@ -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; } diff --git a/tests/unit/commands/config-image-analysis-command.test.ts b/tests/unit/commands/config-image-analysis-command.test.ts new file mode 100644 index 00000000..b31af9f2 --- /dev/null +++ b/tests/unit/commands/config-image-analysis-command.test.ts @@ -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: {}'); + }); + }); +}); From cb8de2c8e8da2ac5e97b8e1893fc4586d0bf5c8c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 22:32:49 -0500 Subject: [PATCH 17/34] fix(hooks): improve error handling and edge cases for image analysis - Add empty model string validation in config command - Add --enable/--disable conflict detection - Truncate long model names in display (>40 chars) - Add EACCES/EPERM file permission error handler - Add filesystem error classification (ENOSPC, EROFS) - Log config upgrade save failures instead of silent catch --- lib/hooks/image-analyzer-transformer.cjs | 23 ++- src/commands/config-image-analysis-command.ts | 18 ++- src/config/unified-config-loader.ts | 151 +++++++++++++++--- 3 files changed, 164 insertions(+), 28 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index 793a08e9..d8bb71e1 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -517,6 +517,25 @@ function outputApiError(filePath, statusCode, responseBody) { 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) */ @@ -685,7 +704,7 @@ async function processHook() { debugLog('File encoded', { mediaType: mediaType, - base64Length: `${(base64Data.length / 1024).toFixed(1)} KB`, + base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`, }); // Analyze via CLIProxy @@ -730,6 +749,8 @@ async function processHook() { 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); } diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index 630f1ab9..401915fe 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -130,7 +130,9 @@ function showStatus(forceReload = false): void { provider as keyof typeof DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models ] === model; const suffix = isDefault ? dim(' (default)') : ''; - console.log(` ${color(provider.padEnd(10), 'command')} ${model}${suffix}`); + // 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(''); @@ -158,6 +160,12 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< 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(); @@ -185,9 +193,15 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< 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]: options.setModel.model, + [options.setModel.provider]: model, }; hasChanges = true; } diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index b6f741ec..1bb0e3c2 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -30,6 +30,8 @@ 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 @@ -45,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 */ @@ -105,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 } } @@ -559,39 +627,72 @@ function generateYamlWithComments(config: UnifiedConfig): string { /** * 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; + } + // Wait before retry + const start = Date.now(); + while (Date.now() - start < retryDelayMs) { + // Busy wait + } } - // 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(); } } From 4d87a649de3873786926dad0f598d4f481b1b563 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 22:33:31 -0500 Subject: [PATCH 18/34] feat(ui): improve settings page UX and responsiveness - Add mobile-responsive layout for settings panels - Add empty state UI for websearch providers - Improve connection indicator with WebSocket status --- .../shared/connection-indicator.tsx | 22 ++++++++++++++----- ui/src/hooks/use-websocket.ts | 11 +++++++++- ui/src/pages/settings/index.tsx | 20 ++++++++++++++++- .../settings/sections/websearch/index.tsx | 17 +++++++++++++- 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/ui/src/components/shared/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx index eb00487d..84932a53 100644 --- a/ui/src/components/shared/connection-indicator.tsx +++ b/ui/src/components/shared/connection-indicator.tsx @@ -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' }, + 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 (

- + {config.label}
); diff --git a/ui/src/hooks/use-websocket.ts b/ui/src/hooks/use-websocket.ts index 198b10ca..81164214 100644 --- a/ui/src/hooks/use-websocket.ts +++ b/ui/src/hooks/use-websocket.ts @@ -18,6 +18,7 @@ type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'; export function useWebSocket() { const [status, setStatus] = useState('disconnected'); + const [isReconnecting, setIsReconnecting] = useState(false); const wsRef = useRef(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] + ); } diff --git a/ui/src/pages/settings/index.tsx b/ui/src/pages/settings/index.tsx index b7d447af..1571fdde 100644 --- a/ui/src/pages/settings/index.tsx +++ b/ui/src/pages/settings/index.tsx @@ -115,7 +115,25 @@ function SettingsPageInner() { return (
- + {/* Mobile View - Stacked vertically */} +
+
+ +
+ + }> + {activeTab === 'websearch' && } + {activeTab === 'globalenv' && } + {activeTab === 'thinking' && } + {activeTab === 'proxy' && } + {activeTab === 'auth' && } + {activeTab === 'backups' && } + + +
+ + {/* Desktop View - Side-by-side panels */} + {/* Left Panel - Settings Controls */}
diff --git a/ui/src/pages/settings/sections/websearch/index.tsx b/ui/src/pages/settings/sections/websearch/index.tsx index 5891f6d2..7b061fd8 100644 --- a/ui/src/pages/settings/sections/websearch/index.tsx +++ b/ui/src/pages/settings/sections/websearch/index.tsx @@ -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() {

Providers

+ {/* Empty state when no providers available */} + {!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && ( +
+ +

No providers configured

+

+ Install CLI tools to enable web search providers +

+ +
+ )} + Date: Tue, 3 Feb 2026 22:34:15 -0500 Subject: [PATCH 19/34] fix(cli): improve network handling and shell escaping - Add network connectivity check before CLIProxy operations - Enhance PowerShell argument escaping for special characters --- src/cliproxy/cliproxy-executor.ts | 23 +++++++++++++++++++++++ src/utils/shell-executor.ts | 26 +++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 626e4519..808a1814 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -286,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; } } diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index ffea9d3c..d60e3574 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -11,9 +11,33 @@ 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, '""') + '"'; + } } /** From 57f7a70d67cb7bc854fa3aa4c0a932134dc1278c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 23:54:29 -0500 Subject: [PATCH 20/34] fix(ui): add missing animate property to connection indicator - Add animate: false to connected state config to fix TypeScript error - Integrate fixImageAnalysisConfig() into auto-repair for Fix 5 --- src/management/repair/auto-repair.ts | 15 +++++++++++++++ ui/src/components/shared/connection-indicator.tsx | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/management/repair/auto-repair.ts b/src/management/repair/auto-repair.ts index 882bb3b1..b769bbc7 100644 --- a/src/management/repair/auto-repair.ts +++ b/src/management/repair/auto-repair.ts @@ -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 { 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) { diff --git a/ui/src/components/shared/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx index 84932a53..3ce36825 100644 --- a/ui/src/components/shared/connection-indicator.tsx +++ b/ui/src/components/shared/connection-indicator.tsx @@ -11,7 +11,7 @@ export function ConnectionIndicator() { const { status, isReconnecting } = useWebSocket(); const statusConfig = { - connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' }, + connected: { icon: Wifi, color: 'text-green-600', label: 'Connected', animate: false }, connecting: { icon: RefreshCw, color: 'text-yellow-500', From ec4e1ae31c882e8422e8defca6c10a9c79addc5d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 3 Feb 2026 23:57:55 -0500 Subject: [PATCH 21/34] perf(config): replace busy-wait with Atomics.wait in lock retry Use Atomics.wait for synchronous sleep instead of CPU-intensive busy-wait loop. This properly sleeps the thread without wasting cycles. Addresses code review feedback on PR #441. --- src/config/unified-config-loader.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 1bb0e3c2..a8e336de 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -642,11 +642,10 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { lockAcquired = true; break; } - // Wait before retry - const start = Date.now(); - while (Date.now() - start < retryDelayMs) { - // Busy wait - } + // 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); } if (!lockAcquired) { From 485ed4eb4e087d7b6ed651947a22b3581108c882 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 05:02:33 +0000 Subject: [PATCH 22/34] chore(release): 7.34.1-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8c0a5d61..2920ed16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.1-dev.4", + "version": "7.34.1-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 3252228e5c230d291fc705fb5f1f4b3f58cb2d99 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 00:11:53 -0500 Subject: [PATCH 23/34] feat(hooks): extend image analyzer to all CLIProxy providers - Derive supported profiles from provider_models config dynamically - Add qwen and iflow to default provider_models - Full parity with WebSearch hook injection pattern Providers now covered: agy, gemini, codex, kiro, ghcp, claude, qwen, iflow --- src/config/unified-config-types.ts | 2 ++ .../hooks/image-analyzer-profile-hook-injector.ts | 11 ++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index c654d9ec..15a65998 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -545,6 +545,8 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', claude: 'claude-haiku-4-5-20251001', + qwen: 'vision-model', + iflow: 'qwen3-vl-plus', }, }; diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index 4d4e4f12..cd7a4db4 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -94,14 +94,15 @@ export function ensureProfileHooks(profileName: string): boolean { return false; } - // Only inject for CLIProxy profiles with vision support - const visionProfiles = ['agy', 'gemini']; - if (!visionProfiles.includes(profileName)) { + const imageConfig = getImageAnalysisConfig(); + + // Only inject for profiles that have a model mapping in provider_models + // This allows dynamic extension without hardcoding profile names + const configuredProviders = Object.keys(imageConfig.provider_models); + if (!configuredProviders.includes(profileName)) { return false; } - const imageConfig = getImageAnalysisConfig(); - // Skip if image analysis is disabled if (!imageConfig.enabled) { return false; From ae3eb282b4a6a0754f90be27e259af45d0d09d9b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 00:16:51 -0500 Subject: [PATCH 24/34] feat(hooks): add ANTHROPIC_MODEL fallback for image analysis Model resolution priority: 1. provider_models[current_provider] 2. ANTHROPIC_MODEL from profile settings 3. DEFAULT_MODEL (gemini-2.5-flash) Allows users to override vision model via profile's ANTHROPIC_MODEL. --- lib/hooks/image-analyzer-transformer.cjs | 92 ++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 5 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index d8bb71e1..1369b690 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -12,6 +12,7 @@ * 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) + * ANTHROPIC_MODEL - Fallback model if provider not in mapping * CCS_DEBUG=1 - Enable debug output * * Exit codes: @@ -107,14 +108,14 @@ function getDebugContext(filePath, stats) { 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]; + const modelsToTry = getModelsToTry(); return { file: path.basename(filePath), size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown', provider: currentProvider, model: model, - config: isDefaultModel ? 'default' : 'user-configured', + modelsToTry: modelsToTry.length > 1 ? modelsToTry.join(' -> ') : model, timeout: `${timeout}s`, endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`, }; @@ -148,6 +149,7 @@ function parseProviderModels(envValue) { /** * Get model for current provider from provider_models mapping + * Returns primary model only (for display/logging) */ function getModelForProvider() { const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; @@ -155,6 +157,85 @@ function getModelForProvider() { return providerModels[currentProvider] || DEFAULT_MODEL; } +/** + * Get list of models to try in order: + * 1. provider_models[current_provider] (if exists) + * 2. DEFAULT_MODEL + * 3. ANTHROPIC_MODEL from profile (if different and exists) + */ +function getModelsToTry() { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const anthropicModel = process.env.ANTHROPIC_MODEL; + + const models = []; + const seen = new Set(); + + // 1. Provider-specific model + if (providerModels[currentProvider]) { + models.push(providerModels[currentProvider]); + seen.add(providerModels[currentProvider]); + } + + // 2. Default model + if (!seen.has(DEFAULT_MODEL)) { + models.push(DEFAULT_MODEL); + seen.add(DEFAULT_MODEL); + } + + // 3. ANTHROPIC_MODEL fallback + if (anthropicModel && !seen.has(anthropicModel)) { + models.push(anthropicModel); + } + + return models; +} + +/** + * Analyze with retry logic - tries models in order until one succeeds + */ +async function analyzeWithRetry(base64Data, mediaType, timeoutMs) { + const models = getModelsToTry(); + let lastError = null; + + for (let i = 0; i < models.length; i++) { + const model = models[i]; + try { + debugLog(`Trying model ${i + 1}/${models.length}`, { model }); + const result = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + if (i > 0) { + debugLog('Retry succeeded', { model, attempt: i + 1 }); + } + return { description: result, model }; + } catch (err) { + lastError = err; + const isLastModel = i === models.length - 1; + + // Don't retry on certain errors (auth, rate limit, timeout, file access) + const errMsg = err.message || ''; + const noRetryPatterns = ['AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', 'EACCES', 'EPERM', 'ECONNREFUSED']; + const shouldNotRetry = noRetryPatterns.some(p => errMsg.includes(p)); + + if (shouldNotRetry || isLastModel) { + debugLog('Analysis failed, no more retries', { + model, + error: errMsg, + reason: shouldNotRetry ? 'non-retryable error' : 'last model' + }); + throw err; + } + + debugLog('Model failed, trying next', { + model, + error: errMsg.substring(0, 100), + nextModel: models[i + 1] + }); + } + } + + throw lastError || new Error('No models available'); +} + /** * Check if file is an analyzable image or PDF */ @@ -707,15 +788,16 @@ async function processHook() { base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`, }); - // Analyze via CLIProxy - const description = await analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs); + // Analyze via CLIProxy with retry logic + const { description, model: usedModel } = await analyzeWithRetry(base64Data, mediaType, timeoutMs); debugLog('Analysis complete', { responseLength: `${description.length} chars`, + model: usedModel, }); // Output success - outputSuccess(filePath, description, model, stats.size); + outputSuccess(filePath, description, usedModel, stats.size); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Error:', err.message); From a8ddf8bd565ac82131dc4ca02ecadd3b04a61197 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 00:27:13 -0500 Subject: [PATCH 25/34] feat(hooks): inject image analyzer hooks into all profile types Add image analyzer hook injection alongside WebSearch hooks: - ccs.ts: CLIProxy, Copilot, and API profile flows - variant-settings.ts: CLIProxy variant settings Ensures image analyzer hooks are present in profile settings.json files. --- src/ccs.ts | 7 +++++++ src/cliproxy/services/variant-settings.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/ccs.ts b/src/ccs.ts index 6cf8140c..d1a76204 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -14,6 +14,7 @@ import { } from './utils/websearch-manager'; import { getGlobalEnvConfig } from './config/unified-config-loader'; import { getImageReadBlockHookEnv } from './utils/hooks/image-read-block-hook-env'; +import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { fail, info } from './utils/ui'; // Import centralized error handling @@ -523,6 +524,8 @@ async function main(): Promise { // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); + // Inject Image Analyzer hook into profile settings before launch + ensureImageAnalyzerHooks(profileInfo.name); const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles @@ -535,6 +538,8 @@ async function main(): Promise { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); + // Inject Image Analyzer hook into profile settings before launch + ensureImageAnalyzerHooks(profileInfo.name); const { executeCopilotProfile } = await import('./copilot'); const copilotConfig = profileInfo.copilotConfig; @@ -549,6 +554,8 @@ async function main(): Promise { // WebSearch is server-side tool - third-party providers have no access // Inject WebSearch hook into profile settings before launch ensureProfileHooks(profileInfo.name); + // Inject Image Analyzer hook into profile settings before launch + ensureImageAnalyzerHooks(profileInfo.name); ensureMcpWebSearch(); diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 872ae910..d50c0cf2 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -14,6 +14,7 @@ import { expandPath } from '../../utils/helpers'; import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIProxyProvider } from '../types'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; +import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; /** Environment settings structure */ interface SettingsEnv { @@ -109,6 +110,9 @@ export function createSettingsFile( // Inject WebSearch hooks into variant settings ensureProfileHooks(`${provider}-${name}`); + // Inject Image Analyzer hooks into variant settings + ensureImageAnalyzerHooks(`${provider}-${name}`); + return settingsPath; } @@ -134,6 +138,9 @@ export function createSettingsFileUnified( // Inject WebSearch hooks into variant settings ensureProfileHooks(`${provider}-${name}`); + // Inject Image Analyzer hooks into variant settings + ensureImageAnalyzerHooks(`${provider}-${name}`); + return settingsPath; } From 26f40217703800cb412af74195e350090d39e435 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 00:29:33 -0500 Subject: [PATCH 26/34] feat(hooks): skip image analyzer for Claude Sub accounts - Add CCS_IMAGE_ANALYSIS_SKIP=1 for account and default profile types - Matches WebSearch behavior: native Claude has native vision support --- src/ccs.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ccs.ts b/src/ccs.ts index d1a76204..7c04006d 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -667,18 +667,22 @@ async function main(): Promise { // Execute Claude with instance isolation // Skip WebSearch hook - account profiles use native server-side WebSearch + // Skip Image Analyzer hook - account profiles have native vision support const envVars: NodeJS.ProcessEnv = { CLAUDE_CONFIG_DIR: instancePath, CCS_PROFILE_TYPE: 'account', CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', }; execClaude(claudeCli, remainingArgs, envVars); } else { // DEFAULT: No profile configured, use Claude's own defaults // Skip WebSearch hook - native Claude has server-side WebSearch + // Skip Image Analyzer hook - native Claude has native vision support const envVars: NodeJS.ProcessEnv = { CCS_PROFILE_TYPE: 'default', CCS_WEBSEARCH_SKIP: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', }; execClaude(claudeCli, remainingArgs, envVars); } From 51b719ef3463950983244d708f1b9bca45774976 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 00:49:52 -0500 Subject: [PATCH 27/34] refactor(hooks): deprecate block-image-read, add CLIProxy fallback - Remove redundant block-image-read.cjs hook (image-analyzer handles all) - Add fallback blocking when CLIProxy unavailable (prevents context overflow) - Simplify fallback message to prevent context pollution/hallucination - Remove image-read-block-hook-env.ts and related exports - Update comments per PR #442 review suggestions - Add defensive check for empty models array Closes #426 --- lib/hooks/block-image-read.cjs | 170 ------------------ lib/hooks/image-analyzer-transformer.cjs | 44 ++++- src/ccs.ts | 5 - src/cliproxy/cliproxy-executor.ts | 3 - src/config/unified-config-types.ts | 1 + .../image-analyzer-profile-hook-injector.ts | 2 +- src/utils/hooks/image-read-block-hook-env.ts | 60 ------- src/utils/hooks/index.ts | 1 - src/utils/image-analysis/hook-installer.ts | 105 +---------- src/utils/image-analysis/index.ts | 12 +- src/utils/shell-executor.ts | 6 +- 11 files changed, 47 insertions(+), 362 deletions(-) delete mode 100644 lib/hooks/block-image-read.cjs delete mode 100644 src/utils/hooks/image-read-block-hook-env.ts diff --git a/lib/hooks/block-image-read.cjs b/lib/hooks/block-image-read.cjs deleted file mode 100644 index 78d87e27..00000000 --- a/lib/hooks/block-image-read.cjs +++ /dev/null @@ -1,170 +0,0 @@ -#!/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); -} diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index 1369b690..fd1504ec 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -196,6 +196,10 @@ function getModelsToTry() { */ async function analyzeWithRetry(base64Data, mediaType, timeoutMs) { const models = getModelsToTry(); + // Defensive check - should never happen but provides clear error + if (models.length === 0) { + throw new Error('No models configured for image analysis'); + } let lastError = null; for (let i = 0; i < models.length; i++) { @@ -636,6 +640,38 @@ function outputUnknownError(filePath, error) { process.exit(2); } +/** + * CLIProxy unavailable fallback - blocks Read to prevent context overflow + * When CLIProxy is not running, we cannot analyze the image. + * Blocking prevents the image from loading into Claude's context (100K+ tokens). + */ +function outputCliProxyUnavailableFallback(filePath) { + const fileName = filePath.split(/[/\\]/).pop() || filePath; + + // Keep message minimal to avoid context pollution and hallucination + const message = [ + '[Image Read Blocked]', + '', + `File: ${fileName}`, + '', + 'CLIProxy unavailable. Image blocked to prevent context overflow.', + ].join('\n'); + + const output = { + decision: 'block', + reason: 'CLIProxy unavailable - image blocked to prevent context overflow', + systemMessage: `[Image Blocked] ${fileName} - CLIProxy unavailable. Start: ccs config`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: message, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} + /** * Output success response and exit */ @@ -763,12 +799,12 @@ async function processHook() { // Check CLIProxy availability const cliProxyAvailable = await isCliProxyAvailable(); if (!cliProxyAvailable) { - debugLog('Skipping: CLIProxy not available', { + debugLog('Blocking: CLIProxy not available', { endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`, - action: 'passing through to native Read', + action: 'blocking to prevent context overflow', }); - // Pass through to native Read - process.exit(0); + outputCliProxyUnavailableFallback(filePath); + return; } const model = getModelForProvider(); diff --git a/src/ccs.ts b/src/ccs.ts index 7c04006d..1d447e48 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -13,7 +13,6 @@ import { ensureProfileHooks, } from './utils/websearch-manager'; import { getGlobalEnvConfig } from './config/unified-config-loader'; -import { getImageReadBlockHookEnv } from './utils/hooks/image-read-block-hook-env'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { fail, info } from './utils/ui'; @@ -166,12 +165,10 @@ 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 }; @@ -622,7 +619,6 @@ async function main(): Promise { // 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 : {}; @@ -644,7 +640,6 @@ async function main(): Promise { ...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); diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 808a1814..0d640a8e 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -37,7 +37,6 @@ 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'; @@ -969,13 +968,11 @@ 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 }; diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 15a65998..5a8dcffe 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -545,6 +545,7 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', claude: 'claude-haiku-4-5-20251001', + // 'vision-model' is a generic placeholder - users can override via config.yaml qwen: 'vision-model', iflow: 'qwen3-vl-plus', }, diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index cd7a4db4..94d414da 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -4,7 +4,7 @@ * 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. + * Injects for profiles configured in image_analysis.provider_models. * * @module utils/hooks/image-analyzer-profile-injector */ diff --git a/src/utils/hooks/image-read-block-hook-env.ts b/src/utils/hooks/image-read-block-hook-env.ts deleted file mode 100644 index b2cc91fc..00000000 --- a/src/utils/hooks/image-read-block-hook-env.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 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 { - const config = getImageReadBlockConfig(); - const env: Record = {}; - - if (config.enabled) { - env.CCS_BLOCK_IMAGE_READ = '1'; - } else { - // Explicit disable signal - env.CCS_BLOCK_IMAGE_READ = '0'; - } - - return env; -} diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index d952a096..7e05c9d5 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -6,7 +6,6 @@ * @module utils/hooks */ -export { getImageReadBlockHookEnv, getImageReadBlockConfig } from './image-read-block-hook-env'; export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env'; export { getImageAnalyzerHookPath, diff --git a/src/utils/image-analysis/hook-installer.ts b/src/utils/image-analysis/hook-installer.ts index 99c4f624..3832131b 100644 --- a/src/utils/image-analysis/hook-installer.ts +++ b/src/utils/image-analysis/hook-installer.ts @@ -1,9 +1,7 @@ /** * 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) + * Manages installation of prompt templates for image analysis (user-customizable). * * @module utils/image-analysis/hook-installer */ @@ -13,16 +11,6 @@ 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 */ @@ -37,72 +25,6 @@ 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) @@ -179,28 +101,3 @@ export function installImageAnalysisPrompts(): boolean { 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; - } -} diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts index 1fea2b08..3a322ac9 100644 --- a/src/utils/image-analysis/index.ts +++ b/src/utils/image-analysis/index.ts @@ -1,15 +1,7 @@ /** * Image Analysis Utilities * - * Exports hook installer functions for image blocking and prompt management + * Exports hook installer functions for prompt management */ -export { - getHookPath, - getCcsHooksDir, - getPromptsDir, - hasImageBlockHook, - installImageBlockHook, - installImageAnalysisPrompts, - uninstallImageBlockHook, -} from './hook-installer'; +export { getCcsHooksDir, getPromptsDir, installImageAnalysisPrompts } from './hook-installer'; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index d60e3574..9ee00511 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -7,7 +7,6 @@ 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) @@ -53,12 +52,11 @@ 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, ...imageReadBlockEnv } - : { ...process.env, ...webSearchEnv, ...imageReadBlockEnv }; + ? { ...process.env, ...envVars, ...webSearchEnv } + : { ...process.env, ...webSearchEnv }; let child: ChildProcess; if (needsShell) { From 1201b4bb4b0b207d1c170fc3dcf39e79bbc545bd Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 00:59:10 -0500 Subject: [PATCH 28/34] fix(hooks): add network errors to noRetryPatterns, update E2E test - Add ENOTFOUND, ENETUNREACH, EAI_AGAIN to noRetryPatterns to prevent infinite retries on network errors - Update E2E test to expect exit code 2 (block) when CLIProxy unavailable - Fix debug message expectation in test --- lib/hooks/image-analyzer-transformer.cjs | 8 ++++++-- tests/e2e/image-analyzer-hook.e2e.test.ts | 14 +++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index fd1504ec..09362bb6 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -215,9 +215,13 @@ async function analyzeWithRetry(base64Data, mediaType, timeoutMs) { lastError = err; const isLastModel = i === models.length - 1; - // Don't retry on certain errors (auth, rate limit, timeout, file access) + // Don't retry on certain errors (auth, rate limit, timeout, file access, network) const errMsg = err.message || ''; - const noRetryPatterns = ['AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', 'EACCES', 'EPERM', 'ECONNREFUSED']; + const noRetryPatterns = [ + 'AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', + 'EACCES', 'EPERM', 'ECONNREFUSED', + 'ENOTFOUND', 'ENETUNREACH', 'EAI_AGAIN' // Network errors - no point retrying + ]; const shouldNotRetry = noRetryPatterns.some(p => errMsg.includes(p)); if (shouldNotRetry || isLastModel) { diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index b3da61dd..de00aa62 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -468,7 +468,7 @@ describe('Image Analyzer Hook', () => { resetMockState(); }); - it('should pass through when CLIProxy is unavailable', async () => { + it('should block when CLIProxy is unavailable to prevent context overflow', async () => { // Force hook to use a port that's definitely not running const result = invokeHook( { @@ -483,9 +483,13 @@ describe('Image Analyzer Hook', () => { } ); - // Should pass through (exit 0) when CLIProxy not available - expect(result.code).toBe(0); - expect(result.stderr).toContain('CLIProxy not available'); + // Should block (exit 2) when CLIProxy not available to prevent context overflow + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain( + 'CLIProxy unavailable' + ); }); it('should analyze PNG via mock CLIProxy and return analysis', () => { @@ -634,7 +638,7 @@ describe('Image Analyzer Hook', () => { // Should output debug info to stderr expect(result.stderr).toContain('[CCS Hook]'); - expect(result.stderr).toContain('Analyzing'); + expect(result.stderr).toContain('Starting image analysis'); }); it('should handle API error response gracefully (pass through)', () => { From 039b005b1416be8e6126f8ff86d1cbed7cbd611f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 06:09:58 +0000 Subject: [PATCH 29/34] chore(release): 7.34.1-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2920ed16..7c2b1dfc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.1-dev.5", + "version": "7.34.1-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From b014c4e8725c484d37827ea6f2a2e5df59464ce8 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 08:09:20 -0500 Subject: [PATCH 30/34] refactor(hooks): consolidate getCcsHooksDir to config-manager - remove duplicate definitions from 3 locations - centralize in config-manager.ts for DRY compliance - update all imports across hook modules --- src/utils/config-manager.ts | 8 ++++++++ src/utils/hooks/image-analyzer-hook-configuration.ts | 9 +-------- src/utils/hooks/image-analyzer-hook-installer.ts | 3 ++- src/utils/image-analysis/hook-installer.ts | 11 ++--------- src/utils/image-analysis/index.ts | 2 +- src/utils/websearch/hook-config.ts | 9 +-------- src/utils/websearch/hook-installer.ts | 3 ++- 7 files changed, 17 insertions(+), 28 deletions(-) diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index adb8bf01..655f5b30 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -26,6 +26,14 @@ export function getCcsDir(): string { return path.join(getCcsHome(), '.ccs'); } +/** + * Get CCS hooks directory (respects CCS_HOME for test isolation) + * @returns Path to hooks directory + */ +export function getCcsHooksDir(): string { + return path.join(getCcsDir(), 'hooks'); +} + /** * Get config file path (legacy JSON path) * @deprecated Use getActiveConfigPath() for mode-aware config path diff --git a/src/utils/hooks/image-analyzer-hook-configuration.ts b/src/utils/hooks/image-analyzer-hook-configuration.ts index a11f4239..45de9481 100644 --- a/src/utils/hooks/image-analyzer-hook-configuration.ts +++ b/src/utils/hooks/image-analyzer-hook-configuration.ts @@ -8,18 +8,11 @@ import * as path from 'path'; import { getImageAnalysisConfig } from '../../config/unified-config-loader'; -import { getCcsDir } from '../config-manager'; +import { getCcsHooksDir } 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 */ diff --git a/src/utils/hooks/image-analyzer-hook-installer.ts b/src/utils/hooks/image-analyzer-hook-installer.ts index a4e92ecc..26f32224 100644 --- a/src/utils/hooks/image-analyzer-hook-installer.ts +++ b/src/utils/hooks/image-analyzer-hook-installer.ts @@ -10,7 +10,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../ui'; -import { getImageAnalyzerHookPath, getCcsHooksDir } from './image-analyzer-hook-configuration'; +import { getImageAnalyzerHookPath } from './image-analyzer-hook-configuration'; +import { getCcsHooksDir } from '../config-manager'; import { getImageAnalysisConfig } from '../../config/unified-config-loader'; import { removeMigrationMarker } from './image-analyzer-profile-hook-injector'; diff --git a/src/utils/image-analysis/hook-installer.ts b/src/utils/image-analysis/hook-installer.ts index 3832131b..75838707 100644 --- a/src/utils/image-analysis/hook-installer.ts +++ b/src/utils/image-analysis/hook-installer.ts @@ -9,20 +9,13 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../ui'; -import { getCcsDir } from '../config-manager'; - -/** - * Get CCS hooks directory - */ -export function getCcsHooksDir(): string { - return path.join(getCcsDir(), 'hooks'); -} +import { getCcsHooksDir } from '../config-manager'; /** * Get prompts directory for image analysis */ export function getPromptsDir(): string { - return path.join(getCcsDir(), 'prompts', 'image-analysis'); + return path.join(getCcsHooksDir(), '..', 'prompts', 'image-analysis'); } /** diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts index 3a322ac9..e239ae6d 100644 --- a/src/utils/image-analysis/index.ts +++ b/src/utils/image-analysis/index.ts @@ -4,4 +4,4 @@ * Exports hook installer functions for prompt management */ -export { getCcsHooksDir, getPromptsDir, installImageAnalysisPrompts } from './hook-installer'; +export { getPromptsDir, installImageAnalysisPrompts } from './hook-installer'; diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index ad88b0bb..cc2754ce 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import * as os from 'os'; import { info, warn } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; -import { getCcsDir } from '../config-manager'; +import { getCcsHooksDir } from '../config-manager'; import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; // Hook file name @@ -32,13 +32,6 @@ function getClaudeSettingsPath(): string { return path.join(os.homedir(), '.claude', 'settings.json'); } -/** - * Get CCS hooks directory (respects CCS_HOME for test isolation) - */ -export function getCcsHooksDir(): string { - return path.join(getCcsDir(), 'hooks'); -} - // Buffer time added to max provider timeout for hook timeout (seconds) const HOOK_TIMEOUT_BUFFER = 30; diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index 16985043..49bccb8f 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -10,7 +10,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { info, warn } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; -import { getHookPath, getCcsHooksDir } from './hook-config'; +import { getCcsHooksDir } from '../config-manager'; +import { getHookPath } from './hook-config'; import { removeMigrationMarker } from './profile-hook-injector'; // Re-export from hook-config for backward compatibility From bfb535037ad297b3d838754af74d30b8a88b34f2 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 08:09:35 -0500 Subject: [PATCH 31/34] fix(checks): use configurable CLIProxy port in health check - replace hardcoded 8317 with CLIPROXY_DEFAULT_PORT - reuse isCliproxyRunning() instead of custom HTTP check --- src/management/checks/image-analysis-check.ts | 37 +++---------------- 1 file changed, 5 insertions(+), 32 deletions(-) diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index fd0d0f3b..39a0f7c5 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -5,40 +5,13 @@ * 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 { isCliproxyRunning } from '../../cliproxy/stats-fetcher'; +import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config-generator'; import type { HealthCheck } from './types'; -/** - * Check CLIProxy availability (simple HTTP check) - */ -async function isCliProxyAvailable(): Promise { - 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 */ @@ -93,7 +66,7 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise console.log(` ${ok('Timeout:')} ${config.timeout}s`); // Check 4: CLIProxy availability (only if enabled) - const cliproxyAvailable = await isCliProxyAvailable(); + const cliproxyAvailable = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT); if (!cliproxyAvailable) { results.details['Image Analysis'] = { status: 'WARN', @@ -104,11 +77,11 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise 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(` ${warn('CLIProxy:')} Not running at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`); console.log(` ${dim('Note:')} Start with: ccs config`); return; } - console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:8317`); + console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`); // All checks passed results.details['Image Analysis'] = { From 8dfd9e937599305dcdeab453bb866f16ad582020 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 08:09:49 -0500 Subject: [PATCH 32/34] fix(config): remove unused forceReload parameter in showStatus --- src/commands/config-image-analysis-command.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index 401915fe..bc13f8c1 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -104,9 +104,8 @@ function showHelp(): void { console.log(''); } -function showStatus(forceReload = false): void { - // Force reload if config was just modified - const config = forceReload ? getImageAnalysisConfig() : getImageAnalysisConfig(); +function showStatus(): void { + const config = getImageAnalysisConfig(); console.log(''); console.log(header('Image Analysis Configuration')); @@ -212,6 +211,6 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< console.log(''); } - // Always show current status (reload if we made changes) - showStatus(hasChanges); + // Always show current status + showStatus(); } From 0f6ee680b2e946a40320b144fd73d26bb41fe729 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 13:11:09 +0000 Subject: [PATCH 33/34] chore(release): 7.34.1-dev.7 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c2b1dfc..05e1daa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.1-dev.6", + "version": "7.34.1-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From c6be09b55b9df21be551f4844b4fba3dfd9a6b3f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 4 Feb 2026 08:20:26 -0500 Subject: [PATCH 34/34] docs: update documentation for v7.34 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add image analysis hook architecture - update test metrics (638 → 1407 tests) - add new hook modules to codebase summary - update all dates to 2026-02-04 --- docs/code-standards.md | 2 +- docs/codebase-summary.md | 29 ++++++++++++++++++++--------- docs/dashboard-auth-cli.md | 2 ++ docs/project-overview-pdr.md | 19 ++++++++++++++++--- docs/project-roadmap.md | 8 +++++--- docs/system-architecture.md | 12 ++++++++++-- docs/websearch.md | 2 +- 7 files changed, 55 insertions(+), 19 deletions(-) diff --git a/docs/code-standards.md b/docs/code-standards.md index 64cca0e8..5eea1cac 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -1,6 +1,6 @@ # CCS Code Standards -Last Updated: 2026-01-06 +Last Updated: 2026-02-04 Code standards, modularization patterns, and conventions for the CCS codebase. diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index e5bf2eaa..4ef7fc7b 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,8 +1,8 @@ # CCS Codebase Summary -Last Updated: 2026-01-06 +Last Updated: 2026-02-04 -Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, and v7.14 Hybrid Quota Management. +Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, and v7.34 Image Analysis Hook. ## Repository Structure @@ -45,6 +45,8 @@ src/ │ ├── commands/ # CLI command handlers │ ├── cliproxy-command.ts # CLIProxy subcommand handling +│ ├── config-command.ts # Config management commands +│ ├── config-image-analysis-command.ts # Image analysis hook config (NEW v7.34) │ ├── doctor-command.ts # Health diagnostics │ ├── help-command.ts # Help text generation │ ├── install-command.ts # Install/uninstall logic @@ -116,7 +118,8 @@ src/ ├── management/ # Doctor diagnostics │ ├── index.ts # Barrel export │ ├── checks/ # Diagnostic checks -│ │ └── index.ts +│ │ ├── index.ts +│ │ └── image-analysis-check.ts # Image hook validation (NEW v7.34) │ └── repair/ # Auto-repair logic │ └── index.ts │ @@ -136,6 +139,15 @@ src/ │ │ └── spinners.ts # Progress spinners │ ├── websearch/ # Search tool integrations │ │ └── index.ts +│ ├── hooks/ # Claude Code hooks (NEW v7.34) +│ │ ├── index.ts +│ │ ├── image-analyzer-hook-installer.ts +│ │ ├── image-analyzer-hook-configuration.ts +│ │ ├── image-analyzer-profile-hook-injector.ts +│ │ └── get-image-analysis-hook-env.ts +│ ├── image-analysis/ # Image analysis hook utilities (NEW v7.34) +│ │ ├── index.ts +│ │ └── hook-installer.ts │ └── [utility files...] │ └── web-server/ # Express web server (heavily modularized) @@ -173,6 +185,7 @@ src/ | Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations (7 CLIProxy providers: gemini, codex, agy, qwen, iflow, kiro, ghcp) | | Quota | `cliproxy/quota-*.ts`, `account-manager.ts` | Hybrid quota management (v7.14) | | Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) | +| Image Analysis | `utils/image-analysis/`, `utils/hooks/` | Vision model proxying (v7.34) | | Services | `web-server/`, `api/` | HTTP server, API services | | Utilities | `utils/`, `management/` | Helpers, diagnostics | @@ -474,14 +487,12 @@ tests/ | Metric | Value | |--------|-------| -| CLI Tests | 539 | -| UI Tests | 99 | -| Total Tests | 638 | -| Passing | 612 | +| Total Tests | 1407 | +| Passing | 1407 | | Skipped | 6 | -| Failed | 0 (CLI), 26 (UI - jsdom setup) | +| Failed | 0 | | Coverage Threshold | 90% | -| Test Files | 38 | +| Test Files | 40+ | --- diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index 0ea982d9..60c442e4 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -1,5 +1,7 @@ # Dashboard Authentication CLI +Last Updated: 2026-02-04 + CLI commands for managing CCS dashboard authentication. ## Overview diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 1de940fa..2fd44000 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -1,6 +1,6 @@ # CCS Product Development Requirements (PDR) -Last Updated: 2026-01-06 +Last Updated: 2026-02-04 ## Product Overview @@ -10,7 +10,7 @@ Last Updated: 2026-01-06 **Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter, Qwen, Kimi, DeepSeek) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances with hybrid quota management. -**Current Version**: v7.14.x (Hybrid Quota Management + Pause/Resume) +**Current Version**: v7.34.x (Image Analysis Hook + Performance Improvements) --- @@ -192,7 +192,7 @@ CCS provides: | Startup time | < 100ms | Achieved | | Dashboard load | < 2s | Achieved | | Error rate | < 1% | Achieved | -| Test coverage | > 90% | 90% (539 CLI + 99 UI tests) | +| Test coverage | > 90% | 90% (1407 tests, 6 skipped) | | File size compliance | 100% < 200 lines | 95% | --- @@ -248,6 +248,19 @@ CCS provides: - [x] Pre-installed AI CLI tools (claude, gemini, grok, opencode) - [x] Entrypoint with privilege dropping +### v7.34 Release (Complete) +- [x] Image Analysis Hook for vision model proxying +- [x] Auto-injection for agy, gemini, codex, cliproxy profiles +- [x] Skip hook for Claude Sub accounts (native vision) +- [x] CLIProxy fallback with deprecated block-image-read +- [x] `ccs config image-analysis` CLI command +- [x] Doctor integration for hook validation +- [x] 791-line E2E test suite for image analysis +- [x] Performance: Replace busy-wait with Atomics.wait in config lock +- [x] Network error handling with noRetryPatterns +- [x] Quota 429 rate limit handling improvements +- [x] WebSocket maxPayload limit (DoS prevention) + ### v8.0 Release (Planned - Q1 2026) - [ ] Multiple CLIProxyAPI instances (load balancing, failover) - [ ] Native git worktree support diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index e8234cee..2aa857fc 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-01-06 +Last Updated: 2026-02-04 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -20,18 +20,19 @@ All major modularization work is complete. The codebase evolved from monolithic | 6 | Settings Page | `pages/settings/` (1,781->20 files) | | 7 | Analytics Page | `pages/analytics/` (420->8 files) | | 8 | Auth Monitor | `monitoring/auth-monitor/` (465->8 files) | -| 9 | Test Infrastructure | 99 UI tests + 539 CLI tests, 90% coverage | +| 9 | Test Infrastructure | 1407 tests, 90% coverage | | 10 | Remote CLIProxy | `proxy-config-resolver.ts`, `remote-proxy-client.ts` | | 11 | Kiro + ghcp Providers | OAuth support via CLIProxyAPIPlus (v7.2) | | 12 | Hybrid Quota Management | `quota-manager.ts`, `quota-fetcher.ts` (v7.14) | | 13 | Docker Support | `docker/` directory with Dockerfile, Compose, entrypoint | +| 14 | Image Analysis Hook | Vision proxying via CLIProxy transformers (v7.34) | **Metrics Achieved**: - Files >500 lines: 12 -> 5 (-58%) - UI files >200 lines: 28 -> 8 (-71%) - Barrel exports: 5 -> 39 (+680%) - Test coverage: 0% -> 90% -- Total tests: 638 (539 CLI + 99 UI) +- Total tests: 1407 (6 skipped) --- @@ -168,6 +169,7 @@ worktrees: | Kiro + GitHub Copilot OAuth (#157) | COMPLETE | v7.2 | | Hybrid Quota Management | COMPLETE | v7.14 | | Docker Support (PR #345) | COMPLETE | v7.23 | +| Image Analysis Hook | COMPLETE | v7.34 | | Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 | | Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 | | Git Worktree Support | PLANNED | Q2 2026 | diff --git a/docs/system-architecture.md b/docs/system-architecture.md index ac8f2daa..cca04f5b 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -1,6 +1,6 @@ # CCS System Architecture -Last Updated: 2026-01-06 +Last Updated: 2026-02-04 High-level architecture documentation for the CCS (Claude Code Switch) system. @@ -13,7 +13,7 @@ CCS is a CLI wrapper that enables seamless switching between multiple Claude acc 1. **CLI Application** (`src/`) - Node.js TypeScript CLI 2. **Dashboard UI** (`ui/`) - React web application served by Express -CCS v7.14 adds Hybrid Quota Management with pause/resume/status commands and auto-failover. +CCS v7.34 adds Image Analysis Hook for vision model proxying through CLIProxy with automatic injection for all profile types. ``` +===========================================================================+ @@ -325,6 +325,14 @@ CCS v7.14 adds Hybrid Quota Management with pause/resume/status commands and aut | v | Anthropic Format --> Provider Format | + +---> Image Analysis Hook (v7.34) + | | + | v + | Vision Model Proxying (gemini, codex, agy, cliproxy) + | - Auto-injected via claude-hooks + | - Skip for Claude Sub accounts (native vision) + | - Fallback with deprecated block-image-read + | +---> Provider APIs | +---> Google (Gemini) diff --git a/docs/websearch.md b/docs/websearch.md index c7f92b58..06e93141 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,6 +1,6 @@ # WebSearch Configuration Guide -Last Updated: 2026-01-06 +Last Updated: 2026-02-04 CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API.