diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 91ee2692..234a00ba 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -221,4 +221,6 @@ export interface OAuthOptions { import?: boolean; /** Enable paste-callback mode: show auth URL and prompt for callback paste */ pasteCallback?: boolean; + /** If true, use port-forwarding mode (skip interactive prompt in headless) */ + portForward?: boolean; } diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index c9b7de05..f8fe82b6 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -58,6 +58,55 @@ async function promptAddAccount(): Promise { }); } +/** + * Prompt user to choose OAuth mode for headless environment + * Returns 'paste' for paste-callback mode or 'forward' for port-forwarding + */ +async function promptOAuthModeChoice(callbackPort: number | null): Promise<'paste' | 'forward'> { + const readline = await import('readline'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + console.log(''); + console.log(info('Headless environment detected (SSH session)')); + console.log(' OAuth requires choosing a mode:'); + console.log(''); + console.log(' [1] Paste-callback (recommended for VPS)'); + console.log(' Open URL in any browser, paste redirect URL back'); + console.log(''); + console.log(' [2] Port forwarding (advanced)'); + if (callbackPort) { + console.log(` Requires: ssh -L ${callbackPort}:localhost:${callbackPort} @`); + } else { + console.log(' Requires SSH tunnel to callback port'); + } + console.log(''); + + return new Promise<'paste' | 'forward'>((resolve) => { + let resolved = false; + + // Handle Ctrl+C gracefully + rl.on('close', () => { + if (!resolved) { + resolved = true; + resolve('paste'); // Safe default on cancel + } + }); + + rl.question('[?] Which mode? (1/2): ', (answer) => { + const choice = answer.trim(); + if (choice !== '1' && choice !== '2') { + console.log(info('Invalid choice, using paste-callback mode')); + } + resolved = true; + rl.close(); + resolve(choice === '2' ? 'forward' : 'paste'); + }); + }); +} + /** * Prompt user for account nickname (required for kiro/ghcp) * Returns null if user cancels @@ -389,6 +438,22 @@ export async function triggerOAuth( const headless = options.headless ?? isHeadlessEnvironment(); const isDeviceCodeFlow = callbackPort === null; + // Interactive mode selection for headless environments + // Skip if explicit mode flag provided or device code flow (no callback needed) + if (headless && !options.pasteCallback && !options.portForward && !isDeviceCodeFlow) { + // Non-interactive environment (piped input) - default to paste mode + if (!process.stdin.isTTY) { + const tokenDir = getProviderTokenDir(provider); + return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); + } + const mode = await promptOAuthModeChoice(callbackPort); + if (mode === 'paste') { + const tokenDir = getProviderTokenDir(provider); + return handlePasteCallbackMode(provider, oauthConfig, verbose, tokenDir, nickname); + } + // mode === 'forward' continues to existing port-forwarding flow below + } + if (existingAccounts.length > 0 && !add) { console.log(''); console.log( diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index a9e0cc73..6b924a35 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -316,7 +316,17 @@ export async function execClaudeWithCLIProxy( // 2. Handle special flags (use argsWithoutProxy - proxy flags already stripped) const forceAuth = argsWithoutProxy.includes('--auth'); const pasteCallback = argsWithoutProxy.includes('--paste-callback'); + const portForward = argsWithoutProxy.includes('--port-forward'); const forceHeadless = argsWithoutProxy.includes('--headless'); + + // Validate conflicting flags + if (pasteCallback && portForward) { + console.error(fail('Cannot use --paste-callback with --port-forward')); + console.error(' --paste-callback: Manually paste OAuth redirect URL'); + console.error(' --port-forward: Use SSH port forwarding for callback'); + process.exit(1); + } + const forceLogout = argsWithoutProxy.includes('--logout'); const forceConfig = argsWithoutProxy.includes('--config'); const addAccount = argsWithoutProxy.includes('--add'); @@ -548,6 +558,7 @@ export async function execClaudeWithCLIProxy( ...(setNickname ? { nickname: setNickname } : {}), ...(noIncognito ? { noIncognito: true } : {}), ...(pasteCallback ? { pasteCallback: true } : {}), + ...(portForward ? { portForward: true } : {}), }); if (!authSuccess) { throw new Error(`Authentication required for ${providerConfig.displayName}`); @@ -991,6 +1002,7 @@ export async function execClaudeWithCLIProxy( const ccsFlags = [ '--auth', '--paste-callback', + '--port-forward', '--headless', '--logout', '--config', diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 6152ce76..1dd631dd 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -184,6 +184,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], + ['ccs --port-forward', 'Force port-forwarding mode (skip prompt)'], ['ccs kiro --import', 'Import token from Kiro IDE'], ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], ['ccs codex "explain code"', 'Use with prompt'],