mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 10:20:52 +00:00
Merge pull request #444 from kaitranntt/dev
feat: image analysis hooks and UX improvements
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
[test]
|
||||
# Exclude UI tests - they use vitest and require jsdom environment
|
||||
# Run UI tests separately with: cd ui && bun run test
|
||||
# Exclude e2e tests - they require manual setup and are slow
|
||||
# Run e2e tests with: bun run test:e2e
|
||||
root = "./tests"
|
||||
timeout = 10000
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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+ |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Dashboard Authentication CLI
|
||||
|
||||
Last Updated: 2026-02-04
|
||||
|
||||
CLI commands for managing CCS dashboard authentication.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Executable
+880
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
Analyze this image/document thoroughly and provide a detailed description.
|
||||
|
||||
Include:
|
||||
1. Overall content and purpose
|
||||
2. Text content (if any) - transcribe important text verbatim
|
||||
3. Visual elements (diagrams, charts, UI components, icons)
|
||||
4. Layout and structure (sections, hierarchy, flow)
|
||||
5. Colors, styling, notable design elements
|
||||
6. Any actionable information (buttons, links, code snippets)
|
||||
|
||||
Be comprehensive - this description replaces direct visual access.
|
||||
The AI assistant reading this cannot see the original image.
|
||||
@@ -0,0 +1,13 @@
|
||||
Analyze this document/PDF thoroughly for a developer.
|
||||
|
||||
Extract and provide:
|
||||
1. Document title, type, and structure
|
||||
2. All text content - transcribe in reading order
|
||||
3. Tables - format as markdown tables
|
||||
4. Lists and bullet points - preserve structure
|
||||
5. Code blocks or technical content
|
||||
6. Diagrams or flowcharts - describe in detail
|
||||
7. Headers and section organization
|
||||
8. Any important metadata visible
|
||||
|
||||
Accuracy in text extraction is critical.
|
||||
@@ -0,0 +1,13 @@
|
||||
Analyze this screenshot in detail for a developer who cannot see it.
|
||||
|
||||
Focus on:
|
||||
1. Application/website type and state
|
||||
2. UI elements visible (buttons, inputs, menus, modals)
|
||||
3. All text content - transcribe exactly
|
||||
4. Error messages or notifications (quote exactly)
|
||||
5. Layout and component hierarchy
|
||||
6. Interactive elements and their states
|
||||
7. Console output or logs if visible
|
||||
8. Any code snippets shown
|
||||
|
||||
Be precise - this enables the assistant to help debug or understand the UI.
|
||||
+3
-2
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}...`));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -49,3 +49,6 @@ export {
|
||||
|
||||
// OAuth checks
|
||||
export { OAuthPortsChecker, runOAuthChecks } from './oauth-check';
|
||||
|
||||
// Image Analysis checks
|
||||
export { runImageAnalysisCheck, fixImageAnalysisConfig } from './image-analysis-check';
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Image Analysis Utilities
|
||||
*
|
||||
* Exports hook installer functions for prompt management
|
||||
*/
|
||||
|
||||
export { getPromptsDir, installImageAnalysisPrompts } from './hook-installer';
|
||||
@@ -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, '""') + '"';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user