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:
98
packages/sdk/create-sdk/src/headless.ts
Normal file
98
packages/sdk/create-sdk/src/headless.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Headless create input: a structured project spec supplied by an agent or CI
|
||||
* instead of interactive prompts.
|
||||
*
|
||||
* @module @deepseek-ai/create-sdk/headless
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { FeatureSelection, PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper'
|
||||
import type { CreateArgs } from './args.ts'
|
||||
|
||||
/**
|
||||
* Structured, non-interactive create input. Scalar fields mirror {@link CreateArgs}
|
||||
* project answers; `features` is the headless feature plan handed to `CreateWizard`
|
||||
* (the interactive tree/suggests prompts are skipped). Absent required answers make
|
||||
* the run fail loud through `HeadlessPromptPort` rather than blocking.
|
||||
*/
|
||||
export interface HeadlessCreateSpec {
|
||||
directory?: string
|
||||
description?: string
|
||||
provider?: 'deepseek' | 'custom'
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
interface?: RunInterface
|
||||
pm?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
features?: readonly FeatureSelection[]
|
||||
}
|
||||
|
||||
/** Resolved headless input: the args the wizard reads plus the feature plan. */
|
||||
export interface ResolvedHeadless {
|
||||
args: CreateArgs
|
||||
features: readonly FeatureSelection[] | undefined
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, source: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${source}: expected a JSON object`)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Parse and shallow-validate a headless spec from JSON text. */
|
||||
export function parseHeadlessSpec(text: string, source: string): HeadlessCreateSpec {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- JSON.parse only throws Error instances; the String() branch is defensive */
|
||||
throw new Error(`${source}: invalid JSON (${error instanceof Error ? error.message : String(error)})`)
|
||||
}
|
||||
const record = asRecord(parsed, source)
|
||||
if (record.features !== undefined && !Array.isArray(record.features)) {
|
||||
throw new Error(`${source}: "features" must be an array`)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a headless spec from `--config-json` (inline) or `--config` (a JSON file),
|
||||
* returning `undefined` when neither is supplied.
|
||||
* @param args - parsed create args.
|
||||
* @param readFileText - file reader seam for tests.
|
||||
* @returns the resolved args + feature plan, or `undefined` for interactive runs.
|
||||
*/
|
||||
export async function resolveHeadless(
|
||||
args: CreateArgs,
|
||||
readFileText: (path: string) => Promise<string> = path => readFile(path, 'utf8'),
|
||||
): Promise<ResolvedHeadless | undefined> {
|
||||
let text: string
|
||||
let source: string
|
||||
if (args.configJson !== undefined) {
|
||||
text = args.configJson
|
||||
source = '--config-json'
|
||||
} else if (args.config !== undefined) {
|
||||
source = args.config
|
||||
text = await readFileText(args.config)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
const spec = parseHeadlessSpec(text, source)
|
||||
const resolvedArgs: CreateArgs = {
|
||||
...spec.directory === undefined ? {} : { directory: spec.directory },
|
||||
...spec.description === undefined ? {} : { description: spec.description },
|
||||
...spec.provider === undefined ? {} : { provider: spec.provider },
|
||||
...spec.baseURL === undefined ? {} : { baseURL: spec.baseURL },
|
||||
...spec.apiKey === undefined ? {} : { apiKey: spec.apiKey },
|
||||
...spec.model === undefined ? {} : { model: spec.model },
|
||||
...spec.interface === undefined ? {} : { runInterface: spec.interface },
|
||||
...spec.pm === undefined ? {} : { packageManager: spec.pm },
|
||||
...spec.install === undefined ? {} : { install: spec.install },
|
||||
...spec.linkWorkspace ? { linkWorkspace: true } : {},
|
||||
help: false,
|
||||
}
|
||||
return { args: resolvedArgs, features: spec.features }
|
||||
}
|
||||
Reference in New Issue
Block a user