feat(create-sdk): headless creation via --config/--config-json + NDJSON + skill

Add a headless create path: --config <file> / --config-json <json> supply a
structured project spec (answers + feature plan) that drives CreateWizard through
a HeadlessPromptPort, bypassing the TTY. --json emits NDJSON lifecycle events
(done / action-required / error) so an agent can fill a missing input and re-run.
Ship a thin SKILL.md playbook for agent-driven creation. Per-file 100% coverage.
This commit is contained in:
imccyu
2026-07-17 15:21:37 +08:00
parent a66b98d270
commit 472a683bdc
6 changed files with 307 additions and 10 deletions

View File

@@ -7,12 +7,15 @@
import { readFile } from 'node:fs/promises'
import {
ClackPromptPort,
HeadlessPromptError,
HeadlessPromptPort,
PromptCancelledError,
type PackageManagerVersionProbe,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
import { parseCreateArgs } from './args.ts'
import { parseCreateArgs, type CreateArgs } from './args.ts'
import { CreateWizard, type ResolvedCreateRequest } from './create-wizard.ts'
import { resolveHeadless } from './headless.ts'
import { scaffoldProject, type ScaffoldResult } from './project-scaffolder.ts'
import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts'
@@ -46,16 +49,18 @@ export async function createProject(
context.stdout.write(CREATE_TEMPLATES.usage.render({}))
return undefined
}
if (!context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
throw new Error('create-sdk requires an interactive TTY')
const headless = await resolveHeadless(args)
if (!headless && !context.port && (!context.stdin.isTTY || !context.stdout.isTTY)) {
throw new Error('create-sdk requires an interactive TTY, --config <file>, or --config-json <json>')
}
const wizard = new CreateWizard({
args,
args: headless ? headless.args : args,
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
port: context.port ?? new ClackPromptPort(context.stdin, context.stdout),
port: context.port ?? (headless ? new HeadlessPromptPort() : new ClackPromptPort(context.stdin, context.stdout)),
cwd: context.cwd,
releaseVersion: context.releaseVersion ?? await readCreateSdkVersion(),
...context.versionProbe ? { versionProbe: context.versionProbe } : {},
...headless?.features ? { features: headless.features } : {},
})
const resolved = await wizard.run()
const result = await scaffoldProject(resolved.directory, resolved.request)
@@ -87,6 +92,17 @@ export async function createProject(
return result
}
/** Whether NDJSON lifecycle events were requested, tolerating unparseable argv. */
function wantsJsonEvents(argv: readonly string[]): boolean {
let parsed: CreateArgs
try {
parsed = parseCreateArgs(argv)
} catch {
return false
}
return parsed.json === true
}
/** Run the create command with process defaults and convert cancellation to a clean exit. */
export async function runCreateCommand(
argv: readonly string[] = process.argv.slice(2),
@@ -97,15 +113,27 @@ export async function runCreateCommand(
stderr: process.stderr,
},
): Promise<number> {
const json = wantsJsonEvents(argv)
const emit = (event: Record<string, unknown>): void => {
context.stdout.write(`${JSON.stringify(event)}\n`)
}
try {
await createProject(argv, context)
if (json) emit({ type: 'done' })
return 0
} catch (error) {
if (error instanceof PromptCancelledError) {
context.stderr.write('create-sdk: cancelled\n')
if (json) emit({ type: 'error', reason: 'cancelled' })
else context.stderr.write('create-sdk: cancelled\n')
return 1
}
context.stderr.write(`create-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
if (json && error instanceof HeadlessPromptError) {
emit({ type: 'action-required', prompt: error.prompt })
return 1
}
const message = error instanceof Error ? error.message : String(error)
if (json) emit({ type: 'error', message })
else context.stderr.write(`create-sdk: ${message}\n`)
return 1
}
}