mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
feat(image-analysis): make MCP-first provisioning self-healing
This commit is contained in:
@@ -380,6 +380,11 @@ function outputSuccess(filePath, description, model, fileSize) {
|
||||
* Determine if hook should skip, with debug logging
|
||||
*/
|
||||
function shouldSkipHook() {
|
||||
if (process.env.CCS_IMAGE_ANALYSIS_SKIP_HOOK === '1') {
|
||||
debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP_HOOK=1');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Explicit skip signal
|
||||
if (process.env.CCS_IMAGE_ANALYSIS_SKIP === '1') {
|
||||
debugLog('Skipping: CCS_IMAGE_ANALYSIS_SKIP=1');
|
||||
|
||||
+58
-40
@@ -37,7 +37,10 @@ import {
|
||||
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 {
|
||||
ensureProfileHooks as ensureImageAnalyzerHooks,
|
||||
removeImageAnalysisProfileHook,
|
||||
} from './utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import {
|
||||
applyImageAnalysisRuntimeOverrides,
|
||||
getImageAnalysisHookEnv,
|
||||
@@ -711,22 +714,30 @@ async function main(): Promise<void> {
|
||||
|
||||
if (profileInfo.type === 'cliproxy') {
|
||||
// CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants
|
||||
const imageAnalysisMcpReady =
|
||||
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
|
||||
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({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
cliproxyProvider: provider,
|
||||
isComposite: profileInfo.isComposite,
|
||||
settingsPath: profileInfo.settingsPath ? expandPath(profileInfo.settingsPath) : undefined,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
const expandedCliproxySettingsPath = profileInfo.settingsPath
|
||||
? expandPath(profileInfo.settingsPath)
|
||||
: undefined;
|
||||
if (resolvedTarget === 'claude') {
|
||||
if (imageAnalysisMcpReady) {
|
||||
removeImageAnalysisProfileHook(profileInfo.name, expandedCliproxySettingsPath);
|
||||
} else {
|
||||
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
cliproxyProvider: provider,
|
||||
isComposite: profileInfo.isComposite,
|
||||
settingsPath: expandedCliproxySettingsPath,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
}
|
||||
}
|
||||
const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles
|
||||
const variantPort = profileInfo.port; // variant-specific port for isolation
|
||||
const cliproxyPort = variantPort || CLIPROXY_DEFAULT_PORT;
|
||||
@@ -883,15 +894,19 @@ async function main(): Promise<void> {
|
||||
} else if (profileInfo.type === 'copilot') {
|
||||
// COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy
|
||||
ensureWebSearchMcpOrThrow();
|
||||
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 imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
if (resolvedTarget === 'claude') {
|
||||
if (imageAnalysisMcpReady) {
|
||||
removeImageAnalysisProfileHook(profileInfo.name);
|
||||
} else {
|
||||
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { executeCopilotProfile } = await import('./copilot');
|
||||
const copilotConfig = profileInfo.copilotConfig;
|
||||
@@ -981,8 +996,6 @@ async function main(): Promise<void> {
|
||||
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
||||
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
const imageAnalysisFallbackHookReady =
|
||||
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
|
||||
const expandedSettingsPath =
|
||||
resolvedSettingsPath ??
|
||||
(profileInfo.settingsPath
|
||||
@@ -990,14 +1003,22 @@ async function main(): Promise<void> {
|
||||
: getSettingsPath(profileInfo.name));
|
||||
const settings = resolvedSettings ?? loadSettings(expandedSettingsPath);
|
||||
const cliproxyBridge = resolvedCliproxyBridge ?? resolveCliproxyBridgeMetadata(settings);
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
settingsPath: expandedSettingsPath,
|
||||
settings,
|
||||
cliproxyBridge,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
let imageAnalysisFallbackHookReady: boolean | undefined;
|
||||
if (resolvedTarget === 'claude') {
|
||||
if (imageAnalysisMcpReady) {
|
||||
removeImageAnalysisProfileHook(profileInfo.name, expandedSettingsPath);
|
||||
} else {
|
||||
imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
settingsPath: expandedSettingsPath,
|
||||
settings,
|
||||
cliproxyBridge,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (resolvedTarget !== 'claude') {
|
||||
const compatibility = evaluateTargetRuntimeCompatibility({
|
||||
target: resolvedTarget,
|
||||
@@ -1116,14 +1137,11 @@ async function main(): Promise<void> {
|
||||
apiKey: runtimeConnection.apiKey,
|
||||
allowSelfSigned: runtimeConnection.allowSelfSigned,
|
||||
});
|
||||
|
||||
if (!imageAnalysisMcpReady) {
|
||||
imageAnalysisEnv = {
|
||||
...imageAnalysisEnv,
|
||||
CCS_CURRENT_PROVIDER: '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
}
|
||||
imageAnalysisEnv = {
|
||||
...imageAnalysisEnv,
|
||||
CCS_IMAGE_ANALYSIS_SKIP_HOOK:
|
||||
resolvedTarget === 'claude' && imageAnalysisMcpReady ? '1' : '0',
|
||||
};
|
||||
|
||||
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
|
||||
if (
|
||||
|
||||
@@ -978,15 +978,12 @@ export async function execClaudeWithCLIProxy(
|
||||
});
|
||||
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 imageAnalysisEnv = {
|
||||
...imageAnalysisResolution.env,
|
||||
CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0',
|
||||
};
|
||||
const imageAnalysisWarning = imageAnalysisProvisioningFailed
|
||||
? 'ImageAnalysis MCP provisioning failed. This session will use native Read.'
|
||||
? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.'
|
||||
: imageAnalysisResolution.warning;
|
||||
|
||||
// 9. Setup tool sanitization proxy
|
||||
|
||||
@@ -16,7 +16,10 @@ 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 {
|
||||
ensureProfileHooks as ensureImageAnalyzerHooks,
|
||||
removeImageAnalysisProfileHook,
|
||||
} from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import { prepareImageAnalysisFallbackHook } from '../../utils/hooks';
|
||||
import { getEffectiveApiKey } from '../auth-token-manager';
|
||||
import { warn } from '../../utils/ui';
|
||||
@@ -157,21 +160,23 @@ export function createSettingsFile(
|
||||
|
||||
try {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
ensureImageAnalysisMcpOrThrow();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
if (imageAnalysisMcpReady) {
|
||||
removeImageAnalysisProfileHook(`${provider}-${name}`, settingsPath);
|
||||
} else {
|
||||
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: `${provider}-${name}`,
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: provider,
|
||||
settingsPath,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
||||
throw error;
|
||||
}
|
||||
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
|
||||
// Inject Image Analyzer hooks into variant settings
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: `${provider}-${name}`,
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: provider,
|
||||
settingsPath,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
|
||||
return settingsPath;
|
||||
}
|
||||
@@ -199,21 +204,23 @@ export function createSettingsFileUnified(
|
||||
|
||||
try {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
ensureImageAnalysisMcpOrThrow();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
if (imageAnalysisMcpReady) {
|
||||
removeImageAnalysisProfileHook(`${provider}-${name}`, settingsPath);
|
||||
} else {
|
||||
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: `${provider}-${name}`,
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: provider,
|
||||
settingsPath,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
|
||||
throw error;
|
||||
}
|
||||
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
|
||||
// Inject Image Analyzer hooks into variant settings
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: `${provider}-${name}`,
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: provider,
|
||||
settingsPath,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
|
||||
return settingsPath;
|
||||
}
|
||||
@@ -305,20 +312,24 @@ export function createCompositeSettingsFile(
|
||||
if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) {
|
||||
try {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
ensureImageAnalysisMcpOrThrow();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
if (imageAnalysisMcpReady) {
|
||||
removeImageAnalysisProfileHook(`composite-${name}`, settingsPath);
|
||||
} else {
|
||||
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: `composite-${name}`,
|
||||
profileType: 'cliproxy',
|
||||
cliproxyProvider: tiers[defaultTier].provider,
|
||||
isComposite: true,
|
||||
settingsPath,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
}
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
|
||||
return settingsPath;
|
||||
|
||||
@@ -267,15 +267,12 @@ export async function executeCopilotProfile(
|
||||
const imageAnalysisProvisioningFailed =
|
||||
!imageAnalysisMcpReady && imageAnalysisResolution.env.CCS_IMAGE_ANALYSIS_ENABLED === '1';
|
||||
const imageAnalysisWarning = imageAnalysisProvisioningFailed
|
||||
? 'ImageAnalysis MCP provisioning failed. This session will use native Read.'
|
||||
? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.'
|
||||
: imageAnalysisResolution.warning;
|
||||
const imageAnalysisEnv = imageAnalysisProvisioningFailed
|
||||
? {
|
||||
...imageAnalysisResolution.env,
|
||||
CCS_CURRENT_PROVIDER: '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
}
|
||||
: imageAnalysisResolution.env;
|
||||
const imageAnalysisEnv = {
|
||||
...imageAnalysisResolution.env,
|
||||
CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0',
|
||||
};
|
||||
const env = stripClaudeCodeEnv({
|
||||
...process.env,
|
||||
...globalEnv,
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
resolveImageAnalysisRuntimeConnection,
|
||||
resolveImageAnalysisRuntimeStatus,
|
||||
} from '../utils/hooks';
|
||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import {
|
||||
ensureProfileHooks as ensureImageAnalyzerHooks,
|
||||
removeImageAnalysisProfileHook,
|
||||
} 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';
|
||||
@@ -127,15 +130,20 @@ export class HeadlessExecutor {
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
|
||||
const imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: profile,
|
||||
profileType: 'settings',
|
||||
settingsPath,
|
||||
settings,
|
||||
cliproxyBridge,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
let imageAnalysisFallbackHookReady: boolean | undefined;
|
||||
if (imageAnalysisMcpReady) {
|
||||
removeImageAnalysisProfileHook(profile, settingsPath);
|
||||
} else {
|
||||
imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: profile,
|
||||
profileType: 'settings',
|
||||
settingsPath,
|
||||
settings,
|
||||
cliproxyBridge,
|
||||
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
||||
});
|
||||
}
|
||||
const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({
|
||||
profileName: profile,
|
||||
profileType: 'settings',
|
||||
@@ -158,14 +166,10 @@ export class HeadlessExecutor {
|
||||
apiKey: runtimeConnection.apiKey,
|
||||
allowSelfSigned: runtimeConnection.allowSelfSigned,
|
||||
});
|
||||
|
||||
if (!imageAnalysisMcpReady) {
|
||||
imageAnalysisEnv = {
|
||||
...imageAnalysisEnv,
|
||||
CCS_CURRENT_PROVIDER: '',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
}
|
||||
imageAnalysisEnv = {
|
||||
...imageAnalysisEnv,
|
||||
CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0',
|
||||
};
|
||||
|
||||
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
|
||||
if (
|
||||
|
||||
@@ -7,10 +7,16 @@
|
||||
|
||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../../config/unified-config-types';
|
||||
import {
|
||||
countManagedImageAnalysisHookFiles,
|
||||
hasImageAnalysisMcpReady,
|
||||
repairImageAnalysisRuntimeState,
|
||||
} from '../../utils/image-analysis';
|
||||
import { ok, warn, dim } from '../../utils/ui';
|
||||
import { isCliproxyRunning } from '../../cliproxy/stats-fetcher';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config-generator';
|
||||
import type { HealthCheck } from './types';
|
||||
import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer';
|
||||
|
||||
/**
|
||||
* Run image analysis configuration check
|
||||
@@ -65,6 +71,16 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise<void>
|
||||
}
|
||||
console.log(` ${ok('Timeout:')} ${config.timeout}s`);
|
||||
|
||||
const staleHookCount = countManagedImageAnalysisHookFiles();
|
||||
if (staleHookCount > 0) {
|
||||
results.warnings.push({
|
||||
name: 'Image Analysis',
|
||||
message: `${staleHookCount} stale CCS-managed image hook setting file(s) were detected`,
|
||||
fix: 'Run: ccs doctor --fix',
|
||||
});
|
||||
console.log(` ${warn('Hooks:')} ${staleHookCount} stale setting file(s) can be repaired`);
|
||||
}
|
||||
|
||||
// Check 4: CLIProxy availability (only if enabled)
|
||||
const cliproxyAvailable = await isCliproxyRunning(CLIPROXY_DEFAULT_PORT);
|
||||
if (!cliproxyAvailable) {
|
||||
@@ -102,6 +118,8 @@ export async function fixImageAnalysisConfig(): Promise<boolean> {
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
let fixed = false;
|
||||
const hadManagedToolReady = hasImageAnalysisMcpReady();
|
||||
const hadSharedHookReady = hasImageAnalyzerHook();
|
||||
|
||||
// Fix missing provider_models
|
||||
if (
|
||||
@@ -130,5 +148,12 @@ export async function fixImageAnalysisConfig(): Promise<boolean> {
|
||||
updateUnifiedConfig({ image_analysis: config.image_analysis });
|
||||
}
|
||||
|
||||
return fixed;
|
||||
const repairStats = repairImageAnalysisRuntimeState();
|
||||
return (
|
||||
fixed ||
|
||||
repairStats.cleanedSettingsFiles > 0 ||
|
||||
repairStats.syncedInstances > 0 ||
|
||||
(!hadManagedToolReady && repairStats.managedToolReady) ||
|
||||
(!hadSharedHookReady && repairStats.sharedHookReady)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Image Analyzer Hook Utilities
|
||||
*
|
||||
* Shared helper functions for CCS-managed image hook detection and cleanup.
|
||||
*
|
||||
* @module utils/hooks/image-analyzer-hook-utils
|
||||
*/
|
||||
|
||||
function normalizeCommand(command: string): string {
|
||||
return command.replace(/\\/g, '/').replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
function extractManagedHookPath(command: string): string | null {
|
||||
const normalizedCommand = normalizeCommand(command);
|
||||
const exactPathMatch = normalizedCommand.match(
|
||||
/(?:^|["'\s])([^"'\s]*\/\.ccs\/hooks\/image-analyzer-transformer\.cjs)(?:["'\s]|$)/
|
||||
);
|
||||
return exactPathMatch?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hook entry is a CCS-managed image analyzer hook.
|
||||
* Matches current and legacy path variants by suffix rather than full path.
|
||||
*/
|
||||
export function isCcsImageAnalyzerHook(hook: Record<string, unknown>): boolean {
|
||||
if (hook.matcher !== 'Read') return false;
|
||||
|
||||
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
|
||||
if (!hookArray?.[0]?.command) return false;
|
||||
|
||||
const command = hookArray[0].command;
|
||||
if (typeof command !== 'string') return false;
|
||||
|
||||
return extractManagedHookPath(command) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove duplicate CCS-managed image hooks from settings, keeping only the first one.
|
||||
*/
|
||||
export function deduplicateCcsImageAnalyzerHooks(settings: Record<string, unknown>): boolean {
|
||||
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
||||
if (!hooks?.PreToolUse) return false;
|
||||
|
||||
let foundFirst = false;
|
||||
const originalLength = hooks.PreToolUse.length;
|
||||
|
||||
hooks.PreToolUse = hooks.PreToolUse.filter((entry: unknown) => {
|
||||
const hook = entry as Record<string, unknown>;
|
||||
if (!isCcsImageAnalyzerHook(hook)) return true;
|
||||
|
||||
if (!foundFirst) {
|
||||
foundFirst = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return hooks.PreToolUse.length < originalLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all CCS-managed image hooks from settings while preserving unrelated hooks.
|
||||
*/
|
||||
export function removeCcsImageAnalyzerHooks(settings: Record<string, unknown>): boolean {
|
||||
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
|
||||
if (!hooks?.PreToolUse) return false;
|
||||
|
||||
const originalLength = hooks.PreToolUse.length;
|
||||
hooks.PreToolUse = hooks.PreToolUse.filter((entry: unknown) => {
|
||||
const hook = entry as Record<string, unknown>;
|
||||
return !isCcsImageAnalyzerHook(hook);
|
||||
});
|
||||
|
||||
if (hooks.PreToolUse.length === originalLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hooks.PreToolUse.length === 0) {
|
||||
delete hooks.PreToolUse;
|
||||
}
|
||||
if (Object.keys(hooks).length === 0) {
|
||||
delete settings.hooks;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
getImageAnalyzerHookConfig,
|
||||
getImageAnalyzerHookPath,
|
||||
} from './image-analyzer-hook-configuration';
|
||||
import {
|
||||
deduplicateCcsImageAnalyzerHooks,
|
||||
isCcsImageAnalyzerHook,
|
||||
removeCcsImageAnalyzerHooks,
|
||||
} from './image-analyzer-hook-utils';
|
||||
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
import {
|
||||
@@ -41,17 +46,7 @@ function hasCcsHook(settings: Record<string, unknown>): boolean {
|
||||
if (!hooks?.PreToolUse) return false;
|
||||
|
||||
return hooks.PreToolUse.some((h: unknown) => {
|
||||
const hook = h as Record<string, unknown>;
|
||||
if (hook.matcher !== 'Read') return false;
|
||||
|
||||
const hookArray = hook.hooks as Array<Record<string, unknown>> | undefined;
|
||||
const command = hookArray?.[0]?.command;
|
||||
if (typeof command !== 'string') return false;
|
||||
|
||||
const normalized = command
|
||||
.replace(/\\/g, '/') // Windows backslashes
|
||||
.replace(/\/+/g, '/'); // Collapse multiple slashes
|
||||
return normalized.includes('.ccs/hooks/image-analyzer-transformer');
|
||||
return isCcsImageAnalyzerHook(h as Record<string, unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,6 +83,39 @@ export function hasImageAnalysisProfileHook(
|
||||
}
|
||||
}
|
||||
|
||||
export function removeImageAnalysisProfileHook(
|
||||
profileName: string,
|
||||
settingsPath?: string | null
|
||||
): boolean {
|
||||
if (!VALID_PROFILE_NAME.test(profileName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedSettingsPath = getImageAnalysisProfileSettingsPath(profileName, settingsPath);
|
||||
if (!fs.existsSync(resolvedSettingsPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(resolvedSettingsPath, 'utf8');
|
||||
const settings = JSON.parse(content) as Record<string, unknown>;
|
||||
const removed = removeCcsImageAnalyzerHooks(settings);
|
||||
if (!removed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.writeFileSync(resolvedSettingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
if (process.env.CCS_DEBUG) {
|
||||
console.error(
|
||||
info(`Removed image analyzer hook from ${path.basename(resolvedSettingsPath)}`)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration marker management
|
||||
*/
|
||||
@@ -174,6 +202,10 @@ export function ensureProfileHooks(input: string | ImageAnalysisResolutionContex
|
||||
|
||||
// Check if CCS hook already present
|
||||
if (hasCcsHook(settings)) {
|
||||
const hadDuplicates = deduplicateCcsImageAnalyzerHooks(settings);
|
||||
if (hadDuplicates) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
}
|
||||
// Update timeout if needed
|
||||
return updateHookTimeoutIfNeeded(settings, settingsPath);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export {
|
||||
uninstallImageAnalyzerHook,
|
||||
} from './image-analyzer-hook-installer';
|
||||
export { ensureProfileHooks as ensureImageAnalyzerProfileHooks } from './image-analyzer-profile-hook-injector';
|
||||
export { removeImageAnalysisProfileHook } from './image-analyzer-profile-hook-injector';
|
||||
|
||||
export function prepareImageAnalysisFallbackHook(): boolean {
|
||||
return hasInstalledImageAnalyzerHook() || installSharedImageAnalyzerHook();
|
||||
|
||||
@@ -25,6 +25,13 @@ export {
|
||||
appendThirdPartyImageAnalysisToolArgs,
|
||||
getImageAnalysisSteeringPrompt,
|
||||
} from './claude-tool-args';
|
||||
export {
|
||||
cleanupManagedImageAnalysisHooks,
|
||||
countManagedImageAnalysisHookFiles,
|
||||
repairImageAnalysisRuntimeState,
|
||||
syncManagedImageAnalysisInstances,
|
||||
type ImageAnalysisRepairStats,
|
||||
} from './repair';
|
||||
|
||||
export const IMAGE_ANALYSIS_PROMPT_TEMPLATES = ['default', 'screenshot', 'document'] as const;
|
||||
export type ImageAnalysisPromptTemplate = (typeof IMAGE_ANALYSIS_PROMPT_TEMPLATES)[number];
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import InstanceManager from '../../management/instance-manager';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
import { prepareImageAnalysisFallbackHook } from '../hooks';
|
||||
import { removeCcsImageAnalyzerHooks } from '../hooks/image-analyzer-hook-utils';
|
||||
import { ensureImageAnalysisMcpOrThrow } from './mcp-installer';
|
||||
|
||||
export interface ImageAnalysisRepairStats {
|
||||
cleanedSettingsFiles: number;
|
||||
syncedInstances: number;
|
||||
managedToolReady: boolean;
|
||||
sharedHookReady: boolean;
|
||||
}
|
||||
|
||||
function visitManagedImageAnalysisSettings(
|
||||
callback: (settings: Record<string, unknown>, settingsPath: string) => void,
|
||||
baseDir = getCcsDir()
|
||||
): void {
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(baseDir)) {
|
||||
if (!entry.endsWith('.settings.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(baseDir, entry);
|
||||
try {
|
||||
const stat = fs.statSync(settingsPath);
|
||||
if (!stat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Record<string, unknown>;
|
||||
callback(settings, settingsPath);
|
||||
} catch {
|
||||
// Best-effort cleanup; preserve malformed files for manual recovery.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function countManagedImageAnalysisHookFiles(baseDir = getCcsDir()): number {
|
||||
let count = 0;
|
||||
visitManagedImageAnalysisSettings((settings) => {
|
||||
if (
|
||||
removeCcsImageAnalyzerHooks(JSON.parse(JSON.stringify(settings)) as Record<string, unknown>)
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
}, baseDir);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function cleanupManagedImageAnalysisHooks(baseDir = getCcsDir()): number {
|
||||
let cleaned = 0;
|
||||
visitManagedImageAnalysisSettings((settings, settingsPath) => {
|
||||
if (!removeCcsImageAnalyzerHooks(settings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
||||
cleaned += 1;
|
||||
}, baseDir);
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function syncManagedImageAnalysisInstances(
|
||||
instanceManager: InstanceManager = new InstanceManager()
|
||||
): number {
|
||||
let synced = 0;
|
||||
for (const instanceName of instanceManager.listInstances()) {
|
||||
const instancePath = instanceManager.getInstancePath(instanceName);
|
||||
if (instanceManager.syncMcpServers(instancePath)) {
|
||||
synced += 1;
|
||||
}
|
||||
}
|
||||
return synced;
|
||||
}
|
||||
|
||||
export function repairImageAnalysisRuntimeState(): ImageAnalysisRepairStats {
|
||||
const managedToolReady = ensureImageAnalysisMcpOrThrow();
|
||||
const sharedHookReady = prepareImageAnalysisFallbackHook();
|
||||
const cleanedSettingsFiles = cleanupManagedImageAnalysisHooks();
|
||||
const syncedInstances = managedToolReady ? syncManagedImageAnalysisInstances() : 0;
|
||||
|
||||
return {
|
||||
cleanedSettingsFiles,
|
||||
syncedInstances,
|
||||
managedToolReady,
|
||||
sharedHookReady,
|
||||
};
|
||||
}
|
||||
@@ -16,14 +16,12 @@ 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,
|
||||
repairImageAnalysisRuntimeState,
|
||||
} from '../../utils/image-analysis';
|
||||
|
||||
const router = Router();
|
||||
@@ -96,13 +94,6 @@ function resolveCurrentTargetMode(
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function syncManagedImageAnalysisToInstances(): void {
|
||||
const instanceManager = new InstanceManager();
|
||||
for (const instanceName of instanceManager.listInstances()) {
|
||||
instanceManager.syncMcpServers(instanceManager.getInstancePath(instanceName));
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBackendState(
|
||||
status: Awaited<ReturnType<typeof resolveImageAnalysisRuntimeStatus>>
|
||||
): BackendState {
|
||||
@@ -469,9 +460,7 @@ router.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
const nextEnabled = body.enabled ?? currentConfig.enabled;
|
||||
if (nextEnabled) {
|
||||
ensureImageAnalysisMcpOrThrow();
|
||||
prepareImageAnalysisFallbackHook();
|
||||
syncManagedImageAnalysisToInstances();
|
||||
repairImageAnalysisRuntimeState();
|
||||
}
|
||||
|
||||
res.json(await buildDashboardPayload());
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../cliproxy';
|
||||
import { regenerateConfig } from '../../cliproxy/config-generator';
|
||||
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
||||
import { removeCcsImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-hook-utils';
|
||||
import { resolveCliproxyBridgeMetadata } from '../../api/services';
|
||||
import {
|
||||
getImageAnalysisConfig,
|
||||
@@ -508,6 +509,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
||||
// Deduplicate CCS hooks to prevent accumulation (fixes #450)
|
||||
// This handles cases where duplicate hooks were added by previous versions
|
||||
deduplicateCcsHooks(normalizedSettings as Record<string, unknown>);
|
||||
removeCcsImageAnalyzerHooks(normalizedSettings as Record<string, unknown>);
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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 { getImageAnalysisConfig } from '../../../../src/config/unified-config-loader';
|
||||
import { fixImageAnalysisConfig } from '../../../../src/management/checks/image-analysis-check';
|
||||
|
||||
describe('image-analysis-check', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-image-analysis-check-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(path.join(ccsDir, 'instances', 'demo'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'image_analysis:',
|
||||
' enabled: true',
|
||||
' timeout: 5',
|
||||
' provider_models: {}',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'glm.settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini',
|
||||
ANTHROPIC_AUTH_TOKEN: 'glm-token',
|
||||
},
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
timeout: 65000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'instances', 'demo', '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
custom: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['custom-server.cjs'],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('repairs invalid config, removes stale hooks, and syncs managed MCP entries into instances', async () => {
|
||||
const fixed = await fixImageAnalysisConfig();
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
|
||||
expect(fixed).toBe(true);
|
||||
|
||||
const config = getImageAnalysisConfig();
|
||||
expect(config.timeout).toBe(60);
|
||||
expect(Object.keys(config.provider_models).length).toBeGreaterThan(0);
|
||||
|
||||
const repairedSettings = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir, 'glm.settings.json'), 'utf8')
|
||||
) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
expect(repairedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
const globalClaudeConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(tempHome, '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(globalClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined();
|
||||
|
||||
const instanceClaudeConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(ccsDir, 'instances', 'demo', '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(instanceClaudeConfig.mcpServers?.custom).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['custom-server.cjs'],
|
||||
env: {},
|
||||
});
|
||||
expect(instanceClaudeConfig.mcpServers?.['ccs-image-analysis']).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,9 @@ describe('settings profile ImageAnalysis launch', () => {
|
||||
`#!/bin/sh
|
||||
printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||
{
|
||||
printf "currentProvider=%s\n" "$CCS_CURRENT_PROVIDER"
|
||||
printf "skip=%s\n" "$CCS_IMAGE_ANALYSIS_SKIP"
|
||||
printf "skipHook=%s\n" "$CCS_IMAGE_ANALYSIS_SKIP_HOOK"
|
||||
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"
|
||||
@@ -167,6 +170,73 @@ exit 0
|
||||
expect(launchedArgs).not.toContain(STEERING_PROMPT_SNIPPET);
|
||||
});
|
||||
|
||||
it('suppresses stale CCS image hooks during a healthy MCP-first launch', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
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',
|
||||
},
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
timeout: 65000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
|
||||
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
|
||||
expect(launchedEnv).toContain('skipHook=1');
|
||||
expect(
|
||||
persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the legacy hook available when MCP provisioning fails', () => {
|
||||
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);
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
const persistedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
|
||||
expect(launchedEnv).not.toContain('skipHook=1');
|
||||
expect(persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('pins bridge-backed image analysis to the current CLIProxy auth token', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
|
||||
@@ -384,7 +384,7 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('headless executor prepares image-analysis MCP and compatibility hook fallback', async () => {
|
||||
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||
const settingsPath = path.join(ccsDir, 'glm.settings.json');
|
||||
@@ -398,11 +398,16 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const launch = spawnCalls[0];
|
||||
const env = launch.options?.env as NodeJS.ProcessEnv;
|
||||
|
||||
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);
|
||||
expect(
|
||||
persistedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false
|
||||
).toBe(false);
|
||||
expect(env.CCS_IMAGE_ANALYSIS_SKIP_HOOK).toBe('1');
|
||||
|
||||
const claudeUserConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(process.env.CCS_HOME as string, '.claude.json'), 'utf8')
|
||||
@@ -415,8 +420,10 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
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);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false);
|
||||
});
|
||||
|
||||
it('headless executor propagates a WebSearch trace launch id when tracing is enabled', async () => {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
deduplicateCcsImageAnalyzerHooks,
|
||||
isCcsImageAnalyzerHook,
|
||||
removeCcsImageAnalyzerHooks,
|
||||
} from '../../../../src/utils/hooks/image-analyzer-hook-utils';
|
||||
|
||||
describe('image-analyzer-hook-utils', () => {
|
||||
it('detects CCS-managed image hooks across current and legacy path variants', () => {
|
||||
expect(
|
||||
isCcsImageAnalyzerHook({
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isCcsImageAnalyzerHook({
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isCcsImageAnalyzerHook({
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
isCcsImageAnalyzerHook({
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer-custom.cjs"',
|
||||
},
|
||||
],
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('deduplicates only CCS-managed image hooks', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
expect(deduplicateCcsImageAnalyzerHooks(settings)).toBe(true);
|
||||
expect((settings.hooks.PreToolUse as unknown[])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('removes only CCS-managed image hooks and preserves unrelated hooks', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
|
||||
},
|
||||
{
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/websearch-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
expect(removeCcsImageAnalyzerHooks(settings)).toBe(true);
|
||||
expect(settings.hooks.PreToolUse).toEqual([
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'node "/tmp/custom-read-hook.cjs"' }],
|
||||
},
|
||||
{
|
||||
matcher: 'WebSearch',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/Users/kaitran/.ccs/hooks/websearch-transformer.cjs"',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -192,6 +192,54 @@ describe('image-analysis routes', () => {
|
||||
});
|
||||
|
||||
it('updates the saved config through the dashboard route and provisions the local runtime', async () => {
|
||||
const staleSettingsPath = path.join(tempHome, '.ccs', 'legacy.settings.json');
|
||||
fs.writeFileSync(
|
||||
staleSettingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://proxy.example/api/provider/gemini',
|
||||
ANTHROPIC_AUTH_TOKEN: 'legacy-token',
|
||||
},
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Read',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "/home/kai/.ccs/hooks/image-analyzer-transformer.cjs"',
|
||||
timeout: 65000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const instanceDir = path.join(tempHome, '.ccs', 'instances', 'demo');
|
||||
fs.mkdirSync(instanceDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(instanceDir, '.claude.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
custom: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['custom-server.cjs'],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/image-analysis`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -245,6 +293,31 @@ describe('image-analysis routes', () => {
|
||||
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
|
||||
env: {},
|
||||
});
|
||||
|
||||
const repairedSettings = JSON.parse(fs.readFileSync(staleSettingsPath, 'utf8')) as {
|
||||
hooks?: { PreToolUse?: Array<{ matcher?: string }> };
|
||||
};
|
||||
expect(repairedSettings.hooks?.PreToolUse?.some((hook) => hook.matcher === 'Read') ?? false).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
const instanceConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(instanceDir, '.claude.json'), 'utf8')
|
||||
) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
expect(instanceConfig.mcpServers?.custom).toEqual({
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['custom-server.cjs'],
|
||||
env: {},
|
||||
});
|
||||
expect(instanceConfig.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 () => {
|
||||
|
||||
Reference in New Issue
Block a user