feat(image-analysis): add provider-backed runtime

This commit is contained in:
Tam Nhu Tran
2026-04-02 15:44:13 -04:00
parent 2c006afbb9
commit 813b2dd4d0
24 changed files with 1715 additions and 487 deletions
@@ -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;
+3
View File
@@ -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;
+37 -5
View File
@@ -31,11 +31,17 @@ 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,
resolveImageAnalysisRuntimeStatus,
} from './utils/hooks';
import { fail, info, warn } from './utils/ui';
@@ -74,7 +80,11 @@ import {
resolveDroidReasoningRuntime,
} from './targets/droid-reasoning-runtime';
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge';
import {
resolveCliproxyBridgeMetadata,
resolveCliproxyBridgeProfile,
} from './api/services/cliproxy-profile-bridge';
import { getEffectiveApiKey } from './cliproxy/auth-token-manager';
// Version and Update check utilities
import { getVersion } from './utils/version';
@@ -685,7 +695,10 @@ async function main(): Promise<void> {
// 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 +707,7 @@ async function main(): Promise<void> {
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 +862,14 @@ async function main(): Promise<void> {
} 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');
@@ -884,7 +901,7 @@ async function main(): Promise<void> {
// Settings-based profiles (glm, glmt) are third-party providers
if (resolvedTarget === 'claude') {
ensureWebSearchMcpOrThrow();
installImageAnalyzerHook();
ensureImageAnalysisMcpOrThrow();
}
// Display WebSearch status (single line, equilibrium UX)
@@ -907,6 +924,9 @@ async function main(): Promise<void> {
}
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
const imageAnalysisFallbackHookReady =
resolvedTarget === 'claude' ? prepareImageAnalysisFallbackHook() : false;
const expandedSettingsPath =
resolvedSettingsPath ??
(profileInfo.settingsPath
@@ -920,6 +940,7 @@ async function main(): Promise<void> {
settingsPath: expandedSettingsPath,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
if (resolvedTarget !== 'claude') {
const compatibility = evaluateTargetRuntimeCompatibility({
@@ -1022,13 +1043,24 @@ async function main(): Promise<void> {
profileType: profileInfo.type,
settings,
cliproxyBridge,
sharedHookInstalled: imageAnalysisFallbackHookReady,
});
const currentBridgeAuthToken = cliproxyBridge
? resolveCliproxyBridgeProfile(cliproxyBridge.provider).apiKey
: getEffectiveApiKey();
let imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: profileInfo.name,
profileType: profileInfo.type,
settings,
cliproxyBridge,
});
imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, {
backendId: imageAnalysisStatus.backendId,
model: imageAnalysisStatus.model,
runtimePath: imageAnalysisStatus.runtimePath,
baseUrl: cliproxyBridge?.currentBaseUrl || '',
apiKey: currentBridgeAuthToken,
});
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
if (
@@ -1130,7 +1162,7 @@ async function main(): Promise<void> {
const launchArgs = [
'--settings',
expandedSettingsPath,
...appendThirdPartyWebSearchToolArgs(remainingArgs),
...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(remainingArgs)),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'ccs.settings-profile',
+27 -2
View File
@@ -20,7 +20,10 @@ 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,
} 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 +33,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 +80,7 @@ interface CliproxyImageAnalysisDeps {
hasImageAnalysisProfileHook: typeof hasImageAnalysisProfileHook;
hasImageAnalyzerHook: typeof hasImageAnalyzerHook;
resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus;
getLocalRuntimeApiKey: typeof getEffectiveApiKey;
}
interface ResolveCliproxyImageAnalysisEnvOptions {
@@ -97,6 +102,7 @@ const defaultCliproxyImageAnalysisDeps: CliproxyImageAnalysisDeps = {
hasImageAnalysisProfileHook,
hasImageAnalyzerHook,
resolveImageAnalysisRuntimeStatus,
getLocalRuntimeApiKey: getEffectiveApiKey,
};
const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i;
@@ -148,6 +154,14 @@ function loadImageAnalysisSettings(settingsPath?: string): Settings | undefined
}
}
function buildDirectProxyBaseUrl(target: ProxyTarget): string {
const isDefaultPort =
(target.protocol === 'https' && target.port === 443) ||
(target.protocol === 'http' && target.port === 80);
const portSuffix = isDefaultPort ? '' : `:${target.port}`;
return `${target.protocol}://${target.host}${portSuffix}`;
}
export async function resolveCliproxyImageAnalysisEnv(
options: ResolveCliproxyImageAnalysisEnvOptions,
deps: Partial<CliproxyImageAnalysisDeps> = {}
@@ -190,7 +204,18 @@ export async function resolveCliproxyImageAnalysisEnv(
};
}
return { env, warning: null };
return {
env: applyImageAnalysisRuntimeOverrides(env, {
backendId: status.backendId,
model: status.model,
runtimePath: status.runtimePath,
baseUrl: buildDirectProxyBaseUrl(options.proxyTarget),
apiKey: options.proxyTarget.isRemote
? options.proxyTarget.authToken || ''
: resolvedDeps.getLocalRuntimeApiKey(),
}),
warning: null,
};
}
/**
+13 -5
View File
@@ -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();
ensureImageAnalysisMcpOrThrow();
displayWebSearchStatus();
// Sync image analyzer hook from npm package to ~/.ccs/hooks/
installImageAnalyzerHook();
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
warnOAuthBanRisk(provider);
@@ -870,6 +872,8 @@ export async function execClaudeWithCLIProxy(
}
}
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
// Build initial env vars to get ANTHROPIC_BASE_URL
const initialEnvVars = buildClaudeEnvironment({
provider,
@@ -1072,7 +1076,11 @@ export async function execClaudeWithCLIProxy(
: getProviderSettingsPath(provider);
let claude: ChildProcess;
const launchArgs = ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)];
const launchArgs = [
'--settings',
settingsPath,
...appendThirdPartyWebSearchToolArgs(appendThirdPartyImageAnalysisToolArgs(claudeArgs)),
];
const traceEnv = createWebSearchTraceContext({
launcher: 'cliproxy.executor',
args: launchArgs,
+11
View File
@@ -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,
});
}
+17 -2
View File
@@ -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,12 +51,25 @@ export async function handleUninstallCommand(): Promise<void> {
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!'));
+28 -3
View File
@@ -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,23 @@ import {
createWebSearchTraceContext,
syncWebSearchMcpToConfigDir,
} from '../utils/websearch-manager';
import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks';
import {
appendThirdPartyImageAnalysisToolArgs,
ensureImageAnalysisMcpOrThrow,
syncImageAnalysisMcpToConfigDir,
} from '../utils/image-analysis';
import {
applyImageAnalysisRuntimeOverrides,
getImageAnalysisHookEnv,
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 +107,7 @@ export async function resolveCopilotImageAnalysisEnv(
ensureCliproxyService,
getImageAnalysisHookEnv,
resolveImageAnalysisRuntimeStatus,
getLocalRuntimeApiKey: getEffectiveApiKey,
...deps,
};
@@ -141,7 +153,16 @@ export async function resolveCopilotImageAnalysisEnv(
}
}
return { env, warning: null };
return {
env: applyImageAnalysisRuntimeOverrides(env, {
backendId: status.backendId,
model: status.model,
runtimePath: status.runtimePath,
baseUrl: `http://127.0.0.1:${CLIPROXY_DEFAULT_PORT}`,
apiKey: resolvedDeps.getLocalRuntimeApiKey(),
}),
warning: null,
};
}
/**
@@ -251,11 +272,15 @@ export async function executeCopilotProfile(
}
console.log('');
ensureImageAnalysisMcpOrThrow();
syncWebSearchMcpToConfigDir(claudeConfigDir);
syncImageAnalysisMcpToConfigDir(claudeConfigDir);
// Spawn Claude CLI
return new Promise((resolve) => {
const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs);
const launchArgs = appendThirdPartyWebSearchToolArgs(
appendThirdPartyImageAnalysisToolArgs(claudeArgs)
);
const traceEnv = createWebSearchTraceContext({
launcher: 'copilot.executor',
args: launchArgs,
+97 -2
View File
@@ -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,
resolveImageAnalysisRuntimeStatus,
} from '../utils/hooks';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../utils/hooks/image-analyzer-profile-hook-injector';
import { resolveCliproxyBridgeMetadata, resolveCliproxyBridgeProfile } from '../api/services';
import { ensureCliproxyService } from '../cliproxy';
import { getEffectiveApiKey } from '../cliproxy/auth-token-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import {
appendThirdPartyWebSearchToolArgs,
appendWebSearchTrace,
@@ -105,7 +121,80 @@ export class HeadlessExecutor {
}
ensureWebSearchMcpOrThrow();
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 currentBridgeAuthToken = cliproxyBridge
? resolveCliproxyBridgeProfile(cliproxyBridge.provider).apiKey
: getEffectiveApiKey();
let imageAnalysisEnv = getImageAnalysisHookEnv({
profileName: profile,
profileType: 'settings',
settings,
cliproxyBridge,
});
imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, {
backendId: imageAnalysisStatus.backendId,
model: imageAnalysisStatus.model,
runtimePath: imageAnalysisStatus.runtimePath,
baseUrl: cliproxyBridge?.currentBaseUrl || '',
apiKey: currentBridgeAuthToken,
});
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 +279,9 @@ export class HeadlessExecutor {
}
}
const launchArgs = appendThirdPartyWebSearchToolArgs(args);
const launchArgs = appendThirdPartyWebSearchToolArgs(
appendThirdPartyImageAnalysisToolArgs(args)
);
const traceEnv = createWebSearchTraceContext({
launcher: 'delegation.headless-executor',
args: launchArgs,
@@ -217,6 +308,7 @@ export class HeadlessExecutor {
sessionId,
sessionMgr,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
traceEnv,
});
}
@@ -235,6 +327,7 @@ export class HeadlessExecutor {
sessionId: string | null;
sessionMgr: SessionManager;
claudeConfigDir?: string;
imageAnalysisEnv?: Record<string, string>;
traceEnv?: Record<string, string>;
}
): Promise<ExecutionResult> {
@@ -246,6 +339,7 @@ export class HeadlessExecutor {
sessionId,
sessionMgr,
claudeConfigDir,
imageAnalysisEnv = {},
traceEnv = {},
} = ctx;
@@ -265,6 +359,7 @@ export class HeadlessExecutor {
...process.env,
...getClaudeLaunchEnvOverrides(),
...getWebSearchHookEnv(),
...imageAnalysisEnv,
...traceEnv,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
CCS_PROFILE_TYPE: 'settings',
@@ -70,15 +70,17 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise<void>
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}`);
+1 -1
View File
@@ -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 {
@@ -8,7 +8,9 @@
*/
import { getImageAnalysisConfig } from '../../config/unified-config-loader';
import { resolveCliproxyBridgeProfile } from '../../api/services/cliproxy-profile-bridge';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import { getPromptsDir } from '../image-analysis/hook-installer';
import {
resolveImageAnalysisStatus,
type ImageAnalysisResolutionContext,
@@ -23,6 +25,14 @@ function serializeProviderModels(providerModels: Record<string, string>): string
.join(',');
}
export interface ImageAnalysisRuntimeOverrides {
backendId?: string | null;
model?: string | null;
runtimePath?: string | null;
baseUrl?: string | null;
apiKey?: string | null;
}
/**
* Get image analysis hook environment variables.
* These env vars control the hook's behavior via Claude Code hook system.
@@ -45,12 +55,66 @@ 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<string, string>,
overrides: ImageAnalysisRuntimeOverrides
): Record<string, string> {
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;
}
}
return nextEnv;
}
@@ -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,
@@ -119,13 +119,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 +135,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,
};
}
@@ -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}`));
}
}
}
@@ -138,6 +138,10 @@ export function ensureProfileHooks(input: string | ImageAnalysisResolutionContex
return false;
}
if (context.sharedHookInstalled === false) {
return false;
}
// One-time migration marker
migrateGlobalHook();
+14 -1
View File
@@ -6,7 +6,16 @@
* @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,
type ImageAnalysisRuntimeOverrides,
} from './get-image-analysis-hook-env';
export {
canonicalizeImageAnalysisConfig,
resolveImageAnalysisStatus,
@@ -26,3 +35,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();
}
@@ -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;
}
+20
View File
@@ -5,3 +5,23 @@
*/
export { getPromptsDir, installImageAnalysisPrompts } from './hook-installer';
export {
getImageAnalysisMcpServerName,
getImageAnalysisMcpServerPath,
getImageAnalysisMcpRuntimePath,
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];
+393
View File
@@ -0,0 +1,393 @@
/**
* Image Analysis MCP installer and ~/.claude.json provisioning.
*/
import * as fs from 'fs';
import * as path from 'path';
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<string, unknown>;
[key: string]: unknown;
}
interface ManagedImageAnalysisMcpConfig {
type: 'stdio';
command: 'node';
args: [string];
env: Record<string, string>;
}
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 removeManagedServerConfig(configPath: string): boolean {
if (!fs.existsSync(configPath)) {
return false;
}
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<string, unknown>) }
: {};
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;
}
}
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);
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;
}
if (!fs.existsSync(claudeUserConfigDir)) {
fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 });
}
const existingServers =
config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
? (config.mcpServers as Record<string, unknown>)
: {};
const desiredServerConfig: ManagedImageAnalysisMcpConfig = {
type: 'stdio',
command: 'node',
args: [getImageAnalysisMcpServerPath()],
env: {},
};
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;
}
}
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(): void {
const imageConfig = getImageAnalysisConfig();
if (!imageConfig.enabled) {
return;
}
if (!ensureImageAnalysisMcp()) {
console.error(
warn(
'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.'
)
);
}
}
@@ -84,7 +84,6 @@ function resolveCurrentTargetMode(
if (target !== 'claude') return 'bypassed';
if (status.nativeReadPreference) return 'native';
if (!status.backendId) return 'unresolved';
if (status.status === 'hook-missing') return 'setup';
if (status.effectiveRuntimeMode === 'native-read') return 'fallback';
return 'active';
}