feat(prompt): add password input utility with masking

Adds new password prompt utility with customizable masking for secure
credential collection. Integrates with help command for improved UX.
This commit is contained in:
kaitranntt
2025-12-04 04:45:26 -05:00
parent 4654c15577
commit 3bdbff9345
3 changed files with 279 additions and 3 deletions
+4 -3
View File
@@ -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 <provider> --auth', 'Authenticate only'],
['ccs <provider> --config', 'Change model (agy, gemini)'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs codex "explain code"', 'Use with prompt'],
+94
View File
@@ -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<string> {
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);
});
});
}
}
+181
View File
@@ -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);
});
});
});