feat(image-analysis): make MCP-first provisioning self-healing

This commit is contained in:
Tam Nhu Tran
2026-04-08 13:36:05 -04:00
parent 15ef3ac9a8
commit 64ecd8b3dc
19 changed files with 818 additions and 136 deletions
+58 -40
View File
@@ -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 (
+5 -8
View File
@@ -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
+44 -33
View File
@@ -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;
+5 -8
View File
@@ -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,
+22 -18
View File
@@ -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 (
+26 -1
View File
@@ -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);
}
+1
View File
@@ -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();
+7
View File
@@ -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];
+96
View File
@@ -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,
};
}
+2 -13
View File
@@ -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());
+2
View File
@@ -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();