Files
deepseek-harness/packages/sdk/create-sdk/src/create-questions.ts
T
Yichen Jiang fee12f1af0 refactor(llm-deepseek)!: rename the provider route to deepseek-official
The native adapter's route was named deepseek, colliding with pi-ai's
catalog provider of the same name, so the two DeepSeek paths could never
be mounted side by side. The web settings page needs both configurable at
once. Compositions, fixtures, goldens, scaffolding defaults, and docs all
move together (pre-release, no shim); TUI/session-query-spill/
missing-credential goldens re-recorded through their keyless refresh
modes because provider-name length shifts box padding and spill
truncation points.
2026-07-29 16:36:07 +08:00

206 lines
6.5 KiB
TypeScript

/**
* 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<ProjectAnswers> {
readonly args: CreateArgs
readonly cwd: string
}
interface WizardStep<TState> {
run(port: PromptPort, state: TState): Promise<void>
}
function questionStep<TState, TValue>(options: {
question: (state: TState) => Question<TValue>
when?: (state: TState) => boolean
prefilled?: (state: TState) => TValue | undefined
apply: (state: TState, value: TValue) => void
}): WizardStep<TState> {
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<ProjectAnswerState> = {
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<ProjectAnswerState>[] = [
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<RunInterface>({
id: 'interface',
message: 'Run interface',
options: [
{ value: 'acp', label: 'ACP automation server' },
{ value: 'tui', label: 'Terminal TUI' },
{ value: 'embed', label: 'Embedded context' },
],
initialValue: 'tui',
}),
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<ProjectAnswers> {
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)
}