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:
@@ -6,14 +6,14 @@ The supported package surface is the `create-sdk` bin. The package root exports
|
||||
|
||||
The initializer rejects every existing target path, creates one `SdkProject` edit session, validates and commits it, then asks whether to install NPM dependencies and build. Install or build failures keep the generated project and print a retry command.
|
||||
|
||||
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, and `--install`/`--no-install`. Flags prefill matching questions, but creation always requires a TTY.
|
||||
Public flags are `[directory]`, `--description`, `--provider`, `--base-url`, `--api-key`, `--model`, `--interface`, `--pm`, `--install`/`--no-install`, plus the headless flags `--config <path>` / `--config-json <json>` and `--json`. Interactive flags prefill matching questions; a headless spec (`--config`/`--config-json`) supplies every answer and its feature plan up front, so creation runs without a TTY and drives through a `HeadlessPromptPort` that fails loud on any missing required answer. `--json` emits NDJSON lifecycle events (`done` / `action-required` / `error`) so an agent can fill the named missing input and re-run.
|
||||
|
||||
The provider choice is DeepSeek or a custom endpoint backed by `llm-pi-ai`. DeepSeek asks only for an API key and uses the public endpoint plus `deepseek-v4-flash`; custom also asks for a base URL. An empty key requires confirmation and creates a commented empty `.env` variable so provider startup fails clearly until it is filled. Existing plugin defaults are omitted; required SDK presets remain typed against the owning package's Config.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the generated project composition and its selected runtime plugins.
|
||||
Indirectly, through the generated project composition and its selected runtime plugins; the headless `--config-json` + `--json` surface additionally lets an agent create a project end to end and react to `action-required` events.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **TTY-only creation** — flags prefill questions, but the wizard still requires an interactive terminal before it writes a project.
|
||||
- **Headless local plugins** — the headless spec supplies project answers and the feature plan; scaffolding a local plugin (the interactive none/plugin/tool choice) is not yet expressible in the spec and defaults to none.
|
||||
@@ -19,6 +19,9 @@ export interface CreateArgs {
|
||||
packageManager?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help: boolean
|
||||
}
|
||||
|
||||
@@ -32,6 +35,9 @@ interface CommanderCreateOptions {
|
||||
pm?: PackageManagerName
|
||||
install?: boolean
|
||||
linkWorkspace?: boolean
|
||||
config?: string
|
||||
configJson?: string
|
||||
json?: boolean
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
@@ -60,6 +66,9 @@ function createProgram(): Command {
|
||||
.addOption(new Option('--install').default(undefined))
|
||||
.addOption(new Option('--no-install').default(undefined))
|
||||
.option('--link-workspace')
|
||||
.option('--config <path>')
|
||||
.option('--config-json <json>')
|
||||
.addOption(new Option('--json').default(undefined))
|
||||
}
|
||||
|
||||
/** Parse create-sdk positionals/options through Commander into a domain-neutral value. */
|
||||
@@ -79,6 +88,9 @@ export function parseCreateArgs(argv: readonly string[]): CreateArgs {
|
||||
...options.pm === undefined ? {} : { packageManager: options.pm },
|
||||
...options.install === undefined ? {} : { install: options.install },
|
||||
...options.linkWorkspace ? { linkWorkspace: true } : {},
|
||||
...options.config === undefined ? {} : { config: options.config },
|
||||
...options.configJson === undefined ? {} : { configJson: options.configJson },
|
||||
...options.json === undefined ? {} : { json: options.json },
|
||||
help: options.help ?? false,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type CreateCommandContext,
|
||||
} from '../src/command.ts'
|
||||
import { CreateWizard } from '../src/create-wizard.ts'
|
||||
import { resolveHeadless } from '../src/headless.ts'
|
||||
import { scaffoldProject } from '../src/project-scaffolder.ts'
|
||||
|
||||
class ScriptedPort implements PromptPort {
|
||||
@@ -482,6 +483,52 @@ describe('create command composition', () => {
|
||||
await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
|
||||
})
|
||||
|
||||
it('creates headlessly from --config-json with no TTY', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-headless-cmd-'))
|
||||
temporary.push(root)
|
||||
const spec = JSON.stringify({
|
||||
directory: 'agent', description: 'test', provider: 'deepseek', apiKey: 'key',
|
||||
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
|
||||
features: [{ id: 'persistence', options: ['jsonl'] }],
|
||||
})
|
||||
const context = commandContext(root)
|
||||
context.stdin.isTTY = false
|
||||
context.stdout.isTTY = false
|
||||
const result = await createProject(['--config-json', spec], context)
|
||||
expect(result?.project.root).toBe(join(root, 'agent'))
|
||||
})
|
||||
|
||||
it('emits NDJSON lifecycle events under --json', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-headless-json-'))
|
||||
temporary.push(root)
|
||||
const base = {
|
||||
description: 'test', model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: false,
|
||||
}
|
||||
const ok = commandContext(root)
|
||||
ok.stdin.isTTY = false
|
||||
ok.stdout.isTTY = false
|
||||
const okSpec = JSON.stringify({ ...base, directory: 'done-agent', provider: 'deepseek', apiKey: 'key', features: [] })
|
||||
await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0)
|
||||
expect(ok.readStdout()).toContain('{"type":"done"}')
|
||||
|
||||
const missing = commandContext(root)
|
||||
missing.stdin.isTTY = false
|
||||
missing.stdout.isTTY = false
|
||||
const missingSpec = JSON.stringify({ ...base, directory: 'miss-agent', provider: 'custom', baseURL: 'https://x', features: [] })
|
||||
await expect(runCreateCommand(['--config-json', missingSpec, '--json'], missing)).resolves.toBe(1)
|
||||
expect(missing.readStdout()).toContain('"type":"action-required"')
|
||||
|
||||
const broken = commandContext(root)
|
||||
broken.stdin.isTTY = false
|
||||
broken.stdout.isTTY = false
|
||||
await expect(runCreateCommand(['--config-json', '{bad', '--json'], broken)).resolves.toBe(1)
|
||||
expect(broken.readStdout()).toContain('"type":"error"')
|
||||
|
||||
const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
|
||||
await expect(runCreateCommand(['--json', ...argv('cancel-agent', false)], cancelled)).resolves.toBe(1)
|
||||
expect(cancelled.readStdout()).toContain('"reason":"cancelled"')
|
||||
})
|
||||
|
||||
it('creates through an injected prompt port and delegates optional setup', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'create-command-success-'))
|
||||
temporary.push(root)
|
||||
@@ -550,3 +597,58 @@ describe('create command composition', () => {
|
||||
await expect(runCreateCommand(['--help'], help)).resolves.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveHeadless', () => {
|
||||
it('returns undefined without a config source', async () => {
|
||||
expect(await resolveHeadless(parseCreateArgs(['agent']))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps every inline --config-json field into args plus the feature plan', async () => {
|
||||
const spec = JSON.stringify({
|
||||
directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
|
||||
model: 'm', interface: 'acp', pm: 'pnpm', install: true, linkWorkspace: true,
|
||||
features: [{ id: 'todo', options: ['default'] }],
|
||||
})
|
||||
const resolved = await resolveHeadless(parseCreateArgs(['--config-json', spec]))
|
||||
expect(resolved?.args).toMatchObject({
|
||||
directory: 'a', description: 'd', provider: 'custom', baseURL: 'https://x', apiKey: 'k',
|
||||
model: 'm', runInterface: 'acp', packageManager: 'pnpm', install: true, linkWorkspace: true, help: false,
|
||||
})
|
||||
expect(resolved?.features).toEqual([{ id: 'todo', options: ['default'] }])
|
||||
})
|
||||
|
||||
it('reads --config from a file via the injected reader and omits absent fields', async () => {
|
||||
const resolved = await resolveHeadless(
|
||||
parseCreateArgs(['--config', '/spec.json']),
|
||||
async () => JSON.stringify({ description: 'from-file' }),
|
||||
)
|
||||
expect(resolved?.args.description).toBe('from-file')
|
||||
expect(resolved?.args.directory).toBeUndefined()
|
||||
expect(resolved?.args.linkWorkspace).toBeUndefined()
|
||||
expect(resolved?.features).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads --config from disk with the default reader', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'create-headless-file-'))
|
||||
temporary.push(dir)
|
||||
const file = join(dir, 'spec.json')
|
||||
await writeFile(file, JSON.stringify({ description: 'on-disk' }))
|
||||
const resolved = await resolveHeadless(parseCreateArgs(['--config', file]))
|
||||
expect(resolved?.args.description).toBe('on-disk')
|
||||
})
|
||||
|
||||
it('fails loud on invalid JSON, a non-object root, or a non-array features field', async () => {
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', '{bad']))).rejects.toThrow('invalid JSON')
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', '[]']))).rejects.toThrow('expected a JSON object')
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', 'null']))).rejects.toThrow('expected a JSON object')
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', '5']))).rejects.toThrow('expected a JSON object')
|
||||
await expect(resolveHeadless(parseCreateArgs(['--config-json', '{"features":1}']))).rejects.toThrow('must be an array')
|
||||
})
|
||||
|
||||
it('accepts a minimal spec, leaving unspecified answers undefined', async () => {
|
||||
const resolved = await resolveHeadless(parseCreateArgs(['--config-json', '{"directory":"x"}']))
|
||||
expect(resolved?.args.directory).toBe('x')
|
||||
expect(resolved?.args.description).toBeUndefined()
|
||||
expect(resolved?.features).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: create-dsh-sdk-project
|
||||
description: Create a DeepSeek Harness SDK project non-interactively (headless), driven by an agent instead of the interactive wizard. Use when asked to scaffold a new DSH SDK project without a terminal.
|
||||
---
|
||||
|
||||
# Create a DeepSeek Harness SDK project headlessly
|
||||
|
||||
The `create-sdk` initializer normally runs an interactive wizard. To create a project
|
||||
**without a terminal**, pass a structured spec and ask for machine-readable events:
|
||||
|
||||
```sh
|
||||
npm create @deepseek-ai/sdk -- --config-json '<spec-json>' --json
|
||||
```
|
||||
|
||||
- `--config-json '<json>'` supplies the whole spec inline (no prompts). Alternatively
|
||||
`--config <path.json>` reads the same spec from a file.
|
||||
- `--json` makes the command emit one NDJSON lifecycle event per line to stdout.
|
||||
|
||||
## Spec shape
|
||||
|
||||
All fields are optional except those a chosen feature requires. Unsupplied answers that
|
||||
have a sensible default are taken from it; a *required* answer with no default (a secret,
|
||||
a custom provider base URL, a required feature option) makes the run fail loud rather than
|
||||
block.
|
||||
|
||||
```json
|
||||
{
|
||||
"directory": "my-agent",
|
||||
"description": "A DeepSeek Harness agent",
|
||||
"provider": "deepseek",
|
||||
"apiKey": "<key>",
|
||||
"model": "deepseek-v4-flash",
|
||||
"interface": "stdio",
|
||||
"pm": "npm",
|
||||
"install": false,
|
||||
"features": [
|
||||
{ "id": "persistence", "options": ["sqlite"] },
|
||||
{ "id": "web", "options": ["exa"], "secrets": { "apiKey": "<exa-key>" } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`features` is the complete set of optional features to enable, each with its chosen
|
||||
options and any secrets/values it needs. The interactive feature tree and its
|
||||
recommended-feature prompts are skipped in headless mode.
|
||||
|
||||
## Reacting to events
|
||||
|
||||
Each line of stdout is one JSON object:
|
||||
|
||||
- `{"type":"done"}` — the project was created (and installed, if `install` was true).
|
||||
- `{"type":"action-required","prompt":"<message>"}` — a required answer was missing.
|
||||
Add the corresponding field to the spec (e.g. an `apiKey`, a feature secret, a custom
|
||||
`baseURL`) and re-run.
|
||||
- `{"type":"error","message":"<message>"}` — the run failed for another reason.
|
||||
|
||||
Iterate: read `action-required`, fill the named input into the spec, re-run until `done`.
|
||||
Reference in New Issue
Block a user