diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 5635e90f..7f17601c 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -151,16 +151,17 @@ Claude Code Profile & Model Switcher`.trim(); 'CLI Proxy (OAuth Providers)', [ 'Zero-config OAuth authentication via CLIProxyAPI', - 'First run: Browser opens for authentication', + 'First run: Browser opens for authentication, then model selection', 'Settings: ~/.ccs/{provider}.settings.json (created after auth)', ], [ - ['ccs gemini', 'Google Gemini (gemini-2.5-pro)'], + ['ccs gemini', 'Google Gemini (gemini-2.5-pro or 3-pro)'], ['ccs codex', 'OpenAI Codex (gpt-5.1-codex-max)'], - ['ccs agy', 'Antigravity (gemini-3-pro-preview)'], + ['ccs agy', 'Antigravity (Claude/Gemini models)'], ['ccs qwen', 'Qwen Code (qwen3-coder)'], ['', ''], // Spacer ['ccs --auth', 'Authenticate only'], + ['ccs --config', 'Change model (agy, gemini)'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], ['ccs codex "explain code"', 'Use with prompt'], diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 36ae0b80..08330081 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -24,6 +24,15 @@ interface PasswordOptions { mask?: string; // Character to show (default: '*') } +interface SelectOption { + id: string; + label: string; +} + +interface SelectOptions { + defaultIndex?: number; +} + export class InteractivePrompt { /** * Ask for confirmation @@ -205,4 +214,89 @@ export class InteractivePrompt { process.stdin.resume(); }); } + + /** + * Select from a numbered list + * + * Displays options with numbers and waits for user selection. + * Shows default with asterisk (*) prefix. + */ + static async selectFromList( + prompt: string, + options: SelectOption[], + selectOptions: SelectOptions = {} + ): Promise { + const { defaultIndex = 0 } = selectOptions; + + // Check for --yes flag (automation) - use default + if ( + process.env.CCS_YES === '1' || + process.argv.includes('--yes') || + process.argv.includes('-y') + ) { + console.error(`[i] Using default: ${options[defaultIndex].label}`); + return options[defaultIndex].id; + } + + // Check for --no-input flag (CI) + if (process.env.CCS_NO_INPUT === '1' || process.argv.includes('--no-input')) { + console.error(`[i] Using default: ${options[defaultIndex].label}`); + return options[defaultIndex].id; + } + + // Non-TTY: use default + if (!process.stdin.isTTY) { + console.error(`[i] Using default: ${options[defaultIndex].label}`); + return options[defaultIndex].id; + } + + // Display prompt and options + console.error(`${prompt}`); + console.error(''); + + options.forEach((opt, i) => { + const marker = i === defaultIndex ? '*' : ' '; + const num = String(i + 1).padStart(2); + console.error(` ${marker}${num}. ${opt.label}`); + }); + + console.error(''); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + terminal: true, + }); + + const defaultNum = String(defaultIndex + 1); + const promptText = `Enter choice [1-${options.length}] (default: ${defaultNum}): `; + + return new Promise((resolve) => { + rl.question(promptText, (answer: string) => { + rl.close(); + + const normalized = answer.trim(); + + // Empty answer: use default + if (normalized === '') { + console.error(`[i] Using default: ${options[defaultIndex].label}`); + resolve(options[defaultIndex].id); + return; + } + + // Parse number + const num = parseInt(normalized, 10); + + // Validate range + if (isNaN(num) || num < 1 || num > options.length) { + console.error(`[!] Invalid choice. Please enter 1-${options.length}`); + resolve(InteractivePrompt.selectFromList(prompt, options, selectOptions)); + return; + } + + const selected = options[num - 1]; + resolve(selected.id); + }); + }); + } } diff --git a/tests/unit/utils/prompt.test.js b/tests/unit/utils/prompt.test.js new file mode 100644 index 00000000..9157e91a --- /dev/null +++ b/tests/unit/utils/prompt.test.js @@ -0,0 +1,181 @@ +/** + * Tests for Interactive Prompt Utilities + * Verifies prompt functions including selectFromList + */ + +const assert = require('assert'); + +describe('InteractivePrompt', () => { + const { InteractivePrompt } = require('../../../dist/utils/prompt'); + let originalArgv; + let originalEnv; + + beforeEach(() => { + originalArgv = [...process.argv]; + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.argv = originalArgv; + process.env = originalEnv; + }); + + describe('selectFromList', () => { + describe('automation flags', () => { + it('uses default when CCS_YES=1', async () => { + process.env.CCS_YES = '1'; + + const options = [ + { id: 'opt1', label: 'Option 1' }, + { id: 'opt2', label: 'Option 2' }, + ]; + + try { + const result = await InteractivePrompt.selectFromList('Select:', options, { + defaultIndex: 0, + }); + assert.strictEqual(result, 'opt1'); + } finally { + delete process.env.CCS_YES; + } + }); + + it('uses default when --yes flag present', async () => { + process.argv = [...process.argv, '--yes']; + + const options = [ + { id: 'first', label: 'First' }, + { id: 'second', label: 'Second' }, + ]; + + const result = await InteractivePrompt.selectFromList('Pick:', options, { + defaultIndex: 1, + }); + assert.strictEqual(result, 'second'); + }); + + it('uses default when -y flag present', async () => { + process.argv = [...process.argv, '-y']; + + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ]; + + const result = await InteractivePrompt.selectFromList('Choose:', options); + assert.strictEqual(result, 'a'); // default is 0 + }); + + it('uses default when CCS_NO_INPUT=1', async () => { + process.env.CCS_NO_INPUT = '1'; + + const options = [ + { id: 'model1', label: 'Model 1' }, + { id: 'model2', label: 'Model 2' }, + { id: 'model3', label: 'Model 3' }, + ]; + + try { + const result = await InteractivePrompt.selectFromList('Select model:', options, { + defaultIndex: 2, + }); + assert.strictEqual(result, 'model3'); + } finally { + delete process.env.CCS_NO_INPUT; + } + }); + + it('uses default when --no-input flag present', async () => { + process.argv = [...process.argv, '--no-input']; + + const options = [ + { id: 'x', label: 'X' }, + { id: 'y', label: 'Y' }, + ]; + + const result = await InteractivePrompt.selectFromList('Pick:', options); + assert.strictEqual(result, 'x'); + }); + }); + + describe('options structure', () => { + it('accepts options with id and label', async () => { + process.env.CCS_YES = '1'; + + const options = [ + { id: 'gemini-claude-opus-4-5-thinking', label: 'Claude Opus 4.5 Thinking' }, + { id: 'gemini-claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }, + ]; + + try { + const result = await InteractivePrompt.selectFromList('Select:', options); + assert.strictEqual(result, 'gemini-claude-opus-4-5-thinking'); + } finally { + delete process.env.CCS_YES; + } + }); + + it('respects custom defaultIndex', async () => { + process.env.CCS_YES = '1'; + + const options = [ + { id: 'first', label: 'First' }, + { id: 'second', label: 'Second' }, + { id: 'third', label: 'Third' }, + ]; + + try { + const result = await InteractivePrompt.selectFromList('Select:', options, { + defaultIndex: 2, + }); + assert.strictEqual(result, 'third'); + } finally { + delete process.env.CCS_YES; + } + }); + + it('defaults to index 0 when no defaultIndex provided', async () => { + process.env.CCS_YES = '1'; + + const options = [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + ]; + + try { + const result = await InteractivePrompt.selectFromList('Select:', options); + assert.strictEqual(result, 'a'); + } finally { + delete process.env.CCS_YES; + } + }); + }); + }); + + describe('confirm', () => { + it('returns true when CCS_YES=1', async () => { + process.env.CCS_YES = '1'; + + try { + const result = await InteractivePrompt.confirm('Proceed?'); + assert.strictEqual(result, true); + } finally { + delete process.env.CCS_YES; + } + }); + + it('returns true when --yes flag present', async () => { + process.argv = [...process.argv, '--yes']; + + const result = await InteractivePrompt.confirm('Continue?'); + assert.strictEqual(result, true); + }); + + it('returns true when -y flag present', async () => { + process.argv = [...process.argv, '-y']; + + const result = await InteractivePrompt.confirm('Continue?'); + assert.strictEqual(result, true); + }); + }); +});