/** * Static create-sdk question sequence; dynamic feature/plugin loops remain * in the wizard orchestrator. * * @module @deepseek-ai/create-sdk/create-questions */ import { existsSync } from 'node:fs' import { basename, resolve } from 'node:path' import { ConfirmQuestion, SecretQuestion, SelectQuestion, TextQuestion, requireAnswer, type PromptPort, type Question, type RunInterface, } from '@deepseek-ai/dsh-helper' import type { CreateArgs } from './args.ts' /** Answers that establish project identity and feature applicability. */ export interface ProjectAnswers { directory: string name: string description: string provider: 'deepseek-official' | 'custom' baseURL: string apiKey: string model: string runInterface: RunInterface } interface ProjectAnswerState extends Partial { readonly args: CreateArgs readonly cwd: string } interface WizardStep { run(port: PromptPort, state: TState): Promise } function questionStep(options: { question: (state: TState) => Question when?: (state: TState) => boolean prefilled?: (state: TState) => TValue | undefined apply: (state: TState, value: TValue) => void }): WizardStep { return { async run(port, state) { if (options.when && !options.when(state)) return const value = requireAnswer(await options.question(state).resolve(port, options.prefilled?.(state))) options.apply(state, value) }, } } /** Validate one required text answer. */ function nonEmpty(value: string): string | undefined { return value.trim().length === 0 ? 'A value is required' : undefined } function packageName(value: string): string | undefined { if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value)) { return 'Use a lowercase npm package name' } return undefined } function projectDirectory(value: string, cwd: string): string | undefined { const empty = nonEmpty(value) if (empty) return empty return existsSync(resolve(cwd, value)) ? 'Target already exists' : undefined } const API_KEY_STEP: WizardStep = { async run(port, state) { let prefilled = state.args.apiKey while (true) { const apiKey = requireAnswer(await new SecretQuestion({ id: 'apiKey', message: state.provider === 'custom' ? 'Custom provider API key' : 'DeepSeek API key', }).resolve(port, prefilled)) if (apiKey.length > 0) { state.apiKey = apiKey return } const keepEmpty = requireAnswer(await new ConfirmQuestion({ id: 'apiKey.empty', message: 'Keep the API key empty and fill .env later?', initialValue: false, tone: 'warning', }).resolve(port)) if (keepEmpty) { state.apiKey = '' return } prefilled = undefined } }, } const PROJECT_QUESTION_STEPS: readonly WizardStep[] = [ questionStep({ question: state => new TextQuestion({ id: 'directory', message: 'Where should the project be created?', placeholder: 'my-agent', defaultValue: 'my-agent', validate: value => projectDirectory(value, state.cwd), }), prefilled: state => state.args.directory, apply: (state, value) => { state.directory = resolve(state.cwd, value) }, }), questionStep({ question: (state) => { /* v8 ignore next -- the preceding directory step always populates this state */ if (!state.directory) throw new Error('directory must resolve before package name') return new TextQuestion({ id: 'name', message: 'Package name', placeholder: basename(state.directory), defaultValue: basename(state.directory), validate: packageName, }) }, apply: (state, value) => { state.name = value }, }), questionStep({ question: (state) => { /* v8 ignore next -- the preceding package-name step always populates this state */ if (!state.name) throw new Error('package name must resolve before description') return new TextQuestion({ id: 'description', message: 'Project description', placeholder: `A DeepSeek Harness agent named ${state.name}`, defaultValue: `A DeepSeek Harness agent named ${state.name}`, validate: nonEmpty, }) }, prefilled: state => state.args.description, apply: (state, value) => { state.description = value }, }), questionStep({ question: () => new SelectQuestion<'deepseek-official' | 'custom'>({ id: 'provider', message: 'Model provider', options: [ { value: 'deepseek-official', label: 'DeepSeek' }, { value: 'custom', label: 'Custom endpoint (pi-ai)' }, ], initialValue: 'deepseek-official', }), prefilled: state => state.args.provider, apply: (state, value) => { state.provider = value }, }), questionStep({ question: () => new TextQuestion({ id: 'baseURL', message: 'Custom provider base URL', validate: nonEmpty, }), when: state => state.provider === 'custom' || state.args.baseURL !== undefined, prefilled: state => state.args.baseURL, apply: (state, value) => { state.baseURL = value }, }), API_KEY_STEP, questionStep({ question: () => new SelectQuestion({ id: 'interface', message: 'Run interface', options: [ { value: 'acp', label: 'ACP automation server' }, { value: 'embed', label: 'Embedded context' }, ], initialValue: 'acp', }), prefilled: state => state.args.runInterface, apply: (state, value) => { state.runInterface = value }, }), ] function completeAnswers(state: ProjectAnswerState): ProjectAnswers { const keys = ['directory', 'name', 'description', 'provider', 'baseURL', 'apiKey', 'model', 'runInterface'] as const for (const key of keys) { /* v8 ignore next -- the fixed step list above populates every key or throws/cancels first */ if (state[key] === undefined) throw new Error(`create question did not resolve ${key}`) } return state as ProjectAnswerState & ProjectAnswers } /** Run the fixed project-context sequence in declaration order. */ export async function collectProjectAnswers( port: PromptPort, args: CreateArgs, cwd: string, ): Promise { const state: ProjectAnswerState = { args, cwd, baseURL: args.baseURL ?? '', model: args.model ?? 'deepseek-v4-flash', } for (const step of PROJECT_QUESTION_STEPS) await step.run(port, state) return completeAnswers(state) }