Merge pull request #444 from kaitranntt/dev

feat: image analysis hooks and UX improvements
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-04 08:20:47 -05:00
committed by GitHub
50 changed files with 3410 additions and 115 deletions
+2
View File
@@ -1,5 +1,7 @@
[test]
# Exclude UI tests - they use vitest and require jsdom environment
# Run UI tests separately with: cd ui && bun run test
# Exclude e2e tests - they require manual setup and are slow
# Run e2e tests with: bun run test:e2e
root = "./tests"
timeout = 10000
+1 -1
View File
@@ -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.
+20 -9
View File
@@ -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+ |
---
+2
View File
@@ -1,5 +1,7 @@
# Dashboard Authentication CLI
Last Updated: 2026-02-04
CLI commands for managing CCS dashboard authentication.
## Overview
+16 -3
View File
@@ -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
+5 -3
View File
@@ -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 |
+10 -2
View File
@@ -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)
+1 -1
View File
@@ -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.
+880
View File
@@ -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 <provider>',
]
);
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);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
Analyze this image/document thoroughly and provide a detailed description.
Include:
1. Overall content and purpose
2. Text content (if any) - transcribe important text verbatim
3. Visual elements (diagrams, charts, UI components, icons)
4. Layout and structure (sections, hierarchy, flow)
5. Colors, styling, notable design elements
6. Any actionable information (buttons, links, code snippets)
Be comprehensive - this description replaces direct visual access.
The AI assistant reading this cannot see the original image.
+13
View File
@@ -0,0 +1,13 @@
Analyze this document/PDF thoroughly for a developer.
Extract and provide:
1. Document title, type, and structure
2. All text content - transcribe in reading order
3. Tables - format as markdown tables
4. Lists and bullet points - preserve structure
5. Code blocks or technical content
6. Diagrams or flowcharts - describe in detail
7. Headers and section organization
8. Any important metadata visible
Accuracy in text extraction is critical.
+13
View File
@@ -0,0 +1,13 @@
Analyze this screenshot in detail for a developer who cannot see it.
Focus on:
1. Application/website type and state
2. UI elements visible (buttons, inputs, menus, modals)
3. All text content - transcribe exactly
4. Error messages or notifications (quote exactly)
5. Layout and component hierarchy
6. Interactive elements and their states
7. Console output or logs if visible
8. Any code snippets shown
Be precise - this enables the assistant to help debug or understand the UI.
+3 -2
View File
@@ -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",
+11
View File
@@ -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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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);
}
+26
View File
@@ -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
};
@@ -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;
}
+15
View File
@@ -62,6 +62,12 @@ function showHelp(): void {
console.log(' auth show Display current auth status');
console.log(' auth disable Disable authentication');
console.log('');
console.log(' image-analysis Manage image analysis settings');
console.log(' --enable Enable image analysis via CLIProxy');
console.log(' --disable Disable image analysis');
console.log(' --timeout <s> Set analysis timeout (seconds)');
console.log(' --set-model <p> <m> Set model for provider');
console.log('');
console.log('Options:');
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
console.log(' --dev Development mode with Vite HMR');
@@ -72,6 +78,8 @@ function showHelp(): void {
console.log(' ccs config --port 3000 Use specific port');
console.log(' ccs config --dev Development mode with hot reload');
console.log(' ccs config auth setup Configure dashboard login');
console.log(' ccs config image-analysis Show image settings');
console.log(' ccs config image-analysis --enable Enable feature');
console.log('');
}
@@ -86,6 +94,13 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
return;
}
// Route image-analysis subcommand
if (args[0] === 'image-analysis') {
const { handleConfigImageAnalysisCommand } = await import('./config-image-analysis-command');
await handleConfigImageAnalysisCommand(args.slice(1));
return;
}
await initUI();
const options = parseArgs(args);
@@ -0,0 +1,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 <seconds>', 'command')} Set analysis timeout (10-600)`);
console.log(` ${color('--set-model <p> <m>', 'command')} Set model for provider`);
console.log(` ${color('--help, -h', 'command')} Show this help`);
console.log('');
console.log(subheader('Provider Models:'));
console.log(` ${dim('Providers with vision support: agy, gemini, codex, kiro, ghcp, claude')}`);
console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`);
console.log('');
console.log(subheader('Examples:'));
console.log(
` $ ${color('ccs config image-analysis', 'command')} ${dim('# Show status')}`
);
console.log(
` $ ${color('ccs config image-analysis --enable', 'command')} ${dim('# Enable feature')}`
);
console.log(
` $ ${color('ccs config image-analysis --timeout 120', 'command')} ${dim('# Set 2min timeout')}`
);
console.log(
` $ ${color('ccs config image-analysis --set-model agy gemini-2.5-pro', 'command')}`
);
console.log('');
console.log(subheader('How it works:'));
console.log(` 1. When Claude's Read tool targets an image/PDF file`);
console.log(` 2. CCS hook intercepts and sends to CLIProxy vision API`);
console.log(` 3. Vision model analyzes and returns text description`);
console.log(` 4. Claude receives description instead of raw image data`);
console.log('');
console.log(subheader('Supported file types:'));
console.log(` ${dim('Images: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff')}`);
console.log(` ${dim('Documents: .pdf')}`);
console.log('');
}
function showStatus(): 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<void> {
await initUI();
const options = parseArgs(args);
if (options.help) {
showHelp();
return;
}
// Validate conflicting flags (Edge case #2: --enable + --disable conflict)
if (options.enable && options.disable) {
console.error(fail('Cannot use --enable and --disable together'));
process.exit(1);
}
// Apply changes if any options provided
let hasChanges = false;
const config = loadOrCreateUnifiedConfig();
const imageConfig = config.image_analysis ?? { ...DEFAULT_IMAGE_ANALYSIS_CONFIG };
if (options.enable) {
imageConfig.enabled = true;
hasChanges = true;
}
if (options.disable) {
imageConfig.enabled = false;
hasChanges = true;
}
if (options.timeout !== undefined) {
imageConfig.timeout = options.timeout;
hasChanges = true;
}
if (options.setModel) {
const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow'];
if (!validProviders.includes(options.setModel.provider)) {
console.error(fail(`Invalid provider: ${options.setModel.provider}`));
console.error(info(`Valid providers: ${validProviders.join(', ')}`));
process.exit(1);
}
// Validate model name (Edge case #1: Empty model string validation)
const model = options.setModel.model;
if (!model || model.trim() === '') {
console.error(fail('Model name cannot be empty'));
process.exit(1);
}
imageConfig.provider_models = {
...imageConfig.provider_models,
[options.setModel.provider]: model,
};
hasChanges = true;
}
if (hasChanges) {
updateUnifiedConfig({ image_analysis: imageConfig });
console.log(ok('Configuration updated'));
console.log('');
}
// Always show current status
showStatus();
}
+15
View File
@@ -242,6 +242,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs config', 'Open web configuration dashboard'],
['ccs config auth setup', 'Configure dashboard login'],
['ccs config auth show', 'Show dashboard auth status'],
['ccs config image-analysis', 'Show image analysis settings'],
['ccs config image-analysis --enable', 'Enable image analysis'],
['ccs config --port 3000', 'Use specific port'],
['ccs persist <profile>', 'Write profile env to ~/.claude/settings.json'],
['ccs persist --list-backups', 'List available settings.json backups'],
@@ -306,6 +308,19 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['', 'before responding. Supported: agy, gemini (thinking models).'],
]);
// Image Analysis
printSubSection('Image Analysis (CLIProxy vision)', [
['ccs config image-analysis', 'Show current settings'],
['ccs config image-analysis --enable', 'Enable for CLIProxy providers'],
['ccs config image-analysis --disable', 'Disable (use native Read)'],
['ccs config image-analysis --timeout 120', 'Set analysis timeout'],
['ccs config image-analysis --set-model <p> <m>', 'Set provider model'],
['', ''],
['Note:', 'When enabled, images/PDFs are analyzed via vision models'],
['', 'instead of passing raw data to Claude. Works with CLIProxy'],
['', 'providers (agy, gemini, codex, kiro, ghcp).'],
]);
// CLI Proxy env vars
printSubSection('CLI Proxy Environment Variables', [
['CCS_PROXY_HOST', 'Remote proxy hostname'],
+1
View File
@@ -6,6 +6,7 @@ export { handleApiCommand } from './api-command';
export { handleCleanupCommand } from './cleanup-command';
export { handleCliproxyCommand } from './cliproxy-command';
export { handleConfigCommand } from './config-command';
export { handleConfigImageAnalysisCommand } from './config-image-analysis-command';
export { handleCopilotCommand } from './copilot-command';
export { handleDoctorCommand } from './doctor-command';
export { handleHelpCommand } from './help-command';
+16 -5
View File
@@ -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 {
+173 -25
View File
@@ -20,14 +20,18 @@ import {
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
DEFAULT_THINKING_CONFIG,
DEFAULT_DASHBOARD_AUTH_CONFIG,
DEFAULT_IMAGE_ANALYSIS_CONFIG,
GlobalEnvConfig,
ThinkingConfig,
DashboardAuthConfig,
ImageAnalysisConfig,
} from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags';
const CONFIG_YAML = 'config.yaml';
const CONFIG_JSON = 'config.json';
const CONFIG_LOCK = 'config.yaml.lock';
const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds
/**
* Get path to unified config.yaml
@@ -43,6 +47,71 @@ export function getConfigJsonPath(): string {
return path.join(getCcsDir(), CONFIG_JSON);
}
/**
* Get path to config lockfile
*/
function getLockFilePath(): string {
return path.join(getCcsDir(), CONFIG_LOCK);
}
/**
* Acquire lockfile for config write operations.
* Returns true if lock acquired, false if already locked by another process.
* Cleans up stale locks (older than LOCK_STALE_MS).
*/
function acquireLock(): boolean {
const lockPath = getLockFilePath();
const lockData = `${process.pid}\n${Date.now()}`;
try {
// Check if lock exists
if (fs.existsSync(lockPath)) {
const content = fs.readFileSync(lockPath, 'utf8');
const [pidStr, timestampStr] = content.trim().split('\n');
const timestamp = parseInt(timestampStr, 10);
// Check if lock is stale
if (Date.now() - timestamp > LOCK_STALE_MS) {
// Stale lock - remove and acquire
fs.unlinkSync(lockPath);
} else {
// Check if process still exists
try {
process.kill(parseInt(pidStr, 10), 0); // Signal 0 checks if process exists
// Process exists - lock is valid
return false;
} catch {
// Process doesn't exist - remove stale lock
fs.unlinkSync(lockPath);
}
}
}
// Acquire lock
fs.writeFileSync(lockPath, lockData, { mode: 0o600 });
return true;
} catch {
// Lock acquisition failed
return false;
}
}
/**
* Release lockfile after config write operation.
*/
function releaseLock(): void {
const lockPath = getLockFilePath();
try {
if (fs.existsSync(lockPath)) {
fs.unlinkSync(lockPath);
}
} catch {
// Ignore cleanup errors
}
}
/**
* Check if unified config.yaml exists
*/
@@ -103,8 +172,9 @@ export function loadUnifiedConfig(): UnifiedConfig | null {
console.error(`[i] Config upgraded to v${UNIFIED_CONFIG_VERSION}`);
}
return upgraded;
} catch {
// Ignore save errors during upgrade - config still works
} catch (saveError) {
console.error('[!] Config upgrade failed to save:', (saveError as Error).message);
// Continue using the upgraded version in-memory even if save fails
}
}
@@ -293,6 +363,13 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
partial.dashboard_auth?.session_timeout_hours ??
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
},
// Image analysis config - enabled by default for CLIProxy providers
image_analysis: {
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: partial.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
partial.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
},
};
}
@@ -520,45 +597,101 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('');
}
// Image analysis section
if (config.image_analysis) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Image Analysis: Vision-based analysis for images and PDFs');
lines.push('# Routes Read tool requests for images/PDFs through CLIProxy vision API.');
lines.push('#');
lines.push('# When enabled: Image files trigger vision analysis instead of raw file read');
lines.push('# Provider models: Vision model used for each CLIProxy provider');
lines.push('# Timeout: Maximum seconds to wait for analysis (10-600)');
lines.push('#');
lines.push('# Supported formats: .jpg, .jpeg, .png, .gif, .webp, .heic, .bmp, .tiff, .pdf');
lines.push('# Configure via: ccs config image-analysis');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml
.dump(
{ image_analysis: config.image_analysis },
{ indent: 2, lineWidth: -1, quotingType: '"' }
)
.trim()
);
lines.push('');
}
return lines.join('\n');
}
/**
* Save unified config to YAML file.
* Uses atomic write (temp file + rename) to prevent corruption.
* Uses lockfile to prevent concurrent writes.
*/
export function saveUnifiedConfig(config: UnifiedConfig): void {
const yamlPath = getConfigYamlPath();
const dir = path.dirname(yamlPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// Acquire lock (retry for up to 1 second)
const maxRetries = 10;
const retryDelayMs = 100;
let lockAcquired = false;
for (let i = 0; i < maxRetries; i++) {
if (acquireLock()) {
lockAcquired = true;
break;
}
// Synchronous sleep without CPU-intensive busy-wait
// Uses Atomics.wait which properly sleeps the thread
// Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs);
}
// Ensure version is set
config.version = UNIFIED_CONFIG_VERSION;
// Generate YAML with section comments
const yamlContent = generateYamlWithComments(config);
const content = generateYamlHeader() + yamlContent;
// Atomic write: write to temp file, then rename
const tempPath = `${yamlPath}.tmp.${process.pid}`;
if (!lockAcquired) {
throw new Error('Config file is locked by another process. Wait a moment and try again.');
}
try {
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.renameSync(tempPath, yamlPath);
} catch (err) {
// Clean up temp file on error
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
throw err;
// Ensure version is set
config.version = UNIFIED_CONFIG_VERSION;
// Generate YAML with section comments
const yamlContent = generateYamlWithComments(config);
const content = generateYamlHeader() + yamlContent;
// Atomic write: write to temp file, then rename
const tempPath = `${yamlPath}.tmp.${process.pid}`;
try {
fs.writeFileSync(tempPath, content, { mode: 0o600 });
fs.renameSync(tempPath, yamlPath);
} catch (error) {
// Clean up temp file on error
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
// Classify filesystem errors
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOSPC') {
throw new Error('Disk full - cannot save config. Free up space and try again.');
} else if (err.code === 'EROFS' || err.code === 'EACCES') {
throw new Error(`Cannot write config - check file permissions: ${err.message}`);
}
throw error;
}
} finally {
// Always release lock
releaseLock();
}
}
@@ -721,3 +854,18 @@ export function getDashboardAuthConfig(): DashboardAuthConfig {
session_timeout_hours: config.dashboard_auth?.session_timeout_hours ?? 24,
};
}
/**
* Get image_analysis configuration.
* Returns defaults if not configured.
*/
export function getImageAnalysisConfig(): ImageAnalysisConfig {
const config = loadOrCreateUnifiedConfig();
return {
enabled: config.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
timeout: config.image_analysis?.timeout ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.timeout,
provider_models:
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
};
}
+36
View File
@@ -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<string, string>;
}
/**
* Default image analysis configuration.
* Enabled by default for CLIProxy providers with vision support.
*/
export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = {
enabled: true,
timeout: 60,
provider_models: {
agy: 'gemini-2.5-flash',
gemini: 'gemini-2.5-flash',
codex: 'gpt-5.1-codex-mini',
kiro: 'kiro-claude-haiku-4-5',
ghcp: 'claude-haiku-4.5',
claude: 'claude-haiku-4-5-20251001',
// '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 },
};
}
+2 -3
View File
@@ -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}...`));
}
+5 -18
View File
@@ -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<string, string> = {
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<string> {
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 = '';
+10 -6
View File
@@ -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);
}
@@ -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<void> {
const config = getImageAnalysisConfig();
// Check 1: Feature status
if (!config.enabled) {
results.details['Image Analysis'] = {
status: 'OK',
info: 'Disabled (using native Read)',
};
console.log(` ${dim('Status:')} Disabled`);
console.log(` ${dim('Tip:')} Enable with: ccs config image-analysis --enable`);
return;
}
// Feature is enabled - run validation checks
console.log(` ${ok('Status:')} Enabled`);
// Check 2: Provider models configured
const providers = Object.keys(config.provider_models);
if (providers.length === 0) {
results.details['Image Analysis'] = {
status: 'ERROR',
info: 'No providers configured',
};
results.errors.push({
name: 'Image Analysis',
message: 'No provider models configured for image analysis',
fix: 'ccs config image-analysis --set-model agy gemini-2.5-flash',
});
console.log(` ${warn('Providers:')} None configured`);
return;
}
console.log(` ${ok('Providers:')} ${providers.join(', ')}`);
// Check 3: Timeout validation
if (config.timeout < 10 || config.timeout > 600) {
results.details['Image Analysis'] = {
status: 'ERROR',
info: `Invalid timeout: ${config.timeout}s`,
};
results.errors.push({
name: 'Image Analysis',
message: `Timeout ${config.timeout}s out of range (10-600)`,
fix: 'ccs config image-analysis --timeout 60',
});
console.log(` ${warn('Timeout:')} ${config.timeout}s (invalid, must be 10-600)`);
return;
}
console.log(` ${ok('Timeout:')} ${config.timeout}s`);
// Check 4: CLIProxy availability (only if enabled)
const cliproxyAvailable = await 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<boolean> {
const { updateUnifiedConfig, loadOrCreateUnifiedConfig } = await import(
'../../config/unified-config-loader'
);
const config = loadOrCreateUnifiedConfig();
let fixed = false;
// Fix missing provider_models
if (
!config.image_analysis?.provider_models ||
Object.keys(config.image_analysis.provider_models).length === 0
) {
config.image_analysis = {
...config.image_analysis,
enabled: config.image_analysis?.enabled ?? true,
timeout: config.image_analysis?.timeout ?? 60,
provider_models: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models },
};
fixed = true;
}
// Fix invalid timeout
if (
config.image_analysis &&
(config.image_analysis.timeout < 10 || config.image_analysis.timeout > 600)
) {
config.image_analysis.timeout = 60;
fixed = true;
}
if (fixed) {
updateUnifiedConfig({ image_analysis: config.image_analysis });
}
return fixed;
}
+3
View File
@@ -49,3 +49,6 @@ export {
// OAuth checks
export { OAuthPortsChecker, runOAuthChecks } from './oauth-check';
// Image Analysis checks
export { runImageAnalysisCheck, fixImageAnalysisConfig } from './image-analysis-check';
+6
View File
@@ -13,6 +13,7 @@ import {
runSymlinkChecks,
runCLIProxyChecks,
runOAuthChecks,
runImageAnalysisCheck,
} from './checks';
import { runAutoRepair } from './repair';
@@ -76,6 +77,11 @@ class Doctor {
await runOAuthChecks(this.results);
console.log('');
// Group 8: Image Analysis Config
console.log(header('IMAGE ANALYSIS'));
await runImageAnalysisCheck(this.results);
console.log('');
this.showReport();
return this.results;
}
+15
View File
@@ -15,6 +15,7 @@ import {
import { getPortProcess, isCLIProxyProcess } from '../../utils/port-utils';
import { killProcessOnPort, getPlatformName } from '../../utils/platform-commands';
import { createSpinner } from '../checks/types';
import { fixImageAnalysisConfig } from '../checks/image-analysis-check';
const ora = createSpinner();
@@ -132,6 +133,20 @@ export async function runAutoRepair(): Promise<void> {
symlinkSpinner.fail(`${fail('Error')} Could not fix symlink: ${(err as Error).message}`);
}
// Fix 5: Image analysis config validation
const imageSpinner = ora('Checking image analysis config').start();
try {
const imageFixed = await fixImageAnalysisConfig();
if (imageFixed) {
imageSpinner.succeed(`${ok('Fixed')} Repaired image analysis configuration`);
fixed++;
} else {
imageSpinner.succeed(`${ok('OK')} Image analysis config is valid`);
}
} catch (err) {
imageSpinner.fail(`${fail('Error')} Could not fix image config: ${(err as Error).message}`);
}
// Summary
console.log('');
if (fixed > 0) {
+42
View File
@@ -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();
}
@@ -0,0 +1,42 @@
/**
* Image Analysis Hook Environment Variables
*
* Provides environment variables for image analysis hook configuration.
* Hook routes image/PDF files through CLIProxy for vision analysis.
*
* @module utils/hooks/image-analysis-hook-env
*/
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
/**
* Serialize provider_models map to env var format: provider:model,provider:model
*/
function serializeProviderModels(providerModels: Record<string, string>): string {
return Object.entries(providerModels)
.map(([provider, model]) => `${provider}:${model}`)
.join(',');
}
/**
* Get image analysis hook environment variables.
* These env vars control the hook's behavior via Claude Code hook system.
*
* @param provider - Current CLIProxy provider (e.g., 'agy', 'gemini', 'codex')
* @returns Environment variables for image analysis hook
*/
export function getImageAnalysisHookEnv(provider?: string): Record<string, string> {
const config = getImageAnalysisConfig();
// Check if current provider has a vision model configured
const hasVisionModel = provider && config.provider_models[provider];
const skipImageAnalysis = !config.enabled || !hasVisionModel;
return {
CCS_IMAGE_ANALYSIS_ENABLED: config.enabled ? '1' : '0',
CCS_IMAGE_ANALYSIS_TIMEOUT: String(Number(config.timeout) || 60),
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: serializeProviderModels(config.provider_models),
CCS_CURRENT_PROVIDER: provider || '',
CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0',
};
}
@@ -0,0 +1,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<string, unknown> {
const hookPath = getImageAnalyzerHookPath();
const imageConfig = getImageAnalysisConfig();
// Add 5 second buffer to analysis timeout for hook execution overhead
const hookTimeout = imageConfig.timeout * 1000 + 5000;
return {
PreToolUse: [
{
matcher: 'Read',
hooks: [
{
type: 'command',
command: `node "${hookPath}"`,
timeout: hookTimeout,
},
],
},
],
};
}
@@ -0,0 +1,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/<profile>.settings.json)
// Global settings (~/.claude/settings.json) are NOT modified here
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to install image analyzer hook: ${(error as Error).message}`));
}
return false;
}
}
/**
* Uninstall image analyzer hook from ~/.ccs/hooks/
*
* Note: Does NOT touch global ~/.claude/settings.json.
* Profile-specific hooks are removed when ~/.ccs/ is deleted.
*
* @returns true if hook uninstalled successfully
*/
export function uninstallImageAnalyzerHook(): boolean {
try {
const hookPath = getImageAnalyzerHookPath();
if (fs.existsSync(hookPath)) {
fs.unlinkSync(hookPath);
if (process.env.CCS_DEBUG) {
console.error(info(`Uninstalled image analyzer hook: ${hookPath}`));
}
}
// Remove migration marker (so fresh install re-runs migration)
removeMigrationMarker();
// Note: Do NOT call removeHookConfig() - global settings should not be touched.
// Per-profile hooks in ~/.ccs/*.settings.json are cleaned up when ~/.ccs/ is deleted.
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to uninstall image analyzer hook: ${(error as Error).message}`));
}
return false;
}
}
@@ -0,0 +1,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<string, unknown>): boolean {
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
if (!hooks?.PreToolUse) return false;
return hooks.PreToolUse.some((h: unknown) => {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'Read') return false;
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
const command = hookArray?.[0]?.command;
if (typeof command !== 'string') return false;
const normalized = command
.replace(/\\/g, '/') // Windows backslashes
.replace(/\/+/g, '/'); // Collapse multiple slashes
return normalized.includes('.ccs/hooks/image-analyzer-transformer');
});
}
/**
* One-time migration marker management
*/
function migrateGlobalHook(): void {
const markerPath = getMigrationMarkerPath();
if (fs.existsSync(markerPath)) {
return; // Already migrated
}
try {
// No global hook to migrate (image analyzer is profile-only from the start)
// Just create marker to prevent future migration attempts
const ccsDir = getCcsDir();
if (!fs.existsSync(ccsDir)) {
fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
}
// Create marker file atomically (wx = fail if exists, prevents race condition)
fs.writeFileSync(markerPath, new Date().toISOString(), { encoding: 'utf8', flag: 'wx' });
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Migration failed: ${(error as Error).message}`));
}
}
}
/**
* Ensure image analyzer hook is configured in profile's settings file
*
* Only injects for CLIProxy profiles with vision support (agy, gemini).
*
* @param profileName - Name of the profile (e.g., 'agy', 'gemini')
* @returns true if hook is configured (existing or newly added)
*/
export function ensureProfileHooks(profileName: string): boolean {
try {
// Validate profile name to prevent path traversal
if (!VALID_PROFILE_NAME.test(profileName)) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Invalid profile name: ${profileName}`));
}
return false;
}
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<string, unknown> = {};
if (fs.existsSync(settingsPath)) {
try {
const content = fs.readFileSync(settingsPath, 'utf8');
settings = JSON.parse(content);
} catch (parseError) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Malformed ${profileName}.settings.json: ${(parseError as Error).message}`)
);
}
// Continue with empty settings, will add hooks
}
}
// Check if CCS hook already present
if (hasCcsHook(settings)) {
// Update timeout if needed
return updateHookTimeoutIfNeeded(settings, settingsPath);
}
// Get hook config
const hookConfig = getImageAnalyzerHookConfig();
// Ensure hooks structure exists
if (!settings.hooks) {
settings.hooks = {};
}
const settingsHooks = settings.hooks as Record<string, unknown[]>;
if (!settingsHooks.PreToolUse) {
settingsHooks.PreToolUse = [];
}
// Add CCS hook
const preToolUseHooks = hookConfig.PreToolUse as unknown[];
settingsHooks.PreToolUse.push(...preToolUseHooks);
// Write updated settings
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info(`Added image analyzer hook to ${profileName}.settings.json`));
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to inject hook: ${(error as Error).message}`));
}
return false;
}
}
/**
* Update hook timeout if it differs from current config
*/
function updateHookTimeoutIfNeeded(
settings: Record<string, unknown>,
settingsPath: string
): boolean {
try {
const hooks = settings.hooks as Record<string, unknown[]>;
const hookConfig = getImageAnalyzerHookConfig();
const expectedHookPath = getImageAnalyzerHookPath();
const expectedCommand = `node "${expectedHookPath}"`;
const expectedHooks = (hookConfig.PreToolUse as Array<Record<string, unknown>>)[0]
.hooks as Array<Record<string, unknown>>;
const expectedTimeout = expectedHooks[0].timeout as number;
let needsUpdate = false;
for (const h of hooks.PreToolUse) {
const hook = h as Record<string, unknown>;
if (hook.matcher !== 'Read') continue;
const hookArray = hook.hooks as Array<Record<string, unknown>>;
if (!hookArray?.[0]?.command) continue;
const command = hookArray[0].command;
if (typeof command !== 'string') continue;
// Normalize path separators for cross-platform matching (Windows uses backslashes)
const normalizedCommand = command
.replace(/\\/g, '/') // Windows backslashes
.replace(/\/+/g, '/'); // Collapse multiple slashes
if (!normalizedCommand.includes('.ccs/hooks/image-analyzer-transformer')) continue;
// Found CCS hook - check if needs update
if (hookArray[0].command !== expectedCommand) {
hookArray[0].command = expectedCommand;
needsUpdate = true;
}
if (hookArray[0].timeout !== expectedTimeout) {
hookArray[0].timeout = expectedTimeout;
needsUpdate = true;
}
}
if (needsUpdate) {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
if (process.env.CCS_DEBUG) {
console.error(info('Updated image analyzer hook timeout in profile settings'));
}
}
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`updateHookTimeoutIfNeeded failed: ${(error as Error).message}`));
}
return false;
}
}
/**
* Remove migration marker (called during uninstall)
*/
export function removeMigrationMarker(): void {
try {
const markerPath = getMigrationMarkerPath();
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(warn(`removeMigrationMarker failed: ${(error as Error).message}`));
}
}
}
+17
View File
@@ -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';
@@ -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;
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Image Analysis Utilities
*
* Exports hook installer functions for prompt management
*/
export { getPromptsDir, installImageAnalysisPrompts } from './hook-installer';
+25 -1
View File
@@ -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, '""') + '"';
}
}
/**
+1 -8
View File
@@ -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;
+2 -1
View File
@@ -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
+11 -5
View File
@@ -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
+13 -8
View File
@@ -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);
+791
View File
@@ -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<string, string | string[] | undefined>;
body: unknown;
}
let mockServer: http.Server | null = null;
let lastRequest: MockServerRequest | null = null;
let mockResponse: { content: string; statusCode: number } = {
content: 'This is a test image showing a red pixel.',
statusCode: 200,
};
/**
* Start mock CLIProxy server
*/
function startMockServer(): Promise<void> {
return new Promise((resolve, reject) => {
mockServer = http.createServer((req, res) => {
// Health check endpoint - always return 200
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
// Capture request for verification
lastRequest = {
method: req.method || 'GET',
path: req.url || '/',
headers: req.headers,
body: body ? JSON.parse(body) : null,
};
// Return mock response in Anthropic format
if (mockResponse.statusCode !== 200) {
res.writeHead(mockResponse.statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Mock error' } }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
content: [{ type: 'text', text: mockResponse.content }],
})
);
});
});
mockServer.on('error', reject);
mockServer.listen(MOCK_PORT, '127.0.0.1', () => {
resolve();
});
});
}
/**
* Stop mock CLIProxy server
*/
function stopMockServer(): Promise<void> {
return new Promise((resolve) => {
if (mockServer) {
mockServer.close(() => {
mockServer = null;
resolve();
});
} else {
resolve();
}
});
}
/**
* Reset mock server state between tests
*/
function resetMockState(): void {
lastRequest = null;
mockResponse = {
content: 'This is a test image showing a red pixel.',
statusCode: 200,
};
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Invoke the hook with JSON input
*/
function invokeHook(
input: object,
env: Record<string, string> = {}
): { code: number; stdout: string; stderr: string } {
const result = spawnSync('node', [HOOK_PATH], {
input: JSON.stringify(input),
encoding: 'utf8',
env: {
...process.env,
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
CCS_CLIPROXY_PORT: String(MOCK_PORT),
// Default provider config for tests (can be overridden)
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS,
CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER,
...env,
},
timeout: 10000, // 10 second timeout per test
});
return {
code: result.status ?? -1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
/**
* Create a minimal valid PNG file (1x1 red pixel)
*/
function createTestPng(filepath: string): void {
// 1x1 PNG with a red pixel (RGB: 255, 0, 0)
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk (red pixel)
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d,
0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk
0x44, 0xae, 0x42, 0x60, 0x82,
]);
fs.writeFileSync(filepath, png);
}
/**
* Create a minimal valid JPEG file
*/
function createTestJpeg(filepath: string): void {
const jpeg = Buffer.from([
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x48,
0x00, 0x48, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01,
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0xff, 0xc4, 0x00, 0x14,
0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0x7f, 0xff, 0xd9,
]);
fs.writeFileSync(filepath, jpeg);
}
/**
* Create a test text file
*/
function createTestTextFile(filepath: string, content: string): void {
fs.writeFileSync(filepath, content, 'utf8');
}
/**
* Create a large file exceeding 10MB
*/
function createLargeFile(filepath: string, sizeMB: number): void {
const bufferSize = 1024 * 1024; // 1MB
const totalBuffers = sizeMB;
const buffer = Buffer.alloc(bufferSize, 'A');
const stream = fs.createWriteStream(filepath);
for (let i = 0; i < totalBuffers; i++) {
stream.write(buffer);
}
stream.end();
}
// ============================================================================
// TEST SUITE
// ============================================================================
describe('Image Analyzer Hook', () => {
let testPngPath: string;
let testJpegPath: string;
let testTextPath: string;
beforeAll(async () => {
// Create test directory
if (!fs.existsSync(TEST_DIR)) {
fs.mkdirSync(TEST_DIR, { recursive: true });
}
// Start mock server
await startMockServer();
console.log(`[Test Setup] Mock CLIProxy started on port ${MOCK_PORT}`);
// Create test files
testPngPath = path.join(TEST_DIR, 'test-image.png');
testJpegPath = path.join(TEST_DIR, 'test-image.jpg');
testTextPath = path.join(TEST_DIR, 'test-file.txt');
createTestPng(testPngPath);
createTestJpeg(testJpegPath);
createTestTextFile(testTextPath, 'This is a test file.');
});
afterAll(async () => {
// Stop mock server
await stopMockServer();
// Clean up test files
const filesToClean = [testPngPath, testJpegPath, testTextPath];
for (const f of filesToClean) {
if (f && fs.existsSync(f)) {
try {
fs.unlinkSync(f);
} catch {
// Ignore cleanup errors
}
}
}
if (fs.existsSync(TEST_DIR)) {
try {
fs.rmdirSync(TEST_DIR);
} catch {
// Ignore if not empty
}
}
});
// ==========================================================================
// GROUP A: FILE DETECTION AND FILTERING
// ==========================================================================
describe('File Detection and Filtering', () => {
it('should pass through non-Read tools', () => {
const result = invokeHook({
tool_name: 'Write',
tool_input: { file_path: testPngPath, content: 'test' },
});
expect(result.code).toBe(0);
});
it('should pass through Read tool for non-image files (.txt)', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: testTextPath },
});
expect(result.code).toBe(0);
});
it('should pass through Read tool for .ts files', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '/tmp/test.ts' },
});
expect(result.code).toBe(0);
});
it('should pass through Read tool for .md files', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '/tmp/test.md' },
});
expect(result.code).toBe(0);
});
it('should pass through Read tool for .json files', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '/tmp/test.json' },
});
expect(result.code).toBe(0);
});
it('should pass through files that do not exist', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '/tmp/nonexistent-file-12345.png' },
});
// Should pass through to let native Read handle the error
expect(result.code).toBe(0);
});
});
// ==========================================================================
// GROUP B: ENVIRONMENT VARIABLE CONTROLS
// ==========================================================================
describe('Environment Variable Controls', () => {
it('should skip when CCS_IMAGE_ANALYSIS_SKIP=1', () => {
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_SKIP: '1' }
);
expect(result.code).toBe(0);
});
it('should skip when CCS_IMAGE_ANALYSIS_ENABLED=0', () => {
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '0' }
);
expect(result.code).toBe(0);
});
it('should skip for account profile type', () => {
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_PROFILE_TYPE: 'account' }
);
expect(result.code).toBe(0);
});
it('should skip for default profile type', () => {
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: testPngPath },
},
{ CCS_PROFILE_TYPE: 'default' }
);
expect(result.code).toBe(0);
});
});
// ==========================================================================
// GROUP C: INPUT VALIDATION
// ==========================================================================
describe('Input Validation', () => {
it('should handle missing file_path gracefully', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: {},
});
expect(result.code).toBe(0);
});
it('should handle empty file_path', () => {
const result = invokeHook({
tool_name: 'Read',
tool_input: { file_path: '' },
});
expect(result.code).toBe(0);
});
it('should handle malformed JSON input', () => {
const hookProcess = spawnSync('node', [HOOK_PATH], {
input: 'not valid json',
encoding: 'utf8',
timeout: 5000,
env: {
...process.env,
CCS_CLIPROXY_API_KEY: CLIPROXY_API_KEY,
CCS_CLIPROXY_PORT: String(MOCK_PORT),
CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: DEFAULT_PROVIDER_MODELS,
CCS_CURRENT_PROVIDER: DEFAULT_PROVIDER,
},
});
// Should exit with error (code 2)
expect(hookProcess.status).toBe(2);
});
});
// ==========================================================================
// GROUP D: FILE SIZE LIMITS
// ==========================================================================
describe('File Size Limits', () => {
it('should reject files larger than 10MB', () => {
// Create 11MB file
const largePath = path.join(TEST_DIR, 'large-test.png');
createLargeFile(largePath, 11);
const result = invokeHook(
{
tool_name: 'Read',
tool_input: { file_path: largePath },
},
{ CCS_IMAGE_ANALYSIS_ENABLED: '1', CCS_PROFILE_TYPE: 'cliproxy' }
);
// Should block with error
expect(result.code).toBe(2);
const output = JSON.parse(result.stdout);
expect(output.decision).toBe('block');
expect(output.hookSpecificOutput.permissionDecisionReason).toContain('File too large');
// Cleanup
if (fs.existsSync(largePath)) fs.unlinkSync(largePath);
});
});
// ==========================================================================
// GROUP E: MOCK CLIPROXY INTEGRATION (FAST, RELIABLE)
// ==========================================================================
describe('CLIProxy Integration (Mock Server)', () => {
beforeAll(() => {
resetMockState();
});
it('should 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');
});
});
});
@@ -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: {}');
});
});
});
@@ -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);
});
@@ -1,19 +1,29 @@
/**
* Connection Indicator (Phase 04)
*
* Shows WebSocket connection status in the header.
* Shows WebSocket connection status in the header with reconnection state.
*/
import { Wifi, WifiOff } from 'lucide-react';
import { Wifi, WifiOff, RefreshCw } from 'lucide-react';
import { useWebSocket } from '@/hooks/use-websocket';
export function ConnectionIndicator() {
const { status } = useWebSocket();
const { status, isReconnecting } = useWebSocket();
const statusConfig = {
connected: { icon: Wifi, color: 'text-green-600', label: 'Connected' },
connecting: { icon: Wifi, color: 'text-yellow-500', label: 'Connecting...' },
disconnected: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' },
connected: { icon: Wifi, color: 'text-green-600', label: 'Connected', animate: false },
connecting: {
icon: RefreshCw,
color: 'text-yellow-500',
label: 'Connecting...',
animate: true,
},
disconnected: {
icon: isReconnecting ? RefreshCw : WifiOff,
color: isReconnecting ? 'text-amber-500' : 'text-red-500',
label: isReconnecting ? 'Reconnecting...' : 'Disconnected',
animate: isReconnecting,
},
};
const config = statusConfig[status];
@@ -21,7 +31,7 @@ export function ConnectionIndicator() {
return (
<div className={`flex items-center gap-1 text-sm ${config.color}`}>
<Icon className="w-4 h-4" />
<Icon className={`w-4 h-4 ${config.animate ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{config.label}</span>
</div>
);
+10 -1
View File
@@ -18,6 +18,7 @@ type ConnectionStatus = 'connecting' | 'connected' | 'disconnected';
export function useWebSocket() {
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [isReconnecting, setIsReconnecting] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const queryClient = useQueryClient();
const reconnectAttempts = useRef(0);
@@ -75,6 +76,7 @@ export function useWebSocket() {
ws.onopen = () => {
setStatus('connected');
setIsReconnecting(false);
reconnectAttempts.current = 0;
console.log('[WS] Connected');
};
@@ -97,6 +99,7 @@ export function useWebSocket() {
// Attempt reconnect with exponential backoff
if (reconnectAttempts.current < maxReconnectAttempts) {
setIsReconnecting(true);
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
reconnectAttempts.current++;
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`);
@@ -104,6 +107,8 @@ export function useWebSocket() {
reconnectTimeoutRef.current = setTimeout(() => {
connectRef.current();
}, delay);
} else {
setIsReconnecting(false);
}
};
@@ -117,6 +122,7 @@ export function useWebSocket() {
const disconnect = useCallback(() => {
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnect
setIsReconnecting(false);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
@@ -142,5 +148,8 @@ export function useWebSocket() {
return () => clearInterval(interval);
}, []);
return useMemo(() => ({ status, connect, disconnect }), [status, connect, disconnect]);
return useMemo(
() => ({ status, isReconnecting, connect, disconnect }),
[status, isReconnecting, connect, disconnect]
);
}
+19 -1
View File
@@ -115,7 +115,25 @@ function SettingsPageInner() {
return (
<div className="h-[calc(100vh-100px)]">
<PanelGroup direction="horizontal" className="h-full">
{/* Mobile View - Stacked vertically */}
<div className="md:hidden h-full flex flex-col">
<div className="border-b bg-background p-4">
<TabNavigation activeTab={activeTab} onTabChange={handleTabChange} />
</div>
<SectionErrorBoundary>
<Suspense fallback={<SectionSkeleton />}>
{activeTab === 'websearch' && <WebSearchSection />}
{activeTab === 'globalenv' && <GlobalEnvSection />}
{activeTab === 'thinking' && <ThinkingSection />}
{activeTab === 'proxy' && <ProxySection />}
{activeTab === 'auth' && <AuthSection />}
{activeTab === 'backups' && <BackupsSection />}
</Suspense>
</SectionErrorBoundary>
</div>
{/* Desktop View - Side-by-side panels */}
<PanelGroup direction="horizontal" className="h-full hidden md:flex">
{/* Left Panel - Settings Controls */}
<Panel defaultSize={40} minSize={30} maxSize={55}>
<div className="h-full border-r flex flex-col bg-muted/30 relative">
@@ -7,7 +7,7 @@ import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import { RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react';
import { RefreshCw, CheckCircle2, AlertCircle, Package } from 'lucide-react';
import { useWebSearchConfig, useRawConfig } from '../../hooks';
import { ProviderCard } from './provider-card';
@@ -141,6 +141,21 @@ export default function WebSearchSection() {
<div className="space-y-3">
<h3 className="text-base font-medium">Providers</h3>
{/* Empty state when no providers available */}
{!status?.geminiCli && !status?.opencodeCli && !status?.grokCli && !statusLoading && (
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg text-center bg-muted/30">
<Package className="w-12 h-12 text-muted-foreground mb-3 opacity-30" />
<p className="font-medium text-foreground mb-1">No providers configured</p>
<p className="text-sm text-muted-foreground mb-4">
Install CLI tools to enable web search providers
</p>
<Button variant="outline" size="sm" onClick={fetchStatus}>
<RefreshCw className="w-4 h-4 mr-2" />
Check for providers
</Button>
</div>
)}
<ProviderCard
name="gemini"
label="Google Gemini CLI (1000 req/day free)"