mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 04:17:38 +00:00
feat(image-analysis): add provider-backed runtime
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user