mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
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:
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user