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/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. diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs new file mode 100755 index 00000000..09362bb6 --- /dev/null +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -0,0 +1,880 @@ +#!/usr/bin/env node +/** + * CCS Image Analyzer Hook - Read Tool Interceptor + * + * Intercepts Claude's Read tool for image/PDF files and analyzes them via CLIProxy. + * Returns detailed text descriptions instead of allowing direct visual access. + * + * Environment Variables (set by CCS): + * CCS_IMAGE_ANALYSIS_SKIP=1 - Skip this hook entirely + * CCS_IMAGE_ANALYSIS_ENABLED=1 - Enable image analysis (default: 1) + * CCS_IMAGE_ANALYSIS_PROVIDER_MODELS - Provider:model mapping (e.g., agy:gemini-2.5-flash,gemini:gemini-2.5-flash) + * CCS_CURRENT_PROVIDER - Current CLIProxy provider (e.g., agy, gemini, codex) + * CCS_IMAGE_ANALYSIS_TIMEOUT=60 - Timeout in seconds (default: 60) + * CCS_PROFILE_TYPE - Profile type (account/default skip) + * ANTHROPIC_MODEL - Fallback model if provider not in mapping + * CCS_DEBUG=1 - Enable debug output + * + * Exit codes: + * 0 - Allow tool (pass-through to native Read) + * 2 - Block tool (deny with analysis/message) + * + * @module hooks/image-analyzer-transformer + */ + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); + +// ============================================================================ +// PLATFORM DETECTION +// ============================================================================ + +const isWindows = process.platform === 'win32'; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.bmp', '.tiff']; +const PDF_EXTENSIONS = ['.pdf']; + +const DEFAULT_MODEL = 'gemini-2.5-flash'; +const DEFAULT_TIMEOUT_SEC = 60; +const MAX_FILE_SIZE_MB = 10; +const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; + +const CLIPROXY_HOST = '127.0.0.1'; +const CLIPROXY_PORT = parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10); +const CLIPROXY_PATH = '/v1/messages'; +// API key passed via env from cliproxy-executor, defaults to CCS internal key +const CLIPROXY_API_KEY = process.env.CCS_CLIPROXY_API_KEY || 'ccs-internal-managed'; + +// ============================================================================ +// ERROR CODES (for categorization) +// ============================================================================ + +const ERROR_CODES = { + FILE_TOO_LARGE: 'FILE_TOO_LARGE', + CLIPROXY_UNAVAILABLE: 'CLIPROXY_UNAVAILABLE', + AUTH_FAILED: 'AUTH_FAILED', + TIMEOUT: 'TIMEOUT', + RATE_LIMIT: 'RATE_LIMIT', + API_ERROR: 'API_ERROR', + PARSE_ERROR: 'PARSE_ERROR', + UNKNOWN: 'UNKNOWN', +}; + +// Default analysis prompt +const DEFAULT_PROMPT = `Analyze this image/document thoroughly and provide a detailed description. + +Include: +1. Overall content and purpose +2. Text content (if any) - transcribe important text +3. Visual elements (diagrams, charts, UI components) +4. Layout and structure +5. Colors, styling, notable design elements +6. Any actionable information (buttons, links, code) + +Be comprehensive - this description replaces direct visual access.`; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Output debug information to stderr + * Only outputs when CCS_DEBUG=1 + */ +function debugLog(message, data = {}) { + if (!process.env.CCS_DEBUG) return; + + const lines = [`[CCS Hook] ${message}`]; + + for (const [key, value] of Object.entries(data)) { + if (value !== undefined && value !== null) { + lines.push(` ${key}: ${value}`); + } + } + + console.error(lines.join('\n')); +} + +/** + * Get detailed debug context + */ +function getDebugContext(filePath, stats) { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || 'unknown'; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const model = providerModels[currentProvider] || DEFAULT_MODEL; + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + const modelsToTry = getModelsToTry(); + + return { + file: path.basename(filePath), + size: stats ? `${(stats.size / 1024).toFixed(1)} KB` : 'unknown', + provider: currentProvider, + model: model, + modelsToTry: modelsToTry.length > 1 ? modelsToTry.join(' -> ') : model, + timeout: `${timeout}s`, + endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}${CLIPROXY_PATH}`, + }; +} + +/** + * Get current provider/model context for error messages + */ +function getProviderContext() { + const provider = process.env.CCS_CURRENT_PROVIDER || 'unknown'; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const model = providerModels[provider] || DEFAULT_MODEL; + return { provider, model }; +} + +/** + * Parse provider_models env var to object + * Format: provider:model,provider:model + */ +function parseProviderModels(envValue) { + if (!envValue) return {}; + const result = {}; + envValue.split(',').forEach((pair) => { + const [provider, model] = pair.split(':'); + if (provider && model && model.trim()) { + result[provider.trim()] = model.trim(); + } + }); + return result; +} + +/** + * Get model for current provider from provider_models mapping + * Returns primary model only (for display/logging) + */ +function getModelForProvider() { + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + 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(); + // 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++) { + 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, network) + const errMsg = err.message || ''; + 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) { + 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 + */ +function isAnalyzableFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext); +} + +/** + * Get MIME type from file extension + */ +function getMediaType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.bmp': 'image/bmp', + '.tiff': 'image/tiff', + '.pdf': 'application/pdf', + }; + return mimeTypes[ext] || 'application/octet-stream'; +} + +/** + * Encode file to base64 + */ +function encodeFileToBase64(filePath) { + const content = fs.readFileSync(filePath); + return content.toString('base64'); +} + +/** + * Check if CLIProxy is available + */ +function isCliProxyAvailable() { + return new Promise((resolve) => { + const req = http.request( + { + hostname: CLIPROXY_HOST, + port: CLIPROXY_PORT, + path: '/', + method: 'GET', + timeout: 2000, + }, + (res) => { + resolve(res.statusCode >= 200 && res.statusCode < 500); + } + ); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} + +/** + * Analyze file via CLIProxy vision API + */ +function analyzeViaCliProxy(base64Data, mediaType, model, timeoutMs) { + return new Promise((resolve, reject) => { + const requestBody = JSON.stringify({ + model: model, + max_tokens: 4096, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: DEFAULT_PROMPT }, + { + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: base64Data, + }, + }, + ], + }, + ], + }); + + const req = http.request( + { + hostname: CLIPROXY_HOST, + port: CLIPROXY_PORT, + path: CLIPROXY_PATH, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody), + 'x-api-key': CLIPROXY_API_KEY, + }, + timeout: timeoutMs, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('error', (err) => { + reject(err); + }); + + res.on('end', () => { + // Categorize by status code + if (res.statusCode === 401 || res.statusCode === 403) { + reject(new Error(`AUTH_ERROR:${res.statusCode}`)); + return; + } + + if (res.statusCode === 429) { + const retryAfter = res.headers['retry-after']; + reject(new Error(`RATE_LIMIT:${retryAfter || ''}`)); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`API_ERROR:${res.statusCode}:${data}`)); + return; + } + + if (!data || !data.trim()) { + reject(new Error('Empty response from CLIProxy')); + return; + } + + try { + const response = JSON.parse(data); + const text = response.content?.[0]?.text; + + if (!text) { + reject(new Error('No text content in response')); + return; + } + + resolve(text); + } catch (err) { + reject(new Error(`Failed to parse response: ${err.message}`)); + } + }); + } + ); + + req.on('error', (err) => reject(err)); + req.on('timeout', () => { + req.destroy(); + reject(new Error('TIMEOUT')); + }); + + req.write(requestBody); + req.end(); + }); +} + +/** + * Format analysis description for Claude (matches websearch format) + */ +function formatDescription(filePath, description, model, fileSize) { + const sizeKB = fileSize ? (fileSize / 1024).toFixed(1) : '?'; + return [ + `[Image Analysis via CLIProxy]`, + '', + `File: ${path.basename(filePath)} (${sizeKB} KB)`, + `Model: ${model}`, + '', + '---', + '', + description, + '', + '---', + '*Use this description to understand the image content.*', + ].join('\n'); +} + +// ============================================================================ +// SPECIALIZED ERROR HANDLERS +// ============================================================================ + +/** + * Format error output for Claude hook + */ +function formatErrorOutput(filePath, errorCode, message, troubleshooting) { + const { provider, model } = getProviderContext(); + + const lines = [ + `[Image Analysis - Error]`, + '', + `File: ${path.basename(filePath)}`, + `Provider: ${provider} | Model: ${model}`, + '', + `Error: ${message}`, + ]; + + if (troubleshooting && troubleshooting.length > 0) { + lines.push(''); + lines.push('Troubleshooting:'); + troubleshooting.forEach((step, i) => { + lines.push(` ${i + 1}. ${step}`); + }); + } + + lines.push(''); + lines.push('For help: ccs config image-analysis --help'); + + return { + decision: 'block', + reason: `Image analysis failed: ${errorCode}`, + systemMessage: `[Image Analysis] Failed: ${message}`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: lines.join('\n'), + }, + }; +} + +/** + * File too large error + */ +function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.FILE_TOO_LARGE, + `File too large (${actualSizeMB.toFixed(2)}MB > ${maxSizeMB}MB limit)`, + [ + 'Reduce image resolution or use compression', + 'For screenshots: use PNG optimizer (pngquant, optipng)', + 'For photos: resize to max 2048px width', + `Current limit: ${maxSizeMB}MB per file`, + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * CLIProxy unavailable error + */ +function outputCliProxyUnavailableError(filePath, endpoint) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.CLIPROXY_UNAVAILABLE, + `CLIProxy not available at ${endpoint}`, + [ + 'CLIProxy service may not be running', + 'Start with: ccs config (opens dashboard, starts CLIProxy)', + 'Or manually: ccs cliproxy start', + `Verify: curl ${endpoint}`, + 'Check status: ccs doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Authentication error + */ +function outputAuthError(filePath, statusCode) { + const { provider } = getProviderContext(); + const output = formatErrorOutput( + filePath, + ERROR_CODES.AUTH_FAILED, + `Authentication failed (HTTP ${statusCode})`, + [ + `Re-authenticate: ccs ${provider} --auth`, + `Check accounts: ccs ${provider} --accounts`, + 'Verify OAuth token is valid', + 'Check: ccs doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Timeout error + */ +function outputTimeoutError(filePath, timeoutSec) { + const { model } = getProviderContext(); + const output = formatErrorOutput( + filePath, + ERROR_CODES.TIMEOUT, + `Request timed out after ${timeoutSec}s`, + [ + 'Large files or complex images take longer', + `Increase timeout: ccs config image-analysis --timeout ${timeoutSec * 2}`, + 'Or via env: CCS_IMAGE_ANALYSIS_TIMEOUT=120', + `Current model (${model}) may be slow - try a faster variant`, + 'Check CLIProxy health: curl http://127.0.0.1:8317', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Rate limit error + */ +function outputRateLimitError(filePath, retryAfterSec) { + const { provider } = getProviderContext(); + const retryHint = retryAfterSec ? `Retry after ${retryAfterSec}s` : 'Wait a moment and retry'; + const output = formatErrorOutput( + filePath, + ERROR_CODES.RATE_LIMIT, + 'Rate limit exceeded', + [ + retryHint, + `Provider ${provider} has usage limits`, + 'Consider switching accounts: ccs ' + provider + ' --accounts', + 'Check quota: ccs cliproxy doctor', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Generic API error + */ +function outputApiError(filePath, statusCode, responseBody) { + // Try to extract error message from response + let errorDetail = `HTTP ${statusCode}`; + try { + const parsed = JSON.parse(responseBody); + if (parsed.error?.message) { + errorDetail = parsed.error.message; + } else if (parsed.message) { + errorDetail = parsed.message; + } + } catch { + // Use raw body if not JSON (truncated) + if (responseBody && responseBody.length < 100) { + errorDetail = responseBody; + } + } + + const output = formatErrorOutput( + filePath, + ERROR_CODES.API_ERROR, + `API error: ${errorDetail}`, + [ + 'Check CLIProxy logs: ccs cleanup --show-logs', + 'Verify provider is authenticated: ccs doctor', + 'Try a different provider or model', + 'Report persistent issues: https://github.com/kaitranntt/ccs/issues', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * File permission error + */ +function outputFileAccessError(filePath, error) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.UNKNOWN, + `File access denied: ${error}`, + [ + 'Check file permissions: ls -l ' + filePath, + isWindows ? 'Run terminal as Administrator if needed' : 'Use sudo or adjust file ownership', + 'Verify file is readable by current user', + 'Move file to accessible location', + ] + ); + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Unknown/fallback error (replaces old outputError) + */ +function outputUnknownError(filePath, error) { + const output = formatErrorOutput( + filePath, + ERROR_CODES.UNKNOWN, + error || 'Unknown error occurred', + [ + 'Check CLIProxy is running: curl http://127.0.0.1:8317', + 'Verify authentication: ccs doctor', + 'Check file is valid image/PDF', + 'Enable debug: CCS_DEBUG=1 ccs ', + ] + ); + console.log(JSON.stringify(output)); + 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 + */ +function outputSuccess(filePath, description, model, fileSize) { + debugLog('Returning analysis result', { + file: path.basename(filePath), + model: model, + descriptionLength: `${description.length} chars`, + }); + + const formattedDescription = formatDescription(filePath, description, model, fileSize); + + const output = { + decision: 'block', + reason: `Image analyzed: ${path.basename(filePath)}`, + systemMessage: `[Image Analysis] ${path.basename(filePath)} analyzed via CLIProxy (${model})`, + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: formattedDescription, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); +} + +/** + * Determine if hook should skip, with debug logging + */ +function shouldSkipHook() { + // Explicit skip signal + if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') { + debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP=1'); + return true; + } + + // Explicit disable + if (process.env.CCS_IMAGE_ANALYSIS_ENABLED === '0') { + debugLog('Skipping: image analysis disabled (CCS_IMAGE_ANALYSIS_ENABLED=0)'); + return true; + } + + // Account/default profiles - use native Read + const profileType = process.env.CCS_PROFILE_TYPE; + if (profileType === 'account' || profileType === 'default') { + debugLog(`Skipping: profile type "${profileType}" uses native Read`); + return true; + } + + // Check if current provider has a vision model configured + const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + + if (!providerModels[currentProvider]) { + debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, { + configured_providers: Object.keys(providerModels).join(', ') || 'none', + }); + return true; + } + + return false; +} + +// ============================================================================ +// MAIN HOOK LOGIC +// ============================================================================ + +// Read input from stdin +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + processHook(); +}); + +// Handle stdin not being available +process.stdin.on('error', () => { + process.exit(0); +}); + +/** + * Main hook processing logic + */ +async function processHook() { + try { + // Skip for native accounts or explicit disable + if (shouldSkipHook()) { + process.exit(0); + } + + const data = JSON.parse(input); + + // Only handle Read tool + if (data.tool_name !== 'Read') { + process.exit(0); + } + + const filePath = data.tool_input?.file_path || ''; + + if (!filePath) { + process.exit(0); + } + + // Check if file exists + if (!fs.existsSync(filePath)) { + // Let native Read handle the error + process.exit(0); + } + + // Check if file is analyzable + if (!isAnalyzableFile(filePath)) { + process.exit(0); + } + + // Check file size + const stats = fs.statSync(filePath); + if (stats.size >= MAX_FILE_SIZE_BYTES) { + outputFileTooLargeError(filePath, stats.size / 1024 / 1024, MAX_FILE_SIZE_MB); + return; + } + + // Check CLIProxy availability + const cliProxyAvailable = await isCliProxyAvailable(); + if (!cliProxyAvailable) { + debugLog('Blocking: CLIProxy not available', { + endpoint: `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`, + action: 'blocking to prevent context overflow', + }); + outputCliProxyUnavailableFallback(filePath); + return; + } + + const model = getModelForProvider(); + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + const timeoutMs = Math.max(1, Math.min(600, timeout)) * 1000; + + // Get debug context before analysis + const debugContext = getDebugContext(filePath, stats); + debugLog('Starting image analysis', debugContext); + + // Encode file to base64 + const base64Data = encodeFileToBase64(filePath); + const mediaType = getMediaType(filePath); + + debugLog('File encoded', { + mediaType: mediaType, + base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`, + }); + + // Analyze via CLIProxy 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, usedModel, stats.size); + } catch (err) { + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Error:', err.message); + } + + // Try to extract file path from parsed input + let filePath = 'unknown file'; + try { + const data = JSON.parse(input); + filePath = data.tool_input?.file_path || 'unknown file'; + } catch { + // Ignore parse errors + } + + // Categorize error by message pattern + const errMsg = err.message || ''; + + if (errMsg.startsWith('AUTH_ERROR:')) { + const statusCode = parseInt(errMsg.split(':')[1], 10); + outputAuthError(filePath, statusCode); + } else if (errMsg.startsWith('RATE_LIMIT:')) { + const retryAfter = errMsg.split(':')[1]; + outputRateLimitError(filePath, retryAfter ? parseInt(retryAfter, 10) : null); + } else if (errMsg.startsWith('API_ERROR:')) { + const parts = errMsg.split(':'); + const statusCode = parseInt(parts[1], 10); + const body = parts.slice(2).join(':'); + outputApiError(filePath, statusCode, body); + } else if (errMsg === 'TIMEOUT' || errMsg.includes('timed out') || errMsg.includes('timeout')) { + const timeout = parseInt(process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || DEFAULT_TIMEOUT_SEC, 10); + outputTimeoutError(filePath, timeout); + } else if (errMsg.includes('ECONNREFUSED') || errMsg.includes('ENOTFOUND')) { + outputCliProxyUnavailableError(filePath, `http://${CLIPROXY_HOST}:${CLIPROXY_PORT}`); + } else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) { + outputFileAccessError(filePath, errMsg); + } else { + outputUnknownError(filePath, errMsg); + } + } +} 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 47d5075e..05e1daa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.34.1", + "version": "7.34.1-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", @@ -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/ccs.ts b/src/ccs.ts index 84a65178..1d447e48 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 { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { fail, info } from './utils/ui'; // Import centralized error handling @@ -520,6 +521,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 @@ -532,6 +535,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; @@ -546,6 +551,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(); @@ -655,18 +662,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); } diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index 0d824da4..0d640a8e 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 { getImageAnalysisHookEnv } from '../utils/hooks/get-image-analysis-hook-env'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from './model-catalog'; import { CodexReasoningProxy } from './codex-reasoning-proxy'; import { ToolSanitizationProxy } from './tool-sanitization-proxy'; @@ -284,6 +285,29 @@ export async function execClaudeWithCLIProxy( spinner.succeed('CLIProxy binary ready'); } catch (error) { spinner.fail('Failed to prepare CLIProxy'); + const err = error as Error; + + // Check if network offline (DNS, connection, or timeout failure) + const networkErrors = [ + 'getaddrinfo', + 'ENOTFOUND', + 'ETIMEDOUT', + 'ECONNREFUSED', + 'ENETUNREACH', + 'EAI_AGAIN', + ]; + const isNetworkError = networkErrors.some((errCode) => err.message.includes(errCode)); + + if (isNetworkError) { + console.error(''); + console.error(fail('No network connection detected')); + console.error(''); + console.error('CLIProxy binary download requires internet access.'); + console.error('Please check your network connection and try again.'); + console.error(''); + process.exit(1); + } + throw error; } } @@ -944,10 +968,12 @@ export async function execClaudeWithCLIProxy( ANTHROPIC_BASE_URL: finalBaseUrl, }; const webSearchEnv = getWebSearchHookEnv(); + const imageAnalysisEnv = getImageAnalysisHookEnv(provider); const env = { ...process.env, ...effectiveEnvVars, ...webSearchEnv, + ...imageAnalysisEnv, CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider }; 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; } 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..bc13f8c1 --- /dev/null +++ b/src/commands/config-image-analysis-command.ts @@ -0,0 +1,216 @@ +/** + * 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(): void { + const config = getImageAnalysisConfig(); + + console.log(''); + console.log(header('Image Analysis Configuration')); + console.log(''); + + // Status + const statusText = config.enabled ? ok('Enabled') : warn('Disabled'); + console.log(` Status: ${statusText}`); + console.log(` Timeout: ${config.timeout}s`); + console.log(''); + + // Provider models + console.log(subheader('Provider Models:')); + const providers = Object.entries(config.provider_models); + if (providers.length === 0) { + console.log(` ${dim('No providers configured')}`); + } else { + for (const [provider, model] of providers) { + const isDefault = + DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models[ + provider as keyof typeof DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models + ] === model; + const suffix = isDefault ? dim(' (default)') : ''; + // Edge case #3: Long model name truncation + const truncatedModel = model.length > 40 ? model.slice(0, 37) + '...' : model; + console.log(` ${color(provider.padEnd(10), 'command')} ${truncatedModel}${suffix}`); + } + } + console.log(''); + + // Config location + console.log(subheader('Configuration:')); + console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`); + console.log(` Section: ${dim('image_analysis')}`); + console.log(''); + + // Troubleshooting hint if disabled + if (!config.enabled) { + console.log(info('To enable: ccs config image-analysis --enable')); + console.log(''); + } +} + +export async function handleConfigImageAnalysisCommand(args: string[]): Promise { + await initUI(); + + const options = parseArgs(args); + + if (options.help) { + showHelp(); + return; + } + + // Validate conflicting flags (Edge case #2: --enable + --disable conflict) + if (options.enable && options.disable) { + console.error(fail('Cannot use --enable and --disable together')); + process.exit(1); + } + + // Apply changes if any options provided + let hasChanges = false; + const config = loadOrCreateUnifiedConfig(); + const imageConfig = config.image_analysis ?? { ...DEFAULT_IMAGE_ANALYSIS_CONFIG }; + + if (options.enable) { + imageConfig.enabled = true; + hasChanges = true; + } + + if (options.disable) { + imageConfig.enabled = false; + hasChanges = true; + } + + if (options.timeout !== undefined) { + imageConfig.timeout = options.timeout; + hasChanges = true; + } + + if (options.setModel) { + const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + if (!validProviders.includes(options.setModel.provider)) { + console.error(fail(`Invalid provider: ${options.setModel.provider}`)); + console.error(info(`Valid providers: ${validProviders.join(', ')}`)); + process.exit(1); + } + // Validate model name (Edge case #1: Empty model string validation) + const model = options.setModel.model; + if (!model || model.trim() === '') { + console.error(fail('Model name cannot be empty')); + process.exit(1); + } + imageConfig.provider_models = { + ...imageConfig.provider_models, + [options.setModel.provider]: model, + }; + hasChanges = true; + } + + if (hasChanges) { + updateUnifiedConfig({ image_analysis: imageConfig }); + console.log(ok('Configuration updated')); + console.log(''); + } + + // Always show current status + showStatus(); +} 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/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 { diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 63a97fd1..a8e336de 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -20,14 +20,18 @@ import { DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, + DEFAULT_IMAGE_ANALYSIS_CONFIG, GlobalEnvConfig, ThinkingConfig, DashboardAuthConfig, + ImageAnalysisConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; const CONFIG_YAML = 'config.yaml'; const CONFIG_JSON = 'config.json'; +const CONFIG_LOCK = 'config.yaml.lock'; +const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds /** * Get path to unified config.yaml @@ -43,6 +47,71 @@ export function getConfigJsonPath(): string { return path.join(getCcsDir(), CONFIG_JSON); } +/** + * Get path to config lockfile + */ +function getLockFilePath(): string { + return path.join(getCcsDir(), CONFIG_LOCK); +} + +/** + * Acquire lockfile for config write operations. + * Returns true if lock acquired, false if already locked by another process. + * Cleans up stale locks (older than LOCK_STALE_MS). + */ + +function acquireLock(): boolean { + const lockPath = getLockFilePath(); + const lockData = `${process.pid}\n${Date.now()}`; + + try { + // Check if lock exists + if (fs.existsSync(lockPath)) { + const content = fs.readFileSync(lockPath, 'utf8'); + const [pidStr, timestampStr] = content.trim().split('\n'); + const timestamp = parseInt(timestampStr, 10); + + // Check if lock is stale + if (Date.now() - timestamp > LOCK_STALE_MS) { + // Stale lock - remove and acquire + fs.unlinkSync(lockPath); + } else { + // Check if process still exists + try { + process.kill(parseInt(pidStr, 10), 0); // Signal 0 checks if process exists + // Process exists - lock is valid + return false; + } catch { + // Process doesn't exist - remove stale lock + fs.unlinkSync(lockPath); + } + } + } + + // Acquire lock + fs.writeFileSync(lockPath, lockData, { mode: 0o600 }); + return true; + } catch { + // Lock acquisition failed + return false; + } +} + +/** + * Release lockfile after config write operation. + */ + +function releaseLock(): void { + const lockPath = getLockFilePath(); + try { + if (fs.existsSync(lockPath)) { + fs.unlinkSync(lockPath); + } + } catch { + // Ignore cleanup errors + } +} + /** * Check if unified config.yaml exists */ @@ -103,8 +172,9 @@ export function loadUnifiedConfig(): UnifiedConfig | null { console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`); } return upgraded; - } catch { - // Ignore save errors during upgrade - config still works + } catch (saveError) { + console.error('[!] Config upgrade failed to save:', (saveError as Error).message); + // Continue using the upgraded version in-memory even if save fails } } @@ -293,6 +363,13 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { partial.dashboard_auth?.session_timeout_hours ?? DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours, }, + // Image analysis config - enabled by default for CLIProxy providers + image_analysis: { + enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, + timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, + provider_models: + partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, + }, }; } @@ -520,45 +597,101 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Image analysis section + if (config.image_analysis) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Image Analysis: Vision-based analysis for images and PDFs'); + lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.'); + lines.push('#'); + lines.push('# When enabled: Image files trigger vision analysis instead of raw file read'); + lines.push('# Provider models: Vision model used for each CLIProxy provider'); + lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)'); + lines.push('#'); + lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf'); + lines.push('# Configure via: ccs config image-analysis'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump( + { image_analysis: config.image_analysis }, + { indent: 2, lineWidth: -1, quotingType: '"' } + ) + .trim() + ); + lines.push(''); + } + return lines.join('\n'); } /** * Save unified config to YAML file. * Uses atomic write (temp file + rename) to prevent corruption. + * Uses lockfile to prevent concurrent writes. */ export function saveUnifiedConfig(config: UnifiedConfig): void { const yamlPath = getConfigYamlPath(); const dir = path.dirname(yamlPath); - // Ensure directory exists - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + // Acquire lock (retry for up to 1 second) + const maxRetries = 10; + const retryDelayMs = 100; + let lockAcquired = false; + for (let i = 0; i < maxRetries; i++) { + if (acquireLock()) { + lockAcquired = true; + break; + } + // Synchronous sleep without CPU-intensive busy-wait + // Uses Atomics.wait which properly sleeps the thread + // Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); } - // Ensure version is set - config.version = UNIFIED_CONFIG_VERSION; - - // Generate YAML with section comments - const yamlContent = generateYamlWithComments(config); - const content = generateYamlHeader() + yamlContent; - - // Atomic write: write to temp file, then rename - const tempPath = `${yamlPath}.tmp.${process.pid}`; + if (!lockAcquired) { + throw new Error('Config file is locked by another process. Wait a moment and try again.'); + } try { - fs.writeFileSync(tempPath, content, { mode: 0o600 }); - fs.renameSync(tempPath, yamlPath); - } catch (err) { - // Clean up temp file on error - if (fs.existsSync(tempPath)) { - try { - fs.unlinkSync(tempPath); - } catch { - // Ignore cleanup errors - } + // Ensure directory exists + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } - throw err; + + // Ensure version is set + config.version = UNIFIED_CONFIG_VERSION; + + // Generate YAML with section comments + const yamlContent = generateYamlWithComments(config); + const content = generateYamlHeader() + yamlContent; + + // Atomic write: write to temp file, then rename + const tempPath = `${yamlPath}.tmp.${process.pid}`; + + try { + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, yamlPath); + } catch (error) { + // Clean up temp file on error + if (fs.existsSync(tempPath)) { + try { + fs.unlinkSync(tempPath); + } catch { + // Ignore cleanup errors + } + } + // Classify filesystem errors + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOSPC') { + throw new Error('Disk full - cannot save config. Free up space and try again.'); + } else if (err.code === 'EROFS' || err.code === 'EACCES') { + throw new Error(`Cannot write config - check file permissions: ${err.message}`); + } + throw error; + } + } finally { + // Always release lock + releaseLock(); } } @@ -721,3 +854,18 @@ export function getDashboardAuthConfig(): DashboardAuthConfig { session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24, }; } + +/** + * Get image_analysis configuration. + * Returns defaults if not configured. + */ +export function getImageAnalysisConfig(): ImageAnalysisConfig { + const config = loadOrCreateUnifiedConfig(); + + return { + enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled, + timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout, + provider_models: + config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index cf7e0f1f..5a8dcffe 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -518,6 +518,39 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = { session_timeout_hours: 24, }; +/** + * Image analysis configuration. + * Routes image/PDF files through CLIProxy for vision analysis. + */ +export interface ImageAnalysisConfig { + /** Enable image analysis via CLIProxy (default: true) */ + enabled: boolean; + /** Timeout in seconds (default: 60) */ + timeout: number; + /** Provider-to-model mapping for vision analysis */ + provider_models: Record; +} + +/** + * Default image analysis configuration. + * Enabled by default for CLIProxy providers with vision support. + */ +export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { + enabled: true, + timeout: 60, + provider_models: { + agy: 'gemini-2.5-flash', + gemini: 'gemini-2.5-flash', + codex: 'gpt-5.1-codex-mini', + kiro: 'kiro-claude-haiku-4-5', + ghcp: 'claude-haiku-4.5', + claude: 'claude-haiku-4-5-20251001', + // 'vision-model' is a generic placeholder - users can override via config.yaml + qwen: 'vision-model', + iflow: 'qwen3-vl-plus', + }, +}; + /** * Main unified configuration structure. * Stored in ~/.ccs/config.yaml @@ -551,6 +584,8 @@ export interface UnifiedConfig { thinking?: ThinkingConfig; /** Dashboard authentication configuration (optional) */ dashboard_auth?: DashboardAuthConfig; + /** Image analysis configuration (vision via CLIProxy) */ + image_analysis?: ImageAnalysisConfig; } /** @@ -644,6 +679,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/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/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); } diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts new file mode 100644 index 00000000..39a0f7c5 --- /dev/null +++ b/src/management/checks/image-analysis-check.ts @@ -0,0 +1,132 @@ +/** + * Image Analysis Config Check + * + * Validates image_analysis configuration in config.yaml. + * Checks: enabled status, provider_models, timeout, CLIProxy availability. + */ + +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'; + +/** + * 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 isCliproxyRunning(CLIPROXY_DEFAULT_PORT); + 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:${CLIPROXY_DEFAULT_PORT}`); + console.log(` ${dim('Note:')} Start with: ccs config`); + return; + } + console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`); + + // 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/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/src/utils/config-manager.ts b/src/utils/config-manager.ts index e1169137..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 @@ -246,3 +254,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/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..0dd9aa14 --- /dev/null +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -0,0 +1,42 @@ +/** + * Image Analysis Hook Environment Variables + * + * Provides environment variables for image analysis hook configuration. + * Hook routes image/PDF files through CLIProxy for vision analysis. + * + * @module utils/hooks/image-analysis-hook-env + */ + +import { getImageAnalysisConfig } from '../../config/unified-config-loader'; + +/** + * Serialize provider_models map to env var format: provider:model,provider:model + */ +function serializeProviderModels(providerModels: Record): string { + return Object.entries(providerModels) + .map(([provider, model]) => `${provider}:${model}`) + .join(','); +} + +/** + * Get image analysis hook environment variables. + * These env vars control the hook's behavior via Claude Code hook system. + * + * @param provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex') + * @returns Environment variables for image analysis hook + */ +export function getImageAnalysisHookEnv(provider?: string): Record { + const config = getImageAnalysisConfig(); + + // Check if current provider has a vision model configured + const hasVisionModel = provider && config.provider_models[provider]; + const skipImageAnalysis = !config.enabled || !hasVisionModel; + + return { + CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0', + CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60), + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models), + CCS_CURRENT_PROVIDER: provider || '', + CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', + }; +} 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..45de9481 --- /dev/null +++ b/src/utils/hooks/image-analyzer-hook-configuration.ts @@ -0,0 +1,48 @@ +/** + * 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 { getCcsHooksDir } from '../config-manager'; + +// Hook file name +const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs'; + +/** + * 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..26f32224 --- /dev/null +++ b/src/utils/hooks/image-analyzer-hook-installer.ts @@ -0,0 +1,138 @@ +/** + * 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 } 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'; + +// 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..94d414da --- /dev/null +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -0,0 +1,254 @@ +/** + * Image Analyzer Profile Hook Injector + * + * Injects image analyzer hooks into per-profile settings files. + * This replaces the global ~/.claude/settings.json approach. + * + * Injects for profiles configured in image_analysis.provider_models. + * + * @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; + } + + 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; + } + + // 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 new file mode 100644 index 00000000..7e05c9d5 --- /dev/null +++ b/src/utils/hooks/index.ts @@ -0,0 +1,17 @@ +/** + * Hooks Utilities Index + * + * Centralized exports for all hook-related utilities. + * + * @module utils/hooks + */ + +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..75838707 --- /dev/null +++ b/src/utils/image-analysis/hook-installer.ts @@ -0,0 +1,96 @@ +/** + * Image Analysis Hook Installer + * + * Manages installation of 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 { getCcsHooksDir } from '../config-manager'; + +/** + * Get prompts directory for image analysis + */ +export function getPromptsDir(): string { + return path.join(getCcsHooksDir(), '..', 'prompts', 'image-analysis'); +} + +/** + * 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; + } +} diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts new file mode 100644 index 00000000..e239ae6d --- /dev/null +++ b/src/utils/image-analysis/index.ts @@ -0,0 +1,7 @@ +/** + * Image Analysis Utilities + * + * Exports hook installer functions for prompt management + */ + +export { getPromptsDir, installImageAnalysisPrompts } from './hook-installer'; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 56d1223b..9ee00511 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -10,9 +10,33 @@ import { getWebSearchHookEnv } from './websearch-manager'; /** * 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, '""') + '"'; + } } /** 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 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); 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..de00aa62 --- /dev/null +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -0,0 +1,791 @@ +/** + * E2E Tests for Image Analyzer Hook + * + * ⚠️ NOT RUN IN NORMAL CI/CD - This is an E2E test file (.e2e.ts) + * + * Run manually with: bun test tests/integration/hooks/image-analyzer-hook.e2e.ts --bail + * + * Tests the image-analyzer-transformer.cjs hook with: + * - Generated test fixtures with predictable content + * - Mock CLIProxy server for reliable, fast tests + * - Direct hook invocation via stdin + * + * Uses a mock HTTP server that returns predictable responses to verify + * the hook correctly formats requests and parses responses. + * + * Use --bail flag to exit on first failure (recommended for long tests). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as http from 'http'; + +// ============================================================================ +// TEST CONFIGURATION +// ============================================================================ + +const HOOK_PATH = path.join(__dirname, '../../lib/hooks/image-analyzer-transformer.cjs'); +const TEST_DIR = '/tmp/ccs-hook-tests'; +const MOCK_PORT = 59876; // Use a unique port for mock server +const CLIPROXY_API_KEY = 'test-api-key-12345'; + +// Default provider models for testing (matches DEFAULT_IMAGE_ANALYSIS_CONFIG) +const DEFAULT_PROVIDER_MODELS = 'agy:gemini-2.5-flash,gemini:gemini-2.5-flash,codex:gpt-5.1-codex-mini,kiro:kiro-claude-haiku-4-5,ghcp:claude-haiku-4.5,claude:claude-haiku-4-5-20251001'; +const DEFAULT_PROVIDER = 'agy'; // Default test provider + +// ============================================================================ +// MOCK SERVER +// ============================================================================ + +interface MockServerRequest { + method: string; + path: string; + headers: Record; + 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), + // Default provider config for tests (can be overridden) + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS, + CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER, + ...env, + }, + timeout: 10000, // 10 second timeout per test + }); + + return { + code: result.status ?? -1, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +/** + * Create a minimal valid PNG file (1x1 red pixel) + */ +function createTestPng(filepath: string): void { + // 1x1 PNG with a red pixel (RGB: 255, 0, 0) + const png = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk (red pixel) + 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, + 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk + 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + fs.writeFileSync(filepath, png); +} + +/** + * Create a minimal valid JPEG file + */ +function createTestJpeg(filepath: string): void { + const jpeg = Buffer.from([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x48, + 0x00, 0x48, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, + 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0xff, 0xc4, 0x00, 0x14, + 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x7f, 0xff, 0xd9, + ]); + fs.writeFileSync(filepath, jpeg); +} + +/** + * Create a test text file + */ +function createTestTextFile(filepath: string, content: string): void { + fs.writeFileSync(filepath, content, 'utf8'); +} + +/** + * Create a large file exceeding 10MB + */ +function createLargeFile(filepath: string, sizeMB: number): void { + const bufferSize = 1024 * 1024; // 1MB + const totalBuffers = sizeMB; + const buffer = Buffer.alloc(bufferSize, 'A'); + const stream = fs.createWriteStream(filepath); + + for (let i = 0; i < totalBuffers; i++) { + stream.write(buffer); + } + stream.end(); +} + +// ============================================================================ +// TEST SUITE +// ============================================================================ + +describe('Image Analyzer Hook', () => { + let testPngPath: string; + let testJpegPath: string; + let testTextPath: string; + + beforeAll(async () => { + // Create test directory + if (!fs.existsSync(TEST_DIR)) { + fs.mkdirSync(TEST_DIR, { recursive: true }); + } + + // Start mock server + await startMockServer(); + console.log(`[Test Setup] Mock CLIProxy started on port ${MOCK_PORT}`); + + // Create test files + testPngPath = path.join(TEST_DIR, 'test-image.png'); + testJpegPath = path.join(TEST_DIR, 'test-image.jpg'); + testTextPath = path.join(TEST_DIR, 'test-file.txt'); + + createTestPng(testPngPath); + createTestJpeg(testJpegPath); + createTestTextFile(testTextPath, 'This is a test file.'); + }); + + afterAll(async () => { + // Stop mock server + await stopMockServer(); + + // Clean up test files + const filesToClean = [testPngPath, testJpegPath, testTextPath]; + for (const f of filesToClean) { + if (f && fs.existsSync(f)) { + try { + fs.unlinkSync(f); + } catch { + // Ignore cleanup errors + } + } + } + if (fs.existsSync(TEST_DIR)) { + try { + fs.rmdirSync(TEST_DIR); + } catch { + // Ignore if not empty + } + } + }); + + // ========================================================================== + // GROUP A: FILE DETECTION AND FILTERING + // ========================================================================== + + describe('File Detection and Filtering', () => { + it('should pass through non-Read tools', () => { + const result = invokeHook({ + tool_name: 'Write', + tool_input: { file_path: testPngPath, content: 'test' }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through Read tool for non-image files (.txt)', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: testTextPath }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through Read tool for .ts files', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/test.ts' }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through Read tool for .md files', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/test.md' }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through Read tool for .json files', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/test.json' }, + }); + + expect(result.code).toBe(0); + }); + + it('should pass through files that do not exist', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/nonexistent-file-12345.png' }, + }); + + // Should pass through to let native Read handle the error + expect(result.code).toBe(0); + }); + }); + + // ========================================================================== + // GROUP B: ENVIRONMENT VARIABLE CONTROLS + // ========================================================================== + + describe('Environment Variable Controls', () => { + it('should skip when CCS_IMAGE_ANALYSIS_SKIP=1', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_SKIP: '1' } + ); + + expect(result.code).toBe(0); + }); + + it('should skip when CCS_IMAGE_ANALYSIS_ENABLED=0', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '0' } + ); + + expect(result.code).toBe(0); + }); + + it('should skip for account profile type', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_PROFILE_TYPE: 'account' } + ); + + expect(result.code).toBe(0); + }); + + it('should skip for default profile type', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_PROFILE_TYPE: 'default' } + ); + + expect(result.code).toBe(0); + }); + }); + + // ========================================================================== + // GROUP C: INPUT VALIDATION + // ========================================================================== + + describe('Input Validation', () => { + it('should handle missing file_path gracefully', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: {}, + }); + + expect(result.code).toBe(0); + }); + + it('should handle empty file_path', () => { + const result = invokeHook({ + tool_name: 'Read', + tool_input: { file_path: '' }, + }); + + expect(result.code).toBe(0); + }); + + it('should handle malformed JSON input', () => { + const hookProcess = spawnSync('node', [HOOK_PATH], { + input: 'not valid json', + encoding: 'utf8', + timeout: 5000, + env: { + ...process.env, + CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY, + CCS_CLIPROXY_PORT: String(MOCK_PORT), + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS, + CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER, + }, + }); + + // Should exit with error (code 2) + expect(hookProcess.status).toBe(2); + }); + }); + + // ========================================================================== + // GROUP D: FILE SIZE LIMITS + // ========================================================================== + + describe('File Size Limits', () => { + it('should reject files larger than 10MB', () => { + // Create 11MB file + const largePath = path.join(TEST_DIR, 'large-test.png'); + createLargeFile(largePath, 11); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: largePath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // Should block with error + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('File too large'); + + // Cleanup + if (fs.existsSync(largePath)) fs.unlinkSync(largePath); + }); + }); + + // ========================================================================== + // GROUP E: MOCK CLIPROXY INTEGRATION (FAST, RELIABLE) + // ========================================================================== + + describe('CLIProxy Integration (Mock Server)', () => { + beforeAll(() => { + resetMockState(); + }); + + it('should block when CLIProxy is unavailable to prevent context overflow', 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 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', () => { + resetMockState(); + mockResponse = { + content: 'This image shows a small red square, likely a single pixel or very minimal graphic.', + statusCode: 200, + }; + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // Should block with analysis (exit 2) + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('red square'); + }); + + it('should analyze JPEG via mock CLIProxy', () => { + resetMockState(); + mockResponse = { + content: 'A minimalist white image, possibly a blank canvas or placeholder.', + statusCode: 200, + }; + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testJpegPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('white image'); + }); + + it('should include API key in request header', () => { + resetMockState(); + + invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // Verify API key was sent + expect(lastRequest).not.toBeNull(); + expect(lastRequest?.headers['x-api-key']).toBe(CLIPROXY_API_KEY); + }); + + it('should send correct request format to CLIProxy', () => { + resetMockState(); + + invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + } + ); + + // Verify request format + expect(lastRequest).not.toBeNull(); + expect(lastRequest?.method).toBe('POST'); + expect(lastRequest?.path).toBe('/v1/messages'); + + const body = lastRequest?.body as { + model: string; + max_tokens: number; + messages: Array<{ + role: string; + content: Array<{ + type: string; + text?: string; + source?: { type: string; media_type: string; data: string }; + }>; + }>; + }; + expect(body.model).toBe('gemini-2.5-flash'); + expect(body.max_tokens).toBe(4096); + expect(body.messages).toHaveLength(1); + expect(body.messages[0].role).toBe('user'); + + // Should have text prompt and image content + const content = body.messages[0].content; + expect(content.some((c) => c.type === 'text')).toBe(true); + expect(content.some((c) => c.type === 'image')).toBe(true); + + // Verify image is base64 encoded + const imageContent = content.find((c) => c.type === 'image'); + expect(imageContent?.source?.type).toBe('base64'); + expect(imageContent?.source?.media_type).toBe('image/png'); + expect(imageContent?.source?.data).toBeDefined(); + }); + + it('should use correct media type for JPEG', () => { + resetMockState(); + + invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testJpegPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + const body = lastRequest?.body as { + messages: Array<{ + content: Array<{ + type: string; + source?: { media_type: string }; + }>; + }>; + }; + const imageContent = body.messages[0].content.find((c) => c.type === 'image'); + expect(imageContent?.source?.media_type).toBe('image/jpeg'); + }); + + it('should respect debug mode and output debug messages', () => { + resetMockState(); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy', CCS_DEBUG: '1' } + ); + + // Should output debug info to stderr + expect(result.stderr).toContain('[CCS Hook]'); + expect(result.stderr).toContain('Starting image analysis'); + }); + + it('should handle API error response gracefully (pass through)', () => { + resetMockState(); + mockResponse = { + content: '', + statusCode: 500, + }; + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // On API error, hook blocks with error message (exit 2) + // This ensures Claude knows the analysis failed rather than silently passing through + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error'); + }); + + it('should use model from provider_models mapping', () => { + resetMockState(); + + invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_CURRENT_PROVIDER: 'codex', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'codex:gpt-5.1-codex-mini,agy:gemini-2.5-flash', + } + ); + + const body = lastRequest?.body as { model: string }; + expect(body.model).toBe('gpt-5.1-codex-mini'); // Model from provider_models + }); + + it('should skip when provider is not in provider_models', () => { + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_PROFILE_TYPE: 'cliproxy', + CCS_CURRENT_PROVIDER: 'unknown-provider', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-flash', + } + ); + + expect(result.code).toBe(0); // Skip - provider not in map + }); + }); + + // ========================================================================== + // GROUP F: OUTPUT FORMAT VALIDATION + // ========================================================================== + + describe('Output Format Validation', () => { + it('should output valid JSON structure on success', () => { + resetMockState(); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + + // Validate structure + expect(output.decision).toBe('block'); + expect(output.reason).toBeDefined(); + expect(output.systemMessage).toBeDefined(); + expect(output.hookSpecificOutput).toBeDefined(); + expect(output.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + expect(output.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(output.hookSpecificOutput.permissionDecisionReason).toBeDefined(); + }); + + it('should include filename in output', () => { + resetMockState(); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + const output = JSON.parse(result.stdout); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('test-image.png'); + }); + + it('should include model name in output', () => { + resetMockState(); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: testPngPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + const output = JSON.parse(result.stdout); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('gemini-2.5-flash'); + }); + + it('should output valid JSON structure on file read error', () => { + // Create and immediately delete file to trigger error + const errorPath = path.join(TEST_DIR, 'error-test.png'); + createTestPng(errorPath); + + // Make file unreadable (simulate permission error) + fs.chmodSync(errorPath, 0o000); + + const result = invokeHook( + { + tool_name: 'Read', + tool_input: { file_path: errorPath }, + }, + { CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' } + ); + + // Restore permissions and cleanup + fs.chmodSync(errorPath, 0o644); + fs.unlinkSync(errorPath); + + // Should output error in JSON format + expect(result.code).toBe(2); + const output = JSON.parse(result.stdout); + expect(output.decision).toBe('block'); + expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error'); + }); + }); +}); 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: {}'); + }); + }); +}); 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); }); diff --git a/ui/src/components/shared/connection-indicator.tsx b/ui/src/components/shared/connection-indicator.tsx index eb00487d..3ce36825 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' }, + connected: { icon: Wifi, color: 'text-green-600', label: 'Connected', animate: false }, + connecting: { + icon: RefreshCw, + color: 'text-yellow-500', + label: 'Connecting...', + animate: true, + }, + disconnected: { + icon: isReconnecting ? RefreshCw : WifiOff, + color: isReconnecting ? 'text-amber-500' : 'text-red-500', + label: isReconnecting ? 'Reconnecting...' : 'Disconnected', + animate: isReconnecting, + }, }; const config = statusConfig[status]; @@ -21,7 +31,7 @@ export function ConnectionIndicator() { return (

- + {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 +

+ +
+ )} +