mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-16 08:25:14 +02:00
feat: add searchable setup prompt pickers
This commit is contained in:
parent
fd2ba62d92
commit
6e1a31e159
6 changed files with 181 additions and 46 deletions
|
|
@ -58,33 +58,35 @@ function makePromptAdapter(options: {
|
|||
const textValues = [...(options.textValues ?? [])];
|
||||
const passwordValues = [...(options.passwordValues ?? [])];
|
||||
let providerPromptCount = 0;
|
||||
const choose = async ({ message }: { message: string }) => {
|
||||
if (message.includes('LLM provider')) {
|
||||
providerPromptCount += 1;
|
||||
const nextProviderChoice = selectValues[0];
|
||||
if (
|
||||
nextProviderChoice === 'anthropic' ||
|
||||
nextProviderChoice === 'vertex' ||
|
||||
nextProviderChoice === 'claude-code' ||
|
||||
nextProviderChoice === 'back'
|
||||
) {
|
||||
return selectValues.shift() ?? nextProviderChoice;
|
||||
}
|
||||
if (options.credentialChoice === 'back' && providerPromptCount > 1) {
|
||||
return 'back';
|
||||
}
|
||||
return options.providerChoice ?? 'anthropic';
|
||||
}
|
||||
const nextValue = selectValues.shift();
|
||||
if (nextValue) {
|
||||
return nextValue;
|
||||
}
|
||||
if (message.includes('Anthropic API key')) {
|
||||
return options.credentialChoice ?? 'env';
|
||||
}
|
||||
return options.modelChoice ?? 'claude-sonnet-4-6';
|
||||
};
|
||||
return {
|
||||
select: vi.fn(async ({ message }) => {
|
||||
if (message.includes('LLM provider')) {
|
||||
providerPromptCount += 1;
|
||||
const nextProviderChoice = selectValues[0];
|
||||
if (
|
||||
nextProviderChoice === 'anthropic' ||
|
||||
nextProviderChoice === 'vertex' ||
|
||||
nextProviderChoice === 'claude-code' ||
|
||||
nextProviderChoice === 'back'
|
||||
) {
|
||||
return selectValues.shift() ?? nextProviderChoice;
|
||||
}
|
||||
if (options.credentialChoice === 'back' && providerPromptCount > 1) {
|
||||
return 'back';
|
||||
}
|
||||
return options.providerChoice ?? 'anthropic';
|
||||
}
|
||||
const nextValue = selectValues.shift();
|
||||
if (nextValue) {
|
||||
return nextValue;
|
||||
}
|
||||
if (message.includes('Anthropic API key')) {
|
||||
return options.credentialChoice ?? 'env';
|
||||
}
|
||||
return options.modelChoice ?? 'claude-sonnet-4-6';
|
||||
}),
|
||||
select: vi.fn(choose),
|
||||
autocomplete: vi.fn(choose),
|
||||
text: vi.fn(async () => textValues.shift() ?? ''),
|
||||
password: vi.fn(
|
||||
async () =>
|
||||
|
|
@ -152,7 +154,7 @@ describe('setup Anthropic model step', () => {
|
|||
},
|
||||
);
|
||||
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Anthropic model should KTX use?'),
|
||||
options: [
|
||||
|
|
@ -417,7 +419,7 @@ describe('setup Anthropic model step', () => {
|
|||
expect(readGcloudProject).toHaveBeenCalled();
|
||||
expect(listGcloudProjects).toHaveBeenCalled();
|
||||
expect(prompts.text).not.toHaveBeenCalled();
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'),
|
||||
options: [
|
||||
|
|
@ -428,7 +430,7 @@ describe('setup Anthropic model step', () => {
|
|||
],
|
||||
}),
|
||||
);
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Anthropic model should KTX use?'),
|
||||
options: [
|
||||
|
|
@ -480,7 +482,7 @@ describe('setup Anthropic model step', () => {
|
|||
message: expect.stringContaining('How should KTX authenticate with Google Vertex AI?'),
|
||||
}),
|
||||
);
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'),
|
||||
}),
|
||||
|
|
@ -548,7 +550,7 @@ describe('setup Anthropic model step', () => {
|
|||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Google Cloud project should KTX use for Vertex AI?'),
|
||||
options: [
|
||||
|
|
@ -595,25 +597,25 @@ describe('setup Anthropic model step', () => {
|
|||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(listGcloudProjects).toHaveBeenCalledTimes(2);
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Could not list Google Cloud projects with gcloud'),
|
||||
options: expect.arrayContaining([{ value: 'retry', label: 'Retry loading Google Cloud projects' }]),
|
||||
}),
|
||||
);
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(
|
||||
`${String.fromCharCode(0x1b)}[33mCould not list Google Cloud projects with gcloud`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('gcloud auth login --update-adc'),
|
||||
}),
|
||||
);
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(
|
||||
`${String.fromCharCode(0x1b)}[33mRun \`gcloud auth login --update-adc\``,
|
||||
|
|
@ -643,7 +645,7 @@ describe('setup Anthropic model step', () => {
|
|||
|
||||
expect(result.status).toBe('back');
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
2,
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which LLM provider should KTX use?'),
|
||||
}),
|
||||
|
|
@ -887,7 +889,7 @@ describe('setup Anthropic model step', () => {
|
|||
);
|
||||
|
||||
expect(result.status).toBe('back');
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Anthropic model should KTX use?'),
|
||||
options: expect.not.arrayContaining([expect.objectContaining({ value: 'skip' })]),
|
||||
|
|
@ -919,7 +921,7 @@ describe('setup Anthropic model step', () => {
|
|||
);
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expectedPromptMessage,
|
||||
}),
|
||||
|
|
@ -965,7 +967,7 @@ describe('setup Anthropic model step', () => {
|
|||
|
||||
expect(result.status).toBe('missing-input');
|
||||
expect(BUNDLED_ANTHROPIC_MODELS.length).toBeGreaterThan(0);
|
||||
expect(prompts.select).toHaveBeenCalledWith(
|
||||
expect(prompts.autocomplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('Which Anthropic model should KTX use?'),
|
||||
options: expect.arrayContaining([
|
||||
|
|
@ -1058,7 +1060,8 @@ describe('setup Anthropic model step', () => {
|
|||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(healthCheck).toHaveBeenCalledTimes(2);
|
||||
expect(prompts.select).toHaveBeenCalledTimes(5);
|
||||
expect(prompts.select).toHaveBeenCalledTimes(3);
|
||||
expect(prompts.autocomplete).toHaveBeenCalledTimes(2);
|
||||
expect(io.stderr()).toContain('Anthropic model health check failed: model not found');
|
||||
expect(io.stderr()).toContain('Choose a different credential source or model, or Back.');
|
||||
const config = parseKtxProjectConfig(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8'));
|
||||
|
|
@ -1110,7 +1113,7 @@ describe('setup Anthropic model step', () => {
|
|||
|
||||
expect(result.status).toBe('back');
|
||||
expect(prompts.select).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
3,
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('How should KTX find your Anthropic API key?'),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@ export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code';
|
|||
/** @internal */
|
||||
export interface KtxSetupModelPromptAdapter {
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
autocomplete(options: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
options: KtxSetupPromptOption[];
|
||||
}): Promise<string>;
|
||||
text(options: { message: string; placeholder?: string }): Promise<string | undefined>;
|
||||
password(options: { message: string }): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
|
|
@ -617,13 +622,14 @@ async function chooseInteractiveVertexProject(
|
|||
io.stdout.write('│ gcloud did not return any visible Google Cloud projects. Enter a project ID manually or choose Back.\n');
|
||||
}
|
||||
|
||||
const choice = await prompts.select({
|
||||
const choice = await prompts.autocomplete({
|
||||
message: `Which Google Cloud project should KTX use for Vertex AI?\n\n${[
|
||||
VERTEX_PROJECT_PROMPT_CONTEXT,
|
||||
listFailureMessage,
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join('\n\n')}`,
|
||||
placeholder: 'Type to search projects',
|
||||
options: [
|
||||
...orderedProjects.map((project) => ({
|
||||
value: project.projectId,
|
||||
|
|
@ -778,8 +784,9 @@ async function chooseModel(
|
|||
{ value: 'manual', label: 'Enter a model ID manually' },
|
||||
{ value: 'back', label: 'Back' },
|
||||
];
|
||||
const choice = await prompts.select({
|
||||
const choice = await prompts.autocomplete({
|
||||
message: `Which Anthropic model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
|
||||
placeholder: 'Type to search models',
|
||||
options: modelOptions,
|
||||
});
|
||||
if (choice === 'back') {
|
||||
|
|
@ -810,8 +817,9 @@ async function chooseVertexModel(args: KtxSetupModelArgs, io: KtxCliIo, deps: Kt
|
|||
|
||||
const selectableModels = VERTEX_ANTHROPIC_MODELS.filter(isSelectableAnthropicModel);
|
||||
const prompts = deps.prompts ?? createPromptAdapter();
|
||||
const choice = await prompts.select({
|
||||
const choice = await prompts.autocomplete({
|
||||
message: `Which Anthropic model should KTX use?\n\n${ANTHROPIC_MODEL_PROMPT_CONTEXT}`,
|
||||
placeholder: 'Type to search models',
|
||||
options: [
|
||||
...selectableModels.map((model) => ({
|
||||
value: model.id,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => {
|
|||
isCancel: vi.fn((value: unknown): value is symbol => value === cancelSymbol),
|
||||
log: { info: vi.fn() },
|
||||
multiselect: vi.fn(),
|
||||
autocomplete: vi.fn(),
|
||||
autocompleteMultiselect: vi.fn(),
|
||||
note: vi.fn(),
|
||||
password: vi.fn(),
|
||||
select: vi.fn(),
|
||||
|
|
@ -29,6 +31,8 @@ vi.mock('@clack/prompts', () => ({
|
|||
isCancel: mocks.isCancel,
|
||||
log: mocks.log,
|
||||
multiselect: mocks.multiselect,
|
||||
autocomplete: mocks.autocomplete,
|
||||
autocompleteMultiselect: mocks.autocompleteMultiselect,
|
||||
note: mocks.note,
|
||||
password: mocks.password,
|
||||
select: mocks.select,
|
||||
|
|
@ -47,6 +51,8 @@ describe('setup prompt adapter', () => {
|
|||
mocks.isCancel.mockClear();
|
||||
mocks.log.info.mockReset();
|
||||
mocks.multiselect.mockReset();
|
||||
mocks.autocomplete.mockReset();
|
||||
mocks.autocompleteMultiselect.mockReset();
|
||||
mocks.note.mockReset();
|
||||
mocks.password.mockReset();
|
||||
mocks.select.mockReset();
|
||||
|
|
@ -160,6 +166,52 @@ describe('setup prompt adapter', () => {
|
|||
expect(mocks.cancel).toHaveBeenCalledWith('Setup cancelled.');
|
||||
});
|
||||
|
||||
it('returns autocomplete selections and maps cancel to back', async () => {
|
||||
mocks.autocomplete.mockResolvedValueOnce('analytics');
|
||||
const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back' });
|
||||
|
||||
await expect(
|
||||
adapter.autocomplete({
|
||||
message: 'Dataset',
|
||||
placeholder: 'Type to search',
|
||||
options: [{ value: 'analytics', label: 'analytics' }],
|
||||
}),
|
||||
).resolves.toBe('analytics');
|
||||
|
||||
mocks.autocomplete.mockResolvedValueOnce(mocks.cancelSymbol);
|
||||
await expect(
|
||||
adapter.autocomplete({
|
||||
message: 'Dataset',
|
||||
options: [{ value: 'analytics', label: 'analytics' }],
|
||||
}),
|
||||
).resolves.toBe('back');
|
||||
});
|
||||
|
||||
it('returns autocomplete multiselect selections and maps cancel to back', async () => {
|
||||
mocks.autocompleteMultiselect.mockResolvedValueOnce(['analytics', 'mart']);
|
||||
const adapter = createKtxSetupPromptAdapter({ selectCancelValue: 'back', multiselectCancelValue: 'back' });
|
||||
|
||||
await expect(
|
||||
adapter.autocompleteMultiselect({
|
||||
message: 'Datasets',
|
||||
placeholder: 'Type to filter',
|
||||
options: [
|
||||
{ value: 'analytics', label: 'analytics', hint: 'suggested' },
|
||||
{ value: 'mart', label: 'mart' },
|
||||
],
|
||||
initialValues: ['analytics'],
|
||||
}),
|
||||
).resolves.toEqual(['analytics', 'mart']);
|
||||
|
||||
mocks.autocompleteMultiselect.mockResolvedValueOnce(mocks.cancelSymbol);
|
||||
await expect(
|
||||
adapter.autocompleteMultiselect({
|
||||
message: 'Datasets',
|
||||
options: [{ value: 'analytics', label: 'analytics' }],
|
||||
}),
|
||||
).resolves.toEqual(['back']);
|
||||
});
|
||||
|
||||
it('keeps setup intro and note plain for non-stream output', async () => {
|
||||
const { createKtxSetupUiAdapter } = await import('./setup-prompts.js');
|
||||
const chunks: string[] = [];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { Writable } from 'node:stream';
|
||||
import {
|
||||
autocomplete,
|
||||
autocompleteMultiselect,
|
||||
cancel,
|
||||
confirm,
|
||||
intro,
|
||||
|
|
@ -38,6 +40,22 @@ interface KtxSetupMultiselectOptions<Value extends string = string> {
|
|||
cursorAt?: Value;
|
||||
}
|
||||
|
||||
interface KtxSetupAutocompleteOptions<Value extends string = string> {
|
||||
message: string;
|
||||
options: Array<KtxSetupPromptOption<Value>>;
|
||||
placeholder?: string;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
interface KtxSetupAutocompleteMultiselectOptions<Value extends string = string> {
|
||||
message: string;
|
||||
options: Array<KtxSetupPromptOption<Value>>;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
maxItems?: number;
|
||||
initialValues?: Value[];
|
||||
}
|
||||
|
||||
interface KtxSetupTextOptions {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
|
|
@ -53,6 +71,8 @@ interface KtxSetupPasswordOptions {
|
|||
export interface KtxSetupPromptAdapter {
|
||||
select(options: KtxSetupSelectOptions): Promise<string>;
|
||||
multiselect(options: KtxSetupMultiselectOptions): Promise<string[]>;
|
||||
autocomplete(options: KtxSetupAutocompleteOptions): Promise<string>;
|
||||
autocompleteMultiselect(options: KtxSetupAutocompleteMultiselectOptions): Promise<string[]>;
|
||||
text(options: KtxSetupTextOptions): Promise<string | undefined>;
|
||||
password(options: KtxSetupPasswordOptions): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
|
|
@ -117,6 +137,50 @@ export function createKtxSetupPromptAdapter(options: KtxSetupPromptAdapterOption
|
|||
return selected;
|
||||
}
|
||||
},
|
||||
async autocomplete(promptOptions) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
autocomplete(withMenuOptionsSpacing(promptOptions)),
|
||||
);
|
||||
if (isCancel(value)) {
|
||||
if (cancelOnSelectCancel) {
|
||||
cancel(cancelMessage);
|
||||
}
|
||||
return options.selectCancelValue;
|
||||
}
|
||||
return String(value);
|
||||
},
|
||||
async autocompleteMultiselect(promptOptions) {
|
||||
while (true) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
autocompleteMultiselect(withMenuOptionsSpacing(promptOptions)),
|
||||
);
|
||||
if (isCancel(value)) {
|
||||
if (cancelOnMultiselectCancel) {
|
||||
cancel(cancelMessage);
|
||||
}
|
||||
return [multiselectCancelValue];
|
||||
}
|
||||
const selected = [...value].map(String);
|
||||
if (
|
||||
selected.length === 0 &&
|
||||
!promptOptions.required &&
|
||||
options.confirmEmptyOptionalMultiselect === true
|
||||
) {
|
||||
const skipConfirmed = await confirm({
|
||||
message: 'Nothing selected. Skip this step?',
|
||||
initialValue: false,
|
||||
});
|
||||
if (isCancel(skipConfirmed)) {
|
||||
cancel(cancelMessage);
|
||||
return [multiselectCancelValue];
|
||||
}
|
||||
if (!skipConfirmed) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
},
|
||||
async text(promptOptions) {
|
||||
const value = await withSetupInterruptConfirmation(() =>
|
||||
text({ ...promptOptions, message: withTextInputNavigation(promptOptions.message) }),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ function prompts(values: {
|
|||
return {
|
||||
multiselect: vi.fn(async () => multiselectValues.shift() ?? []),
|
||||
select: vi.fn(async () => selectValues.shift() ?? 'skip'),
|
||||
autocomplete: vi.fn(async () => selectValues.shift() ?? 'skip'),
|
||||
text: vi.fn(async () => (textValues.length > 0 ? textValues.shift() : '')),
|
||||
password: vi.fn(async () => (passwordValues.length > 0 ? passwordValues.shift() : undefined)),
|
||||
cancel: vi.fn(),
|
||||
|
|
@ -548,8 +549,9 @@ describe('setup sources step', () => {
|
|||
],
|
||||
});
|
||||
if (testCase.source === 'metabase') {
|
||||
expect(testPrompts.select).toHaveBeenCalledWith({
|
||||
expect(testPrompts.autocomplete).toHaveBeenCalledWith({
|
||||
message: 'Metabase database',
|
||||
placeholder: 'Type to search databases',
|
||||
options: [
|
||||
{ value: '1', label: '1: Finance (postgres)' },
|
||||
{ value: '2', label: '2: Analytics (postgres)' },
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ export interface KtxSetupSourcesPromptAdapter {
|
|||
required?: boolean;
|
||||
}): Promise<string[]>;
|
||||
select(options: { message: string; options: KtxSetupPromptOption[] }): Promise<string>;
|
||||
autocomplete(options: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
options: KtxSetupPromptOption[];
|
||||
}): Promise<string>;
|
||||
text(options: { message: string; placeholder?: string; initialValue?: string }): Promise<string | undefined>;
|
||||
password(options: { message: string }): Promise<string | undefined>;
|
||||
cancel(message: string): void;
|
||||
|
|
@ -931,8 +936,9 @@ async function chooseMetabaseDatabaseId(input: {
|
|||
return discovered[0].id;
|
||||
}
|
||||
if (discovered.length > 1) {
|
||||
const selected = await input.prompts.select({
|
||||
const selected = await input.prompts.autocomplete({
|
||||
message: 'Metabase database',
|
||||
placeholder: 'Type to search databases',
|
||||
options: [
|
||||
...discovered
|
||||
.slice()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue