diff --git a/README.md b/README.md index c10672d9..2f210a9c 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ The dashboard provides visual management for all account types: > Setting `websearch.enabled: false` disables the managed local runtime, but CCS still suppresses Anthropic's native `WebSearch` on third-party backends because those providers cannot execute it correctly. > **Image backend visibility:** `ccs config image-analysis --set-fallback ` defines the backend CCS should use when a profile alias cannot be inferred directly. Use `--set-profile-backend ` and `--clear-profile-backend ` for explicit per-profile mappings. In the dashboard, the global `Settings -> Image` section now shows the shared backend routing state, while each profile editor keeps a compact `Image` status card that links back to those global controls. +> Third-party launches now expose a first-class local `ImageAnalysis` MCP tool when the runtime is ready, route requests directly to the resolved CCS provider path, and fall back to native `Read` when the managed runtime is unavailable. > **Copilot config behavior:** Opening the dashboard or other read-only Copilot endpoints does not rewrite `~/.ccs/copilot.settings.json`. If CCS detects deprecated Copilot model IDs such as `raptor-mini`, it shows warnings immediately and only persists replacements when you explicitly save the Copilot configuration. @@ -687,6 +688,38 @@ See [docs/websearch.md](./docs/websearch.md) for detailed configuration and trou
+## Image Analysis + +Third-party profiles (Gemini, Codex, GLM bridge profiles, Copilot, and similar routes) now use a first-class local `ImageAnalysis` MCP tool instead of relying on a denied `Read` hook as the normal experience. + +### How It Works + +| Profile Type | Image Method | +|--------------|--------------| +| Claude (native) | Native Claude vision / native `Read` | +| Third-party profiles | CCS local MCP `ImageAnalysis` tool when available | +| Third-party when runtime unavailable | Native `Read` fallback | + +### Direct Provider Routing + +When the managed tool is used, CCS resolves the backend before launch and posts image-analysis requests directly to the provider-scoped CCS route: + +```text +/api/provider//v1/messages +``` + +That path goes from Claude -> `ccs-image-analysis.ImageAnalysis` -> CCS/CLIProxy provider routing. It does not bounce through Claude Code, a helper CLI, or a second model wrapper. + +### Prompting and Fallback + +CCS appends a short steering hint telling Claude to prefer `ImageAnalysis` over `Read` for local image and PDF files. The tool uses editable prompt templates from `~/.ccs/prompts/image-analysis/` and automatically picks `default`, `screenshot`, or `document`. + +If the local runtime, auth, or proxy path is unavailable, CCS keeps the launch non-fatal and falls back to native `Read`. The legacy `Read` hook remains only as a compatibility fallback when CCS can install it safely. + +See [docs/image-analysis.md](./docs/image-analysis.md) for configuration, routing details, and troubleshooting. + +
+ ## Remote CLIProxy CCS v7.x supports connecting to remote CLIProxyAPI instances, enabling: diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 3e3e1607..dbe3f73b 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -54,7 +54,7 @@ src/ │ │ └── [subcommand files...] │ ├── cliproxy-command.ts # CLIProxy subcommand handling │ ├── config-command.ts # Config management commands -│ ├── config-image-analysis-command.ts # Image analysis hook config (NEW v7.34) +│ ├── config-image-analysis-command.ts # First-class ImageAnalysis config (NEW v7.34) │ ├── named-command-router.ts # Reusable named-command dispatcher │ ├── doctor-command.ts # Health diagnostics │ ├── env-command.ts # Export shell env vars for third-party tools (v7.39) @@ -149,7 +149,7 @@ src/ │ ├── index.ts # Barrel export │ ├── checks/ # Diagnostic checks │ │ ├── index.ts -│ │ └── image-analysis-check.ts # Image hook validation (NEW v7.34) +│ │ └── image-analysis-check.ts # ImageAnalysis runtime validation (NEW v7.34) │ └── repair/ # Auto-repair logic │ └── index.ts │ @@ -169,15 +169,17 @@ src/ │ │ └── spinners.ts # Progress spinners │ ├── websearch/ # Search tool integrations │ │ └── index.ts -│ ├── hooks/ # Claude Code hooks (NEW v7.34) +│ ├── hooks/ # Claude Code compatibility 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) +│ ├── image-analysis/ # ImageAnalysis MCP/runtime utilities (NEW v7.34) │ │ ├── index.ts -│ │ └── hook-installer.ts +│ │ ├── hook-installer.ts +│ │ ├── mcp-installer.ts +│ │ └── claude-tool-args.ts │ └── [utility files...] │ └── web-server/ # Express web server (heavily modularized) @@ -636,4 +638,5 @@ tests/ - [System Architecture](./system-architecture/index.md) - High-level architecture diagrams - [Project Roadmap](./project-roadmap.md) - Modularization phases and future work - [WebSearch](./websearch.md) - WebSearch feature documentation +- [Image Analysis](./image-analysis.md) - First-class ImageAnalysis runtime documentation - [CLAUDE.md](../CLAUDE.md) - AI-facing development guidance diff --git a/docs/image-analysis.md b/docs/image-analysis.md new file mode 100644 index 00000000..27217400 --- /dev/null +++ b/docs/image-analysis.md @@ -0,0 +1,108 @@ +# Image Analysis Configuration Guide + +CCS provides first-class image and PDF analysis for third-party Claude launches that do not have reliable native vision support. + +## How Image Analysis Works + +Native Claude accounts keep Anthropic's own vision flow. + +Third-party profiles now use a CCS-managed local MCP tool named `ImageAnalysis` when the runtime is available. CCS also appends a short steering hint so Claude prefers that tool over `Read` for local image and PDF files. + +If the managed runtime, auth, or proxy path is unavailable, CCS falls back to native `Read` instead of failing the whole launch. The old `Read` hook remains only as a compatibility fallback when it can be installed safely. + +## Routing Model + +ImageAnalysis requests go straight to the CCS-managed provider route: + +```text +Claude -> ccs-image-analysis MCP -> CCS provider route -> /api/provider//v1/messages +``` + +Important: +- CCS does not relay image analysis through Claude Code, another CLI, or a second model wrapper. +- For bridge-backed settings profiles, CCS resolves the backend and provider path before launch. +- CCS avoids leaking a profile's ordinary third-party `ANTHROPIC_BASE_URL` or token into image analysis unless that profile is explicitly using a CLIProxy bridge. + +## Profile Behavior + +| Profile Type | Image Method | +|--------------|--------------| +| Claude `default` / `account` | Native Claude vision / native `Read` | +| Third-party settings / CLIProxy / Copilot | CCS local `ImageAnalysis` MCP tool when ready | +| Third-party when runtime unavailable | Native `Read` fallback | + +## Configuration + +Configure via dashboard (`Settings -> Image`) or `~/.ccs/config.yaml`: + +```yaml +image_analysis: + enabled: true + timeout: 60 + fallback_backend: agy + provider_models: + agy: gemini-3-1-flash-preview + codex: gpt-5.1-codex-mini + ghcp: claude-haiku-4.5 +``` + +Useful commands: + +```bash +ccs config image-analysis +ccs config image-analysis --enable +ccs config image-analysis --disable +ccs config image-analysis --set-fallback agy +ccs config image-analysis --set-profile-backend glm agy +ccs config image-analysis --clear-profile-backend glm +``` + +## Prompt Templates + +CCS installs editable prompt templates at: + +```text +~/.ccs/prompts/image-analysis/ +``` + +Templates: +- `default.txt` +- `screenshot.txt` +- `document.txt` + +CCS automatically selects `screenshot` for screenshot-like filenames, `document` for PDFs, and `default` otherwise. + +## Runtime Environment + +Key runtime env vars: + +| Variable | Purpose | +|----------|---------| +| `CCS_IMAGE_ANALYSIS_SKIP` | Disable image analysis for the current launch | +| `CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL` | Explicit CCS runtime base URL | +| `CCS_IMAGE_ANALYSIS_RUNTIME_PATH` | Provider route such as `/api/provider/agy` | +| `CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY` | Explicit CCS runtime auth key | +| `CCS_IMAGE_ANALYSIS_MODEL` | Force a single image-analysis model | +| `CCS_DEBUG` | Verbose runtime logging | + +## Troubleshooting + +### Claude still uses `Read` + +- Confirm `ccs config image-analysis` shows `enabled: true` +- Check the active profile resolves to a configured backend +- Run with `CCS_DEBUG=1` to see runtime preparation details + +### ImageAnalysis is not exposed + +- Verify CLIProxy auth for the resolved backend +- Verify the local or remote CLIProxy target is reachable +- Check `~/.claude.json` and inherited account configs for `ccs-image-analysis` + +### I need to prove requests are going directly to the provider route + +Run with `CCS_DEBUG=1` and inspect the resolved runtime path. The request target should be provider-scoped, for example: + +```text +/api/provider/agy/v1/messages +``` diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 9df5d38a..a1ea1475 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -1,6 +1,6 @@ # CCS Product Development Requirements (PDR) -Last Updated: 2026-03-24 +Last Updated: 2026-04-02 ## Product Overview @@ -10,7 +10,7 @@ Last Updated: 2026-03-24 **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, hybrid quota management, and official Claude channel runtime setup for Telegram, Discord, and iMessage. -**Current Version**: v7.34.x (Image Analysis Hook + Performance Improvements) +**Current Version**: v7.34.x+ (First-class ImageAnalysis MCP tooling, WebSearch MCP, performance improvements) --- @@ -36,8 +36,9 @@ CCS provides: 4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API 5. **Visual Dashboard**: React SPA for configuration management 6. **Automatic WebSearch**: First-class local WebSearch tool with deterministic provider chain for third-party providers -7. **Usage Analytics**: Token tracking, cost analysis, model breakdown -8. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage +7. **Automatic Image Analysis**: First-class local ImageAnalysis tool with direct provider routing for third-party profiles +8. **Usage Analytics**: Token tracking, cost analysis, model breakdown +9. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage --- @@ -99,6 +100,13 @@ CCS provides: - Keep Gemini CLI, OpenCode, and Grok as optional legacy fallback - Graceful fallback chain +### FR-007A: First-Class Image Analysis +- Expose a CCS-managed local `ImageAnalysis` MCP tool for third-party profiles that need provider-backed vision +- Resolve the provider route before launch and send requests directly to `/api/provider//v1/messages` +- Use editable prompt templates for `default`, `screenshot`, and `document` analysis modes +- Keep the old `Read` hook as compatibility fallback only, not the primary user experience +- Fall back to native `Read` without failing the whole launch when the managed runtime is unavailable + ### FR-008: Remote CLIProxy Support - Connect to remote CLIProxyAPI instances - CLI flags for proxy configuration (--proxy-host, --proxy-port, etc.) @@ -277,10 +285,11 @@ CCS provides: - [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] First-class `ImageAnalysis` MCP tool for third-party launches +- [x] Direct provider-scoped routing for image analysis requests +- [x] Prompt template selection for default / screenshot / document flows +- [x] Hook fallback retained only for compatibility +- [x] Non-fatal native `Read` fallback when managed runtime is unavailable - [x] `ccs config image-analysis` CLI command - [x] Doctor integration for hook validation - [x] 791-line E2E test suite for image analysis diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 182449dc..f8d5ca5c 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-01 +Last Updated: 2026-04-02 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-02**: Third-party image and PDF analysis now follows the same first-class local-tool model as WebSearch. CCS provisions `ccs-image-analysis` as a managed MCP tool, routes requests directly to provider-scoped CCS endpoints such as `/api/provider/agy/v1/messages`, keeps editable prompt templates under `~/.ccs/prompts/image-analysis/`, and demotes the old `Read` hook to a best-effort compatibility fallback. Launches now stay non-fatal and fall back to native `Read` when the managed runtime cannot be prepared. - **2026-04-01**: The `Compatible -> Codex CLI` dashboard now exposes manual long-context controls for `model_context_window` and `model_auto_compact_token_limit`. CCS reads and patches those upstream Codex config keys directly, adds official guidance that GPT-5.4 long context is experimental and opt-in, and keeps the behavior manual-only so the dashboard never auto-fills or auto-saves long-context values for the user. - **2026-03-30**: **#862** Third-party WebSearch now uses a first-class CCS-managed MCP tool path instead of relying on a denied native Anthropic `WebSearch` call as the normal UX. CCS provisions `ccs-websearch` into `~/.claude.json`, syncs it into isolated account configs when needed, suppresses native `WebSearch` on third-party launches, preserves the provider order `Exa -> Tavily -> Brave -> DuckDuckGo -> legacy CLI fallback`, and keeps the old hook runtime only as shared provider plumbing plus compatibility fallback. Uninstall cleanup now also removes the managed WebSearch MCP runtime. - **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file. diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index a9e4c8a1..768c8e0f 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -537,29 +537,29 @@ Image Analysis Hook enables vision model proxying through CLIProxy with automati Claude CLI processes image request | v - Hook intercepts image request + Claude prefers ImageAnalysis MCP tool | v - Vision Model Proxying (via CLIProxyAPI) + CCS provider-backed image analysis | - +---> Gemini, Codex, AGY support vision + +---> Provider route resolved before launch | - +---> Kiro (Claude native vision) + +---> Direct request to /api/provider//v1/messages | - +---> Skip for Claude Sub accounts (native vision) + +---> Native Read fallback if runtime/auth/proxy is unavailable | v - Vision response returned to Claude CLI + Text description returned to Claude CLI ``` -### Hook Environment +### Runtime Environment ```typescript // getImageAnalysisHookEnv() { - ANTHROPIC_IMAGE_HOOK_URL: 'http://localhost:8317/api/image-analysis', - // or for remote proxy: - ANTHROPIC_IMAGE_HOOK_URL: 'https://proxy.example.com:8317/api/image-analysis', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: 'http://127.0.0.1:8317', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy', + CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'ccs-internal-managed', } ``` @@ -567,12 +567,12 @@ Image Analysis Hook enables vision model proxying through CLIProxy with automati | Provider | Vision Support | Notes | |----------|---|---| -| Gemini | ✓ | Via CLIProxy image analysis | -| Codex | ✓ | Via CLIProxy image analysis | -| Antigravity | ✓ | Via CLIProxy image analysis | -| Kiro | ✓ | Native Claude vision (no proxy needed) | -| Copilot | ✗ | Not supported | -| GLM/Kimi | ✗ | Requires direct API implementation | +| Gemini | ✓ | Via CCS ImageAnalysis provider route | +| Codex | ✓ | Via CCS ImageAnalysis provider route | +| Antigravity | ✓ | Via CCS ImageAnalysis provider route | +| Kiro | ✓ | Via mapped CCS provider route when configured | +| Copilot | ✓ | Via mapped ghcp provider route | +| GLM/Kimi | ✓ | Via explicit or fallback backend mapping | --- diff --git a/lib/hooks/image-analysis-runtime.cjs b/lib/hooks/image-analysis-runtime.cjs new file mode 100644 index 00000000..13f1e66b --- /dev/null +++ b/lib/hooks/image-analysis-runtime.cjs @@ -0,0 +1,469 @@ +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const path = require('path'); + +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 MAX_PROMPT_TEMPLATE_BYTES = 32 * 1024; +const SCREENSHOT_NAME_REGEX = + /(screen[-_ ]?shot|screen[-_ ]?capture|screencap|snapshot|snip|clip|capture)/i; +const TEMPLATE_FILE_NAMES = { + default: 'default.txt', + screenshot: 'screenshot.txt', + document: 'document.txt', +}; +const FALLBACK_PROMPTS = { + default: `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.`, + screenshot: `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.`, + document: `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.`, +}; + +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 && value !== '') { + lines.push(` ${key}: ${value}`); + } + } + console.error(lines.join('\n')); +} + +function parseProviderModels(envValue) { + if (!envValue) return {}; + return envValue.split(',').reduce((acc, pair) => { + const [provider, ...modelParts] = pair.split(':'); + const model = modelParts.join(':').trim(); + if (provider && model) { + acc[provider.trim()] = model; + } + return acc; + }, {}); +} + +function normalizeTemplateName(value) { + if (typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + return Object.prototype.hasOwnProperty.call(TEMPLATE_FILE_NAMES, normalized) ? normalized : null; +} + +function selectPromptTemplate(filePath, requestedTemplate) { + const explicitTemplate = normalizeTemplateName(requestedTemplate); + if (explicitTemplate) { + return explicitTemplate; + } + + const extension = path.extname(filePath).toLowerCase(); + if (PDF_EXTENSIONS.includes(extension)) { + return 'document'; + } + + return SCREENSHOT_NAME_REGEX.test(path.basename(filePath)) ? 'screenshot' : 'default'; +} + +function readPromptFile(filePath) { + try { + const stats = fs.statSync(filePath); + if (stats.size > MAX_PROMPT_TEMPLATE_BYTES) { + return null; + } + const content = fs.readFileSync(filePath, 'utf8').trim(); + return content.length > 0 ? content : null; + } catch { + return null; + } +} + +function loadPromptTemplate(filePath, requestedTemplate, focus) { + const template = selectPromptTemplate(filePath, requestedTemplate); + const promptsDir = process.env.CCS_IMAGE_ANALYSIS_PROMPTS_DIR || ''; + const promptPath = promptsDir + ? path.join(promptsDir, TEMPLATE_FILE_NAMES[template]) + : null; + const promptText = (promptPath && readPromptFile(promptPath)) || FALLBACK_PROMPTS[template]; + + if (!focus || !focus.trim()) { + return { + template, + promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback', + prompt: promptText, + }; + } + + return { + template, + promptSource: promptPath ? 'installed-or-fallback' : 'bundled-fallback', + prompt: `${promptText}\n\nSpecific focus:\n${focus.trim()}`, + }; +} + +function getCurrentProvider() { + return process.env.CCS_CURRENT_PROVIDER || ''; +} + +function getConfiguredModel() { + const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL; + if (explicitModel && explicitModel.trim()) { + return explicitModel.trim(); + } + + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + return providerModels[getCurrentProvider()] || DEFAULT_MODEL; +} + +function getModelsToTry() { + const models = []; + const seen = new Set(); + + const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL; + if (explicitModel && explicitModel.trim()) { + models.push(explicitModel.trim()); + seen.add(explicitModel.trim()); + } + + const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); + const providerModel = providerModels[getCurrentProvider()]; + if (providerModel && !seen.has(providerModel)) { + models.push(providerModel); + seen.add(providerModel); + } + + if (models.length === 0) { + models.push(DEFAULT_MODEL); + } + + return models; +} + +function getRuntimeBaseUrl() { + const runtimePath = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH || '') + .trim() + .replace(/\/+$/, ''); + const explicitBaseUrl = process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL; + if (explicitBaseUrl && explicitBaseUrl.trim()) { + const normalizedBaseUrl = explicitBaseUrl.trim().replace(/\/+$/, ''); + if (!runtimePath) { + return normalizedBaseUrl; + } + + try { + const parsed = new URL(normalizedBaseUrl); + const normalizedPath = parsed.pathname.replace(/\/+$/, ''); + if (normalizedPath === runtimePath) { + return normalizedBaseUrl; + } + + parsed.pathname = runtimePath; + return parsed.toString().replace(/\/+$/, ''); + } catch { + return `${normalizedBaseUrl}${runtimePath}`; + } + } + + const port = Number.parseInt(process.env.CCS_CLIPROXY_PORT || '8317', 10); + return `http://127.0.0.1:${port}${runtimePath}`; +} + +function getRuntimeEndpoint() { + return `${getRuntimeBaseUrl()}/v1/messages`; +} + +function getApiKey() { + if (Object.prototype.hasOwnProperty.call(process.env, 'CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY')) { + const explicitApiKey = (process.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY || '').trim(); + return explicitApiKey || 'ccs-internal-managed'; + } + + return process.env.CCS_CLIPROXY_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || 'ccs-internal-managed'; +} + +function shouldAllowSelfSigned() { + const value = `${process.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED || ''}`.trim().toLowerCase(); + return value === '1' || value === 'true' || value === 'yes'; +} + +function getTimeoutMs(timeoutMs) { + if (typeof timeoutMs === 'number' && timeoutMs > 0) { + return timeoutMs; + } + + const timeoutSec = Number.parseInt( + process.env.CCS_IMAGE_ANALYSIS_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`, + 10 + ); + return Math.max(1, Math.min(600, timeoutSec)) * 1000; +} + +function isAnalyzableFile(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return IMAGE_EXTENSIONS.includes(ext) || PDF_EXTENSIONS.includes(ext); +} + +function getMediaType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return ( + { + '.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', + }[ext] || 'application/octet-stream' + ); +} + +function encodeFileToBase64(filePath) { + return fs.readFileSync(filePath).toString('base64'); +} + +function buildContentBlock(base64Data, mediaType) { + const source = { + type: 'base64', + media_type: mediaType, + data: base64Data, + }; + + if (mediaType === 'application/pdf') { + return { + type: 'document', + source, + }; + } + + return { + type: 'image', + source, + }; +} + +function extractTextContent(response) { + if (!response || !Array.isArray(response.content)) { + return null; + } + + const textBlocks = response.content + .filter((block) => block && block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .filter((text) => text.trim()); + + return textBlocks.length > 0 ? textBlocks.join('\n\n') : null; +} + +function parseCliProxyResponse(data) { + const response = JSON.parse(data); + const text = extractTextContent(response); + if (!text) { + throw new Error('No text content in response'); + } + return text; +} + +function analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs) { + return new Promise((resolve, reject) => { + const endpoint = new URL(getRuntimeEndpoint()); + const transport = endpoint.protocol === 'https:' ? https : http; + const apiKey = getApiKey(); + const requestBody = JSON.stringify({ + model, + max_tokens: 4096, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + buildContentBlock(base64Data, mediaType), + ], + }, + ], + }); + + const req = transport.request( + { + protocol: endpoint.protocol, + hostname: endpoint.hostname, + port: endpoint.port, + path: `${endpoint.pathname}${endpoint.search}`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody), + 'x-api-key': apiKey, + Authorization: `Bearer ${apiKey}`, + }, + timeout: timeoutMs, + ...(endpoint.protocol === 'https:' && shouldAllowSelfSigned() + ? { rejectUnauthorized: false } + : {}), + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + if (res.statusCode === 401 || res.statusCode === 403) { + reject(new Error(`AUTH_ERROR:${res.statusCode}`)); + return; + } + + if (res.statusCode === 429) { + reject(new Error(`RATE_LIMIT:${res.headers['retry-after'] || ''}`)); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`API_ERROR:${res.statusCode}:${data}`)); + return; + } + + try { + resolve(parseCliProxyResponse(data)); + } catch (error) { + reject(error); + } + }); + } + ); + + req.on('error', (error) => reject(error)); + req.on('timeout', () => { + req.destroy(); + reject(new Error('TIMEOUT')); + }); + req.write(requestBody); + req.end(); + }); +} + +async function analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs) { + const models = getModelsToTry(); + let lastError = null; + + for (const [index, model] of models.entries()) { + try { + debugLog(`Trying model ${index + 1}/${models.length}`, { model }); + const description = await analyzeViaCliProxy(base64Data, mediaType, model, prompt, timeoutMs); + return { description, model }; + } catch (error) { + lastError = error; + const message = error.message || ''; + if ( + index === models.length - 1 || + ['AUTH_ERROR', 'RATE_LIMIT', 'TIMEOUT', 'EACCES', 'EPERM', 'ECONNREFUSED'].some((token) => + message.includes(token) + ) + ) { + throw error; + } + } + } + + throw lastError || new Error('No models configured for image analysis'); +} + +async function analyzeFile(filePath, options = {}) { + const stats = fs.statSync(filePath); + if (stats.size >= MAX_FILE_SIZE_BYTES) { + throw new Error(`FILE_TOO_LARGE:${stats.size}`); + } + + const timeoutMs = getTimeoutMs(options.timeoutMs); + const { template, prompt, promptSource } = loadPromptTemplate( + filePath, + options.template, + options.focus + ); + const model = getConfiguredModel(); + + debugLog('Starting image analysis', { + file: path.basename(filePath), + size: `${(stats.size / 1024).toFixed(1)} KB`, + provider: getCurrentProvider() || 'unknown', + model, + modelsToTry: getModelsToTry().join(' -> '), + timeout: `${timeoutMs / 1000}s`, + endpoint: getRuntimeEndpoint(), + template, + promptSource, + }); + + const base64Data = encodeFileToBase64(filePath); + const mediaType = getMediaType(filePath); + debugLog('File encoded', { + mediaType, + base64Length: `${(base64Data.length / 1024).toFixed(1)}KB`, + }); + + const result = await analyzeWithRetry(base64Data, mediaType, prompt, timeoutMs); + debugLog('Analysis complete', { + responseLength: `${result.description.length} chars`, + model: result.model, + template, + }); + + return { + description: result.description, + model: result.model, + fileSize: stats.size, + mediaType, + template, + }; +} + +module.exports = { + DEFAULT_MODEL, + DEFAULT_TIMEOUT_SEC, + MAX_FILE_SIZE_BYTES, + analyzeFile, + getRuntimeEndpoint, + isAnalyzableFile, + parseProviderModels, + selectPromptTemplate, +}; diff --git a/lib/hooks/image-analyzer-transformer.cjs b/lib/hooks/image-analyzer-transformer.cjs index ba86f4d3..77f1ee11 100755 --- a/lib/hooks/image-analyzer-transformer.cjs +++ b/lib/hooks/image-analyzer-transformer.cjs @@ -24,7 +24,7 @@ const fs = require('fs'); const path = require('path'); -const http = require('http'); +const { analyzeFile, isAnalyzableFile, parseProviderModels } = require('./image-analysis-runtime.cjs'); // ============================================================================ // PLATFORM DETECTION @@ -36,19 +36,8 @@ 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) @@ -65,23 +54,6 @@ const ERROR_CODES = { 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 @@ -105,19 +77,19 @@ function debugLog(message, data = {}) { */ 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 model = + process.env.CCS_IMAGE_ANALYSIS_MODEL || + parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[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}`, + endpoint: process.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL || '(runtime fallback)', }; } @@ -126,319 +98,12 @@ function getDebugContext(filePath, stats) { */ 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; + const model = + process.env.CCS_IMAGE_ANALYSIS_MODEL || + parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS)[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; -} - -/** - * Extract concatenated text content from CLIProxy response blocks. - * Skips thinking and other non-text blocks. - */ -function extractTextContent(response) { - if (!response || !Array.isArray(response.content)) { - return null; - } - - const textBlocks = response.content - .filter((block) => block && block.type === 'text' && typeof block.text === 'string') - .map((block) => block.text) - .filter((text) => text.trim()); - - if (textBlocks.length === 0) { - return null; - } - - return textBlocks.join('\n\n'); -} - -/** - * Parse raw CLIProxy response body and extract text content. - */ -function parseCliProxyResponse(data) { - let response; - try { - response = JSON.parse(data); - } catch (err) { - throw new Error(`Failed to parse response: ${err.message}`); - } - - const text = extractTextContent(response); - if (!text) { - throw new Error('No text content in response'); - } - - return text; -} - -/** - * 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 when no provider-specific vision model is configured - * - * ANTHROPIC_MODEL is intentionally ignored here because the chat model may - * not be vision-capable on the current provider route. - */ -function getModelsToTry() { - const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; - const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); - - const models = []; - const seen = new Set(); - - // 1. Provider-specific model - if (providerModels[currentProvider]) { - models.push(providerModels[currentProvider]); - seen.add(providerModels[currentProvider]); - } - - // 2. Default model — only when no provider-specific model is configured, - // since DEFAULT_MODEL (gemini-2.5-flash) may not be routable on the - // current provider's CLIProxy endpoint (e.g. codex, claude) - if (models.length === 0 && !seen.has(DEFAULT_MODEL)) { - models.push(DEFAULT_MODEL); - seen.add(DEFAULT_MODEL); - } - - 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 text = parseCliProxyResponse(data); - resolve(text); - } catch (err) { - reject(err); - } - }); - } - ); - - 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) */ @@ -520,29 +185,6 @@ function outputFileTooLargeError(filePath, actualSizeMB, maxSizeMB) { 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( @@ -758,10 +400,11 @@ function shouldSkipHook() { } // Check if current provider has a vision model configured + const explicitModel = process.env.CCS_IMAGE_ANALYSIS_MODEL; const currentProvider = process.env.CCS_CURRENT_PROVIDER || ''; const providerModels = parseProviderModels(process.env.CCS_IMAGE_ANALYSIS_PROVIDER_MODELS); - if (!providerModels[currentProvider]) { + if (!explicitModel?.trim() && !providerModels[currentProvider]) { debugLog(`Skipping: provider "${currentProvider}" not in provider_models`, { configured_providers: Object.keys(providerModels).join(', ') || 'none', }); @@ -832,57 +475,15 @@ async function processHook() { process.exit(0); } - // Check if file exists if (!fs.existsSync(filePath)) { - // Let native Read handle the error 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; - } + const debugContext = getDebugContext(filePath, null); + debugLog('Image analysis runtime prepared', debugContext); - // 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); + const result = await analyzeFile(filePath); + outputSuccess(filePath, result.description, result.model, result.fileSize); } catch (err) { if (process.env.CCS_DEBUG) { console.error('[CCS Hook] Error:', err.message); @@ -893,7 +494,10 @@ async function processHook() { // Categorize error by message pattern const errMsg = err.message || ''; - if (errMsg.startsWith('AUTH_ERROR:')) { + if (errMsg.startsWith('FILE_TOO_LARGE:')) { + const fileSizeMb = Number.parseInt(errMsg.split(':')[1], 10) / 1024 / 1024; + outputFileTooLargeError(filePath, fileSizeMb, 10); + } else if (errMsg.startsWith('AUTH_ERROR:')) { const statusCode = parseInt(errMsg.split(':')[1], 10); outputAuthError(filePath, statusCode); } else if (errMsg.startsWith('RATE_LIMIT:')) { @@ -907,8 +511,13 @@ async function processHook() { } 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('ECONNREFUSED') || + errMsg.includes('ENOTFOUND') || + errMsg.includes('ENETUNREACH') || + errMsg.includes('EAI_AGAIN') + ) { + outputCliProxyUnavailableFallback(filePath); } else if (errMsg.includes('EACCES') || errMsg.includes('EPERM')) { outputFileAccessError(filePath, errMsg); } else { diff --git a/lib/mcp/ccs-image-analysis-server.cjs b/lib/mcp/ccs-image-analysis-server.cjs new file mode 100644 index 00000000..a5d2a057 --- /dev/null +++ b/lib/mcp/ccs-image-analysis-server.cjs @@ -0,0 +1,440 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +function loadRuntimeModule() { + const candidates = [ + path.join(__dirname, 'image-analysis-runtime.cjs'), + path.join(__dirname, '../hooks/image-analysis-runtime.cjs'), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return require(candidate); + } + } + + throw new Error( + `ccs-image-analysis runtime not found. Checked: ${candidates.map((candidate) => path.basename(candidate)).join(', ')}` + ); +} + +const { analyzeFile, isAnalyzableFile } = loadRuntimeModule(); + +const PROTOCOL_VERSION = '2024-11-05'; +const SERVER_NAME = 'ccs-image-analysis'; +const SERVER_VERSION = '1.0.0'; +const TOOL_NAME = 'ImageAnalysis'; +const TOOL_ALIASES = ['AnalyzeImage', 'ReadImage']; +const TEMPLATE_NAMES = ['default', 'screenshot', 'document']; +const TOOL_DESCRIPTION = + 'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.'; + +function isSupportedToolName(name) { + return name === TOOL_NAME || TOOL_ALIASES.includes(name); +} + +function shouldExposeTools() { + return ( + process.env.CCS_IMAGE_ANALYSIS_ENABLED === '1' && + process.env.CCS_IMAGE_ANALYSIS_SKIP !== '1' && + Boolean(process.env.CCS_CURRENT_PROVIDER || process.env.CCS_IMAGE_ANALYSIS_MODEL) + ); +} + +function getTools() { + if (!shouldExposeTools()) { + return []; + } + + return [ + { + name: TOOL_NAME, + description: TOOL_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: + 'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.', + }, + focus: { + type: 'string', + description: + 'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".', + }, + template: { + type: 'string', + enum: TEMPLATE_NAMES, + description: + 'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.', + }, + }, + required: ['filePath'], + additionalProperties: false, + }, + }, + ]; +} + +function writeMessage(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function writeResponse(id, result) { + writeMessage({ + jsonrpc: '2.0', + id, + result, + }); +} + +function writeError(id, code, message) { + writeMessage({ + jsonrpc: '2.0', + id, + error: { + code, + message, + }, + }); +} + +function formatResult(filePath, result, focus) { + const lines = [ + '[Image Analysis via CCS]', + '', + `File: ${path.basename(filePath)} (${(result.fileSize / 1024).toFixed(1)} KB)`, + `Model: ${result.model}`, + `Template: ${result.template}`, + ]; + + if (focus && focus.trim()) { + lines.push(`Focus: ${focus.trim()}`); + } + + lines.push('', '---', '', result.description); + return lines.join('\n'); +} + +function normalizeTemplate(value) { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + return TEMPLATE_NAMES.includes(normalized) ? normalized : undefined; +} + +function normalizePathForComparison(value) { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +function isPathWithinWorkspace(workspaceRoot, candidatePath) { + const relativePath = path.relative(workspaceRoot, candidatePath); + return ( + relativePath === '' || + (!relativePath.startsWith('..') && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath)) + ); +} + +function resolveFilePath(toolArgs) { + if (!toolArgs || typeof toolArgs !== 'object') { + return ''; + } + + const candidates = [toolArgs.filePath, toolArgs.file_path, toolArgs.path]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate.trim(); + } + } + + return ''; +} + +function resolveWorkspaceFilePath(toolArgs) { + const requestedPath = resolveFilePath(toolArgs); + if (!requestedPath) { + return { filePath: '', error: null }; + } + + const workspaceRoot = (() => { + try { + return fs.realpathSync(process.cwd()); + } catch { + return path.resolve(process.cwd()); + } + })(); + const absolutePath = path.resolve(process.cwd(), requestedPath); + const comparisonPath = (() => { + if (fs.existsSync(absolutePath)) { + return fs.realpathSync(absolutePath); + } + + const suffixSegments = []; + let currentPath = absolutePath; + while (!fs.existsSync(currentPath)) { + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + break; + } + suffixSegments.unshift(path.basename(currentPath)); + currentPath = parentPath; + } + + const resolvedExistingPath = fs.existsSync(currentPath) + ? fs.realpathSync(currentPath) + : path.resolve(currentPath); + return path.join(resolvedExistingPath, ...suffixSegments); + })(); + + if ( + !isPathWithinWorkspace( + normalizePathForComparison(workspaceRoot), + normalizePathForComparison(comparisonPath) + ) + ) { + return { + filePath: '', + error: 'ImageAnalysis only allows files inside the current workspace.', + }; + } + + return { + filePath: absolutePath, + error: null, + }; +} + +function resolveFocus(toolArgs) { + return typeof toolArgs.focus === 'string' && toolArgs.focus.trim().length > 0 + ? toolArgs.focus.trim() + : undefined; +} + +function formatErrorDetail(filePath, error) { + const message = error instanceof Error ? error.message : String(error); + + if (message.startsWith('FILE_TOO_LARGE:')) { + const fileSizeBytes = Number.parseInt(message.split(':')[1], 10); + const sizeMb = Number.isFinite(fileSizeBytes) ? (fileSizeBytes / 1024 / 1024).toFixed(1) : '?'; + return `ImageAnalysis cannot process ${path.basename(filePath)} because it is too large (${sizeMb} MB). The limit is 10 MB.`; + } + + if (message.startsWith('AUTH_ERROR:')) { + return `ImageAnalysis failed because CCS vision auth for this provider is unavailable (${message.split(':')[1]}).`; + } + + if (message.startsWith('RATE_LIMIT:')) { + return `ImageAnalysis hit a provider rate limit while analyzing ${path.basename(filePath)}.`; + } + + if (message.startsWith('API_ERROR:')) { + return `ImageAnalysis failed at the CCS provider route while analyzing ${path.basename(filePath)}.`; + } + + if ( + message === 'TIMEOUT' || + message.includes('timed out') || + message.includes('timeout') || + message.includes('ECONNREFUSED') || + message.includes('ENOTFOUND') || + message.includes('ENETUNREACH') || + message.includes('EAI_AGAIN') + ) { + return `ImageAnalysis could not reach the configured CCS provider route for ${path.basename(filePath)}.`; + } + + if (message.includes('EACCES') || message.includes('EPERM')) { + return `ImageAnalysis could not read ${filePath} because access was denied.`; + } + + return `ImageAnalysis failed for ${path.basename(filePath)}: ${message}`; +} + +async function handleToolCall(message) { + const id = message.id; + const params = message.params || {}; + const toolArgs = params.arguments || {}; + const toolName = params.name || ''; + + if (!isSupportedToolName(toolName)) { + writeError(id, -32602, `Unknown tool: ${toolName}`); + return; + } + + if (!shouldExposeTools()) { + writeResponse(id, { + content: [ + { + type: 'text', + text: 'CCS ImageAnalysis is unavailable for this profile or no provider-backed vision route is ready.', + }, + ], + isError: true, + }); + return; + } + + const { filePath, error: filePathError } = resolveWorkspaceFilePath(toolArgs); + if (!filePath) { + writeError( + id, + -32602, + filePathError || `Tool "${TOOL_NAME}" requires a non-empty filePath.` + ); + return; + } + + if (!fs.existsSync(filePath)) { + writeResponse(id, { + content: [{ type: 'text', text: `ImageAnalysis could not find file: ${filePath}` }], + isError: true, + }); + return; + } + + if (!isAnalyzableFile(filePath)) { + writeResponse(id, { + content: [ + { + type: 'text', + text: `ImageAnalysis only supports image and PDF files. Use Read for ${path.basename(filePath)} instead.`, + }, + ], + isError: true, + }); + return; + } + + const focus = resolveFocus(toolArgs); + const template = normalizeTemplate(toolArgs.template); + + try { + const result = await analyzeFile(filePath, { focus, template }); + writeResponse(id, { + content: [{ type: 'text', text: formatResult(filePath, result, focus) }], + }); + } catch (error) { + writeResponse(id, { + content: [{ type: 'text', text: formatErrorDetail(filePath, error) }], + isError: true, + }); + } +} + +async function handleMessage(message) { + if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return; + } + + switch (message.method) { + case 'initialize': + writeResponse(message.id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { + tools: {}, + }, + serverInfo: { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + }); + return; + case 'notifications/initialized': + return; + case 'ping': + writeResponse(message.id, {}); + return; + case 'tools/list': + writeResponse(message.id, { tools: getTools() }); + return; + case 'tools/call': + await handleToolCall(message); + return; + default: + if (message.id !== undefined) { + writeError(message.id, -32601, `Method not found: ${message.method}`); + } + } +} + +let inputBuffer = Buffer.alloc(0); + +function processIncomingBuffer() { + while (true) { + let body; + const startsWithLegacyHeaders = inputBuffer + .slice(0, Math.min(inputBuffer.length, 32)) + .toString('utf8') + .toLowerCase() + .startsWith('content-length:'); + + if (startsWithLegacyHeaders) { + const headerEnd = inputBuffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } + + const headerText = inputBuffer.slice(0, headerEnd).toString('utf8'); + const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i); + if (!contentLengthMatch) { + inputBuffer = Buffer.alloc(0); + return; + } + + const contentLength = Number.parseInt(contentLengthMatch[1], 10); + const messageEnd = headerEnd + 4 + contentLength; + if (inputBuffer.length < messageEnd) { + return; + } + + body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8'); + inputBuffer = inputBuffer.slice(messageEnd); + } else { + const newlineIndex = inputBuffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + inputBuffer = inputBuffer.slice(newlineIndex + 1); + if (!body) { + continue; + } + } + + try { + const message = JSON.parse(body); + Promise.resolve(handleMessage(message)).catch((error) => { + if (message && message.id !== undefined) { + writeError(message.id, -32603, (error && error.message) || 'Internal error'); + } else if (process.env.CCS_DEBUG) { + console.error(`[ccs-image-analysis] ${error instanceof Error ? error.stack : error}`); + } + }); + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + `[ccs-image-analysis] Failed to parse JSON-RPC message: ${ + error instanceof Error ? error.message : error + }` + ); + } + } + } +} + +process.stdin.on('data', (chunk) => { + inputBuffer = Buffer.concat([inputBuffer, chunk]); + processIncomingBuffer(); +}); + +process.stdin.on('end', () => { + process.exit(0); +}); diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index 50f3b86b..28d43d11 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -11,6 +11,7 @@ import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { isSensitiveKey } from '../../utils/sensitive-keys'; import { isReservedName } from '../../config/reserved-names'; import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader'; @@ -219,6 +220,7 @@ export function registerApiProfileOrphans(options?: { try { if (orphan.validation.valid) { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false); result.registered.push(orphan.name); @@ -269,6 +271,7 @@ export function copyApiProfile( writeJsonObjectAtomically(destinationSettingsPath, sourceSettings); try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (hookError) { rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted); throw hookError; @@ -394,6 +397,7 @@ export function importApiProfileBundle( writeJsonObjectAtomically(settingsPath, settings); try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (hookError) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw hookError; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index efb70c32..a39fdce9 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -9,6 +9,7 @@ import { expandPath } from '../../utils/helpers'; import { validateApiName } from './validation-service'; import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; import { isReservedName } from '../../config/reserved-names'; @@ -127,6 +128,7 @@ function createSettingsFile( try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; @@ -216,6 +218,7 @@ function createApiProfileUnified( try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; diff --git a/src/ccs.ts b/src/ccs.ts index aafaee74..f45172c3 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -31,11 +31,18 @@ import { appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, } from './utils/websearch-manager'; +import { + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, + appendThirdPartyImageAnalysisToolArgs, +} from './utils/image-analysis'; import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; import { + applyImageAnalysisRuntimeOverrides, getImageAnalysisHookEnv, - installImageAnalyzerHook, + prepareImageAnalysisFallbackHook, + resolveImageAnalysisRuntimeConnection, resolveImageAnalysisRuntimeStatus, } from './utils/hooks'; import { fail, info, warn } from './utils/ui'; @@ -685,7 +692,10 @@ async function main(): Promise { // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } + const imageAnalysisFallbackHookReady = + resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false; const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider); // Inject Image Analyzer hook into profile settings before launch ensureImageAnalyzerHooks({ @@ -694,6 +704,7 @@ async function main(): Promise { cliproxyProvider: provider, isComposite: profileInfo.isComposite, settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles const variantPort = profileInfo.port; // variant-specific port for isolation @@ -848,11 +859,14 @@ async function main(): Promise { } else if (profileInfo.type === 'copilot') { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy ensureWebSearchMcpOrThrow(); - installImageAnalyzerHook(); + ensureImageAnalysisMcpOrThrow(); + const imageAnalysisFallbackHookReady = + resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false; // Inject Image Analyzer hook into profile settings before launch ensureImageAnalyzerHooks({ profileName: profileInfo.name, profileType: profileInfo.type, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); const { executeCopilotProfile } = await import('./copilot'); @@ -882,9 +896,10 @@ async function main(): Promise { process.exit(exitCode); } else if (profileInfo.type === 'settings') { // Settings-based profiles (glm, glmt) are third-party providers + const imageAnalysisMcpReady = + resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true; if (resolvedTarget === 'claude') { ensureWebSearchMcpOrThrow(); - installImageAnalyzerHook(); } // Display WebSearch status (single line, equilibrium UX) @@ -907,6 +922,9 @@ async function main(): Promise { } const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); + syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + const imageAnalysisFallbackHookReady = + resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false; const expandedSettingsPath = resolvedSettingsPath ?? (profileInfo.settingsPath @@ -920,6 +938,7 @@ async function main(): Promise { settingsPath: expandedSettingsPath, settings, cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); if (resolvedTarget !== 'claude') { const compatibility = evaluateTargetRuntimeCompatibility({ @@ -1022,13 +1041,31 @@ async function main(): Promise { profileType: profileInfo.type, settings, cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); + const runtimeConnection = resolveImageAnalysisRuntimeConnection(); let imageAnalysisEnv = getImageAnalysisHookEnv({ profileName: profileInfo.name, profileType: profileInfo.type, settings, cliproxyBridge, }); + imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, { + backendId: imageAnalysisStatus.backendId, + model: imageAnalysisStatus.model, + runtimePath: imageAnalysisStatus.runtimePath, + baseUrl: runtimeConnection.baseUrl, + apiKey: runtimeConnection.apiKey, + allowSelfSigned: runtimeConnection.allowSelfSigned, + }); + + if (!imageAnalysisMcpReady) { + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; if ( @@ -1127,10 +1164,13 @@ async function main(): Promise { return; } + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(remainingArgs) + : remainingArgs; const launchArgs = [ '--settings', expandedSettingsPath, - ...appendThirdPartyWebSearchToolArgs(remainingArgs), + ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs), ]; const traceEnv = createWebSearchTraceContext({ launcher: 'ccs.settings-profile', diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 83a0294b..a6233879 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -20,7 +20,11 @@ import { applyExtendedContextConfig } from '../config/extended-context-config'; import { CLIProxyProvider } from '../types'; import { CompositeTierConfig } from '../../config/unified-config-types'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; -import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env'; +import { + applyImageAnalysisRuntimeOverrides, + getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeConnection, +} from '../../utils/hooks/get-image-analysis-hook-env'; import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks/image-analysis-runtime-status'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; @@ -30,6 +34,7 @@ import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer'; import type { ProxyTarget } from '../proxy-target-resolver'; +import { getEffectiveApiKey } from '../auth-token-manager'; import { isSettings, type Settings } from '../../types/config'; export interface RemoteProxyConfig { @@ -76,6 +81,7 @@ interface CliproxyImageAnalysisDeps { hasImageAnalysisProfileHook: typeof hasImageAnalysisProfileHook; hasImageAnalyzerHook: typeof hasImageAnalyzerHook; resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus; + getLocalRuntimeApiKey: typeof getEffectiveApiKey; } interface ResolveCliproxyImageAnalysisEnvOptions { @@ -84,6 +90,7 @@ interface ResolveCliproxyImageAnalysisEnvOptions { profileSettingsPath?: string; isComposite?: boolean; proxyTarget: ProxyTarget; + tunnelPort?: number | null; proxyReachable: boolean; } @@ -97,6 +104,7 @@ const defaultCliproxyImageAnalysisDeps: CliproxyImageAnalysisDeps = { hasImageAnalysisProfileHook, hasImageAnalyzerHook, resolveImageAnalysisRuntimeStatus, + getLocalRuntimeApiKey: getEffectiveApiKey, }; const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i; @@ -190,7 +198,24 @@ export async function resolveCliproxyImageAnalysisEnv( }; } - return { env, warning: null }; + const runtimeConnection = resolveImageAnalysisRuntimeConnection({ + proxyTarget: options.proxyTarget, + tunnelPort: options.tunnelPort, + }); + + return { + env: applyImageAnalysisRuntimeOverrides(env, { + backendId: status.backendId, + model: status.model, + runtimePath: status.runtimePath, + baseUrl: runtimeConnection.baseUrl, + apiKey: options.proxyTarget.isRemote + ? runtimeConnection.apiKey + : resolvedDeps.getLocalRuntimeApiKey(), + allowSelfSigned: runtimeConnection.allowSelfSigned, + }), + warning: null, + }; } /** diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index b7b6cdeb..b7a38327 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -58,8 +58,12 @@ import { appendThirdPartyWebSearchToolArgs, createWebSearchTraceContext, } from '../../utils/websearch-manager'; +import { + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, + appendThirdPartyImageAnalysisToolArgs, +} from '../../utils/image-analysis'; import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; -import { installImageAnalyzerHook } from '../../utils/hooks'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types'; import { resolveProfileContinuityInheritance } from '../../auth/profile-continuity-inheritance'; @@ -205,11 +209,9 @@ export async function execClaudeWithCLIProxy( // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); displayWebSearchStatus(); - // Sync image analyzer hook from npm package to ~/.ccs/hooks/ - installImageAnalyzerHook(); - const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); warnOAuthBanRisk(provider); @@ -841,15 +843,27 @@ export async function execClaudeWithCLIProxy( protocol: 'http' as const, isRemote: false as const, }; - const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = - await resolveCliproxyImageAnalysisEnv({ - profileName: cfg.profileName || provider, - provider, - profileSettingsPath: cfg.customSettingsPath, - isComposite: cfg.isComposite, - proxyTarget: imageAnalysisProxyTarget, - proxyReachable: true, - }); + const imageAnalysisResolution = await resolveCliproxyImageAnalysisEnv({ + profileName: cfg.profileName || provider, + provider, + profileSettingsPath: cfg.customSettingsPath, + isComposite: cfg.isComposite, + proxyTarget: imageAnalysisProxyTarget, + tunnelPort, + proxyReachable: true, + }); + const imageAnalysisProvisioningFailed = + !imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1'; + const imageAnalysisEnv = imageAnalysisProvisioningFailed + ? { + ...imageAnalysisResolution.env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + } + : imageAnalysisResolution.env; + const imageAnalysisWarning = imageAnalysisProvisioningFailed + ? 'ImageAnalysis MCP provisioning failed. This session will use native Read.' + : imageAnalysisResolution.warning; // 9. Setup tool sanitization proxy let toolSanitizationProxy: ToolSanitizationProxy | null = null; @@ -870,6 +884,8 @@ export async function execClaudeWithCLIProxy( } } + syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + // Build initial env vars to get ANTHROPIC_BASE_URL const initialEnvVars = buildClaudeEnvironment({ provider, @@ -1072,7 +1088,14 @@ export async function execClaudeWithCLIProxy( : getProviderSettingsPath(provider); let claude: ChildProcess; - const launchArgs = ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)]; + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) + : claudeArgs; + const launchArgs = [ + '--settings', + settingsPath, + ...appendThirdPartyWebSearchToolArgs(imageAnalysisArgs), + ]; const traceEnv = createWebSearchTraceContext({ launcher: 'cliproxy.executor', args: launchArgs, diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 924aa95d..2e3b89ec 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -15,7 +15,9 @@ import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIProxyProvider } from '../types'; import { CompositeTierConfig } from '../../config/unified-config-types'; import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; +import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { prepareImageAnalysisFallbackHook } from '../../utils/hooks'; import { getEffectiveApiKey } from '../auth-token-manager'; import { warn } from '../../utils/ui'; import { normalizeModelIdForProvider } from '../model-id-normalizer'; @@ -155,10 +157,12 @@ export function createSettingsFile( try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); // Inject Image Analyzer hooks into variant settings ensureImageAnalyzerHooks({ @@ -166,6 +170,7 @@ export function createSettingsFile( profileType: 'cliproxy', cliproxyProvider: provider, settingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); return settingsPath; @@ -194,10 +199,12 @@ export function createSettingsFileUnified( try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); // Inject Image Analyzer hooks into variant settings ensureImageAnalyzerHooks({ @@ -205,6 +212,7 @@ export function createSettingsFileUnified( profileType: 'cliproxy', cliproxyProvider: provider, settingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); return settingsPath; @@ -297,16 +305,19 @@ export function createCompositeSettingsFile( if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) { try { ensureWebSearchMcpOrThrow(); + ensureImageAnalysisMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; } + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); ensureImageAnalyzerHooks({ profileName: `composite-${name}`, profileType: 'cliproxy', cliproxyProvider: tiers[defaultTier].provider, isComposite: true, settingsPath, + sharedHookInstalled: imageAnalysisFallbackHookReady, }); } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index a30e6829..07ee1766 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -578,7 +578,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // Image Analysis printSubSection( - 'Image Analysis (CLIProxy vision)', + 'Image Analysis (first-class local tool)', [ ['ccs config image-analysis', 'Show current settings'], ['ccs config image-analysis --enable', 'Enable for CLIProxy providers'], @@ -586,9 +586,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs config image-analysis --timeout 120', 'Set analysis timeout'], ['ccs config image-analysis --set-model

