fix(cli): replace duplicate directory prompt with direct path options

Extract confirmProjectDir helper and split the "Create a new project
folder" option into "New subfolder (./ktx-project)" and "Custom path"
so users reach their target directory with fewer prompts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luca Martial 2026-05-12 16:59:30 -07:00
parent 17a2fee69a
commit bdca6d0f04
2 changed files with 123 additions and 93 deletions

View file

@ -140,10 +140,11 @@ describe('setup project step', () => {
expect(result.projectDir).toBe(projectDir);
expect(prompts.select).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Which KTX project should setup use?',
message: 'Where should KTX create the project?',
options: [
expect.objectContaining({ value: 'current', label: 'Use current directory' }),
expect.objectContaining({ value: 'new', label: 'Create a new project folder' }),
expect.objectContaining({ value: 'current', label: 'Current directory' }),
expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }),
expect.objectContaining({ value: 'new-custom', label: 'Custom path' }),
expect.objectContaining({ value: 'exit', label: 'Exit' }),
],
}),
@ -156,7 +157,7 @@ describe('setup project step', () => {
it('offers an absolute default destination for a new project folder', async () => {
const startDir = join(tempDir, 'start');
const projectDir = join(startDir, 'ktx-project');
const prompts = makePromptAdapter({ choices: ['new', 'default', 'create'] });
const prompts = makePromptAdapter({ choices: ['new-default', 'create'] });
const testIo = makeIo({ stdoutIsTty: true });
const result = await runKtxSetupProjectStep(
@ -168,21 +169,16 @@ describe('setup project step', () => {
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(projectDir);
expect(prompts.select).toHaveBeenNthCalledWith(
2,
1,
expect.objectContaining({
message: 'Where should KTX create the project?',
options: [
expect.objectContaining({
value: 'default',
label: `Create the default project folder: ${projectDir}`,
}),
expect.objectContaining({ value: 'custom', label: 'Enter a custom path' }),
expect.objectContaining({ value: 'back', label: 'Back' }),
],
options: expect.arrayContaining([
expect.objectContaining({ value: 'new-default', label: 'New subfolder (./ktx-project)' }),
]),
}),
);
expect(prompts.select).toHaveBeenNthCalledWith(
3,
2,
expect.objectContaining({ message: `Create KTX project at ${projectDir}?` }),
);
expect(prompts.text).not.toHaveBeenCalled();
@ -194,7 +190,7 @@ describe('setup project step', () => {
it('prompts for a custom path and resolves it inside the current setup directory', async () => {
const startDir = join(tempDir, 'start');
const projectDir = join(startDir, 'analytics-ktx');
const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: 'analytics-ktx' });
const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: 'analytics-ktx' });
const result = await runKtxSetupProjectStep(
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
@ -217,7 +213,7 @@ describe('setup project step', () => {
const startDir = join(tempDir, 'start');
const homeDir = join(tempDir, 'home');
const projectDir = join(homeDir, 'analytics-ktx');
const prompts = makePromptAdapter({ choices: ['new', 'custom', 'create'], textValue: '~/analytics-ktx' });
const prompts = makePromptAdapter({ choices: ['new-custom', 'create'], textValue: '~/analytics-ktx' });
const result = await runKtxSetupProjectStep(
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
@ -235,7 +231,7 @@ describe('setup project step', () => {
const homeDir = join(tempDir, 'home');
const customProjectDir = join(homeDir, 'analytics-ktx');
const prompts = makePromptAdapter({
choices: ['new', 'custom', 'back', 'exit'],
choices: ['new-custom', 'back', 'exit'],
textValue: '~/analytics-ktx',
});
@ -248,7 +244,7 @@ describe('setup project step', () => {
expect(result.status).toBe('cancelled');
expect(result.projectDir).toBe(startDir);
expect(prompts.select).toHaveBeenNthCalledWith(
3,
2,
expect.objectContaining({
message: `Create KTX project at ${customProjectDir}?`,
options: [
@ -259,15 +255,15 @@ describe('setup project step', () => {
}),
);
expect(prompts.select).toHaveBeenNthCalledWith(
4,
expect.objectContaining({ message: 'Which KTX project should setup use?' }),
3,
expect.objectContaining({ message: 'Where should KTX create the project?' }),
);
await expect(stat(join(customProjectDir, 'ktx.yaml'))).rejects.toThrow();
});
it('rejects an empty new folder path without creating a project in the process cwd', async () => {
const startDir = join(tempDir, 'start');
const prompts = makePromptAdapter({ choices: ['new', 'custom'], textValue: ' ' });
const prompts = makePromptAdapter({ choices: ['new-custom'], textValue: ' ' });
const initProject = vi.fn(async () => {
throw new Error('initProject should not run for an empty path');
});
@ -292,7 +288,7 @@ describe('setup project step', () => {
const projectDir = join(startDir, 'analytics-ktx');
await mkdir(projectDir, { recursive: true });
await writeFile(join(projectDir, 'README.md'), 'Existing project notes\n', 'utf-8');
const prompts = makePromptAdapter({ choices: ['new', 'custom', 'use-existing'], textValue: 'analytics-ktx' });
const prompts = makePromptAdapter({ choices: ['new-custom', 'use-existing'], textValue: 'analytics-ktx' });
const result = await runKtxSetupProjectStep(
{ projectDir: startDir, mode: 'auto', inputMode: 'auto', yes: false },
@ -303,7 +299,7 @@ describe('setup project step', () => {
expect(result.status).toBe('ready');
expect(result.projectDir).toBe(projectDir);
expect(prompts.select).toHaveBeenNthCalledWith(
3,
2,
expect.objectContaining({
message: `That folder already exists and is not empty: ${projectDir}`,
options: expect.arrayContaining([

View file

@ -109,6 +109,55 @@ async function existingFolderState(
}
}
type ConfirmProjectDirResult =
| { status: 'confirmed'; confirmedCreation: boolean }
| { status: 'choose-another' }
| { status: 'back' }
| { status: 'cancelled' }
| { status: 'not-directory' };
async function confirmProjectDir(
selectedDir: string,
io: KtxCliIo,
prompts: KtxSetupProjectPromptAdapter,
): Promise<ConfirmProjectDirResult> {
const state = await existingFolderState(selectedDir);
if (state === 'not-directory') {
io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`);
return { status: 'not-directory' };
}
if (state === 'non-empty-directory') {
const action = await prompts.select({
message: `That folder already exists and is not empty: ${selectedDir}`,
options: [
{ value: 'use-existing', label: 'Yes, create KTX files there' },
{ value: 'choose-another', label: 'Choose another folder' },
{ value: 'back', label: 'Back' },
],
});
if (action === 'choose-another') return { status: 'choose-another' };
if (action === 'back') return { status: 'back' };
if (action !== 'use-existing') return { status: 'cancelled' };
return { status: 'confirmed', confirmedCreation: true };
}
io.stdout.write(`KTX will create:\n ${selectedDir}\n`);
const action = await prompts.select({
message: `Create KTX project at ${selectedDir}?`,
options: [
{ value: 'create', label: 'Create project' },
{ value: 'choose-another', label: 'Choose another folder' },
{ value: 'back', label: 'Back' },
],
});
if (action === 'choose-another') return { status: 'choose-another' };
if (action === 'back') return { status: 'back' };
if (action !== 'create') return { status: 'cancelled' };
return { status: 'confirmed', confirmedCreation: true };
}
async function normalizeSetupGitignore(projectDir: string): Promise<void> {
const gitignorePath = join(projectDir, '.ktx/.gitignore');
await mkdir(join(projectDir, '.ktx'), { recursive: true });
@ -186,55 +235,12 @@ async function promptForNewProjectDir(
return { status: 'cancelled', projectDir };
}
const state = await existingFolderState(selectedDir);
let confirmedCreation = false;
if (state === 'not-directory') {
io.stderr.write(`Project folder path exists and is not a directory: ${selectedDir}\n`);
return { status: 'missing-input', projectDir };
}
if (state === 'non-empty-directory') {
const existingAction = await prompts.select({
message: `That folder already exists and is not empty: ${selectedDir}`,
options: [
{ value: 'use-existing', label: 'Yes, create KTX files there' },
{ value: 'choose-another', label: 'Choose another folder' },
{ value: 'back', label: 'Back' },
],
});
if (existingAction === 'choose-another') {
continue;
}
if (existingAction === 'back') {
return { status: 'back', projectDir };
}
if (existingAction !== 'use-existing') {
return { status: 'cancelled', projectDir };
}
confirmedCreation = true;
}
io.stdout.write(`KTX will create:\n ${selectedDir}\n`);
if (state !== 'non-empty-directory') {
const createAction = await prompts.select({
message: `Create KTX project at ${selectedDir}?`,
options: [
{ value: 'create', label: 'Create project' },
{ value: 'choose-another', label: 'Choose another folder' },
{ value: 'back', label: 'Back' },
],
});
if (createAction === 'choose-another') {
continue;
}
if (createAction === 'back') {
return { status: 'back', projectDir };
}
if (createAction !== 'create') {
return { status: 'cancelled', projectDir };
}
confirmedCreation = true;
}
return { status: 'selected', projectDir: selectedDir, confirmedCreation };
const confirmed = await confirmProjectDir(selectedDir, io, prompts);
if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir };
if (confirmed.status === 'choose-another') continue;
if (confirmed.status === 'back') return { status: 'back', projectDir };
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
return { status: 'selected', projectDir: selectedDir, confirmedCreation: confirmed.confirmedCreation };
}
}
@ -316,15 +322,17 @@ export async function runKtxSetupProjectStep(
}
const prompts = deps.prompts ?? createClackSetupProjectPromptAdapter();
const defaultProjectDir = join(projectDir, DEFAULT_NEW_PROJECT_FOLDER_NAME);
io.stdout.write(
'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.\n',
);
while (true) {
const choice = await prompts.select({
message: 'Which KTX project should setup use?',
message: 'Where should KTX create the project?',
options: [
{ value: 'current', label: 'Use current directory' },
{ value: 'new', label: 'Create a new project folder' },
{ value: 'current', label: 'Current directory' },
{ value: 'new-default', label: 'New subfolder (./ktx-project)' },
{ value: 'new-custom', label: 'Custom path' },
...(args.allowBack ? [{ value: 'back', label: 'Back' }] : []),
...(args.allowBack ? [] : [{ value: 'exit', label: 'Exit' }]),
],
@ -339,27 +347,53 @@ export async function runKtxSetupProjectStep(
return { status: 'cancelled', projectDir };
}
let selectedDir = projectDir;
let confirmedCreation = false;
if (choice === 'new') {
const selected = await promptForNewProjectDir(projectDir, homeDir, io, prompts);
if (selected.status === 'back') {
continue;
}
if (selected.status !== 'selected') {
return selected;
}
selectedDir = selected.projectDir;
confirmedCreation = selected.confirmedCreation;
if (choice === 'current') {
const project = await createProject(projectDir, deps);
printProjectSummary(io, projectDir);
return { status: 'ready', projectDir, project };
}
if (choice !== 'current' && choice !== 'new') {
prompts.cancel('Setup cancelled.');
return { status: 'cancelled', projectDir };
if (choice === 'new-default') {
const confirmed = await confirmProjectDir(defaultProjectDir, io, prompts);
if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue;
if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir };
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
const project = await createProject(defaultProjectDir, deps);
printProjectSummary(io, defaultProjectDir);
return {
status: 'ready',
projectDir: defaultProjectDir,
project,
confirmedCreation: confirmed.confirmedCreation,
};
}
const project = await createProject(selectedDir, deps);
printProjectSummary(io, selectedDir);
return { status: 'ready', projectDir: selectedDir, project, confirmedCreation };
if (choice === 'new-custom') {
io.stdout.write(`Relative paths are resolved from:\n ${projectDir}\n`);
io.stdout.write(`Home paths are resolved from:\n ${homeDir}\n`);
const rawPath = await prompts.text({
message: withTextInputNavigation('Project folder path'),
placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
});
if (rawPath === undefined) continue;
const trimmed = rawPath.trim();
if (trimmed.length === 0) {
io.stderr.write(
'Enter a relative path like ./analytics-ktx, a home path like ~/analytics-ktx, or an absolute path.\n',
);
return { status: 'missing-input', projectDir };
}
const customDir = resolveFromProjectDir(projectDir, trimmed, homeDir);
const confirmed = await confirmProjectDir(customDir, io, prompts);
if (confirmed.status === 'choose-another' || confirmed.status === 'back') continue;
if (confirmed.status === 'not-directory') return { status: 'missing-input', projectDir };
if (confirmed.status === 'cancelled') return { status: 'cancelled', projectDir };
const project = await createProject(customDir, deps);
printProjectSummary(io, customDir);
return { status: 'ready', projectDir: customDir, project, confirmedCreation: confirmed.confirmedCreation };
}
prompts.cancel('Setup cancelled.');
return { status: 'cancelled', projectDir };
}
}