diff --git a/src/ccs.ts b/src/ccs.ts index 5a282bd0..aafaee74 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -33,7 +33,11 @@ import { } from './utils/websearch-manager'; import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; -import { getImageAnalysisHookEnv, installImageAnalyzerHook } from './utils/hooks'; +import { + getImageAnalysisHookEnv, + installImageAnalyzerHook, + resolveImageAnalysisRuntimeStatus, +} from './utils/hooks'; import { fail, info, warn } from './utils/ui'; import { isCopilotSubcommandToken } from './copilot/constants'; import { @@ -1013,6 +1017,12 @@ async function main(): Promise { } const webSearchEnv = getWebSearchHookEnv(); + const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({ + profileName: profileInfo.name, + profileType: profileInfo.type, + settings, + cliproxyBridge, + }); let imageAnalysisEnv = getImageAnalysisHookEnv({ profileName: profileInfo.name, profileType: profileInfo.type, @@ -1032,10 +1042,10 @@ async function main(): Promise { targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v'); - if (!isAuthenticated(imageAnalysisProvider as CLIProxyProvider)) { + if (imageAnalysisStatus.effectiveRuntimeMode === 'native-read') { console.error( info( - `Image analysis via ${imageAnalysisProvider} is configured, but CLIProxy auth is missing. This session will use native Read. Run "ccs ${imageAnalysisProvider} --auth" to enable it.` + `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This session will use native Read.` ) ); imageAnalysisEnv = { @@ -1043,7 +1053,7 @@ async function main(): Promise { CCS_CURRENT_PROVIDER: '', CCS_IMAGE_ANALYSIS_SKIP: '1', }; - } else { + } else if (imageAnalysisStatus.proxyReadiness === 'stopped') { const ensureServiceResult = await ensureCliproxyService( CLIPROXY_DEFAULT_PORT, verboseProxyLaunch diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 53c40263..e727fbb4 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -8,6 +8,8 @@ import { spawn } from 'child_process'; import { CopilotConfig } from '../config/unified-config-types'; import { getGlobalEnvConfig } from '../config/unified-config-loader'; +import { ensureCliproxyService } from '../cliproxy'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { checkAuthStatus, isCopilotApiInstalled } from './copilot-auth'; import { isDaemonRunning, startDaemon } from './copilot-daemon'; import { ensureCopilotApi } from './copilot-package-manager'; @@ -20,9 +22,20 @@ import { createWebSearchTraceContext, syncWebSearchMcpToConfigDir, } from '../utils/websearch-manager'; -import { getImageAnalysisHookEnv } from '../utils/hooks'; +import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; +interface CopilotImageAnalysisDeps { + ensureCliproxyService: typeof ensureCliproxyService; + getImageAnalysisHookEnv: typeof getImageAnalysisHookEnv; + resolveImageAnalysisRuntimeStatus: typeof resolveImageAnalysisRuntimeStatus; +} + +interface CopilotImageAnalysisResolution { + env: Record; + warning: string | null; +} + /** * Get full copilot status (auth + daemon). */ @@ -75,6 +88,62 @@ export function generateCopilotEnv( }; } +export async function resolveCopilotImageAnalysisEnv( + verbose = false, + deps: Partial = {} +): Promise { + const resolvedDeps: CopilotImageAnalysisDeps = { + ensureCliproxyService, + getImageAnalysisHookEnv, + resolveImageAnalysisRuntimeStatus, + ...deps, + }; + + const env = resolvedDeps.getImageAnalysisHookEnv({ + profileName: 'copilot', + profileType: 'copilot', + }); + const provider = env['CCS_CURRENT_PROVIDER']; + if (env['CCS_IMAGE_ANALYSIS_SKIP'] === '1' || !provider) { + return { env, warning: null }; + } + + const status = await resolvedDeps.resolveImageAnalysisRuntimeStatus({ + profileName: 'copilot', + profileType: 'copilot', + }); + + if (status.effectiveRuntimeMode === 'native-read') { + return { + env: { + ...env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + warning: `${status.effectiveRuntimeReason || `Image analysis via ${provider} is unavailable.`} This session will use native Read.`, + }; + } + + if (status.proxyReadiness === 'stopped') { + const ensureServiceResult = await resolvedDeps.ensureCliproxyService( + CLIPROXY_DEFAULT_PORT, + verbose + ); + if (!ensureServiceResult.started) { + return { + env: { + ...env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + warning: `Image analysis via ${provider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.`, + }; + } + } + + return { env, warning: null }; +} + /** * Execute Claude Code with copilot-api proxy. * @@ -165,10 +234,8 @@ export async function executeCopilotProfile( // Merge with current environment (global env first, copilot overrides, then hook env vars) const webSearchEnv = getWebSearchHookEnv(); - const imageAnalysisEnv = getImageAnalysisHookEnv({ - profileName: 'copilot', - profileType: 'copilot', - }); + const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = + await resolveCopilotImageAnalysisEnv(); const env = stripClaudeCodeEnv({ ...process.env, ...globalEnv, @@ -179,6 +246,9 @@ export async function executeCopilotProfile( }); console.log(info(`Using GitHub Copilot proxy (model: ${normalizedConfig.model})`)); + if (imageAnalysisWarning) { + console.log(info(imageAnalysisWarning)); + } console.log(''); syncWebSearchMcpToConfigDir(claudeConfigDir); diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index 6dcfc8a3..89ae2afe 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -33,6 +33,16 @@ export type ImageAnalysisStatusCode = | 'skipped' | 'hook-missing'; +export type ImageAnalysisAuthReadiness = 'not-needed' | 'ready' | 'missing' | 'unknown'; +export type ImageAnalysisProxyReadiness = + | 'not-needed' + | 'ready' + | 'remote' + | 'stopped' + | 'unavailable' + | 'unknown'; +export type ImageAnalysisEffectiveRuntimeMode = 'cliproxy-image-analysis' | 'native-read'; + export interface ImageAnalysisResolutionContext { profileName: string; profileType?: ProfileType; @@ -61,6 +71,14 @@ export interface ImageAnalysisStatus { usesCurrentAuthToken: boolean | null; hookInstalled: boolean | null; sharedHookInstalled: boolean | null; + authReadiness: ImageAnalysisAuthReadiness; + authProvider: string | null; + authDisplayName: string | null; + authReason: string | null; + proxyReadiness: ImageAnalysisProxyReadiness; + proxyReason: string | null; + effectiveRuntimeMode: ImageAnalysisEffectiveRuntimeMode; + effectiveRuntimeReason: string | null; } function resolveProviderFromBaseUrl(baseUrl: unknown): string | null { @@ -397,5 +415,34 @@ export function resolveImageAnalysisStatus( usesCurrentAuthToken: context.cliproxyBridge?.usesCurrentAuthToken ?? null, hookInstalled: context.hookInstalled ?? null, sharedHookInstalled: context.sharedHookInstalled ?? null, + authReadiness: + resolution.backendId && model && isCLIProxyProvider(resolution.backendId) + ? 'unknown' + : 'not-needed', + authProvider: + resolution.backendId && isCLIProxyProvider(resolution.backendId) + ? resolution.backendId + : null, + authDisplayName: + resolution.backendId && isCLIProxyProvider(resolution.backendId) + ? getProviderDisplayName(resolution.backendId) + : null, + authReason: + resolution.backendId && model && isCLIProxyProvider(resolution.backendId) + ? 'Auth readiness has not been verified yet.' + : null, + proxyReadiness: resolution.backendId && model ? 'unknown' : 'not-needed', + proxyReason: + resolution.backendId && model + ? 'CLIProxy runtime readiness has not been verified yet.' + : null, + effectiveRuntimeMode: + config.enabled && resolution.backendId && model && status !== 'hook-missing' + ? 'cliproxy-image-analysis' + : 'native-read', + effectiveRuntimeReason: + status === 'hook-missing' || !config.enabled || !resolution.backendId || !model + ? reason + : null, }; } diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts new file mode 100644 index 00000000..8fd910d2 --- /dev/null +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -0,0 +1,175 @@ +import { getAuthStatus, initializeAccounts, type AuthStatus } from '../../cliproxy/auth-handler'; +import { fetchRemoteAuthStatus, type RemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; +import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; +import { isCliproxyRunning } from '../../cliproxy/stats-fetcher'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import { + DEFAULT_IMAGE_ANALYSIS_CONFIG, + type ImageAnalysisConfig, +} from '../../config/unified-config-types'; +import { + resolveImageAnalysisStatus, + type ImageAnalysisResolutionContext, + type ImageAnalysisStatus, +} from './image-analysis-backend-resolver'; + +interface ImageAnalysisRuntimeStatusDeps { + fetchRemoteAuthStatus: (target: ProxyTarget) => Promise; + getAuthStatus: (provider: CLIProxyProvider) => AuthStatus; + getProxyTarget: () => ProxyTarget; + initializeAccounts: () => void; + isCliproxyRunning: () => Promise; +} + +const defaultDeps: ImageAnalysisRuntimeStatusDeps = { + fetchRemoteAuthStatus, + getAuthStatus, + getProxyTarget, + initializeAccounts, + isCliproxyRunning: () => isCliproxyRunning(), +}; + +async function resolveAuthReadiness( + status: ImageAnalysisStatus, + deps: ImageAnalysisRuntimeStatusDeps +): Promise< + Pick +> { + if (!status.backendId || !status.model || !isCLIProxyProvider(status.backendId)) { + return { + authReadiness: 'not-needed', + authProvider: null, + authDisplayName: null, + authReason: null, + }; + } + + const authProvider = status.backendId; + const authDisplayName = getProviderDisplayName(authProvider); + + try { + let authenticated = false; + const target = deps.getProxyTarget(); + if (target.isRemote) { + const remoteStatuses = await deps.fetchRemoteAuthStatus(target); + authenticated = remoteStatuses.some( + (entry) => entry.provider === authProvider && entry.authenticated + ); + } else { + deps.initializeAccounts(); + authenticated = deps.getAuthStatus(authProvider).authenticated; + } + + return { + authReadiness: authenticated ? 'ready' : 'missing', + authProvider, + authDisplayName, + authReason: authenticated + ? null + : `${authDisplayName} auth is missing. Run "ccs ${authProvider} --auth" to enable image analysis.`, + }; + } catch (error) { + return { + authReadiness: 'unknown', + authProvider, + authDisplayName, + authReason: `CCS could not verify ${authDisplayName} auth readiness: ${(error as Error).message}`, + }; + } +} + +async function resolveProxyReadiness( + status: ImageAnalysisStatus, + deps: ImageAnalysisRuntimeStatusDeps +): Promise> { + if (!status.backendId || !status.model) { + return { + proxyReadiness: 'not-needed', + proxyReason: null, + }; + } + + const target = deps.getProxyTarget(); + const reachable = await deps.isCliproxyRunning(); + if (target.isRemote) { + return { + proxyReadiness: reachable ? 'remote' : 'unavailable', + proxyReason: reachable + ? `Remote CLIProxy target ${target.host}:${target.port} is reachable.` + : `Remote CLIProxy target ${target.host}:${target.port} is unreachable.`, + }; + } + + return { + proxyReadiness: reachable ? 'ready' : 'stopped', + proxyReason: reachable + ? 'Local CLIProxy service is reachable.' + : 'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.', + }; +} + +function resolveEffectiveRuntime( + status: ImageAnalysisStatus +): Pick { + if (!status.enabled || !status.backendId || !status.model) { + return { + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: status.reason, + }; + } + + if (status.status === 'hook-missing') { + return { + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: status.reason, + }; + } + + if (status.authReadiness === 'missing' || status.authReadiness === 'unknown') { + return { + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: status.authReason, + }; + } + + if (status.proxyReadiness === 'unavailable' || status.proxyReadiness === 'unknown') { + return { + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: status.proxyReason, + }; + } + + return { + effectiveRuntimeMode: 'cliproxy-image-analysis', + effectiveRuntimeReason: status.status === 'attention' ? status.reason : null, + }; +} + +export async function hydrateImageAnalysisRuntimeStatus( + baseStatus: ImageAnalysisStatus, + deps: Partial = {} +): Promise { + const resolvedDeps = { ...defaultDeps, ...deps }; + const authStatus = await resolveAuthReadiness(baseStatus, resolvedDeps); + const proxyStatus = await resolveProxyReadiness(baseStatus, resolvedDeps); + const mergedStatus = { + ...baseStatus, + ...authStatus, + ...proxyStatus, + }; + + return { + ...mergedStatus, + ...resolveEffectiveRuntime(mergedStatus), + }; +} + +export async function resolveImageAnalysisRuntimeStatus( + context: ImageAnalysisResolutionContext, + config: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG, + deps: Partial = {} +): Promise { + const baseStatus = resolveImageAnalysisStatus(context, config); + return hydrateImageAnalysisRuntimeStatus(baseStatus, deps); +} diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 0c44e572..68973870 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -14,6 +14,10 @@ export { type ImageAnalysisResolutionContext, type ImageAnalysisStatus, } from './image-analysis-backend-resolver'; +export { + hydrateImageAnalysisRuntimeStatus, + resolveImageAnalysisRuntimeStatus, +} from './image-analysis-runtime-status'; export { getImageAnalyzerHookPath, getImageAnalyzerHookConfig, diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 576fe3ad..531b5cd9 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -41,7 +41,7 @@ import { hasImageAnalysisProfileHook, } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { hasImageAnalyzerHook } from '../../utils/hooks/image-analyzer-hook-installer'; -import { resolveImageAnalysisStatus } from '../../utils/hooks'; +import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks'; const router = Router(); const MODEL_ENV_KEYS = [ @@ -269,16 +269,16 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting return changed ? next : settings; } -function resolveImageAnalysisStatusForProfile( +async function resolveImageAnalysisStatusForProfile( profileOrVariant: string, settings: Settings, settingsPath: string -) { +): Promise>> { const variants = listVariants(); const variant = variants[profileOrVariant]; const cliproxyProvider = resolveProviderForProfile(profileOrVariant); const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); - const status = resolveImageAnalysisStatus( + const status = await resolveImageAnalysisRuntimeStatus( { profileName: profileOrVariant, profileType: cliproxyProvider ? 'cliproxy' : 'settings', @@ -303,7 +303,7 @@ function resolveImageAnalysisStatusForProfile( }; } -function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) { +async function resolvePreviewImageAnalysisStatus(profileOrVariant: string, settings: Settings) { const normalizedSettings = canonicalizeProfileSettings(profileOrVariant, settings); const settingsPath = resolveSettingsPath(profileOrVariant); @@ -377,7 +377,7 @@ function maskApiKeys(settings: Settings): Settings { /** * GET /api/settings/:profile - Get settings with masked API keys */ -router.get('/:profile', (req: Request, res: Response): void => { +router.get('/:profile', async (req: Request, res: Response): Promise => { try { const { profile } = req.params; const settingsPath = resolveSettingsPath(profile); @@ -397,7 +397,11 @@ router.get('/:profile', (req: Request, res: Response): void => { mtime: stat.mtime.getTime(), path: settingsPath, cliproxyBridge: resolveCliproxyBridgeMetadata(settings), - imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath), + imageAnalysisStatus: await resolveImageAnalysisStatusForProfile( + profile, + settings, + settingsPath + ), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -407,7 +411,7 @@ router.get('/:profile', (req: Request, res: Response): void => { /** * GET /api/settings/:profile/raw - Get full settings (for editing) */ -router.get('/:profile/raw', (req: Request, res: Response): void => { +router.get('/:profile/raw', async (req: Request, res: Response): Promise => { if (!requireSensitiveLocalAccess(req, res)) return; try { @@ -428,7 +432,11 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { mtime: stat.mtime.getTime(), path: settingsPath, cliproxyBridge: resolveCliproxyBridgeMetadata(settings), - imageAnalysisStatus: resolveImageAnalysisStatusForProfile(profile, settings, settingsPath), + imageAnalysisStatus: await resolveImageAnalysisStatusForProfile( + profile, + settings, + settingsPath + ), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -438,25 +446,28 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { /** * POST /api/settings/:profile/image-analysis-status - Preview image analysis status from editor JSON */ -router.post('/:profile/image-analysis-status', (req: Request, res: Response): void => { - if (!requireSensitiveLocalAccess(req, res)) return; +router.post( + '/:profile/image-analysis-status', + async (req: Request, res: Response): Promise => { + if (!requireSensitiveLocalAccess(req, res)) return; - try { - const { profile } = req.params; - const { settings } = req.body; + try { + const { profile } = req.params; + const { settings } = req.body; - if (!settings || typeof settings !== 'object') { - res.status(400).json({ error: 'settings object is required in request body' }); - return; + if (!settings || typeof settings !== 'object') { + res.status(400).json({ error: 'settings object is required in request body' }); + return; + } + + res.json({ + imageAnalysisStatus: await resolvePreviewImageAnalysisStatus(profile, settings as Settings), + }); + } catch (error) { + respondInternalError(res, error, 'Internal server error.'); } - - res.json({ - imageAnalysisStatus: resolvePreviewImageAnalysisStatus(profile, settings as Settings), - }); - } catch (error) { - respondInternalError(res, error, 'Internal server error.'); } -}); +); /** Required env vars for CLIProxy providers to function */ const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; diff --git a/tests/unit/copilot/copilot-executor-env.test.ts b/tests/unit/copilot/copilot-executor-env.test.ts index 233c1e42..609be2b5 100644 --- a/tests/unit/copilot/copilot-executor-env.test.ts +++ b/tests/unit/copilot/copilot-executor-env.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test'; -import { generateCopilotEnv } from '../../../src/copilot/copilot-executor'; +import { + generateCopilotEnv, + resolveCopilotImageAnalysisEnv, +} from '../../../src/copilot/copilot-executor'; import type { CopilotConfig } from '../../../src/config/unified-config-types'; const baseConfig: CopilotConfig = { @@ -50,4 +53,94 @@ describe('generateCopilotEnv', () => { const env = generateCopilotEnv(baseConfig); expect(env.CLAUDE_CONFIG_DIR).toBeUndefined(); }); + + it('falls back to native read when copilot image analysis auth is missing', async () => { + const result = await resolveCopilotImageAnalysisEnv(false, { + getImageAnalysisHookEnv: () => ({ + CCS_CURRENT_PROVIDER: 'ghcp', + CCS_IMAGE_ANALYSIS_SKIP: '0', + }), + resolveImageAnalysisRuntimeStatus: async () => ({ + enabled: true, + supported: true, + status: 'active', + backendId: 'ghcp', + backendDisplayName: 'GitHub Copilot (OAuth)', + model: 'claude-haiku-4.5', + resolutionSource: 'copilot-alias', + reason: null, + shouldPersistHook: true, + persistencePath: 'copilot.settings.json', + runtimePath: '/api/provider/ghcp', + usesCurrentTarget: true, + usesCurrentAuthToken: true, + hookInstalled: true, + sharedHookInstalled: true, + authReadiness: 'missing', + authProvider: 'ghcp', + authDisplayName: 'GitHub Copilot (OAuth)', + authReason: + 'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.', + proxyReadiness: 'stopped', + proxyReason: + 'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.', + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: + 'GitHub Copilot (OAuth) auth is missing. Run "ccs ghcp --auth" to enable image analysis.', + }), + }); + + expect(result.env.CCS_CURRENT_PROVIDER).toBe(''); + expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1'); + expect(result.warning).toContain('ccs ghcp --auth'); + }); + + it('starts local CLIProxy on demand when copilot image analysis is launchable', async () => { + let ensureCalls = 0; + const result = await resolveCopilotImageAnalysisEnv(false, { + getImageAnalysisHookEnv: () => ({ + CCS_CURRENT_PROVIDER: 'ghcp', + CCS_IMAGE_ANALYSIS_SKIP: '0', + }), + resolveImageAnalysisRuntimeStatus: async () => ({ + enabled: true, + supported: true, + status: 'active', + backendId: 'ghcp', + backendDisplayName: 'GitHub Copilot (OAuth)', + model: 'claude-haiku-4.5', + resolutionSource: 'copilot-alias', + reason: null, + shouldPersistHook: true, + persistencePath: 'copilot.settings.json', + runtimePath: '/api/provider/ghcp', + usesCurrentTarget: true, + usesCurrentAuthToken: true, + hookInstalled: true, + sharedHookInstalled: true, + authReadiness: 'ready', + authProvider: 'ghcp', + authDisplayName: 'GitHub Copilot (OAuth)', + authReason: null, + proxyReadiness: 'stopped', + proxyReason: + 'Local CLIProxy service is idle. CCS will start it automatically when image analysis is needed.', + effectiveRuntimeMode: 'cliproxy-image-analysis', + effectiveRuntimeReason: null, + }), + ensureCliproxyService: async () => { + ensureCalls += 1; + return { + started: true, + alreadyRunning: false, + port: 8317, + }; + }, + }); + + expect(ensureCalls).toBe(1); + expect(result.env.CCS_CURRENT_PROVIDER).toBe('ghcp'); + expect(result.env.CCS_IMAGE_ANALYSIS_SKIP).toBe('0'); + expect(result.warning).toBeNull(); + }); }); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts new file mode 100644 index 00000000..844046f0 --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'bun:test'; +import { hydrateImageAnalysisRuntimeStatus } from '../../../../src/utils/hooks/image-analysis-runtime-status'; +import type { ImageAnalysisStatus } from '../../../../src/utils/hooks/image-analysis-backend-resolver'; + +function createStatus(overrides: Partial = {}): ImageAnalysisStatus { + return { + enabled: true, + supported: true, + status: 'active', + backendId: 'ghcp', + backendDisplayName: 'GitHub Copilot (OAuth)', + model: 'claude-haiku-4.5', + resolutionSource: 'profile-backend', + reason: null, + shouldPersistHook: true, + persistencePath: '/tmp/orq.settings.json', + runtimePath: '/api/provider/ghcp', + usesCurrentTarget: true, + usesCurrentAuthToken: true, + hookInstalled: true, + sharedHookInstalled: true, + authReadiness: 'unknown', + authProvider: 'ghcp', + authDisplayName: 'GitHub Copilot (OAuth)', + authReason: 'Auth readiness has not been verified yet.', + proxyReadiness: 'unknown', + proxyReason: 'CLIProxy runtime readiness has not been verified yet.', + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: null, + ...overrides, + }; +} + +describe('image-analysis-runtime-status', () => { + it('falls back to native read when provider auth is missing', async () => { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'ghcp', + authenticated: false, + tokenDir: '/tmp/auth', + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + }); + + expect(status.authReadiness).toBe('missing'); + expect(status.effectiveRuntimeMode).toBe('native-read'); + expect(status.effectiveRuntimeReason).toContain('ccs ghcp --auth'); + }); + + it('marks an idle local proxy as launchable when auth is ready', async () => { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'ghcp', + authenticated: true, + tokenDir: '/tmp/auth', + tokenFiles: ['github-copilot-test.json'], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => false, + }); + + expect(status.authReadiness).toBe('ready'); + expect(status.proxyReadiness).toBe('stopped'); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + }); + + it('treats an unreachable remote proxy as unavailable', async () => { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ + host: 'remote.example', + port: 443, + protocol: 'https', + authToken: 'token', + managementKey: 'secret', + isRemote: true, + }), + fetchRemoteAuthStatus: async () => [ + { + provider: 'ghcp', + displayName: 'GitHub Copilot (OAuth)', + authenticated: true, + tokenFiles: 1, + accounts: [], + defaultAccount: null, + source: 'remote', + }, + ], + isCliproxyRunning: async () => false, + }); + + expect(status.authReadiness).toBe('ready'); + expect(status.proxyReadiness).toBe('unavailable'); + expect(status.effectiveRuntimeMode).toBe('native-read'); + expect(status.effectiveRuntimeReason).toContain('remote.example:443'); + }); + + it('keeps hook-missing on native read even when auth and proxy are ready', async () => { + const status = await hydrateImageAnalysisRuntimeStatus( + createStatus({ + status: 'hook-missing', + reason: 'Profile hook is missing from the persisted settings file.', + }), + { + getProxyTarget: () => ({ + host: '127.0.0.1', + port: 8317, + protocol: 'http', + isRemote: false, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'ghcp', + authenticated: true, + tokenDir: '/tmp/auth', + tokenFiles: ['github-copilot-test.json'], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.authReadiness).toBe('ready'); + expect(status.proxyReadiness).toBe('ready'); + expect(status.effectiveRuntimeMode).toBe('native-read'); + expect(status.effectiveRuntimeReason).toContain('Profile hook is missing'); + }); +}); diff --git a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts index cae53e5e..dcfc575d 100644 --- a/tests/unit/web-server/settings-routes-image-analysis-status.test.ts +++ b/tests/unit/web-server/settings-routes-image-analysis-status.test.ts @@ -103,6 +103,8 @@ describe('settings-routes image-analysis status', () => { resolutionSource: string; model: string | null; persistencePath: string | null; + authReadiness: string; + effectiveRuntimeMode: string; }; }; @@ -111,6 +113,8 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.resolutionSource).toBe('fallback-backend'); expect(body.imageAnalysisStatus.model).toBe('gemini-2.5-flash'); expect(body.imageAnalysisStatus.persistencePath).toContain('glm.settings.json'); + expect(body.imageAnalysisStatus.authReadiness).toBe('missing'); + expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read'); }); it('keeps direct Anthropic settings profiles on native read diagnostics', async () => { @@ -130,6 +134,8 @@ describe('settings-routes image-analysis status', () => { shouldPersistHook: boolean; runtimePath: string | null; reason: string | null; + authReadiness: string; + proxyReadiness: string; }; }; @@ -138,6 +144,8 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.shouldPersistHook).toBe(false); expect(body.imageAnalysisStatus.runtimePath).toBeNull(); expect(body.imageAnalysisStatus.reason).toContain('native file access'); + expect(body.imageAnalysisStatus.authReadiness).toBe('not-needed'); + expect(body.imageAnalysisStatus.proxyReadiness).toBe('not-needed'); }); it('returns explicit mapped status for custom aliases', async () => { @@ -169,6 +177,8 @@ describe('settings-routes image-analysis status', () => { backendId: string | null; resolutionSource: string; model: string | null; + authReadiness: string; + effectiveRuntimeMode: string; }; }; @@ -176,6 +186,8 @@ describe('settings-routes image-analysis status', () => { expect(body.imageAnalysisStatus.backendId).toBe('ghcp'); expect(body.imageAnalysisStatus.resolutionSource).toBe('profile-backend'); expect(body.imageAnalysisStatus.model).toBe('claude-haiku-4.5'); + expect(body.imageAnalysisStatus.authReadiness).toBe('missing'); + expect(body.imageAnalysisStatus.effectiveRuntimeMode).toBe('native-read'); }); it('uses the configured custom settings path for status and persistence diagnostics', async () => { @@ -235,10 +247,12 @@ describe('settings-routes image-analysis status', () => { imageAnalysisStatus: { backendId: string | null; resolutionSource: string; + authReadiness: string; }; }; expect(body.imageAnalysisStatus.backendId).toBe('ghcp'); expect(body.imageAnalysisStatus.resolutionSource).toBe('cliproxy-bridge'); + expect(body.imageAnalysisStatus.authReadiness).toBe('missing'); }); }); diff --git a/ui/src/components/profiles/editor/image-analysis-status-section.tsx b/ui/src/components/profiles/editor/image-analysis-status-section.tsx index ab9aa0ef..daaa238e 100644 --- a/ui/src/components/profiles/editor/image-analysis-status-section.tsx +++ b/ui/src/components/profiles/editor/image-analysis-status-section.tsx @@ -9,66 +9,103 @@ interface ImageAnalysisStatusSectionProps { previewState?: 'saved' | 'preview' | 'refreshing' | 'invalid'; } -function getBadge(status: ImageAnalysisStatus | null | undefined): { - label: string; - variant: 'default' | 'secondary' | 'destructive' | 'outline'; -} { - switch (status?.status) { - case 'active': - return { label: 'Configured', variant: 'default' }; - case 'mapped': - return { label: 'Saved mapping', variant: 'secondary' }; - case 'attention': - return { label: 'Needs review', variant: 'outline' }; - case 'disabled': - return { label: 'Disabled', variant: 'outline' }; - case 'hook-missing': - return { label: 'Setup needed', variant: 'destructive' }; - case 'skipped': - return { label: 'Not available', variant: 'outline' }; - default: - return { label: 'Checking', variant: 'outline' }; +function getBadge(status: ImageAnalysisStatus | null | undefined) { + if (!status) return { label: 'Checking', variant: 'outline' as const }; + if (status.status === 'disabled') return { label: 'Disabled', variant: 'outline' as const }; + if (status.status === 'hook-missing') + return { label: 'Setup needed', variant: 'destructive' as const }; + if (status.authReadiness === 'missing') + return { label: 'Needs auth', variant: 'destructive' as const }; + if (status.proxyReadiness === 'unavailable') + return { label: 'Needs proxy', variant: 'destructive' as const }; + if ( + status.effectiveRuntimeMode === 'cliproxy-image-analysis' && + status.proxyReadiness === 'stopped' + ) { + return { label: 'Starts on launch', variant: 'secondary' as const }; } + if (status.effectiveRuntimeMode === 'cliproxy-image-analysis') { + return { + label: status.resolutionSource === 'profile-backend' ? 'Ready via mapping' : 'Ready', + variant: 'default' as const, + }; + } + if ( + status.authReadiness === 'unknown' || + status.proxyReadiness === 'unknown' || + status.status === 'attention' + ) { + return { label: 'Needs review', variant: 'outline' as const }; + } + if (status.status === 'skipped' && status.reason?.includes('native file access')) { + return { label: 'Native Claude', variant: 'secondary' as const }; + } + return { label: 'Native Read', variant: 'outline' as const }; } function getSummary(status: ImageAnalysisStatus): string { const backendName = status.backendDisplayName || status.backendId || 'this backend'; - switch (status.status) { - case 'disabled': - return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off."; - case 'mapped': - return `Configured via saved ${backendName} mapping. CCS could not infer the backend from this alias, so it uses the mapping saved in CCS config when image analysis is available for the session.`; - case 'attention': - return `Configured via ${backendName}, but ${status.reason || 'runtime details no longer match the saved profile state.'}`; - case 'hook-missing': - return `Configured for ${backendName}, but ${status.reason || 'the image-analysis hook is not fully installed yet.'}`; - case 'skipped': - return status.reason || 'Skipped for this profile.'; - case 'active': - default: - if (status.resolutionSource === 'cliproxy-bridge') { - return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`; - } - - if (status.resolutionSource === 'fallback-backend') { - return `Configured via ${backendName} fallback. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`; - } - - return `Configured via ${backendName}. Image and PDF reads use CLIProxy when the local hook runtime is available for this session.`; + if (status.status === 'disabled') { + return "Disabled globally. This profile uses Claude's built-in file reading because CCS image analysis is turned off."; } + + if (!status.backendId) { + return status.reason || "This profile uses Claude's built-in file reading."; + } + + if (status.status === 'hook-missing') { + return `Configured for ${backendName}, but ${status.reason || 'the image-analysis hook is not fully installed yet.'}`; + } + + if (status.effectiveRuntimeMode === 'native-read') { + return `Configured via ${backendName}, but ${status.effectiveRuntimeReason || status.reason || 'runtime readiness could not be confirmed.'}`; + } + + if (status.proxyReadiness === 'stopped') { + return `Configured via ${backendName}. Auth is ready and CCS will start the local CLIProxy runtime on launch, so image and PDF reads still use CLIProxy.`; + } + + if (status.resolutionSource === 'profile-backend') { + return `Configured via saved ${backendName} mapping. Auth and runtime are ready, so image and PDF reads use CLIProxy.`; + } + + if (status.status === 'attention' && status.reason) { + return `Configured via ${backendName}. Image and PDF reads use CLIProxy, but ${status.reason}`; + } + + return `Configured via ${backendName}. Image and PDF reads use CLIProxy for this profile.`; } function getRuntimeLine(status: ImageAnalysisStatus): string { - if (status.status === 'hook-missing') { - return 'Read -> native file access (hook install required)'; - } - - if (!status.runtimePath) { + if (status.effectiveRuntimeMode === 'native-read') { return 'Read -> native file access'; } - return `Read -> image-analysis hook -> ${status.runtimePath}`; + if (status.proxyReadiness === 'stopped') { + return 'Read -> image-analysis hook -> start local CLIProxy'; + } + + if (status.proxyReadiness === 'remote') { + return 'Read -> image-analysis hook -> remote CLIProxy'; + } + + return `Read -> image-analysis hook -> ${status.runtimePath || 'CLIProxy'}`; +} + +function getAuthLine(status: ImageAnalysisStatus): string { + if (status.authReadiness === 'not-needed') return 'Not required'; + if (status.authReadiness === 'ready') + return `${status.authDisplayName || status.authProvider} ready`; + return status.authReason || 'Auth readiness could not be verified.'; +} + +function getProxyLine(status: ImageAnalysisStatus): string { + if (status.proxyReadiness === 'not-needed') return 'Not required'; + if (status.proxyReadiness === 'ready') return 'Local CLIProxy ready'; + if (status.proxyReadiness === 'remote') return status.proxyReason || 'Remote CLIProxy ready'; + if (status.proxyReadiness === 'stopped') return 'Local CLIProxy idle; starts on launch'; + return status.proxyReason || 'CLIProxy runtime readiness could not be verified.'; } function getStatusContext( @@ -78,28 +115,21 @@ function getStatusContext( if (previewState === 'invalid') { return 'Showing last saved runtime status. The live preview resumes when the JSON above is valid again.'; } - if (previewState === 'refreshing') { return 'Refreshing the live preview from the current editor state.'; } - if (source === 'editor') { - return 'Live preview from the current editor state. Save to persist these backend and hook changes.'; + return 'Live preview from the current editor state. Save to persist config changes; auth and proxy readiness stay derived below.'; } - - return 'Saved runtime status for this profile. This section is derived and is not written into the JSON editor above.'; + return 'Saved runtime status for this profile. Config stays in the JSON editor above; auth and proxy readiness are derived at runtime.'; } function getPersistenceLine(status: ImageAnalysisStatus): string { - if (!status.shouldPersistHook || !status.persistencePath) { + if (!status.shouldPersistHook || !status.persistencePath) return 'Not persisted for this profile type'; - } - - if (status.hookInstalled) { - return `${status.persistencePath} hook`; - } - - return `${status.persistencePath} hook missing`; + return status.hookInstalled + ? `${status.persistencePath} hook` + : `${status.persistencePath} hook missing`; } export function ImageAnalysisStatusSection({ @@ -118,8 +148,12 @@ export function ImageAnalysisStatusSection({ } const badge = getBadge(status); - const detailLabel = status.supported ? 'Model' : 'Reason'; - const detailValue = status.supported ? status.model : status.reason || 'Unavailable'; + const notice = + status.effectiveRuntimeMode === 'native-read' + ? status.effectiveRuntimeReason + : status.status === 'attention' || status.status === 'hook-missing' + ? status.reason + : null; return (
@@ -149,7 +183,6 @@ export function ImageAnalysisStatusSection({
{status.backendDisplayName || 'Unresolved'}
-
Runtime @@ -161,7 +194,18 @@ export function ImageAnalysisStatusSection({ {getRuntimeLine(status)}
- +
+
+ Auth +
+
{getAuthLine(status)}
+
+
+
+ Proxy +
+
{getProxyLine(status)}
+
Persistence @@ -173,21 +217,20 @@ export function ImageAnalysisStatusSection({ {getPersistenceLine(status)}
-
- {detailLabel} + Model
-
- {detailValue} +
+ {status.model || status.reason || 'Unavailable'}
- {(status.status === 'attention' || status.status === 'hook-missing') && ( + {notice && (
- {status.reason} + {notice}
)} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 7fef8193..e8159ef4 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -138,6 +138,14 @@ export interface ImageAnalysisStatus { usesCurrentAuthToken: boolean | null; hookInstalled: boolean | null; sharedHookInstalled: boolean | null; + authReadiness: 'not-needed' | 'ready' | 'missing' | 'unknown'; + authProvider: string | null; + authDisplayName: string | null; + authReason: string | null; + proxyReadiness: 'not-needed' | 'ready' | 'remote' | 'stopped' | 'unavailable' | 'unknown'; + proxyReason: string | null; + effectiveRuntimeMode: 'cliproxy-image-analysis' | 'native-read'; + effectiveRuntimeReason: string | null; } export interface Profile { diff --git a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx index 87b91b4f..9a29cb7d 100644 --- a/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx +++ b/ui/tests/unit/components/profiles/editor/image-analysis-status-section.test.tsx @@ -48,6 +48,14 @@ function createStatus(overrides: Partial = {}): ImageAnalys usesCurrentAuthToken: true, hookInstalled: true, sharedHookInstalled: true, + authReadiness: 'ready', + authProvider: 'gemini', + authDisplayName: 'Google Gemini', + authReason: null, + proxyReadiness: 'ready', + proxyReason: 'Local CLIProxy service is reachable.', + effectiveRuntimeMode: 'cliproxy-image-analysis', + effectiveRuntimeReason: null, ...overrides, }; } @@ -75,13 +83,17 @@ describe('ImageAnalysisStatusSection', () => { expect(screen.getByText('Image-analysis backend')).toBeInTheDocument(); expect( screen.getByText( - /Saved runtime status for this profile\. This section is derived and is not written into the JSON editor above\./i + /Saved runtime status for this profile\. Config stays in the JSON editor above; auth and proxy readiness are derived at runtime\./i ) ).toBeInTheDocument(); - expect(screen.getByText('Configured')).toBeInTheDocument(); - expect(screen.getByText(/Configured via Google Gemini\./i)).toBeInTheDocument(); + expect(screen.getByText('Ready')).toBeInTheDocument(); + expect( + screen.getByText(/Configured via Google Gemini\. Image and PDF reads use CLIProxy/i) + ).toBeInTheDocument(); expect(screen.getByText('Google Gemini')).toBeInTheDocument(); expect(screen.getByTitle(/\/api\/provider\/gemini/)).toBeInTheDocument(); + expect(screen.getAllByText('Google Gemini ready')).toHaveLength(1); + expect(screen.getByText('Local CLIProxy ready')).toBeInTheDocument(); expect(screen.getByText('gemini-2.5-flash')).toBeInTheDocument(); }); @@ -89,19 +101,22 @@ describe('ImageAnalysisStatusSection', () => { render( ); - expect(screen.getByText('Saved mapping')).toBeInTheDocument(); + expect(screen.getByText('Ready via mapping')).toBeInTheDocument(); expect( - screen.getByText(/Configured via saved GitHub Copilot \(OAuth\) mapping/i) + screen.getByText( + /Configured via saved GitHub Copilot \(OAuth\) mapping\. Auth and runtime are ready/i + ) ).toBeInTheDocument(); expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); expect(screen.getByText('claude-haiku-4.5')).toBeInTheDocument(); @@ -113,6 +128,8 @@ describe('ImageAnalysisStatusSection', () => { status={createStatus({ status: 'hook-missing', reason: 'Profile hook is missing from the persisted settings file.', + effectiveRuntimeMode: 'native-read', + effectiveRuntimeReason: 'Profile hook is missing from the persisted settings file.', })} /> ); @@ -121,31 +138,55 @@ describe('ImageAnalysisStatusSection', () => { expect( screen.getByText(/Configured for Google Gemini, but Profile hook is missing/i) ).toBeInTheDocument(); - expect(screen.getByTitle(/native file access \(hook install required\)/i)).toBeInTheDocument(); + expect(screen.getByTitle(/native file access/i)).toBeInTheDocument(); }); - it('uses the resolver reason for attention states like auth-token drift', () => { + it('shows auth readiness gaps separately from backend resolution', () => { render( ); - expect(screen.getByText('Needs review')).toBeInTheDocument(); + expect(screen.getByText('Needs auth')).toBeInTheDocument(); expect( screen.getByText( - /Configured via Google Gemini, but Runtime uses the current CLIProxy auth token/i + /Configured via GitHub Copilot \(OAuth\), but GitHub Copilot \(OAuth\) auth is missing/i ) ).toBeInTheDocument(); + expect(screen.getAllByText(/Run "ccs ghcp --auth" to enable image analysis/i)).toHaveLength(3); + }); + + it('treats an idle local proxy as launchable instead of unavailable', () => { + render( + + ); + + expect(screen.getByText('Starts on launch')).toBeInTheDocument(); expect( - screen.getAllByText(/current CLIProxy auth token instead of the saved token/i) - ).toHaveLength(2); + screen.getByText(/Auth is ready and CCS will start the local CLIProxy runtime on launch/i) + ).toBeInTheDocument(); + expect(screen.getByText('Local CLIProxy idle; starts on launch')).toBeInTheDocument(); + expect(screen.getByTitle(/start local CLIProxy/i)).toBeInTheDocument(); }); it('switches the panel to a live preview when the current editor JSON changes', async () => { @@ -178,6 +219,9 @@ describe('ImageAnalysisStatusSection', () => { backendDisplayName: 'GitHub Copilot (OAuth)', model: 'claude-haiku-4.5', runtimePath: '/api/provider/ghcp', + authReadiness: 'ready', + authProvider: 'ghcp', + authDisplayName: 'GitHub Copilot (OAuth)', }), }) ); @@ -212,7 +256,7 @@ describe('ImageAnalysisStatusSection', () => { }); expect( screen.getByText( - /Live preview from the current editor state\. Save to persist these backend and hook changes\./i + /Live preview from the current editor state\. Save to persist config changes; auth and proxy readiness stay derived below\./i ) ).toBeInTheDocument(); });