', 'Set provider model'], ['', ''], - ['Note:', 'When enabled, images/PDFs are analyzed via vision models'], - ['', 'instead of passing raw data to Claude. Works with CLIProxy'], - ['', 'providers (agy, gemini, codex, kiro, ghcp).'], + ['Note:', 'When ready, third-party launches expose the local ImageAnalysis MCP tool'], + ['', 'and route requests directly to the resolved CCS provider path.'], + ['', 'If runtime/auth/proxy is unavailable, CCS falls back to native Read.'], ], writeLine ); diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index fb45f770..4353c670 100644 --- a/src/commands/install-command.ts +++ b/src/commands/install-command.ts @@ -6,6 +6,8 @@ import { info, ok, color, box, initUI } from '../utils/ui'; import { uninstallWebSearchHook, uninstallWebSearchMcp } from '../utils/websearch'; +import { uninstallImageAnalysisMcp } from '../utils/image-analysis'; +import { uninstallImageAnalyzerHook } from '../utils/hooks'; import { ClaudeSymlinkManager } from '../utils/claude-symlink-manager'; /** @@ -49,18 +51,31 @@ export async function handleUninstallCommand(): Promise { removed += 1; } - // 3. Remove symlinks from ~/.claude/ + // 3. Remove Image Analysis hook fallback + managed MCP runtime + const imageHookRemoved = uninstallImageAnalyzerHook(); + if (imageHookRemoved) { + console.log(ok('Removed Image Analysis hook fallback')); + removed += 1; + } + + const imageMcpRemoved = uninstallImageAnalysisMcp(); + if (imageMcpRemoved) { + console.log(ok('Removed Image Analysis MCP runtime')); + removed += 1; + } + + // 4. Remove symlinks from ~/.claude/ const symlinkManager = new ClaudeSymlinkManager(); const symlinksRemoved = symlinkManager.uninstall(); removed += symlinksRemoved; // Add actual count of symlinks removed - // 4. Summary + // 5. Summary console.log(''); if (removed > 0) { console.log(ok('Uninstall complete!')); console.log(''); console.log(info('~/.ccs/ directory preserved')); - console.log(info('To reinstall: ccs --install')); + console.log(info('To reinstall: npm install -g @kaitranntt/ccs --force')); } else { console.log(info('Nothing to uninstall')); } diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index e727fbb4..18b84154 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -9,6 +9,7 @@ import { spawn } from 'child_process'; import { CopilotConfig } from '../config/unified-config-types'; import { getGlobalEnvConfig } from '../config/unified-config-loader'; import { ensureCliproxyService } from '../cliproxy'; +import { getEffectiveApiKey } from '../cliproxy/auth-token-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { isDaemonRunning, startDaemon } from './copilot-daemon'; @@ -22,13 +23,24 @@ import { createWebSearchTraceContext, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; -import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks'; +import { + appendThirdPartyImageAnalysisToolArgs, + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, +} from '../utils/image-analysis'; +import { + applyImageAnalysisRuntimeOverrides, + getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeConnection, + resolveImageAnalysisRuntimeStatus, +} from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; interface CopilotImageAnalysisDeps { ensureCliproxyService: typeof ensureCliproxyService; getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv; resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus; + getLocalRuntimeApiKey: typeof getEffectiveApiKey; } interface CopilotImageAnalysisResolution { @@ -96,6 +108,7 @@ export async function resolveCopilotImageAnalysisEnv( ensureCliproxyService, getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus, + getLocalRuntimeApiKey: getEffectiveApiKey, ...deps, }; @@ -141,7 +154,20 @@ export async function resolveCopilotImageAnalysisEnv( } } - return { env, warning: null }; + const runtimeConnection = resolveImageAnalysisRuntimeConnection(); + return { + env: applyImageAnalysisRuntimeOverrides(env, { + backendId: status.backendId, + model: status.model, + runtimePath: status.runtimePath, + baseUrl: runtimeConnection.baseUrl, + apiKey: runtimeConnection.proxyTarget.isRemote + ? runtimeConnection.apiKey + : resolvedDeps.getLocalRuntimeApiKey(), + allowSelfSigned: runtimeConnection.allowSelfSigned, + }), + warning: null, + }; } /** @@ -231,11 +257,25 @@ export async function executeCopilotProfile( // Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); + syncWebSearchMcpToConfigDir(claudeConfigDir); + syncImageAnalysisMcpToConfigDir(claudeConfigDir); // Merge with current environment (global env first, copilot overrides, then hook env vars) const webSearchEnv = getWebSearchHookEnv(); - const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = - await resolveCopilotImageAnalysisEnv(); + const imageAnalysisResolution = await resolveCopilotImageAnalysisEnv(); + const imageAnalysisProvisioningFailed = + !imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1'; + const imageAnalysisWarning = imageAnalysisProvisioningFailed + ? 'ImageAnalysis MCP provisioning failed. This session will use native Read.' + : imageAnalysisResolution.warning; + const imageAnalysisEnv = imageAnalysisProvisioningFailed + ? { + ...imageAnalysisResolution.env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + } + : imageAnalysisResolution.env; const env = stripClaudeCodeEnv({ ...process.env, ...globalEnv, @@ -251,11 +291,12 @@ export async function executeCopilotProfile( } console.log(''); - syncWebSearchMcpToConfigDir(claudeConfigDir); - // Spawn Claude CLI return new Promise((resolve) => { - const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs); + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(claudeArgs) + : claudeArgs; + const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs); const traceEnv = createWebSearchTraceContext({ launcher: 'copilot.executor', args: launchArgs, diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 074016b9..a9d05247 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -15,10 +15,26 @@ 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, getModelDisplayName } from '../utils/config-manager'; +import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager'; import { getProfileLookupCandidates } from '../utils/profile-compat'; import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; +import { + appendThirdPartyImageAnalysisToolArgs, + ensureImageAnalysisMcpOrThrow, + syncImageAnalysisMcpToConfigDir, +} from '../utils/image-analysis'; +import { + applyImageAnalysisRuntimeOverrides, + getImageAnalysisHookEnv, + prepareImageAnalysisFallbackHook, + resolveImageAnalysisRuntimeConnection, + resolveImageAnalysisRuntimeStatus, +} from '../utils/hooks'; +import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../utils/hooks/image-analyzer-profile-hook-injector'; +import { resolveCliproxyBridgeMetadata } from '../api/services'; +import { ensureCliproxyService } from '../cliproxy'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { appendThirdPartyWebSearchToolArgs, appendWebSearchTrace, @@ -105,7 +121,87 @@ export class HeadlessExecutor { } ensureWebSearchMcpOrThrow(); + const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); + syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); + + const settings = loadSettings(settingsPath); + const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); + const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook(); + ensureImageAnalyzerHooks({ + profileName: profile, + profileType: 'settings', + settingsPath, + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ + profileName: profile, + profileType: 'settings', + settings, + cliproxyBridge, + sharedHookInstalled: imageAnalysisFallbackHookReady, + }); + const runtimeConnection = resolveImageAnalysisRuntimeConnection(); + let imageAnalysisEnv = getImageAnalysisHookEnv({ + profileName: profile, + profileType: 'settings', + settings, + cliproxyBridge, + }); + imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, { + backendId: imageAnalysisStatus.backendId, + model: imageAnalysisStatus.model, + runtimePath: imageAnalysisStatus.runtimePath, + baseUrl: runtimeConnection.baseUrl, + apiKey: runtimeConnection.apiKey, + allowSelfSigned: runtimeConnection.allowSelfSigned, + }); + + if (!imageAnalysisMcpReady) { + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + + const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER']; + if ( + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider && + imageAnalysisStatus.effectiveRuntimeMode === 'native-read' + ) { + console.error( + info( + `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } else if ( + imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' && + imageAnalysisProvider && + imageAnalysisStatus.proxyReadiness === 'stopped' + ) { + const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, false); + if (!ensureServiceResult.started) { + console.error( + warn( + `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.` + ) + ); + imageAnalysisEnv = { + ...imageAnalysisEnv, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }; + } + } // Smart slash command detection and preservation const processedPrompt = this._processSlashCommand(enhancedPrompt); @@ -190,7 +286,10 @@ export class HeadlessExecutor { } } - const launchArgs = appendThirdPartyWebSearchToolArgs(args); + const imageAnalysisArgs = imageAnalysisMcpReady + ? appendThirdPartyImageAnalysisToolArgs(args) + : args; + const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs); const traceEnv = createWebSearchTraceContext({ launcher: 'delegation.headless-executor', args: launchArgs, @@ -217,6 +316,7 @@ export class HeadlessExecutor { sessionId, sessionMgr, claudeConfigDir: inheritedClaudeConfigDir, + imageAnalysisEnv, traceEnv, }); } @@ -235,6 +335,7 @@ export class HeadlessExecutor { sessionId: string | null; sessionMgr: SessionManager; claudeConfigDir?: string; + imageAnalysisEnv?: Record; traceEnv?: Record; } ): Promise { @@ -246,6 +347,7 @@ export class HeadlessExecutor { sessionId, sessionMgr, claudeConfigDir, + imageAnalysisEnv = {}, traceEnv = {}, } = ctx; @@ -265,6 +367,7 @@ export class HeadlessExecutor { ...process.env, ...getClaudeLaunchEnvOverrides(), ...getWebSearchHookEnv(), + ...imageAnalysisEnv, ...traceEnv, ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), CCS_PROFILE_TYPE: 'settings', diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 34d35394..7ef0645c 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -70,15 +70,17 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise if (!cliproxyAvailable) { results.details['Image Analysis'] = { status: 'WARN', - info: `Enabled but CLIProxy not running`, + info: 'Enabled; local CLIProxy will start on launch if needed', }; results.warnings.push({ name: 'Image Analysis', - message: 'CLIProxy not running - image analysis will fail', - fix: 'ccs config (starts CLIProxy)', + message: + 'CLIProxy not running yet - CCS will start it automatically when ImageAnalysis is used', + fix: 'Optional warm-up: ccs config', }); - console.log(` ${warn('CLIProxy:')} Not running at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`); - console.log(` ${dim('Note:')} Start with: ccs config`); + console.log( + ` ${warn('CLIProxy:')} Idle at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT} (auto-start on launch)` + ); return; } console.log(` ${ok('CLIProxy:')} Available at http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`); diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index 776ebf98..b9729459 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -14,7 +14,7 @@ import { DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context'; import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsDir, getCcsHome } from '../utils/config-manager'; -const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch']); +const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis']); /** Options for instance creation */ export interface InstanceOptions { diff --git a/src/utils/hooks/get-image-analysis-hook-env.ts b/src/utils/hooks/get-image-analysis-hook-env.ts index 40835023..1bae3cf8 100644 --- a/src/utils/hooks/get-image-analysis-hook-env.ts +++ b/src/utils/hooks/get-image-analysis-hook-env.ts @@ -8,7 +8,15 @@ */ import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { resolveCliproxyBridgeProfile } from '../../api/services/cliproxy-profile-bridge'; +import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { + buildProxyUrl, + getProxyTarget, + type ProxyTarget, +} from '../../cliproxy/proxy-target-resolver'; +import { getPromptsDir } from '../image-analysis/hook-installer'; import { resolveImageAnalysisStatus, type ImageAnalysisResolutionContext, @@ -23,6 +31,61 @@ function serializeProviderModels(providerModels: Record): string .join(','); } +export interface ImageAnalysisRuntimeOverrides { + backendId?: string | null; + model?: string | null; + runtimePath?: string | null; + baseUrl?: string | null; + apiKey?: string | null; + allowSelfSigned?: boolean | null; +} + +export interface ImageAnalysisRuntimeConnection { + baseUrl: string; + apiKey: string; + allowSelfSigned: boolean; + proxyTarget: ProxyTarget; +} + +export interface ResolveImageAnalysisRuntimeConnectionOptions { + proxyTarget?: ProxyTarget; + tunnelPort?: number | null; +} + +function stripTrailingSlash(value: string): string { + return value.replace(/\/+$/, ''); +} + +export function resolveImageAnalysisRuntimeConnection( + options: ResolveImageAnalysisRuntimeConnectionOptions = {} +): ImageAnalysisRuntimeConnection { + const proxyTarget = options.proxyTarget ?? getProxyTarget(); + const apiKey = proxyTarget.authToken?.trim() || getEffectiveApiKey(); + + if (proxyTarget.isRemote && options.tunnelPort && options.tunnelPort > 0) { + return { + baseUrl: `http://127.0.0.1:${options.tunnelPort}`, + apiKey, + allowSelfSigned: false, + proxyTarget: { + host: '127.0.0.1', + port: options.tunnelPort, + protocol: 'http', + isRemote: false, + }, + }; + } + + return { + baseUrl: stripTrailingSlash(buildProxyUrl(proxyTarget, '')), + apiKey, + allowSelfSigned: Boolean( + proxyTarget.isRemote && proxyTarget.protocol === 'https' && proxyTarget.allowSelfSigned + ), + proxyTarget, + }; +} + /** * Get image analysis hook environment variables. * These env vars control the hook's behavior via Claude Code hook system. @@ -45,12 +108,70 @@ export function getImageAnalysisHookEnv( ? resolveImageAnalysisStatus(context, config) : resolveImageAnalysisStatus({ profileName: '' }, config); const skipImageAnalysis = !status.supported; + const runtimeApiKey = + typeof context === 'object' && context.cliproxyBridge + ? resolveCliproxyBridgeProfile(context.cliproxyBridge.provider).apiKey + : ''; 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: status.backendId || '', + CCS_IMAGE_ANALYSIS_BACKEND_ID: status.backendId || '', + CCS_IMAGE_ANALYSIS_MODEL: status.model || '', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: status.runtimePath || '', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: + typeof context === 'object' ? context.cliproxyBridge?.currentBaseUrl || '' : '', + ...(runtimeApiKey ? { CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: runtimeApiKey } : {}), + CCS_IMAGE_ANALYSIS_PROMPTS_DIR: getPromptsDir(), CCS_IMAGE_ANALYSIS_SKIP: skipImageAnalysis ? '1' : '0', }; } + +/** + * Overlay execution-specific runtime values onto the baseline image-analysis env. + * Launch paths use this to pin analysis to the exact provider route and auth + * token selected for the current session rather than any stale saved values. + */ +export function applyImageAnalysisRuntimeOverrides( + env: Record, + overrides: ImageAnalysisRuntimeOverrides +): Record { + const nextEnv = { ...env }; + + const backendId = overrides.backendId?.trim(); + if (backendId) { + nextEnv.CCS_CURRENT_PROVIDER = backendId; + nextEnv.CCS_IMAGE_ANALYSIS_BACKEND_ID = backendId; + } + + const model = overrides.model?.trim(); + if (model) { + nextEnv.CCS_IMAGE_ANALYSIS_MODEL = model; + } + + const runtimePath = overrides.runtimePath?.trim(); + if (runtimePath) { + nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_PATH = runtimePath; + } + + if (overrides.baseUrl !== undefined) { + nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL = overrides.baseUrl?.trim() || ''; + } + + if (overrides.apiKey !== undefined) { + const apiKey = overrides.apiKey?.trim(); + if (apiKey) { + nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY = apiKey; + } else { + delete nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY; + } + } + + if (overrides.allowSelfSigned !== undefined) { + nextEnv.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED = overrides.allowSelfSigned ? '1' : '0'; + } + + return nextEnv; +} diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index ec7921e4..2138893a 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -566,13 +566,13 @@ export function resolveImageAnalysisStatus( ? 'CLIProxy runtime readiness has not been verified yet.' : null, effectiveRuntimeMode: - config.enabled && resolution.backendId && model && status !== 'hook-missing' - ? 'cliproxy-image-analysis' - : 'native-read', + config.enabled && resolution.backendId && model ? 'cliproxy-image-analysis' : 'native-read', effectiveRuntimeReason: - status === 'hook-missing' || !config.enabled || !resolution.backendId || !model + !config.enabled || !resolution.backendId || !model ? reason - : null, + : status === 'attention' || status === 'hook-missing' + ? reason + : null, profileModel: nativeSupport.profileModel, nativeReadPreference: nativeSupport.nativeReadPreference, nativeImageCapable: nativeSupport.nativeImageCapable, diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts index 8fd910d2..9ec8a388 100644 --- a/src/utils/hooks/image-analysis-runtime-status.ts +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -1,4 +1,9 @@ import { getAuthStatus, initializeAccounts, type AuthStatus } from '../../cliproxy/auth-handler'; +import { + checkRemoteProxy, + type RemoteProxyClientConfig, + type RemoteProxyStatus, +} from '../../cliproxy/remote-proxy-client'; import { fetchRemoteAuthStatus, type RemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy-target-resolver'; import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; @@ -15,6 +20,7 @@ import { } from './image-analysis-backend-resolver'; interface ImageAnalysisRuntimeStatusDeps { + checkRemoteProxy: (config: RemoteProxyClientConfig) => Promise; fetchRemoteAuthStatus: (target: ProxyTarget) => Promise; getAuthStatus: (provider: CLIProxyProvider) => AuthStatus; getProxyTarget: () => ProxyTarget; @@ -23,6 +29,7 @@ interface ImageAnalysisRuntimeStatusDeps { } const defaultDeps: ImageAnalysisRuntimeStatusDeps = { + checkRemoteProxy, fetchRemoteAuthStatus, getAuthStatus, getProxyTarget, @@ -91,16 +98,25 @@ async function resolveProxyReadiness( } const target = deps.getProxyTarget(); - const reachable = await deps.isCliproxyRunning(); if (target.isRemote) { + const remoteStatus = await deps.checkRemoteProxy({ + host: target.host, + port: target.port, + protocol: target.protocol, + authToken: target.authToken, + allowSelfSigned: target.allowSelfSigned, + }); + return { - proxyReadiness: reachable ? 'remote' : 'unavailable', - proxyReason: reachable + proxyReadiness: remoteStatus.reachable ? 'remote' : 'unavailable', + proxyReason: remoteStatus.reachable ? `Remote CLIProxy target ${target.host}:${target.port} is reachable.` - : `Remote CLIProxy target ${target.host}:${target.port} is unreachable.`, + : remoteStatus.error || + `Remote CLIProxy target ${target.host}:${target.port} is unreachable.`, }; } + const reachable = await deps.isCliproxyRunning(); return { proxyReadiness: reachable ? 'ready' : 'stopped', proxyReason: reachable @@ -119,13 +135,6 @@ function resolveEffectiveRuntime( }; } - if (status.status === 'hook-missing') { - return { - effectiveRuntimeMode: 'native-read', - effectiveRuntimeReason: status.reason, - }; - } - if (status.authReadiness === 'missing' || status.authReadiness === 'unknown') { return { effectiveRuntimeMode: 'native-read', @@ -142,7 +151,8 @@ function resolveEffectiveRuntime( return { effectiveRuntimeMode: 'cliproxy-image-analysis', - effectiveRuntimeReason: status.status === 'attention' ? status.reason : null, + effectiveRuntimeReason: + status.status === 'attention' || status.status === 'hook-missing' ? status.reason : null, }; } diff --git a/src/utils/hooks/image-analyzer-hook-installer.ts b/src/utils/hooks/image-analyzer-hook-installer.ts index 26f32224..e124c74a 100644 --- a/src/utils/hooks/image-analyzer-hook-installer.ts +++ b/src/utils/hooks/image-analyzer-hook-installer.ts @@ -14,6 +14,7 @@ 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'; +import { installImageAnalysisPrompts } from '../image-analysis/hook-installer'; // Re-export from hook-configuration for backward compatibility export { @@ -23,12 +24,65 @@ export { // Hook file name const IMAGE_ANALYZER_HOOK = 'image-analyzer-transformer.cjs'; +const IMAGE_ANALYSIS_RUNTIME = 'image-analysis-runtime.cjs'; + +function getImageAnalysisRuntimeHookPath(): string { + return path.join(getCcsHooksDir(), IMAGE_ANALYSIS_RUNTIME); +} + +function getHookArtifacts(): Array<{ fileName: string; destinationPath: string }> { + return [ + { fileName: IMAGE_ANALYZER_HOOK, destinationPath: getImageAnalyzerHookPath() }, + { + fileName: IMAGE_ANALYSIS_RUNTIME, + destinationPath: getImageAnalysisRuntimeHookPath(), + }, + ]; +} + +function resolveHookSourceBasePath( + artifacts: Array<{ fileName: string; destinationPath: string }> +): string | null { + const possibleBasePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'hooks'), + path.join(__dirname, '..', '..', 'lib', 'hooks'), + path.join(__dirname, '..', 'lib', 'hooks'), + ]; + + for (const basePath of possibleBasePaths) { + if (artifacts.every(({ fileName }) => fs.existsSync(path.join(basePath, fileName)))) { + return basePath; + } + } + + return null; +} + +function artifactsMatch(sourcePath: string, destinationPath: string): boolean { + try { + return fs.readFileSync(sourcePath).equals(fs.readFileSync(destinationPath)); + } catch { + return false; + } +} /** * Check if image analyzer hook is installed */ export function hasImageAnalyzerHook(): boolean { - return fs.existsSync(getImageAnalyzerHookPath()); + const artifacts = getHookArtifacts(); + if (!artifacts.every(({ destinationPath }) => fs.existsSync(destinationPath))) { + return false; + } + + const sourceBasePath = resolveHookSourceBasePath(artifacts); + if (!sourceBasePath) { + return true; + } + + return artifacts.every(({ fileName, destinationPath }) => + artifactsMatch(path.join(sourceBasePath, fileName), destinationPath) + ); } /** @@ -56,38 +110,25 @@ export function installImageAnalyzerHook(): boolean { fs.mkdirSync(hooksDir, { recursive: true, mode: 0o700 }); } - const hookPath = getImageAnalyzerHookPath(); + const artifacts = getHookArtifacts(); + const sourceBasePath = resolveHookSourceBasePath(artifacts); - // 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 (!sourceBasePath) { 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); + for (const { fileName, destinationPath } of artifacts) { + fs.copyFileSync(path.join(sourceBasePath, fileName), destinationPath); + fs.chmodSync(destinationPath, 0o755); + } + + installImageAnalysisPrompts(); if (process.env.CCS_DEBUG) { - console.error(info(`Installed image analyzer hook: ${hookPath}`)); + console.error(info(`Installed image analyzer hook runtime: ${hooksDir}`)); } // Note: Hook registration is handled by ensureProfileHooks() in image-analyzer-profile-injector.ts @@ -113,12 +154,14 @@ export function installImageAnalyzerHook(): boolean { */ export function uninstallImageAnalyzerHook(): boolean { try { - const hookPath = getImageAnalyzerHookPath(); + const artifactPaths = [getImageAnalyzerHookPath(), getImageAnalysisRuntimeHookPath()]; - if (fs.existsSync(hookPath)) { - fs.unlinkSync(hookPath); - if (process.env.CCS_DEBUG) { - console.error(info(`Uninstalled image analyzer hook: ${hookPath}`)); + for (const artifactPath of artifactPaths) { + if (fs.existsSync(artifactPath)) { + fs.unlinkSync(artifactPath); + if (process.env.CCS_DEBUG) { + console.error(info(`Uninstalled image analyzer artifact: ${artifactPath}`)); + } } } diff --git a/src/utils/hooks/image-analyzer-profile-hook-injector.ts b/src/utils/hooks/image-analyzer-profile-hook-injector.ts index a2b28978..7517dcf9 100644 --- a/src/utils/hooks/image-analyzer-profile-hook-injector.ts +++ b/src/utils/hooks/image-analyzer-profile-hook-injector.ts @@ -138,6 +138,10 @@ export function ensureProfileHooks(input: string | ImageAnalysisResolutionContex return false; } + if (context.sharedHookInstalled === false) { + return false; + } + // One-time migration marker migrateGlobalHook(); diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 68973870..6f61058e 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -6,7 +6,19 @@ * @module utils/hooks */ -export { getImageAnalysisHookEnv } from './get-image-analysis-hook-env'; +import { + hasImageAnalyzerHook as hasInstalledImageAnalyzerHook, + installImageAnalyzerHook as installSharedImageAnalyzerHook, +} from './image-analyzer-hook-installer'; + +export { + getImageAnalysisHookEnv, + applyImageAnalysisRuntimeOverrides, + resolveImageAnalysisRuntimeConnection, + type ImageAnalysisRuntimeOverrides, + type ImageAnalysisRuntimeConnection, + type ResolveImageAnalysisRuntimeConnectionOptions, +} from './get-image-analysis-hook-env'; export { canonicalizeImageAnalysisConfig, resolveImageAnalysisStatus, @@ -26,3 +38,7 @@ export { uninstallImageAnalyzerHook, } from './image-analyzer-hook-installer'; export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector'; + +export function prepareImageAnalysisFallbackHook(): boolean { + return hasInstalledImageAnalyzerHook() || installSharedImageAnalyzerHook(); +} diff --git a/src/utils/image-analysis/claude-tool-args.ts b/src/utils/image-analysis/claude-tool-args.ts new file mode 100644 index 00000000..f1d0cf0b --- /dev/null +++ b/src/utils/image-analysis/claude-tool-args.ts @@ -0,0 +1,74 @@ +/** + * Claude launch argument helpers for first-class Image Analysis. + */ + +const APPEND_SYSTEM_PROMPT_FLAG = '--append-system-prompt'; +const IMAGE_ANALYSIS_STEERING_PROMPT = + 'For local image or PDF files, prefer the CCS MCP tool ImageAnalysis instead of Read. Use Read for text, code, and other plain files. If the user asks a specific question about the visual, pass that question as the focus field when useful. If ImageAnalysis is unavailable or fails, you may fall back to Read.'; + +function splitArgsAtTerminator(args: string[]): { optionArgs: string[]; trailingArgs: string[] } { + const terminatorIndex = args.indexOf('--'); + if (terminatorIndex === -1) { + return { optionArgs: args, trailingArgs: [] }; + } + + return { + optionArgs: args.slice(0, terminatorIndex), + trailingArgs: args.slice(terminatorIndex), + }; +} + +function getImmediateFlagValue(args: string[], index: number): string | null { + const value = args[index + 1]; + if (value === undefined || value === '--' || value.startsWith('--')) { + return null; + } + return value; +} + +function hasExactFlagValue(args: string[], flag: string, expectedValue: string): boolean { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === flag) { + const value = getImmediateFlagValue(args, index); + if (value === expectedValue) { + return true; + } + continue; + } + + if (arg === `${flag}=${expectedValue}`) { + return true; + } + + if (arg.startsWith(`${flag}=`) && arg.slice(flag.length + 1) === expectedValue) { + return true; + } + } + + return false; +} + +function ensureImageAnalysisSteeringPrompt(args: string[]): string[] { + const { optionArgs, trailingArgs } = splitArgsAtTerminator(args); + + if (hasExactFlagValue(optionArgs, APPEND_SYSTEM_PROMPT_FLAG, IMAGE_ANALYSIS_STEERING_PROMPT)) { + return args; + } + + return [ + ...optionArgs, + APPEND_SYSTEM_PROMPT_FLAG, + IMAGE_ANALYSIS_STEERING_PROMPT, + ...trailingArgs, + ]; +} + +export function appendThirdPartyImageAnalysisToolArgs(args: string[]): string[] { + return ensureImageAnalysisSteeringPrompt(args); +} + +export function getImageAnalysisSteeringPrompt(): string { + return IMAGE_ANALYSIS_STEERING_PROMPT; +} diff --git a/src/utils/image-analysis/index.ts b/src/utils/image-analysis/index.ts index e239ae6d..852a612a 100644 --- a/src/utils/image-analysis/index.ts +++ b/src/utils/image-analysis/index.ts @@ -5,3 +5,26 @@ */ export { getPromptsDir, installImageAnalysisPrompts } from './hook-installer'; +export { + getImageAnalysisMcpServerName, + getImageAnalysisMcpServerPath, + getImageAnalysisMcpRuntimePath, + hasImageAnalysisMcpServerInstalled, + hasImageAnalysisMcpConfig, + hasImageAnalysisMcpReady, + installImageAnalysisMcpServer, + ensureImageAnalysisMcpConfig, + ensureImageAnalysisMcp, + uninstallImageAnalysisMcpServer, + removeImageAnalysisMcpConfig, + uninstallImageAnalysisMcp, + syncImageAnalysisMcpToConfigDir, + ensureImageAnalysisMcpOrThrow, +} from './mcp-installer'; +export { + appendThirdPartyImageAnalysisToolArgs, + getImageAnalysisSteeringPrompt, +} from './claude-tool-args'; + +export const IMAGE_ANALYSIS_PROMPT_TEMPLATES = ['default', 'screenshot', 'document'] as const; +export type ImageAnalysisPromptTemplate = (typeof IMAGE_ANALYSIS_PROMPT_TEMPLATES)[number]; diff --git a/src/utils/image-analysis/mcp-installer.ts b/src/utils/image-analysis/mcp-installer.ts new file mode 100644 index 00000000..76b8c3d2 --- /dev/null +++ b/src/utils/image-analysis/mcp-installer.ts @@ -0,0 +1,500 @@ +/** + * Image Analysis MCP installer and ~/.claude.json provisioning. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; +import { getImageAnalysisConfig } from '../../config/unified-config-loader'; +import { getCcsDir } from '../config-manager'; +import { getClaudeUserConfigPath } from '../claude-config-path'; +import { info, warn } from '../ui'; +import { InstanceManager } from '../../management/instance-manager'; +import { installImageAnalysisPrompts } from './hook-installer'; + +const IMAGE_ANALYSIS_MCP_SERVER = 'ccs-image-analysis-server.cjs'; +const IMAGE_ANALYSIS_MCP_RUNTIME = 'image-analysis-runtime.cjs'; +const IMAGE_ANALYSIS_MCP_SERVER_NAME = 'ccs-image-analysis'; + +interface ClaudeUserConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +interface ManagedImageAnalysisMcpConfig { + type: 'stdio'; + command: 'node'; + args: [string]; + env: Record; +} + +function getCcsMcpDir(): string { + return path.join(getCcsDir(), 'mcp'); +} + +export function getImageAnalysisMcpServerName(): string { + return IMAGE_ANALYSIS_MCP_SERVER_NAME; +} + +export function getImageAnalysisMcpServerPath(): string { + return path.join(getCcsMcpDir(), IMAGE_ANALYSIS_MCP_SERVER); +} + +export function getImageAnalysisMcpRuntimePath(): string { + return path.join(getCcsMcpDir(), IMAGE_ANALYSIS_MCP_RUNTIME); +} + +function hasMatchingContents(sourcePath: string, destinationPath: string): boolean { + if (!fs.existsSync(destinationPath)) { + return false; + } + + const source = fs.readFileSync(sourcePath); + try { + const destination = fs.readFileSync(destinationPath); + return source.equals(destination); + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Existing Image Analysis MCP server is unreadable: ${(error as Error).message}`) + ); + } + return false; + } +} + +function getTempPath(targetPath: string): string { + const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${targetPath}.${suffix}.tmp`; +} + +function resolveBundledArtifactSourcePath(fileName: string): string | null { + const possiblePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'mcp', fileName), + path.join(__dirname, '..', '..', 'lib', 'mcp', fileName), + path.join(__dirname, '..', 'lib', 'mcp', fileName), + path.join(__dirname, '..', '..', '..', 'lib', 'hooks', fileName), + path.join(__dirname, '..', '..', 'lib', 'hooks', fileName), + path.join(__dirname, '..', 'lib', 'hooks', fileName), + ]; + + for (const candidate of possiblePaths) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null { + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsed as ClaudeUserConfig; + } catch { + return null; + } +} + +function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean { + const tempPath = getTempPath(configPath); + const fileMode = fs.existsSync(configPath) ? fs.statSync(configPath).mode & 0o777 : 0o600; + + try { + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.chmodSync(tempPath, fileMode); + fs.renameSync(tempPath, configPath); + return true; + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} + +function withClaudeUserConfigLock(configPath: string, callback: () => T): T { + const configDir = path.dirname(configPath); + const lockTarget = path.join(configDir, `${path.basename(configPath)}.ccs-lock`); + let release: (() => void) | undefined; + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + } + + if (!fs.existsSync(lockTarget)) { + fs.writeFileSync(lockTarget, '', { encoding: 'utf8', mode: 0o600 }); + } + + try { + release = lockfile.lockSync(lockTarget, { stale: 10000 }) as () => void; + return callback(); + } finally { + if (release) { + try { + release(); + } catch { + // Best-effort release. + } + } + } +} + +function isLockUnavailableError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === 'ELOCKED' || code === 'ENOTACQUIRED'; +} + +export function hasImageAnalysisMcpServerInstalled(): boolean { + return ( + fs.existsSync(getImageAnalysisMcpServerPath()) && + fs.existsSync(getImageAnalysisMcpRuntimePath()) + ); +} + +export function hasImageAnalysisMcpConfig(configPath = getClaudeUserConfigPath()): boolean { + const config = readClaudeUserConfig(configPath); + if (config === null) { + return false; + } + + const existingServers = + config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) + ? (config.mcpServers as Record) + : {}; + const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; + + return ( + typeof currentConfig === 'object' && + currentConfig !== null && + JSON.stringify(currentConfig) === + JSON.stringify({ + type: 'stdio', + command: 'node', + args: [getImageAnalysisMcpServerPath()], + env: {}, + }) + ); +} + +export function hasImageAnalysisMcpReady(configPath = getClaudeUserConfigPath()): boolean { + return hasImageAnalysisMcpServerInstalled() && hasImageAnalysisMcpConfig(configPath); +} + +function removeManagedServerConfig(configPath: string): boolean { + if (!fs.existsSync(configPath)) { + return false; + } + + try { + return withClaudeUserConfigLock(configPath, () => { + const config = readClaudeUserConfig(configPath); + if (config === null) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)); + } + return false; + } + + const existingServers = + config.mcpServers && + typeof config.mcpServers === 'object' && + !Array.isArray(config.mcpServers) + ? { ...(config.mcpServers as Record) } + : {}; + + if (!(IMAGE_ANALYSIS_MCP_SERVER_NAME in existingServers)) { + return false; + } + + delete existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; + + const nextConfig: ClaudeUserConfig = { ...config }; + if (Object.keys(existingServers).length === 0) { + delete nextConfig.mcpServers; + } else { + nextConfig.mcpServers = existingServers; + } + + try { + writeClaudeUserConfig(configPath, nextConfig); + if (process.env.CCS_DEBUG) { + console.error(info(`Removed Image Analysis MCP config from ${configPath}`)); + } + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn( + `Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}` + ) + ); + } + return false; + } + }); + } catch (error) { + if (isLockUnavailableError(error)) { + if (process.env.CCS_DEBUG) { + console.error( + warn( + `Image Analysis MCP cleanup skipped because ${configPath} is locked by another process` + ) + ); + } + return false; + } + throw error; + } +} + +export function installImageAnalysisMcpServer(): boolean { + const config = getImageAnalysisConfig(); + if (!config.enabled) { + return false; + } + + const artifacts = [ + { + fileName: IMAGE_ANALYSIS_MCP_SERVER, + sourcePath: resolveBundledArtifactSourcePath(IMAGE_ANALYSIS_MCP_SERVER), + destinationPath: getImageAnalysisMcpServerPath(), + }, + { + fileName: IMAGE_ANALYSIS_MCP_RUNTIME, + sourcePath: resolveBundledArtifactSourcePath(IMAGE_ANALYSIS_MCP_RUNTIME), + destinationPath: getImageAnalysisMcpRuntimePath(), + }, + ]; + + const missingArtifact = artifacts.find((artifact) => !artifact.sourcePath); + if (missingArtifact) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Image Analysis MCP runtime source not found: ${missingArtifact.fileName}`) + ); + } + return false; + } + + const mcpDir = getCcsMcpDir(); + if (!fs.existsSync(mcpDir)) { + fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 }); + } + + try { + for (const artifact of artifacts) { + const sourcePath = artifact.sourcePath; + if (!sourcePath) { + continue; + } + + if (hasMatchingContents(sourcePath, artifact.destinationPath)) { + continue; + } + + const tempPath = getTempPath(artifact.destinationPath); + + try { + fs.copyFileSync(sourcePath, tempPath); + fs.chmodSync(tempPath, 0o755); + try { + fs.renameSync(tempPath, artifact.destinationPath); + } catch (renameError) { + const errorCode = (renameError as NodeJS.ErrnoException).code; + if (errorCode !== 'EEXIST' && errorCode !== 'EPERM') { + throw renameError; + } + + if (!hasMatchingContents(sourcePath, artifact.destinationPath)) { + fs.copyFileSync(tempPath, artifact.destinationPath); + fs.chmodSync(artifact.destinationPath, 0o755); + } + } + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } + } + + installImageAnalysisPrompts(); + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Failed to install Image Analysis MCP server: ${(error as Error).message}`) + ); + } + return false; + } +} + +export function ensureImageAnalysisMcpConfig(): boolean { + const imageConfig = getImageAnalysisConfig(); + if (!imageConfig.enabled) { + return false; + } + + const claudeUserConfigPath = getClaudeUserConfigPath(); + const claudeUserConfigDir = path.dirname(claudeUserConfigPath); + if (!fs.existsSync(claudeUserConfigDir)) { + fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 }); + } + const desiredServerConfig: ManagedImageAnalysisMcpConfig = { + type: 'stdio', + command: 'node', + args: [getImageAnalysisMcpServerPath()], + env: {}, + }; + + try { + return withClaudeUserConfigLock(claudeUserConfigPath, () => { + const config = readClaudeUserConfig(claudeUserConfigPath); + + if (config === null) { + if (process.env.CCS_DEBUG) { + console.error(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning')); + } + return false; + } + + const existingServers = + config.mcpServers && + typeof config.mcpServers === 'object' && + !Array.isArray(config.mcpServers) + ? (config.mcpServers as Record) + : {}; + const currentConfig = existingServers[IMAGE_ANALYSIS_MCP_SERVER_NAME]; + if ( + typeof currentConfig === 'object' && + currentConfig !== null && + JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) + ) { + return true; + } + + const nextConfig: ClaudeUserConfig = { + ...config, + mcpServers: { + ...existingServers, + [IMAGE_ANALYSIS_MCP_SERVER_NAME]: desiredServerConfig, + }, + }; + + try { + writeClaudeUserConfig(claudeUserConfigPath, nextConfig); + if (process.env.CCS_DEBUG) { + console.error(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`)); + } + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)); + } + return false; + } + }); + } catch (error) { + if (isLockUnavailableError(error)) { + if (process.env.CCS_DEBUG) { + console.error( + warn( + `Image Analysis MCP provisioning skipped because ${claudeUserConfigPath} is locked by another process` + ) + ); + } + return false; + } + throw error; + } +} + +export function ensureImageAnalysisMcp(): boolean { + const imageConfig = getImageAnalysisConfig(); + if (!imageConfig.enabled) { + return false; + } + + const installed = installImageAnalysisMcpServer(); + const configured = installed && ensureImageAnalysisMcpConfig(); + return installed && configured; +} + +export function syncImageAnalysisMcpToConfigDir(claudeConfigDir: string | undefined): boolean { + if (!claudeConfigDir) { + return false; + } + + return new InstanceManager().syncMcpServers(claudeConfigDir); +} + +export function uninstallImageAnalysisMcpServer(): boolean { + const artifactPaths = [getImageAnalysisMcpServerPath(), getImageAnalysisMcpRuntimePath()]; + if (!artifactPaths.some((artifactPath) => fs.existsSync(artifactPath))) { + return false; + } + + try { + let removed = false; + for (const artifactPath of artifactPaths) { + if (!fs.existsSync(artifactPath)) { + continue; + } + fs.unlinkSync(artifactPath); + removed = true; + } + return removed; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Failed to remove Image Analysis MCP server: ${(error as Error).message}`) + ); + } + return false; + } +} + +export function removeImageAnalysisMcpConfig(): boolean { + let removed = removeManagedServerConfig(getClaudeUserConfigPath()); + + const instanceManager = new InstanceManager(); + for (const instanceName of instanceManager.listInstances()) { + const instancePath = instanceManager.getInstancePath(instanceName); + const instanceClaudeConfigPath = path.join(instancePath, '.claude.json'); + removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed; + } + + return removed; +} + +export function uninstallImageAnalysisMcp(): boolean { + const removedConfig = removeImageAnalysisMcpConfig(); + const removedServer = uninstallImageAnalysisMcpServer(); + return removedConfig || removedServer; +} + +export function ensureImageAnalysisMcpOrThrow(): boolean { + const imageConfig = getImageAnalysisConfig(); + if (!imageConfig.enabled) { + return false; + } + + const ready = ensureImageAnalysisMcp(); + if (!ready) { + console.error( + warn( + 'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.' + ) + ); + } + + return ready; +} diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index 3b98337e..970d0e02 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -16,9 +16,15 @@ import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer' import { normalizeImageAnalysisBackendId, resolveImageAnalysisRuntimeStatus, + prepareImageAnalysisFallbackHook, } from '../../utils/hooks'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; import { hasImageAnalysisProfileHook } from '../../utils/hooks/image-analyzer-profile-hook-injector'; +import { InstanceManager } from '../../management/instance-manager'; +import { + ensureImageAnalysisMcpOrThrow, + hasImageAnalysisMcpReady, +} from '../../utils/image-analysis'; const router = Router(); const IMAGE_ANALYSIS_LOCAL_ACCESS_ERROR = @@ -78,17 +84,25 @@ function resolveTarget(target: unknown): DashboardTarget { function resolveCurrentTargetMode( target: DashboardTarget, - status: Awaited> + status: Awaited>, + managedToolReady: boolean ): CurrentTargetMode { if (!status.enabled) return 'disabled'; if (target !== 'claude') return 'bypassed'; if (status.nativeReadPreference) return 'native'; + if (!managedToolReady) return 'setup'; if (!status.backendId) return 'unresolved'; - if (status.status === 'hook-missing') return 'setup'; if (status.effectiveRuntimeMode === 'native-read') return 'fallback'; return 'active'; } +function syncManagedImageAnalysisToInstances(): void { + const instanceManager = new InstanceManager(); + for (const instanceName of instanceManager.listInstances()) { + instanceManager.syncMcpServers(instanceManager.getInstancePath(instanceName)); + } +} + function resolveBackendState( status: Awaited> ): BackendState { @@ -110,6 +124,7 @@ async function buildDashboardPayload() { const config = getImageAnalysisConfig(); const { profiles, variants } = listApiProfiles(); const sharedHookInstalled = hasImageAnalyzerHook(); + const managedToolReady = hasImageAnalysisMcpReady(); const profileRows = await Promise.all( profiles.map(async (profile) => { @@ -147,7 +162,11 @@ async function buildDashboardPayload() { status: status.status, effectiveRuntimeMode: status.effectiveRuntimeMode, effectiveRuntimeReason: status.effectiveRuntimeReason, - currentTargetMode: resolveCurrentTargetMode(resolveTarget(profile.target), status), + currentTargetMode: resolveCurrentTargetMode( + resolveTarget(profile.target), + status, + managedToolReady + ), profileModel: status.profileModel, nativeReadPreference: status.nativeReadPreference, nativeImageCapable: status.nativeImageCapable, @@ -191,7 +210,11 @@ async function buildDashboardPayload() { status: status.status, effectiveRuntimeMode: status.effectiveRuntimeMode, effectiveRuntimeReason: status.effectiveRuntimeReason, - currentTargetMode: resolveCurrentTargetMode(resolveTarget(variant.target), status), + currentTargetMode: resolveCurrentTargetMode( + resolveTarget(variant.target), + status, + managedToolReady + ), profileModel: status.profileModel, nativeReadPreference: status.nativeReadPreference, nativeImageCapable: status.nativeImageCapable, @@ -261,6 +284,11 @@ async function buildDashboardPayload() { summaryState = 'disabled'; title = 'Disabled'; detail = 'Image is turned off globally. Images and PDFs fall back to native file access.'; + } else if (!managedToolReady) { + summaryState = 'needs_setup'; + title = 'Needs local runtime'; + detail = + 'CCS could not provision the local ImageAnalysis MCP runtime yet. Profiles will fall back to native Read until provisioning succeeds.'; } else if (backendRows.length === 0) { summaryState = 'needs_setup'; title = 'Needs provider models'; @@ -289,6 +317,10 @@ async function buildDashboardPayload() { bypassedProfileCount, nativeProfileCount, }, + runtime: { + managedToolReady, + sharedHookInstalled, + }, backends: backendRows, profiles: allProfileRows, catalog: { @@ -435,6 +467,13 @@ router.put('/', async (req: Request, res: Response): Promise => { }; }); + const nextEnabled = body.enabled ?? currentConfig.enabled; + if (nextEnabled) { + ensureImageAnalysisMcpOrThrow(); + prepareImageAnalysisFallbackHook(); + syncManagedImageAnalysisToInstances(); + } + res.json(await buildDashboardPayload()); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/tests/e2e/image-analyzer-hook.e2e.test.ts b/tests/e2e/image-analyzer-hook.e2e.test.ts index d9a3015b..332f95da 100644 --- a/tests/e2e/image-analyzer-hook.e2e.test.ts +++ b/tests/e2e/image-analyzer-hook.e2e.test.ts @@ -17,7 +17,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; -import { spawnSync } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as http from 'http'; @@ -163,6 +163,56 @@ function invokeHook( }; } +function invokeHookAsync( + input: object, + env: Record = {} +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn('node', [HOOK_PATH], { + 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, + ...env, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('Hook timed out')); + }, 10000); + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + + child.on('close', (code) => { + clearTimeout(timer); + resolve({ + code: code ?? -1, + stdout, + stderr, + }); + }); + + child.stdin.end(JSON.stringify(input)); + }); +} + /** * Create a minimal valid PNG file (1x1 red pixel) */ @@ -272,15 +322,8 @@ function createTestTextFile(filepath: string, content: string): void { * 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(); + const totalBytes = sizeMB * 1024 * 1024; + fs.writeFileSync(filepath, Buffer.alloc(totalBytes, 'A')); } // ============================================================================ @@ -498,12 +541,12 @@ describe('Image Analyzer Hook', () => { // ========================================================================== describe('File Size Limits', () => { - it('should reject files larger than 10MB', () => { + it('should reject files larger than 10MB', async () => { // Create 11MB file const largePath = path.join(TEST_DIR, 'large-test.png'); createLargeFile(largePath, 11); - const result = invokeHook( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: largePath }, @@ -533,7 +576,7 @@ describe('Image Analyzer Hook', () => { 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( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -553,7 +596,7 @@ describe('Image Analyzer Hook', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toContain('CLIProxy unavailable'); }); - it('should analyze PNG via mock CLIProxy and return analysis', () => { + it('should analyze PNG via mock CLIProxy and return analysis', async () => { resetMockState(); mockResponse = { content: @@ -561,7 +604,7 @@ describe('Image Analyzer Hook', () => { statusCode: 200, }; - const result = invokeHook( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -577,14 +620,14 @@ describe('Image Analyzer Hook', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toContain('red square'); }); - it('should analyze JPEG via mock CLIProxy', () => { + it('should analyze JPEG via mock CLIProxy', async () => { resetMockState(); mockResponse = { content: 'A minimalist white image, possibly a blank canvas or placeholder.', statusCode: 200, }; - const result = invokeHook( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testJpegPath }, @@ -598,10 +641,10 @@ describe('Image Analyzer Hook', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toContain('white image'); }); - it('should include API key in request header', () => { + it('should include API key in request header', async () => { resetMockState(); - invokeHook( + await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -614,10 +657,10 @@ describe('Image Analyzer Hook', () => { expect(lastRequest?.headers['x-api-key']).toBe(CLIPROXY_API_KEY); }); - it('should send correct request format to CLIProxy', () => { + it('should send correct request format to CLIProxy', async () => { resetMockState(); - invokeHook( + await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -627,13 +670,14 @@ describe('Image Analyzer Hook', () => { CCS_PROFILE_TYPE: 'cliproxy', CCS_CURRENT_PROVIDER: 'agy', CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-3-1-flash-preview', + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy', } ); // Verify request format expect(lastRequest).not.toBeNull(); expect(lastRequest?.method).toBe('POST'); - expect(lastRequest?.path).toBe('/v1/messages'); + expect(lastRequest?.path).toBe('/api/provider/agy/v1/messages'); const body = lastRequest?.body as { model: string; @@ -664,10 +708,10 @@ describe('Image Analyzer Hook', () => { expect(imageContent?.source?.data).toBeDefined(); }); - it('should use correct media type for JPEG', () => { + it('should use correct media type for JPEG', async () => { resetMockState(); - invokeHook( + await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testJpegPath }, @@ -687,10 +731,10 @@ describe('Image Analyzer Hook', () => { expect(imageContent?.source?.media_type).toBe('image/jpeg'); }); - it('should respect debug mode and output debug messages', () => { + it('should respect debug mode and output debug messages', async () => { resetMockState(); - const result = invokeHook( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -703,14 +747,14 @@ describe('Image Analyzer Hook', () => { expect(result.stderr).toContain('Starting image analysis'); }); - it('should handle API error response gracefully (pass through)', () => { + it('should handle API error response gracefully (pass through)', async () => { resetMockState(); mockResponse = { content: '', statusCode: 500, }; - const result = invokeHook( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -726,10 +770,10 @@ describe('Image Analyzer Hook', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toContain('Error'); }); - it('should use model from provider_models mapping', () => { + it('should use model from provider_models mapping', async () => { resetMockState(); - invokeHook( + await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -747,8 +791,8 @@ describe('Image Analyzer Hook', () => { 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( + it('should skip when provider is not in provider_models', async () => { + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -770,10 +814,10 @@ describe('Image Analyzer Hook', () => { // ========================================================================== describe('Output Format Validation', () => { - it('should output valid JSON structure on success', () => { + it('should output valid JSON structure on success', async () => { resetMockState(); - const result = invokeHook( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -794,10 +838,10 @@ describe('Image Analyzer Hook', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toBeDefined(); }); - it('should include filename in output', () => { + it('should include filename in output', async () => { resetMockState(); - const result = invokeHook( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -809,10 +853,10 @@ describe('Image Analyzer Hook', () => { expect(output.hookSpecificOutput.permissionDecisionReason).toContain('test-image.png'); }); - it('should include model name in output', () => { + it('should include model name in output', async () => { resetMockState(); - const result = invokeHook( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: testPngPath }, @@ -826,7 +870,7 @@ describe('Image Analyzer Hook', () => { ); }); - it('should output valid JSON structure on file read error', () => { + it('should output valid JSON structure on file read error', async () => { // Create and immediately delete file to trigger error const errorPath = path.join(TEST_DIR, 'error-test.png'); createTestPng(errorPath); @@ -834,7 +878,7 @@ describe('Image Analyzer Hook', () => { // Make file unreadable (simulate permission error) fs.chmodSync(errorPath, 0o000); - const result = invokeHook( + const result = await invokeHookAsync( { tool_name: 'Read', tool_input: { file_path: errorPath }, diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index bd832b47..310d9af5 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -171,15 +171,22 @@ describe('profile lifecycle service', () => { fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 12\nwebsearch:\n enabled: false\n', 'utf8'); - const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => { - throw new Error('copy should not run when WebSearch is disabled'); + const originalCopyFileSync = fs.copyFileSync.bind(fs); + const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => { + const sourcePath = String(source); + const destinationPath = String(destination); + if (sourcePath.includes('websearch') || destinationPath.includes('websearch')) { + throw new Error('websearch copy should not run when WebSearch is disabled'); + } + return originalCopyFileSync(source, destination); }); const result = await runInScopedCcsDir(() => registerApiProfileOrphans({ names: ['extra'] })); - expect(copyFileSpy).not.toHaveBeenCalled(); + expect(copyFileSpy).toHaveBeenCalled(); expect(result.registered).toEqual(['extra']); expect(result.skipped).toEqual([]); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'websearch-transformer.cjs'))).toBe(false); }); it('registers malformed orphan settings when force bypasses validation', async () => { diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index d3c52fa7..6eab981b 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -130,8 +130,14 @@ describe('profile-writer Anthropic direct', () => { 'utf8' ); - const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => { - throw new Error('copy should not run when WebSearch is disabled'); + const originalCopyFileSync = fs.copyFileSync.bind(fs); + const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => { + const sourcePath = String(source); + const destinationPath = String(destination); + if (sourcePath.includes('websearch') || destinationPath.includes('websearch')) { + throw new Error('websearch copy should not run when WebSearch is disabled'); + } + return originalCopyFileSync(source, destination); }); const result = createApiProfile( @@ -142,9 +148,12 @@ describe('profile-writer Anthropic direct', () => { ); expect(result.success).toBe(true); - expect(copyFileSpy).not.toHaveBeenCalled(); + expect(copyFileSpy).toHaveBeenCalled(); expect(fs.existsSync(path.join(tempHome, '.ccs', 'disabled-websearch.settings.json'))).toBe( true ); + expect(fs.existsSync(path.join(tempHome, '.ccs', 'hooks', 'websearch-transformer.cjs'))).toBe( + false + ); }); }); diff --git a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts index 0978ede3..36036cfd 100644 --- a/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts +++ b/tests/unit/cliproxy/env-resolver-codex-fallback.test.ts @@ -258,6 +258,84 @@ describe('resolveCliproxyImageAnalysisEnv', () => { expect(result.env.CCS_CURRENT_PROVIDER).toBe('agy'); expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('https://remote.example.com:9443'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('1'); + expect(result.warning).toBeNull(); + }); + + it('pins local cliproxy image analysis to the resolved local API key', async () => { + const result = await resolveCliproxyImageAnalysisEnv( + { + profileName: 'orq', + provider: 'agy', + profileSettingsPath: '/tmp/orq.settings.json', + proxyTarget: { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }, + proxyReachable: true, + }, + { + getImageAnalysisHookEnv: () => ({ + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_TIMEOUT: '60', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_SKIP: '0', + }), + hasImageAnalysisProfileHook: () => true, + hasImageAnalyzerHook: () => true, + resolveImageAnalysisRuntimeStatus: async () => createImageAnalysisStatus(), + getLocalRuntimeApiKey: () => 'local-runtime-token', + } + ); + + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/agy'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0'); + expect(result.warning).toBeNull(); + }); + + it('routes remote HTTPS image analysis through the local tunnel when available', async () => { + const result = await resolveCliproxyImageAnalysisEnv( + { + profileName: 'orq', + provider: 'agy', + profileSettingsPath: '/tmp/orq.settings.json', + proxyTarget: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-token', + managementKey: 'remote-management-key', + allowSelfSigned: true, + isRemote: true, + }, + tunnelPort: 9911, + proxyReachable: true, + }, + { + getImageAnalysisHookEnv: () => ({ + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_TIMEOUT: '60', + CCS_IMAGE_ANALYSIS_PROVIDER_MODELS: 'agy:gemini-2.5-pro', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_SKIP: '0', + }), + hasImageAnalysisProfileHook: () => true, + hasImageAnalyzerHook: () => true, + resolveImageAnalysisRuntimeStatus: async () => createImageAnalysisStatus(), + } + ); + + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:9911'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('remote-token'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_ALLOW_SELF_SIGNED).toBe('0'); expect(result.warning).toBeNull(); }); }); diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts index 609be2b5..982287eb 100644 --- a/tests/unit/copilot/copilot-executor-env.test.ts +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -136,11 +136,15 @@ describe('generateCopilotEnv', () => { port: 8317, }; }, + getLocalRuntimeApiKey: () => 'local-runtime-token', }); expect(ensureCalls).toBe(1); expect(result.env.CCS_CURRENT_PROVIDER).toBe('ghcp'); expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL).toBe('http://127.0.0.1:8317'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_PATH).toBe('/api/provider/ghcp'); + expect(result.env.CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY).toBe('local-runtime-token'); expect(result.warning).toBeNull(); }); }); diff --git a/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts b/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts new file mode 100644 index 00000000..b4919ede --- /dev/null +++ b/tests/unit/hooks/ccs-image-analysis-mcp-server.test.ts @@ -0,0 +1,481 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { spawn } from 'child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import * as http from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-image-analysis-server.cjs'); + +function encodeMessage(message: unknown): string { + return `${JSON.stringify(message)}\n`; +} + +function encodeLegacyMessage(message: unknown): string { + const body = JSON.stringify(message); + return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; +} + +function collectResponses( + child: ReturnType, + expectedCount: number +): Promise>> { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const responses: Array> = []; + const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 5000); + + function tryParse(): void { + while (true) { + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex === -1) { + return; + } + + const body = buffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!body) { + continue; + } + + responses.push(JSON.parse(body) as Record); + if (responses.length >= expectedCount) { + clearTimeout(timer); + resolve(responses); + return; + } + } + } + + child.stdout.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + try { + tryParse(); + } catch (error) { + clearTimeout(timer); + reject(error); + } + }); + + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function createTestPng(filePath: string): void { + const png = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, + 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, + 0xcf, 0xc0, 0x00, 0x00, 0x01, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb4, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + writeFileSync(filePath, png); +} + +function createTestPdf(filePath: string): void { + writeFileSync(filePath, '%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n', 'utf8'); +} + +describe('ccs-image-analysis MCP server', () => { + let tempDir = ''; + let imagePath = ''; + let mockServer: http.Server | null = null; + + afterEach(async () => { + await new Promise((resolve) => { + mockServer?.close(() => resolve()); + if (!mockServer) resolve(); + }); + mockServer = null; + + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + imagePath = ''; + } + }); + + it('lists the ImageAnalysis tool and posts directly to the provider-scoped CLIProxy route', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-')); + imagePath = join(tempDir, 'screen.png'); + createTestPng(imagePath); + + let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null; + const mockPort = await new Promise((resolve, reject) => { + mockServer = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + receivedRequest = { + path: req.url || '/', + apiKey: req.headers['x-api-key'] as string, + body: body ? JSON.parse(body) : null, + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + content: [{ type: 'text', text: 'The screenshot shows a red pixel debug fixture.' }], + }) + ); + }); + }); + + mockServer.once('error', reject); + mockServer.listen(0, '127.0.0.1', () => { + const address = mockServer?.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to resolve mock server port')); + return; + } + resolve(address.port); + }); + }); + + const child = spawn('node', [serverPath], { + cwd: tempDir, + env: { + ...process.env, + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_SKIP: '0', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`, + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy', + CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 3); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' })); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'ImageAnalysis', + arguments: { filePath: imagePath, focus: 'Describe the visible issue' }, + }, + }) + ); + + const responses = await responsesPromise; + const toolsList = responses.find((message) => message.id === 2); + const toolCall = responses.find((message) => message.id === 3); + + expect(toolsList?.result).toEqual({ + tools: [ + { + name: 'ImageAnalysis', + description: + 'Analyze a local image or PDF file with CCS provider-backed vision. Prefer this tool over Read for image and PDF paths. Use Read for text, code, and other plain files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: + 'Workspace-relative path, or an absolute path inside the current workspace, to a local image or PDF file to analyze.', + }, + focus: { + type: 'string', + description: + 'Optional question or area of focus, for example "explain the error dialog" or "transcribe the visible text".', + }, + template: { + type: 'string', + enum: ['default', 'screenshot', 'document'], + description: + 'Optional prompt template override. Use screenshot for UI captures, document for PDFs/docs, or default for general images.', + }, + }, + required: ['filePath'], + additionalProperties: false, + }, + }, + ], + }); + expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages'); + expect(receivedRequest?.apiKey).toBe('test-api-key'); + expect( + (((receivedRequest?.body as { messages: Array<{ content: Array<{ text?: string }> }> }) + ?.messages[0]?.content[0] || {}) as { text?: string }).text + ).toContain('Specific focus'); + expect(toolCall?.result).toBeDefined(); + expect( + ((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text + ).toContain('red pixel debug fixture'); + expect( + ((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text + ).toContain('Model: gemini-3-1-flash-preview'); + } finally { + child.kill(); + } + }); + + it('returns a structured tool error when the file does not exist', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-missing-')); + const missingPath = join(tempDir, 'missing-image.png'); + const child = spawn('node', [serverPath], { + cwd: tempDir, + env: { + ...process.env, + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_SKIP: '0', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 2); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'ImageAnalysis', + arguments: { filePath: missingPath }, + }, + }) + ); + + const responses = await responsesPromise; + const toolCall = responses.find((message) => message.id === 2); + + expect(toolCall?.result).toEqual({ + content: [ + { + type: 'text', + text: `ImageAnalysis could not find file: ${missingPath}`, + }, + ], + isError: true, + }); + } finally { + child.kill(); + } + }); + + it('rejects paths outside the current workspace', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-scope-')); + const workspaceDir = join(tempDir, 'workspace'); + mkdirSync(workspaceDir, { recursive: true }); + const outsidePath = join(tempDir, 'outside.png'); + createTestPng(outsidePath); + + const child = spawn('node', [serverPath], { + cwd: workspaceDir, + env: { + ...process.env, + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_SKIP: '0', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 2); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'ImageAnalysis', + arguments: { filePath: '../outside.png' }, + }, + }) + ); + + const responses = await responsesPromise; + const toolCall = responses.find((message) => message.id === 2); + expect(toolCall?.error).toEqual({ + code: -32602, + message: 'ImageAnalysis only allows files inside the current workspace.', + }); + } finally { + child.kill(); + } + }); + + it('sends PDF files as document blocks to the provider-scoped route', async () => { + tempDir = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-mcp-server-pdf-')); + const pdfPath = join(tempDir, 'manual.pdf'); + createTestPdf(pdfPath); + + let receivedRequest: { path?: string; apiKey?: string; body?: unknown } | null = null; + const mockPort = await new Promise((resolve, reject) => { + mockServer = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + receivedRequest = { + path: req.url || '/', + apiKey: req.headers['x-api-key'] as string, + body: body ? JSON.parse(body) : null, + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + content: [{ type: 'text', text: 'The PDF contains a one-page fixture.' }], + }) + ); + }); + }); + + mockServer.once('error', reject); + mockServer.listen(0, '127.0.0.1', () => { + const address = mockServer?.address(); + if (!address || typeof address === 'string') { + reject(new Error('Failed to resolve mock server port')); + return; + } + resolve(address.port); + }); + }); + + const child = spawn('node', [serverPath], { + cwd: tempDir, + env: { + ...process.env, + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_SKIP: '0', + CCS_CURRENT_PROVIDER: 'agy', + CCS_IMAGE_ANALYSIS_MODEL: 'gemini-3-1-flash-preview', + CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL: `http://127.0.0.1:${mockPort}`, + CCS_IMAGE_ANALYSIS_RUNTIME_PATH: '/api/provider/agy', + CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY: 'test-api-key', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 2); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'ImageAnalysis', + arguments: { filePath: pdfPath }, + }, + }) + ); + + const responses = await responsesPromise; + const requestBody = receivedRequest?.body as { + messages: Array<{ content: Array<{ type: string; source?: { media_type?: string } }> }>; + }; + + expect(receivedRequest?.path).toBe('/api/provider/agy/v1/messages'); + expect(receivedRequest?.apiKey).toBe('test-api-key'); + expect(requestBody.messages[0]?.content[1]?.type).toBe('document'); + expect(requestBody.messages[0]?.content[1]?.source?.media_type).toBe('application/pdf'); + expect(((responses[1]?.result as { content: Array<{ text: string }> }).content[0] || {}).text).toContain( + 'one-page fixture' + ); + } finally { + child.kill(); + } + }); + + it('accepts legacy Content-Length framed requests for compatibility', async () => { + const child = spawn('node', [serverPath], { + env: { + ...process.env, + CCS_IMAGE_ANALYSIS_ENABLED: '1', + CCS_IMAGE_ANALYSIS_SKIP: '1', + CCS_CURRENT_PROVIDER: 'agy', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 2); + child.stdin.write( + encodeLegacyMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write(encodeLegacyMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' })); + + const responses = await responsesPromise; + const toolsList = responses.find((message) => message.id === 2); + expect(toolsList?.result).toEqual({ tools: [] }); + } finally { + child.kill(); + } + }); +}); diff --git a/tests/unit/targets/settings-profile-image-analysis-launch.test.ts b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts new file mode 100644 index 00000000..5154512e --- /dev/null +++ b/tests/unit/targets/settings-profile-image-analysis-launch.test.ts @@ -0,0 +1,210 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool ImageAnalysis instead of Read'; + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult { + const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts'); + const result = spawnSync(process.execPath, [ccsEntry, ...args], { + encoding: 'utf8', + env, + timeout: 20000, + }); + + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +describe('settings profile ImageAnalysis launch', () => { + let tmpHome = ''; + let ccsDir = ''; + let settingsPath = ''; + let fakeClaudePath = ''; + let claudeArgsLogPath = ''; + let claudeEnvLogPath = ''; + let baseEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + if (process.platform === 'win32') { + return; + } + + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-launch-')); + ccsDir = path.join(tmpHome, '.ccs'); + settingsPath = path.join(ccsDir, 'glm.settings.json'); + fakeClaudePath = path.join(tmpHome, 'fake-claude.sh'); + claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt'); + claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt'); + + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'websearch:', + ' enabled: false', + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' fallback_backend: agy', + ' provider_models:', + ' agy: gemini-3-1-flash-preview', + 'cliproxy:', + ' auth:', + ' api_key: current-token', + '', + ].join('\n'), + 'utf8' + ); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'stale-token', + ANTHROPIC_MODEL: 'glm-5', + }, + }, + null, + 2 + ) + '\n' + ); + + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh +printf "%s\n" "$@" > "${claudeArgsLogPath}" +{ + printf "runtimeApiKey=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_API_KEY" + printf "runtimeBaseUrl=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_BASE_URL" + printf "runtimePath=%s\n" "$CCS_IMAGE_ANALYSIS_RUNTIME_PATH" +} > "${claudeEnvLogPath}" +exit 0 +`, + { encoding: 'utf8', mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + baseEnv = { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CLAUDE_PATH: fakeClaudePath, + CCS_DEBUG: '1', + }; + }); + + afterEach(() => { + if (process.platform === 'win32') { + return; + } + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it('keeps launch non-fatal when the shared Read-hook fallback cannot be prepared', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); + + const result = runCcs(['glm', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(true); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).toContain('--append-system-prompt'); + expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET); + }); + + it('keeps launch non-fatal when Image Analysis is disabled', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + 'version: 12\nwebsearch:\n enabled: false\nimage_analysis:\n enabled: false\n', + 'utf8' + ); + fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); + + const result = runCcs(['glm', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(result.stderr).not.toContain('could not prepare the local ImageAnalysis tool'); + expect(fs.existsSync(claudeArgsLogPath)).toBe(true); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); + }); + + it('falls back to native Read when the ImageAnalysis MCP runtime cannot be provisioned', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync(path.join(tmpHome, '.claude.json'), '{not-json', 'utf8'); + + const result = runCcs(['glm', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(result.stderr).toContain('could not prepare the local ImageAnalysis tool'); + const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8'); + expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET); + }); + + it('pins bridge-backed image analysis to the current CLIProxy auth token', () => { + if (process.platform === 'win32') return; + + const result = runCcs(['glm', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(fs.existsSync(claudeEnvLogPath)).toBe(true); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain('runtimeApiKey=current-token'); + expect(launchedEnv).not.toContain('stale-token'); + expect(launchedEnv).toContain('runtimePath=/api/provider/agy'); + }); + + it('pins direct settings image analysis to the current local CLIProxy auth token', () => { + if (process.platform === 'win32') return; + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/v1', + ANTHROPIC_AUTH_TOKEN: 'stale-token', + ANTHROPIC_MODEL: 'glm-5', + }, + }, + null, + 2 + ) + '\n' + ); + + const result = runCcs(['glm', 'smoke'], baseEnv); + + expect(result.status).toBe(0); + expect(fs.existsSync(claudeEnvLogPath)).toBe(true); + const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8'); + expect(launchedEnv).toContain('runtimeApiKey=current-token'); + expect(launchedEnv).not.toContain('stale-token'); + expect(launchedEnv).toContain('runtimePath=/api/provider/'); + }); +}); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index acd83eb7..aa13eefd 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -384,6 +384,41 @@ describe('CLAUDECODE environment stripping', () => { }); }); + it('headless executor prepares image-analysis MCP and compatibility hook fallback', async () => { + writeConfigWithAutoUpdatePreference(false); + const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); + const settingsPath = path.join(ccsDir, 'glm.settings.json'); + fs.writeFileSync(settingsPath, '{}\n', 'utf8'); + process.env.CCS_CLAUDE_PATH = 'claude'; + + const result = await HeadlessExecutor.execute('glm', 'describe screenshot', { + permissionMode: 'default', + timeout: 1000, + }); + + expect(result.success).toBe(true); + expect(spawnCalls.length).toBeGreaterThan(0); + + const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + expect(persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true); + + const claudeUserConfig = JSON.parse( + fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8') + ) as { + mcpServers?: Record; + }; + expect(claudeUserConfig.mcpServers?.['ccs-image-analysis']).toEqual({ + type: 'stdio', + command: 'node', + args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], + env: {}, + }); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(true); + }); + it('headless executor propagates a WebSearch trace launch id when tracing is enabled', async () => { writeConfigWithAutoUpdatePreference(false); const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs'); diff --git a/tests/unit/utils/hooks/get-image-analysis-hook-env.test.ts b/tests/unit/utils/hooks/get-image-analysis-hook-env.test.ts new file mode 100644 index 00000000..4dbf93bb --- /dev/null +++ b/tests/unit/utils/hooks/get-image-analysis-hook-env.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'bun:test'; +import { resolveImageAnalysisRuntimeConnection } from '../../../../src/utils/hooks'; + +describe('resolveImageAnalysisRuntimeConnection', () => { + it('returns a direct local runtime connection for local CLIProxy targets', () => { + const connection = resolveImageAnalysisRuntimeConnection({ + proxyTarget: { + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }, + }); + + expect(connection.baseUrl).toBe('http://127.0.0.1:8317'); + expect(connection.allowSelfSigned).toBe(false); + expect(connection.proxyTarget.isRemote).toBe(false); + }); + + it('returns the remote runtime base URL and TLS flag for self-signed targets', () => { + const connection = resolveImageAnalysisRuntimeConnection({ + proxyTarget: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-token', + allowSelfSigned: true, + isRemote: true, + }, + }); + + expect(connection.baseUrl).toBe('https://remote.example.com:9443'); + expect(connection.apiKey).toBe('remote-token'); + expect(connection.allowSelfSigned).toBe(true); + expect(connection.proxyTarget.isRemote).toBe(true); + }); + + it('prefers the local tunnel when one is active for a remote target', () => { + const connection = resolveImageAnalysisRuntimeConnection({ + proxyTarget: { + host: 'remote.example.com', + port: 9443, + protocol: 'https', + authToken: 'remote-token', + allowSelfSigned: true, + isRemote: true, + }, + tunnelPort: 9911, + }); + + expect(connection.baseUrl).toBe('http://127.0.0.1:9911'); + expect(connection.apiKey).toBe('remote-token'); + expect(connection.allowSelfSigned).toBe(false); + expect(connection.proxyTarget).toMatchObject({ + host: '127.0.0.1', + port: 9911, + protocol: 'http', + isRemote: false, + }); + }); +}); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts index dd099371..ccb02d26 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts @@ -38,6 +38,7 @@ function createStatus(overrides: Partial = {}): ImageAnalys describe('image-analysis-runtime-status', () => { it('falls back to native read when provider auth is missing', async () => { const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }), getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, @@ -63,6 +64,7 @@ describe('image-analysis-runtime-status', () => { it('marks an idle local proxy as launchable when auth is ready', async () => { const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }), getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, @@ -107,7 +109,10 @@ describe('image-analysis-runtime-status', () => { source: 'remote', }, ], - isCliproxyRunning: async () => false, + checkRemoteProxy: async () => ({ + reachable: false, + error: 'Remote CLIProxy target remote.example:443 is unreachable.', + }), }); expect(status.authReadiness).toBe('ready'); @@ -116,13 +121,14 @@ describe('image-analysis-runtime-status', () => { expect(status.effectiveRuntimeReason).toContain('remote.example:443'); }); - it('keeps hook-missing on native read even when auth and proxy are ready', async () => { + it('keeps hook-missing as a degraded fallback state while primary runtime stays active', async () => { const status = await hydrateImageAnalysisRuntimeStatus( createStatus({ status: 'hook-missing', reason: 'Profile hook is missing from the persisted settings file.', }), { + checkRemoteProxy: async () => ({ reachable: false, error: 'not-used' }), getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, @@ -144,7 +150,41 @@ describe('image-analysis-runtime-status', () => { expect(status.authReadiness).toBe('ready'); expect(status.proxyReadiness).toBe('ready'); - expect(status.effectiveRuntimeMode).toBe('native-read'); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); expect(status.effectiveRuntimeReason).toContain('Profile hook is missing'); }); + + it('marks a reachable remote proxy as remote instead of inspecting local 8317', async () => { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ + host: 'remote.example', + port: 443, + protocol: 'https', + authToken: 'token', + managementKey: 'secret', + allowSelfSigned: true, + isRemote: true, + }), + fetchRemoteAuthStatus: async () => [ + { + provider: 'ghcp', + displayName: 'GitHub Copilot (OAuth)', + authenticated: true, + tokenFiles: 1, + accounts: [], + defaultAccount: null, + source: 'remote', + }, + ], + checkRemoteProxy: async () => ({ + reachable: true, + latencyMs: 42, + }), + isCliproxyRunning: async () => false, + }); + + expect(status.proxyReadiness).toBe('remote'); + expect(status.proxyReason).toContain('remote.example:443'); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + }); }); diff --git a/tests/unit/utils/hooks/image-analyzer-hook-installer.test.ts b/tests/unit/utils/hooks/image-analyzer-hook-installer.test.ts new file mode 100644 index 00000000..ca8671e2 --- /dev/null +++ b/tests/unit/utils/hooks/image-analyzer-hook-installer.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + hasImageAnalyzerHook, + installImageAnalyzerHook, +} from '../../../../src/utils/hooks/image-analyzer-hook-installer'; +import { prepareImageAnalysisFallbackHook } from '../../../../src/utils/hooks'; + +describe('image-analyzer-hook-installer', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + let runtimePath = ''; + let bundledRuntimePath = ''; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analyzer-hook-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + 'version: 12\nimage_analysis:\n enabled: true\n', + 'utf8' + ); + + runtimePath = path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'); + bundledRuntimePath = path.join(process.cwd(), 'lib', 'hooks', 'image-analysis-runtime.cjs'); + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (tempHome) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('treats a missing runtime artifact as not ready and repairs it on prepare', () => { + expect(installImageAnalyzerHook()).toBe(true); + fs.unlinkSync(runtimePath); + + expect(hasImageAnalyzerHook()).toBe(false); + expect(prepareImageAnalysisFallbackHook()).toBe(true); + expect(fs.existsSync(runtimePath)).toBe(true); + expect(hasImageAnalyzerHook()).toBe(true); + }); + + it('refreshes stale runtime content during fallback hook preparation', () => { + expect(installImageAnalyzerHook()).toBe(true); + fs.writeFileSync(runtimePath, 'stale runtime\n', 'utf8'); + + expect(hasImageAnalyzerHook()).toBe(false); + expect(prepareImageAnalysisFallbackHook()).toBe(true); + expect(fs.readFileSync(runtimePath, 'utf8')).toBe(fs.readFileSync(bundledRuntimePath, 'utf8')); + }); +}); diff --git a/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts index 96c07f32..f0e47f19 100644 --- a/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts +++ b/tests/unit/utils/hooks/image-analyzer-profile-hook-injector.test.ts @@ -67,4 +67,23 @@ describe('image-analyzer-profile-hook-injector', () => { expect(fs.existsSync(defaultSettingsPath)).toBe(false); expect(persisted.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(true); }); + + it('skips copilot hook persistence when the shared fallback hook is unavailable', () => { + const settingsPath = path.join(tempHome, '.ccs', 'copilot.settings.json'); + writeJson(settingsPath, {}); + + const ensured = ensureProfileHooks({ + profileName: 'copilot', + profileType: 'copilot', + settingsPath, + sharedHookInstalled: false, + }); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + hooks?: { PreToolUse?: Array<{ matcher?: string }> }; + }; + + expect(ensured).toBe(false); + expect(persisted.hooks).toBeUndefined(); + }); }); diff --git a/tests/unit/utils/image-analysis/claude-tool-args.test.ts b/tests/unit/utils/image-analysis/claude-tool-args.test.ts new file mode 100644 index 00000000..8f62322b --- /dev/null +++ b/tests/unit/utils/image-analysis/claude-tool-args.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'bun:test'; +import { + appendThirdPartyImageAnalysisToolArgs, + getImageAnalysisSteeringPrompt, +} from '../../../../src/utils/image-analysis'; + +describe('appendThirdPartyImageAnalysisToolArgs', () => { + it('appends the steering prompt for image analysis', () => { + const args = appendThirdPartyImageAnalysisToolArgs(['-p', 'describe the screenshot']); + + expect(args).toEqual(['-p', 'describe the screenshot', '--append-system-prompt', getImageAnalysisSteeringPrompt()]); + }); + + it('does not duplicate the steering prompt when already present', () => { + const steeringPrompt = getImageAnalysisSteeringPrompt(); + const args = appendThirdPartyImageAnalysisToolArgs([ + '-p', + 'describe the screenshot', + '--append-system-prompt', + steeringPrompt, + ]); + + expect(args.filter((arg) => arg === steeringPrompt)).toHaveLength(1); + expect(args.filter((arg) => arg === '--append-system-prompt')).toHaveLength(1); + }); + + it('preserves trailing arguments after --', () => { + const args = appendThirdPartyImageAnalysisToolArgs(['-p', 'describe', '--', 'extra']); + + expect(args).toEqual([ + '-p', + 'describe', + '--append-system-prompt', + getImageAnalysisSteeringPrompt(), + '--', + 'extra', + ]); + }); +}); diff --git a/tests/unit/utils/image-analysis/mcp-installer.test.ts b/tests/unit/utils/image-analysis/mcp-installer.test.ts new file mode 100644 index 00000000..40b617f5 --- /dev/null +++ b/tests/unit/utils/image-analysis/mcp-installer.test.ts @@ -0,0 +1,219 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; +import { + ensureImageAnalysisMcp, + getImageAnalysisMcpRuntimePath, + getImageAnalysisMcpServerName, + getImageAnalysisMcpServerPath, + hasImageAnalysisMcpReady, + uninstallImageAnalysisMcp, +} from '../../../../src/utils/image-analysis'; + +describe('ensureImageAnalysisMcp', () => { + let tempHome: string | undefined; + let originalCcsHome: string | undefined; + + function setupTempHome(): string { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-mcp-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + return tempHome; + } + + function getCcsDir(): string { + if (!tempHome) { + throw new Error('tempHome not initialized'); + } + return path.join(tempHome, '.ccs'); + } + + function writeEnabledConfig(): void { + const ccsDir = getCcsDir(); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' fallback_backend: agy', + ' provider_models:', + ' agy: gemini-3-1-flash-preview', + '', + ].join('\n'), + 'utf8' + ); + } + + function getManagedConfig() { + return { + type: 'stdio', + command: 'node', + args: [getImageAnalysisMcpServerPath()], + env: {}, + }; + } + + afterEach(() => { + mock.restore(); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + + tempHome = undefined; + originalCcsHome = undefined; + }); + + it('installs the MCP server and preserves existing user mcpServers entries', () => { + setupTempHome(); + writeEnabledConfig(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureImageAnalysisMcp()).toBe(true); + expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(true); + expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(true); + + const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + + expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); + expect(config.mcpServers[getImageAnalysisMcpServerName()]).toEqual(getManagedConfig()); + expect(hasImageAnalysisMcpReady(claudeUserConfigPath)).toBe(true); + }); + + it('removes the managed MCP runtime while preserving unrelated server entries', () => { + setupTempHome(); + writeEnabledConfig(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureImageAnalysisMcp()).toBe(true); + + const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work'); + fs.mkdirSync(instancePath, { recursive: true }); + fs.writeFileSync( + path.join(instancePath, '.claude.json'), + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['instance-server'] }, + [getImageAnalysisMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] }, + }, + otherKey: 'keep-me', + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(uninstallImageAnalysisMcp()).toBe(true); + expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(false); + expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(false); + + const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + expect(globalConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['some-server'] }, + }); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') + ) as { + otherKey: string; + mcpServers: Record; + }; + expect(instanceConfig.otherKey).toBe('keep-me'); + expect(instanceConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['instance-server'] }, + }); + }); + + it('installs the first-class MCP runtime even when the legacy hooks path is unusable', () => { + setupTempHome(); + writeEnabledConfig(); + + const hooksPath = path.join(getCcsDir(), 'hooks'); + fs.writeFileSync(hooksPath, 'not-a-directory', 'utf8'); + + expect(ensureImageAnalysisMcp()).toBe(true); + expect(fs.existsSync(getImageAnalysisMcpServerPath())).toBe(true); + expect(fs.existsSync(getImageAnalysisMcpRuntimePath())).toBe(true); + }); + + it('serializes ~/.claude.json updates with a file lock', () => { + setupTempHome(); + writeEnabledConfig(); + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8'); + + const lockSpy = spyOn(lockfile, 'lockSync'); + + expect(ensureImageAnalysisMcp()).toBe(true); + expect(lockSpy).toHaveBeenCalled(); + expect(lockSpy.mock.calls[0]?.[0]).toBe(path.join(tempHome as string, '.claude.json.ccs-lock')); + }); + + it('returns false instead of throwing when ~/.claude.json is already locked', () => { + setupTempHome(); + writeEnabledConfig(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync(claudeUserConfigPath, '{}\n', 'utf8'); + fs.writeFileSync(path.join(tempHome as string, '.claude.json.ccs-lock'), '', 'utf8'); + + const release = lockfile.lockSync(path.join(tempHome as string, '.claude.json.ccs-lock'), { + stale: 10000, + }) as () => void; + + try { + let result: boolean | undefined; + expect(() => { + result = ensureImageAnalysisMcp(); + }).not.toThrow(); + expect(result).toBe(false); + } finally { + release(); + } + }); +}); diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts index 557a9916..2b329b15 100644 --- a/tests/unit/web-server/image-analysis-routes.test.ts +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -185,9 +185,13 @@ describe('image-analysis routes', () => { backendCount: 2, bypassedProfileCount: 1, }); + expect(payload.runtime).toMatchObject({ + managedToolReady: false, + sharedHookInstalled: false, + }); }); - it('updates the saved config through the dashboard route', async () => { + it('updates the saved config through the dashboard route and provisions the local runtime', async () => { const response = await fetch(`${baseUrl}/api/image-analysis`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -219,6 +223,28 @@ describe('image-analysis routes', () => { expect(payload.config.providerModels).toMatchObject({ gemini: 'gemini-2.5-pro', }); + expect(payload.runtime).toMatchObject({ + managedToolReady: true, + sharedHookInstalled: true, + }); + + const ccsDir = path.join(tempHome, '.ccs'); + expect(fs.existsSync(path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs'))).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'mcp', 'image-analysis-runtime.cjs'))).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(true); + expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(true); + + const claudeConfig = JSON.parse( + fs.readFileSync(path.join(tempHome, '.claude.json'), 'utf8') + ) as { + mcpServers?: Record; + }; + expect(claudeConfig.mcpServers?.['ccs-image-analysis']).toEqual({ + type: 'stdio', + command: 'node', + args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')], + env: {}, + }); }); it('rejects profile mappings that point to a missing backend with a client error', async () => { diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts index bb375874..2352830c 100644 --- a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -12,9 +12,17 @@ function writeJson(filePath: string, value: Record): void { } function installSharedHook(tempHome: string): string { - const hookPath = path.join(tempHome, '.ccs', 'hooks', 'image-analyzer-transformer.cjs'); - fs.mkdirSync(path.dirname(hookPath), { recursive: true }); - fs.writeFileSync(hookPath, '#!/usr/bin/env node\n', 'utf8'); + const hooksDir = path.join(tempHome, '.ccs', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + + for (const fileName of ['image-analyzer-transformer.cjs', 'image-analysis-runtime.cjs']) { + fs.copyFileSync( + path.join(process.cwd(), 'lib', 'hooks', fileName), + path.join(hooksDir, fileName) + ); + } + + const hookPath = path.join(hooksDir, 'image-analyzer-transformer.cjs'); return hookPath; }