diff --git a/packages/sdk/create-sdk/README.md b/packages/sdk/create-sdk/README.md index 66cddc030a..a5fa51b480 100644 --- a/packages/sdk/create-sdk/README.md +++ b/packages/sdk/create-sdk/README.md @@ -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 ` / `--config-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. diff --git a/packages/sdk/create-sdk/src/args.ts b/packages/sdk/create-sdk/src/args.ts index 521132caa9..897159bd7c 100644 --- a/packages/sdk/create-sdk/src/args.ts +++ b/packages/sdk/create-sdk/src/args.ts @@ -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 ') + .option('--config-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, } } diff --git a/packages/sdk/create-sdk/src/command.ts b/packages/sdk/create-sdk/src/command.ts index 0c7076c90b..a7855ea027 100644 --- a/packages/sdk/create-sdk/src/command.ts +++ b/packages/sdk/create-sdk/src/command.ts @@ -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 , or --config-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 { + const json = wantsJsonEvents(argv) + const emit = (event: Record): 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 } } diff --git a/packages/sdk/create-sdk/src/headless.ts b/packages/sdk/create-sdk/src/headless.ts new file mode 100644 index 0000000000..e32a053457 --- /dev/null +++ b/packages/sdk/create-sdk/src/headless.ts @@ -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 { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${source}: expected a JSON object`) + } + return value as Record +} + +/** 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 = path => readFile(path, 'utf8'), +): Promise { + 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 } +} diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index f4b66dc8ff..1f2cdb75fc 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -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() + }) +}) diff --git a/skills/create-dsh-sdk-project/SKILL.md b/skills/create-dsh-sdk-project/SKILL.md new file mode 100644 index 0000000000..5b984f3b86 --- /dev/null +++ b/skills/create-dsh-sdk-project/SKILL.md @@ -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 '' --json +``` + +- `--config-json ''` supplies the whole spec inline (no prompts). Alternatively + `--config ` 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": "", + "model": "deepseek-v4-flash", + "interface": "stdio", + "pm": "npm", + "install": false, + "features": [ + { "id": "persistence", "options": ["sqlite"] }, + { "id": "web", "options": ["exa"], "secrets": { "apiKey": "" } } + ] +} +``` + +`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":""}` — 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":""}` — the run failed for another reason. + +Iterate: read `action-required`, fill the named input into the spec, re-run until `done`.