From 98c21efb5a3b7a39b27fda958691837545235f2d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 03:24:59 -0500 Subject: [PATCH 1/4] fix(websearch): detect Gemini CLI auth status before showing Ready Previously, WebSearch status showed "Ready (Gemini)" when the CLI binary was installed, misleading users who hadn't authenticated yet. Changes: - Add isGeminiAuthenticated() to check ~/.gemini/oauth_creds.json - Add 'needs_auth' state to WebSearchReadiness type - Show warning "[!] Gemini: run 'gemini' to login" when not authenticated - Fix incorrect 'gemini auth login' references (no such command exists) - Update docs with correct npm install and authentication instructions --- docs/websearch.md | 16 +++--- lib/hooks/websearch-transformer.cjs | 8 +-- src/utils/websearch-manager.ts | 77 ++++++++++++++++++++++++++--- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/docs/websearch.md b/docs/websearch.md index 623d1b2c..184588df 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -57,21 +57,19 @@ The **ultimate solution** for third-party WebSearch. Uses `gemini` CLI with OAut ### Requirements -- `gemini` CLI installed and authenticated (`gemini auth login`) +- `gemini` CLI installed and authenticated (run `gemini` to authenticate via browser) - OAuth authentication (no GEMINI_API_KEY needed) ### Installation -The Gemini CLI is typically installed via: +The Gemini CLI is installed via npm: ```bash -pip install google-generativeai -# or -pipx install google-generativeai +npm install -g @google/gemini-cli ``` -Then authenticate: +Then authenticate by running gemini once (opens browser): ```bash -gemini auth login +gemini ``` ## MCP Providers @@ -176,8 +174,8 @@ CCS writes MCP configuration to `~/.claude/.mcp.json`. Example: ### Gemini CLI Issues -1. **Not installed**: Install with `pip install google-generativeai` -2. **Not authenticated**: Run `gemini auth login` +1. **Not installed**: Install with `npm install -g @google/gemini-cli` +2. **Not authenticated**: Run `gemini` to open browser for OAuth login 3. **Timeout**: Increase timeout in config or via `CCS_GEMINI_TIMEOUT=90` 4. **Skip Gemini**: Set `CCS_GEMINI_SKIP=1` to use MCP fallback only diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 84d9f8d7..b0270543 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -513,8 +513,8 @@ function outputError(query, error, providerName) { `Query: "${query}"`, '', 'Troubleshooting:', - ' - Check if Gemini CLI is authenticated: gemini auth status', - ' - Re-authenticate if needed: gemini auth login', + ' - Check ~/.gemini/oauth_creds.json exists', + ' - Re-authenticate: run `gemini` (opens browser)', ].join('\n'); const output = { @@ -546,7 +546,7 @@ function outputNoProvidersEnabled(query) { '', '1. Gemini CLI (FREE, 1000 req/day):', ' npm install -g @google/gemini-cli', - ' gemini auth login', + ' gemini # opens browser to authenticate', '', '2. OpenCode (FREE via Zen):', ' curl -fsSL https://opencode.ai/install | bash', @@ -595,7 +595,7 @@ function outputAllFailedMessage(query, errors) { `Query: "${query}"`, '', 'Troubleshooting:', - ' - Gemini: gemini auth status / gemini auth login', + ' - Gemini: run `gemini` to authenticate (opens browser)', ' - OpenCode: opencode --version', ' - Grok: Check XAI_API_KEY environment variable', ].join('\n'); diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index f4a7721c..7ae85d89 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -114,6 +114,32 @@ export function hasGeminiCli(): boolean { return getGeminiCliStatus().installed; } +/** + * Check if Gemini CLI is authenticated + * + * Gemini CLI stores OAuth credentials in ~/.gemini/oauth_creds.json + * Authentication is done by running `gemini` which opens browser for OAuth. + * Note: There is NO `gemini auth login` command - just run `gemini` to authenticate. + * + * @returns true if oauth_creds.json exists with access_token + */ +export function isGeminiAuthenticated(): boolean { + const oauthPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json'); + + if (!fs.existsSync(oauthPath)) { + return false; + } + + try { + const content = fs.readFileSync(oauthPath, 'utf8'); + const creds = JSON.parse(content); + // Check if access_token exists (doesn't validate expiry - Gemini CLI handles refresh) + return !!creds.access_token; + } catch { + return false; + } +} + /** * Clear Gemini CLI cache (for testing or after installation) */ @@ -692,7 +718,7 @@ export function getWebSearchHookEnv(): Record { /** * WebSearch availability status for third-party profiles */ -export type WebSearchReadiness = 'ready' | 'unavailable'; +export type WebSearchReadiness = 'ready' | 'needs_auth' | 'unavailable'; /** * WebSearch status for display @@ -700,6 +726,7 @@ export type WebSearchReadiness = 'ready' | 'unavailable'; export interface WebSearchStatus { readiness: WebSearchReadiness; geminiCli: boolean; + geminiAuthenticated: boolean; grokCli: boolean; opencodeCli: boolean; message: string; @@ -709,6 +736,7 @@ export interface WebSearchStatus { * Get WebSearch readiness status for display * * Called on third-party profile startup to inform user. + * Checks both installation AND authentication status for Gemini CLI. */ export function getWebSearchReadiness(): WebSearchStatus { const wsConfig = getWebSearchConfig(); @@ -718,6 +746,7 @@ export function getWebSearchReadiness(): WebSearchStatus { return { readiness: 'unavailable', geminiCli: false, + geminiAuthenticated: false, grokCli: false, opencodeCli: false, message: 'Disabled in config', @@ -726,28 +755,56 @@ export function getWebSearchReadiness(): WebSearchStatus { // Check all CLIs const geminiInstalled = hasGeminiCli(); + const geminiAuthed = geminiInstalled && isGeminiAuthenticated(); const grokInstalled = hasGrokCli(); const opencodeInstalled = hasOpenCodeCli(); - // Build message based on installed CLIs - const installedClis: string[] = []; - if (geminiInstalled) installedClis.push('Gemini'); - if (grokInstalled) installedClis.push('Grok'); - if (opencodeInstalled) installedClis.push('OpenCode'); + // Build message based on installed + authenticated CLIs + const readyClis: string[] = []; + const needsAuthClis: string[] = []; - if (installedClis.length > 0) { + // Gemini requires auth check + if (geminiInstalled) { + if (geminiAuthed) { + readyClis.push('Gemini'); + } else { + needsAuthClis.push('Gemini'); + } + } + + // Other CLIs don't require auth check (for now) + if (grokInstalled) readyClis.push('Grok'); + if (opencodeInstalled) readyClis.push('OpenCode'); + + // Determine overall status + if (readyClis.length > 0) { + // At least one CLI is ready return { readiness: 'ready', geminiCli: geminiInstalled, + geminiAuthenticated: geminiAuthed, grokCli: grokInstalled, opencodeCli: opencodeInstalled, - message: `Ready (${installedClis.join(' + ')})`, + message: `Ready (${readyClis.join(' + ')})`, + }; + } + + if (needsAuthClis.length > 0) { + // CLIs installed but need auth + return { + readiness: 'needs_auth', + geminiCli: geminiInstalled, + geminiAuthenticated: false, + grokCli: grokInstalled, + opencodeCli: opencodeInstalled, + message: `Gemini: run 'gemini' to login`, }; } return { readiness: 'unavailable', geminiCli: false, + geminiAuthenticated: false, grokCli: false, opencodeCli: false, message: 'Install: npm i -g @google/gemini-cli', @@ -767,6 +824,10 @@ export function displayWebSearchStatus(): void { case 'ready': console.error(ok(`WebSearch: ${status.message}`)); break; + case 'needs_auth': + // CLI installed but not authenticated - show warning + console.error(warn(`WebSearch: ${status.message}`)); + break; case 'unavailable': console.error(fail(`WebSearch: ${status.message}`)); // Show install hints for CLI-only users From a87ef973fca1bad4801669ff593b4e19430ac053 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Dec 2025 08:26:14 +0000 Subject: [PATCH 2/4] chore(release): 6.3.1-dev.1 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index dc0208ab..01750aaf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.3.1 +6.3.1-dev.1 diff --git a/package.json b/package.json index fdba4684..425a4aa3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.3.1", + "version": "6.3.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From a66abba174eb77555b4443f3e930be30264da7e4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 18 Dec 2025 04:11:59 -0500 Subject: [PATCH 3/4] feat(cliproxy): implement interactive project selection for OAuth flows - Add ProjectSelectionHandler to parse and handle CLIProxy project prompts - Integrate project selection into AuthHandler with UI-first support - Add project selection API endpoints and WebSocket events - Implement ProjectSelectionDialog and useProjectSelection hook in UI - Fixes macOS OAuth timeout by handling interactive project selection via UI --- src/cliproxy/auth-handler.ts | 87 +++++-- src/cliproxy/project-selection-handler.ts | 218 ++++++++++++++++++ src/web-server/routes.ts | 44 ++++ src/web-server/websocket.ts | 47 ++++ ui/src/components/layout.tsx | 18 ++ .../components/project-selection-dialog.tsx | 203 ++++++++++++++++ ui/src/hooks/use-project-selection.ts | 111 +++++++++ ui/src/hooks/use-websocket.ts | 3 + 8 files changed, 714 insertions(+), 17 deletions(-) create mode 100644 src/cliproxy/project-selection-handler.ts create mode 100644 ui/src/components/project-selection-dialog.tsx create mode 100644 ui/src/hooks/use-project-selection.ts diff --git a/src/cliproxy/auth-handler.ts b/src/cliproxy/auth-handler.ts index 025f745e..8ec68e3c 100644 --- a/src/cliproxy/auth-handler.ts +++ b/src/cliproxy/auth-handler.ts @@ -32,6 +32,16 @@ import { enhancedPreflightOAuthCheck, OAUTH_CALLBACK_PORTS as OAUTH_PORTS, } from '../management/oauth-port-diagnostics'; +import { + parseProjectList, + parseDefaultProject, + isProjectSelectionPrompt, + isProjectList, + generateSessionId, + requestProjectSelection, + type GCloudProject, + type ProjectSelectionPrompt, +} from './project-selection-handler'; /** * OAuth callback ports used by CLIProxyAPI (hardcoded in binary) @@ -451,20 +461,8 @@ function getTimeoutTroubleshooting(provider: CLIProxyProvider, port: number | nu lines.push(' 1. Check browser completed auth (should show success page)'); if (port) { - if (process.platform === 'win32') { - lines.push(` 2. Verify port ${port} is reachable: curl http://localhost:${port}`); - lines.push(' 3. Windows Firewall may block - run as Administrator:'); - lines.push( - ` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${port}` - ); - lines.push(` 4. Try: ccs ${provider} --auth --verbose`); - } else if (process.platform === 'darwin') { - lines.push(` 2. Check for port conflicts: lsof -ti:${port}`); - lines.push(` 3. Try: ccs ${provider} --auth --verbose`); - } else { - lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`); - lines.push(` 3. Try: ccs ${provider} --auth --verbose`); - } + lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`); + lines.push(` 3. Try: ccs ${provider} --auth --verbose`); } else { lines.push(` 2. Try: ccs ${provider} --auth --verbose`); } @@ -489,11 +487,14 @@ export async function triggerOAuth( account?: string; add?: boolean; nickname?: string; + /** If true, triggered from Web UI (enables project selection prompt) */ + fromUI?: boolean; } = {} ): Promise { const oauthConfig = getOAuthConfig(provider); - const { verbose = false, add = false, nickname } = options; + const { verbose = false, add = false, nickname, fromUI = false } = options; const callbackPort = OAUTH_PORTS[provider]; + const isCLI = !fromUI; // CLI mode = auto-select default project // Auto-detect headless if not explicitly set const headless = options.headless ?? isHeadlessEnvironment(); @@ -620,8 +621,9 @@ export async function triggerOAuth( return new Promise((resolve) => { // Spawn CLIProxyAPI with auth flag + // Use pipe for stdin to auto-respond to interactive prompts (e.g., project selection) const authProcess = spawn(binaryPath, args, { - stdio: ['inherit', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, CLI_PROXY_AUTH_DIR: tokenDir, @@ -631,12 +633,63 @@ export async function triggerOAuth( let stderrData = ''; let urlDisplayed = false; let browserOpened = false; + let projectPromptHandled = false; + let accumulatedOutput = ''; // Accumulate output to parse project list + let parsedProjects: GCloudProject[] = []; + const sessionId = generateSessionId(); // Unique session ID for this auth flow const startTime = Date.now(); - authProcess.stdout?.on('data', (data: Buffer) => { + authProcess.stdout?.on('data', async (data: Buffer) => { const output = data.toString(); log(`stdout: ${output.trim()}`); + // Accumulate output for project list parsing + accumulatedOutput += output; + + // Parse project list when available + if (isProjectList(accumulatedOutput) && parsedProjects.length === 0) { + parsedProjects = parseProjectList(accumulatedOutput); + log(`Parsed ${parsedProjects.length} projects`); + } + + // Handle project selection prompt + if (!projectPromptHandled && isProjectSelectionPrompt(output)) { + projectPromptHandled = true; + + const defaultProjectId = parseDefaultProject(output) || ''; + + // If we have projects and this is a UI-triggered flow, request selection + if (parsedProjects.length > 0 && !isCLI) { + log(`Requesting project selection from UI (session: ${sessionId})`); + + const prompt: ProjectSelectionPrompt = { + sessionId, + provider, + projects: parsedProjects, + defaultProjectId, + supportsAll: output.includes('ALL'), + }; + + try { + // Request selection from UI (with timeout fallback to default) + const selectedId = await requestProjectSelection(prompt); + + // Write selection to stdin (empty = default, else project ID or ALL) + const response = selectedId || ''; + log(`User selected: ${response || '(default)'}`); + authProcess.stdin?.write(response + '\n'); + } catch { + // Fallback to default on error + log('Project selection failed, using default'); + authProcess.stdin?.write('\n'); + } + } else { + // CLI mode or no projects: auto-select default + log('CLI mode or no projects, auto-selecting default'); + authProcess.stdin?.write('\n'); + } + } + // Detect when callback server starts or browser opens if (!browserOpened && (output.includes('listening') || output.includes('http'))) { process.stdout.write('\x1b[1A\x1b[2K'); diff --git a/src/cliproxy/project-selection-handler.ts b/src/cliproxy/project-selection-handler.ts new file mode 100644 index 00000000..bb0a2c2d --- /dev/null +++ b/src/cliproxy/project-selection-handler.ts @@ -0,0 +1,218 @@ +/** + * Project Selection Handler + * + * Manages interactive project selection prompts during OAuth flow. + * Parses CLIProxyAPI stdout for project lists and coordinates + * between backend (stdin writer) and frontend (WebSocket/UI). + */ + +import { EventEmitter } from 'events'; + +/** + * Parsed project from CLIProxyAPI output + */ +export interface GCloudProject { + id: string; + name: string; + index: number; +} + +/** + * Project selection prompt data sent to UI + */ +export interface ProjectSelectionPrompt { + sessionId: string; + provider: string; + projects: GCloudProject[]; + defaultProjectId: string; + supportsAll: boolean; +} + +/** + * Project selection response from UI + */ +export interface ProjectSelectionResponse { + sessionId: string; + selectedId: string; // Project ID or 'ALL' +} + +/** + * Pending project selection with resolver + */ +interface PendingSelection { + prompt: ProjectSelectionPrompt; + resolve: (value: string) => void; + timeout: NodeJS.Timeout; +} + +// Global event emitter for project selection events +export const projectSelectionEvents = new EventEmitter(); + +// Pending selections by session ID +const pendingSelections = new Map(); + +// Default timeout for project selection (30 seconds) +const SELECTION_TIMEOUT_MS = 30000; + +/** + * Parse project list from CLIProxyAPI stdout + * + * Expected format: + * "Available Google Cloud projects: + * [1] project-id-123 (Project Name) + * [2] another-project (Another Name) + * Type 'ALL' to onboard every listed project." + */ +export function parseProjectList(output: string): GCloudProject[] { + const projects: GCloudProject[] = []; + + // Match lines like: [1] project-id (Project Name) + const projectRegex = /\[(\d+)\]\s+(\S+)\s+\(([^)]+)\)/g; + let match; + + while ((match = projectRegex.exec(output)) !== null) { + projects.push({ + index: parseInt(match[1], 10), + id: match[2], + name: match[3], + }); + } + + return projects; +} + +/** + * Parse default project ID from prompt + * + * Expected format: "Enter project ID [default-project-id] or ALL:" + */ +export function parseDefaultProject(output: string): string | null { + const match = output.match(/Enter project ID \[([^\]]+)\] or ALL:/); + return match ? match[1] : null; +} + +/** + * Check if output contains project selection prompt + */ +export function isProjectSelectionPrompt(output: string): boolean { + return output.includes('Enter project ID') && output.includes('or ALL:'); +} + +/** + * Check if output contains project list + */ +export function isProjectList(output: string): boolean { + return output.includes('Available Google Cloud projects:'); +} + +/** + * Generate unique session ID for OAuth flow + */ +export function generateSessionId(): string { + return `auth-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; +} + +/** + * Request project selection from UI + * Returns promise that resolves when user selects a project + * + * @param prompt - Project selection prompt data + * @returns Promise - Selected project ID or 'ALL', or default if timeout + */ +export function requestProjectSelection(prompt: ProjectSelectionPrompt): Promise { + return new Promise((resolve) => { + // Set timeout for auto-selection + const timeout = setTimeout(() => { + const pending = pendingSelections.get(prompt.sessionId); + if (pending) { + pendingSelections.delete(prompt.sessionId); + // Auto-select default on timeout + resolve(prompt.defaultProjectId); + projectSelectionEvents.emit('selection:timeout', prompt.sessionId); + } + }, SELECTION_TIMEOUT_MS); + + // Store pending selection + pendingSelections.set(prompt.sessionId, { + prompt, + resolve, + timeout, + }); + + // Emit event for WebSocket broadcast + projectSelectionEvents.emit('selection:required', prompt); + }); +} + +/** + * Submit project selection from UI + * + * @param response - Selection response with session ID and selected project + * @returns boolean - True if selection was accepted + */ +export function submitProjectSelection(response: ProjectSelectionResponse): boolean { + const pending = pendingSelections.get(response.sessionId); + + if (!pending) { + return false; + } + + // Clear timeout and remove from pending + clearTimeout(pending.timeout); + pendingSelections.delete(response.sessionId); + + // Resolve the promise with selected ID + pending.resolve(response.selectedId); + projectSelectionEvents.emit('selection:submitted', response); + + return true; +} + +/** + * Cancel pending project selection + */ +export function cancelProjectSelection(sessionId: string): boolean { + const pending = pendingSelections.get(sessionId); + + if (!pending) { + return false; + } + + clearTimeout(pending.timeout); + pendingSelections.delete(sessionId); + + // Resolve with default (empty string to auto-select) + pending.resolve(''); + projectSelectionEvents.emit('selection:cancelled', sessionId); + + return true; +} + +/** + * Get pending project selection prompt + */ +export function getPendingSelection(sessionId: string): ProjectSelectionPrompt | null { + const pending = pendingSelections.get(sessionId); + return pending ? pending.prompt : null; +} + +/** + * Check if there's a pending selection + */ +export function hasPendingSelection(sessionId: string): boolean { + return pendingSelections.has(sessionId); +} + +export default { + parseProjectList, + parseDefaultProject, + isProjectSelectionPrompt, + isProjectList, + generateSessionId, + requestProjectSelection, + submitProjectSelection, + cancelProjectSelection, + getPendingSelection, + hasPendingSelection, + projectSelectionEvents, +}; diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 9a4486fd..74066c0c 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -17,6 +17,7 @@ import { initializeAccounts, triggerOAuth, } from '../cliproxy/auth-handler'; +import { submitProjectSelection, getPendingSelection } from '../cliproxy/project-selection-handler'; import { fetchCliproxyStats, fetchCliproxyModels, @@ -639,6 +640,7 @@ apiRoutes.post( add: true, // Always add mode from UI headless: false, // Force interactive mode nickname: nickname || undefined, + fromUI: true, // Enable project selection prompt in UI }); if (account) { @@ -661,6 +663,48 @@ apiRoutes.post( } ); +/** + * GET /api/cliproxy/auth/project-selection/:sessionId - Get pending project selection prompt + * Returns project list for user to select from during OAuth flow + */ +apiRoutes.get( + '/cliproxy/auth/project-selection/:sessionId', + (req: Request, res: Response): void => { + const { sessionId } = req.params; + + const pending = getPendingSelection(sessionId); + if (pending) { + res.json(pending); + } else { + res.status(404).json({ error: 'No pending project selection for this session' }); + } + } +); + +/** + * POST /api/cliproxy/auth/project-selection/:sessionId - Submit project selection + * Submits user's project choice during OAuth flow + */ +apiRoutes.post( + '/cliproxy/auth/project-selection/:sessionId', + (req: Request, res: Response): void => { + const { sessionId } = req.params; + const { selectedId } = req.body; + + if (!selectedId && selectedId !== '') { + res.status(400).json({ error: 'selectedId is required (use empty string for default)' }); + return; + } + + const success = submitProjectSelection({ sessionId, selectedId }); + if (success) { + res.json({ success: true }); + } else { + res.status(404).json({ error: 'No pending project selection for this session' }); + } + } +); + // ==================== Settings (Phase 05) ==================== /** diff --git a/src/web-server/websocket.ts b/src/web-server/websocket.ts index 9ecba5e8..22de8762 100644 --- a/src/web-server/websocket.ts +++ b/src/web-server/websocket.ts @@ -2,11 +2,16 @@ * WebSocket Handler (Phase 04) * * Manages WebSocket connections, broadcasts file changes, and handles client messages. + * Also broadcasts project selection events during OAuth flows. */ import { WebSocketServer, WebSocket } from 'ws'; import { createFileWatcher, FileChangeEvent } from './file-watcher'; import { info, warn } from '../utils/ui'; +import { + projectSelectionEvents, + type ProjectSelectionPrompt, +} from '../cliproxy/project-selection-handler'; export interface WSMessage { type: string; @@ -81,9 +86,51 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } { }); }); + // Listen for project selection events and broadcast to clients + const handleProjectSelectionRequired = (prompt: ProjectSelectionPrompt): void => { + console.log(info(`[WS] Broadcasting project selection prompt (session: ${prompt.sessionId})`)); + broadcast({ + type: 'projectSelectionRequired', + ...prompt, + timestamp: Date.now(), + }); + }; + + const handleProjectSelectionTimeout = (sessionId: string): void => { + console.log(info(`[WS] Project selection timed out (session: ${sessionId})`)); + broadcast({ + type: 'projectSelectionTimeout', + sessionId, + timestamp: Date.now(), + }); + }; + + const handleProjectSelectionSubmitted = (response: { + sessionId: string; + selectedId: string; + }): void => { + console.log(info(`[WS] Project selection submitted (session: ${response.sessionId})`)); + broadcast({ + type: 'projectSelectionSubmitted', + ...response, + timestamp: Date.now(), + }); + }; + + // Subscribe to project selection events + projectSelectionEvents.on('selection:required', handleProjectSelectionRequired); + projectSelectionEvents.on('selection:timeout', handleProjectSelectionTimeout); + projectSelectionEvents.on('selection:submitted', handleProjectSelectionSubmitted); + // Cleanup function const cleanup = (): void => { watcher.close(); + + // Unsubscribe from project selection events + projectSelectionEvents.off('selection:required', handleProjectSelectionRequired); + projectSelectionEvents.off('selection:timeout', handleProjectSelectionTimeout); + projectSelectionEvents.off('selection:submitted', handleProjectSelectionSubmitted); + clients.forEach((client) => { client.close(1001, 'Server shutting down'); }); diff --git a/ui/src/components/layout.tsx b/ui/src/components/layout.tsx index a98494cc..41002086 100644 --- a/ui/src/components/layout.tsx +++ b/ui/src/components/layout.tsx @@ -11,6 +11,8 @@ import { LocalhostDisclaimer } from '@/components/localhost-disclaimer'; import { Skeleton } from '@/components/ui/skeleton'; import { ClaudeKitBadge } from '@/components/claudekit-badge'; import { SponsorButton } from '@/components/sponsor-button'; +import { ProjectSelectionDialog } from '@/components/project-selection-dialog'; +import { useProjectSelection } from '@/hooks/use-project-selection'; function PageLoader() { return ( @@ -22,6 +24,8 @@ function PageLoader() { } export function Layout() { + const { isOpen, prompt, onSelect, onClose } = useProjectSelection(); + return ( @@ -46,6 +50,20 @@ export function Layout() { + + {/* Global project selection dialog for OAuth flows */} + {prompt && ( + + )} ); } diff --git a/ui/src/components/project-selection-dialog.tsx b/ui/src/components/project-selection-dialog.tsx new file mode 100644 index 00000000..b80b00b9 --- /dev/null +++ b/ui/src/components/project-selection-dialog.tsx @@ -0,0 +1,203 @@ +/** + * Project Selection Dialog Component + * + * Displays during OAuth flow when CLIProxyAPI requires user to select + * a Google Cloud project. Shows list of available projects with option + * to select one or ALL. + */ + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Loader2, FolderOpen, Check, Circle, CheckCircle } from 'lucide-react'; + +interface GCloudProject { + id: string; + name: string; + index: number; +} + +interface ProjectSelectionDialogProps { + open: boolean; + onClose: () => void; + sessionId: string; + provider: string; + projects: GCloudProject[]; + defaultProjectId: string; + supportsAll: boolean; + onSelect: (selectedId: string) => Promise; + /** Timeout in seconds before auto-selecting default */ + timeoutSeconds?: number; +} + +export function ProjectSelectionDialog({ + open, + onClose, + sessionId, + provider, + projects, + defaultProjectId, + supportsAll, + onSelect, + timeoutSeconds = 30, +}: ProjectSelectionDialogProps) { + const [selectedId, setSelectedId] = useState(defaultProjectId); + const [isSubmitting, setIsSubmitting] = useState(false); + const [countdown, setCountdown] = useState(timeoutSeconds); + + // Countdown timer for auto-selection + useEffect(() => { + if (!open) { + setCountdown(timeoutSeconds); + return; + } + + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + // Auto-submit on timeout + handleSubmit(true); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, timeoutSeconds]); + + // Reset state when dialog opens + useEffect(() => { + if (open) { + setSelectedId(defaultProjectId); + setIsSubmitting(false); + setCountdown(timeoutSeconds); + } + }, [open, defaultProjectId, timeoutSeconds]); + + const handleSubmit = async (isTimeout = false) => { + if (isSubmitting) return; + setIsSubmitting(true); + + try { + // Empty string means use default (press Enter behavior) + const submitId = isTimeout ? '' : selectedId; + await onSelect(submitId); + onClose(); + } catch (error) { + console.error('Failed to submit project selection:', error); + // On error, submit empty to use default + try { + await onSelect(''); + } catch { + // Ignore double-error + } + onClose(); + } + }; + + const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1); + + // Suppress unused variable warning - sessionId used for identification + void sessionId; + + return ( + !isOpen && !isSubmitting && onClose()}> + + + + + Select Google Cloud Project + + + Choose which project to use for {providerDisplay} authentication. + {countdown > 0 && ( + + (Auto-selecting default in {countdown}s) + + )} + + + +
+
+ {projects.map((project) => ( +
!isSubmitting && setSelectedId(project.id)} + > + {selectedId === project.id ? ( + + ) : ( + + )} +
+
{project.name}
+
{project.id}
+
+ {project.id === defaultProjectId && ( + Default + )} +
+ ))} + + {supportsAll && ( +
!isSubmitting && setSelectedId('ALL')} + > + {selectedId === 'ALL' ? ( + + ) : ( + + )} +
+
All Projects
+
+ Onboard all {projects.length} listed projects +
+
+
+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/hooks/use-project-selection.ts b/ui/src/hooks/use-project-selection.ts new file mode 100644 index 00000000..89f16e68 --- /dev/null +++ b/ui/src/hooks/use-project-selection.ts @@ -0,0 +1,111 @@ +/** + * Project Selection Hook + * + * Listens for WebSocket project selection events and manages the dialog state. + * Used in conjunction with ProjectSelectionDialog component. + */ + +import { useState, useEffect, useCallback } from 'react'; + +interface GCloudProject { + id: string; + name: string; + index: number; +} + +interface ProjectSelectionPrompt { + sessionId: string; + provider: string; + projects: GCloudProject[]; + defaultProjectId: string; + supportsAll: boolean; +} + +interface ProjectSelectionState { + isOpen: boolean; + prompt: ProjectSelectionPrompt | null; +} + +export function useProjectSelection() { + const [state, setState] = useState({ + isOpen: false, + prompt: null, + }); + + // Listen for WebSocket messages via custom events + useEffect(() => { + const handleMessage = (event: CustomEvent<{ type: string; [key: string]: unknown }>) => { + const data = event.detail; + + if (data.type === 'projectSelectionRequired') { + console.log('[ProjectSelection] Received prompt:', data.sessionId); + setState({ + isOpen: true, + prompt: { + sessionId: data.sessionId as string, + provider: data.provider as string, + projects: data.projects as GCloudProject[], + defaultProjectId: data.defaultProjectId as string, + supportsAll: data.supportsAll as boolean, + }, + }); + } else if (data.type === 'projectSelectionTimeout') { + console.log('[ProjectSelection] Timeout:', data.sessionId); + // Close dialog if this session timed out + setState((prev) => { + if (prev.prompt?.sessionId === data.sessionId) { + return { isOpen: false, prompt: null }; + } + return prev; + }); + } else if (data.type === 'projectSelectionSubmitted') { + console.log('[ProjectSelection] Submitted:', data.sessionId); + // Close dialog if this session was submitted + setState((prev) => { + if (prev.prompt?.sessionId === data.sessionId) { + return { isOpen: false, prompt: null }; + } + return prev; + }); + } + }; + + // Listen for custom ws-message events dispatched by useWebSocket + window.addEventListener('ws-message', handleMessage as EventListener); + + return () => { + window.removeEventListener('ws-message', handleMessage as EventListener); + }; + }, []); + + const handleSelect = useCallback( + async (selectedId: string) => { + if (!state.prompt) return; + + const response = await fetch( + `/api/cliproxy/auth/project-selection/${state.prompt.sessionId}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ selectedId }), + } + ); + + if (!response.ok) { + throw new Error('Failed to submit project selection'); + } + }, + [state.prompt] + ); + + const handleClose = useCallback(() => { + setState({ isOpen: false, prompt: null }); + }, []); + + return { + isOpen: state.isOpen, + prompt: state.prompt, + onSelect: handleSelect, + onClose: handleClose, + }; +} diff --git a/ui/src/hooks/use-websocket.ts b/ui/src/hooks/use-websocket.ts index f365bd1d..8ff5de46 100644 --- a/ui/src/hooks/use-websocket.ts +++ b/ui/src/hooks/use-websocket.ts @@ -82,6 +82,9 @@ export function useWebSocket() { try { const message: WSMessage = JSON.parse(event.data); handleMessage(message); + + // Dispatch custom event for other hooks to listen (e.g., project selection) + window.dispatchEvent(new CustomEvent('ws-message', { detail: message })); } catch { console.log('[WS] Invalid message'); } From 898198ad6ec9dd9a11177033e9efc9a8dee19b5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Dec 2025 09:23:56 +0000 Subject: [PATCH 4/4] chore(release): 6.3.1-dev.3 [skip ci] --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 01750aaf..4d4c69df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.3.1-dev.1 +6.3.1-dev.3 diff --git a/package.json b/package.json index 425a4aa3..84c75c52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "6.3.1-dev.1", + "version": "6.3.1-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",