Răsfoiți Sursa

feat(sdk): headless PromptPort + create/config headless plan

Add HeadlessPromptPort (fail-loud non-interactive PromptPort), thread
prefilled feature values through FeatureConfigurator, and let CreateWizard
and ConfigWorkflow accept a headless feature plan that skips the interactive
tree/suggests prompts. Per-file 100% coverage on all changed files.
imccyu 2 luni în urmă
părinte
comite
dcd886798f

+ 43 - 32
packages/sdk/create-sdk/src/create-wizard.ts

@@ -48,6 +48,7 @@ export class CreateWizard {
   private readonly versionProbe: PackageManagerVersionProbe
   private readonly userAgent: string
   private readonly linkWorkspaceRoot: string | undefined
+  private readonly featurePlan: readonly FeatureSelection[] | undefined
 
   /** Bind parsed args and infrastructure to one wizard run. */
   constructor(options: {
@@ -57,6 +58,7 @@ export class CreateWizard {
     releaseVersion: string
     versionProbe?: PackageManagerVersionProbe
     userAgent?: string
+    features?: readonly FeatureSelection[]
   }) {
     this.args = options.args
     this.port = options.port
@@ -68,6 +70,7 @@ export class CreateWizard {
     this.linkWorkspaceRoot = options.args.linkWorkspace
       ? fileURLToPath(new URL('../../../../', import.meta.url))
       : undefined
+    this.featurePlan = options.features
   }
 
   /** Collect all answers before constructing any project files. */
@@ -129,39 +132,43 @@ export class CreateWizard {
     const configurable = registry.all().filter(feature => feature.id === 'bash'
       || feature.id === 'persistence'
       || (!feature.required && feature.isApplicable(profile)))
-    const selected = [...requireAnswer(await this.port.nestedMultiselect({
-      message: 'Select features',
-      options: configurable.map((feature) => {
-        const nested = feature.mode !== 'single'
-        const defaults = new Set(feature.defaultOptions(profile))
-        return {
-          value: feature.id,
-          label: feature.summary,
-          required: feature.required,
-          default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
-            || feature.id === 'skill',
-          ...nested ? {
-            choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
-            choices: feature.options.map(option => ({
-              value: option.id,
-              label: option.label,
-              default: defaults.has(option.id),
-            })),
-          } : {},
+    const selected = this.featurePlan
+      ? this.featurePlan.map(feature => ({ value: feature.id, choices: feature.options }))
+      : [...requireAnswer(await this.port.nestedMultiselect({
+        message: 'Select features',
+        options: configurable.map((feature) => {
+          const nested = feature.mode !== 'single'
+          const defaults = new Set(feature.defaultOptions(profile))
+          return {
+            value: feature.id,
+            label: feature.summary,
+            required: feature.required,
+            default: feature.required || feature.id === 'hmr' || feature.id === 'fs' || feature.id === 'todo'
+              || feature.id === 'skill',
+            ...nested ? {
+              choiceMode: feature.mode === 'multiple' ? 'multiple' as const : 'exclusive' as const,
+              choices: feature.options.map(option => ({
+                value: option.id,
+                label: option.label,
+                default: defaults.has(option.id),
+              })),
+            } : {},
+          }
+        }),
+      }))]
+    if (!this.featurePlan) {
+      for (const { value: id } of [...selected]) {
+        const feature = registry.get(id)
+        for (const suggestedId of feature.suggests) {
+          if (selected.some(item => item.value === suggestedId)) continue
+          const suggested = registry.get(suggestedId)
+          const add = requireAnswer(await new ConfirmQuestion({
+            id: `${feature.id}.${suggested.id}`,
+            message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
+            initialValue: true,
+          }).resolve(this.port))
+          if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
         }
-      }),
-    }))]
-    for (const { value: id } of [...selected]) {
-      const feature = registry.get(id)
-      for (const suggestedId of feature.suggests) {
-        if (selected.some(item => item.value === suggestedId)) continue
-        const suggested = registry.get(suggestedId)
-        const add = requireAnswer(await new ConfirmQuestion({
-          id: `${feature.id}.${suggested.id}`,
-          message: `Add the recommended ${suggested.summary.toLowerCase()} for ${feature.summary.toLowerCase()}?`,
-          initialValue: true,
-        }).resolve(this.port))
-        if (add) selected.push({ value: suggested.id, choices: suggested.defaultOptions(profile) })
       }
     }
     const fixed = new Set(selections.map(selection => selection.id))
@@ -174,12 +181,16 @@ export class CreateWizard {
     for (const choice of selected) {
       choices.set(choice.value, choice.choices.length > 0 ? choice.choices : undefined)
     }
+    const plannedById = new Map((this.featurePlan ?? []).map(feature => [feature.id, feature]))
     for (const [id, options] of choices) {
+      const planned = plannedById.get(id)
       selections.push(await configurator.configure(
         registry.get(id),
         profile,
         undefined,
         options,
+        planned?.secrets ?? {},
+        planned?.values ?? {},
       ))
     }
     return selections

+ 50 - 0
packages/sdk/create-sdk/tests/create.spec.ts

@@ -5,9 +5,11 @@ import { PassThrough, Writable } from 'node:stream'
 import { fileURLToPath } from 'node:url'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
+  HeadlessPromptPort,
   LocalPluginBlueprint,
   featureId,
   NpmPackageManager,
+  type FeatureSelection,
   type NestedMultiSelectValue,
   type PromptPort,
 } from '@deepseek-ai/dsh-helper'
@@ -233,6 +235,54 @@ describe('CreateWizard and scaffolder', () => {
     expect(resolved.request.features.find(item => item.id === 'hmr')).toMatchObject({ options: ['default'] })
   })
 
+  it('runs headlessly from a feature plan without reaching the terminal', async () => {
+    const cwd = await mkdtemp(join(tmpdir(), 'create-headless-'))
+    temporary.push(cwd)
+    const features: FeatureSelection[] = [
+      { id: featureId('persistence'), options: ['sqlite'], values: { region: 'us' } },
+      { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } },
+    ]
+    const resolved = await new CreateWizard({
+      args: parseCreateArgs([
+        'my-agent', '--description=demo', '--provider=deepseek', '--api-key=deepseek-key',
+        '--model=deepseek-v4-flash', '--interface=stdio', '--pm=npm', '--no-install',
+      ]),
+      port: new HeadlessPromptPort(),
+      cwd,
+      releaseVersion: '0.0.1',
+      versionProbe: async () => '10.0.0',
+      features,
+    }).run()
+    expect(resolved.install).toBe(false)
+    expect(resolved.request.localPlugins).toEqual([])
+    expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
+      options: ['exa'], secrets: { apiKey: 'exa-key' },
+    })
+    expect(resolved.request.features.find(item => item.id === 'persistence')).toMatchObject({ options: ['sqlite'] })
+    expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
+      secrets: { apiKey: 'deepseek-key' },
+    })
+  })
+
+  it('rejects a non-string feature value in a headless plan', async () => {
+    const cwd = await mkdtemp(join(tmpdir(), 'create-headless-bad-'))
+    temporary.push(cwd)
+    const features = [
+      { id: featureId('persistence'), options: ['sqlite'], values: { bad: 1 } },
+    ] as unknown as FeatureSelection[]
+    await expect(new CreateWizard({
+      args: parseCreateArgs([
+        'my-agent', '--description=demo', '--provider=deepseek', '--api-key=k',
+        '--model=m', '--interface=stdio', '--pm=npm', '--no-install',
+      ]),
+      port: new HeadlessPromptPort(),
+      cwd,
+      releaseVersion: '0.0.1',
+      versionProbe: async () => '10.0.0',
+      features,
+    }).run()).rejects.toThrow('must be a string')
+  })
+
   it('writes the project once and refuses every existing target', async () => {
     const root = await mkdtemp(join(tmpdir(), 'create-scaffold-'))
     temporary.push(root)

+ 8 - 1
packages/sdk/helper/src/features/feature-configurator.ts

@@ -26,6 +26,7 @@ export class FeatureConfigurator {
    * @param current - currently installed selection, when configuring.
    * @param prefilledOptions - options already chosen by a tree picker.
    * @param prefilledSecrets - non-interactive secret values supplied by creation.
+   * @param prefilledValues - non-interactive value inputs supplied by a headless spec.
    * @returns normalized selection with captured values and secrets.
    */
   async configure(
@@ -34,6 +35,7 @@ export class FeatureConfigurator {
     current?: FeatureSelection,
     prefilledOptions?: readonly string[],
     prefilledSecrets: Readonly<Record<string, string>> = {},
+    prefilledValues: Readonly<Record<string, unknown>> = {},
   ): Promise<FeatureSelection> {
     let options: readonly string[]
     switch (feature.mode) {
@@ -69,6 +71,11 @@ export class FeatureConfigurator {
       id: feature.id,
       options,
     }
+    const coercedPrefilled: Record<string, string> = {}
+    for (const [key, value] of Object.entries(prefilledValues)) {
+      if (typeof value !== 'string') throw new Error(`${feature.id}.${key} value must be a string`)
+      coercedPrefilled[key] = value
+    }
     const values: Record<string, string> = {}
     for (const input of feature.valueInputs(selected, profile)) {
       const existing = current?.values?.[input.id]
@@ -81,7 +88,7 @@ export class FeatureConfigurator {
         ...existing === undefined ? {} : { initialValue: existing },
         validate: value => value.trim().length === 0 ? 'A value is required' : undefined,
       })
-      values[input.id] = requireAnswer(await question.resolve(this.port))
+      values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id]))
     }
     const base: FeatureSelection = Object.keys(values).length === 0
       ? selected

+ 1 - 0
packages/sdk/helper/src/index.ts

@@ -43,3 +43,4 @@ export {
 } from './questions/question.ts'
 export type { Question } from './questions/question.ts'
 export { ClackPromptPort } from './questions/clack-prompt-port.ts'
+export { HeadlessPromptError, HeadlessPromptPort } from './questions/headless-prompt-port.ts'

+ 97 - 0
packages/sdk/helper/src/questions/headless-prompt-port.ts

@@ -0,0 +1,97 @@
+/**
+ * Non-interactive prompt port for headless create/config and skill-driven runs.
+ *
+ * @module @deepseek-ai/dsh-helper/questions/headless-prompt-port
+ */
+
+import type {
+  ConfirmPromptRequest,
+  MultiSelectPromptRequest,
+  NestedMultiSelectRequest,
+  NestedMultiSelectValue,
+  PromptOutcome,
+  PromptPort,
+  SecretPromptRequest,
+  SelectPromptRequest,
+  TextPromptRequest,
+} from './prompt-port.ts'
+
+/**
+ * Raised when a headless run reaches a decision that was neither prefilled nor
+ * carries a usable default. The message names the unanswered prompt so an agent
+ * or CI caller can see exactly which input the spec must supply.
+ */
+export class HeadlessPromptError extends Error {
+  /** The unanswered prompt's user-facing message. */
+  readonly prompt: string
+
+  /** Build an error naming the unanswered prompt. */
+  constructor(prompt: string) {
+    super(`headless run needs an answer for: ${prompt}`)
+    this.name = 'HeadlessPromptError'
+    this.prompt = prompt
+  }
+}
+
+/** Resolve an answered outcome. */
+function answered<T>(value: T): Promise<PromptOutcome<T>> {
+  return Promise.resolve({ status: 'answered', value })
+}
+
+/** Reject with a named unanswered-prompt error. */
+function unanswered<T>(message: string): Promise<PromptOutcome<T>> {
+  return Promise.reject(new HeadlessPromptError(message))
+}
+
+/**
+ * A {@link PromptPort} that never blocks on a terminal.
+ *
+ * Answers are expected to arrive as prefilled values through the `Question` /
+ * `FeatureConfigurator` layers, so in a fully specified run this port is never
+ * reached. When it *is* reached, it takes the prompt's own declared default
+ * (`defaultValue` / `initialValue`) if one exists; otherwise it fails loud with
+ * {@link HeadlessPromptError}. Nested feature selection has no scalar default,
+ * so it always fails loud — headless callers must supply the feature set through
+ * the spec rather than the tree picker.
+ */
+export class HeadlessPromptPort implements PromptPort {
+  /** Answer visible text from its default, or fail loud. */
+  text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
+    const fallback = request.initialValue ?? request.defaultValue
+    if (fallback === undefined) return unanswered(request.message)
+    const diagnostic = request.validate?.(fallback)
+    if (diagnostic) return unanswered(`${request.message} (${diagnostic})`)
+    return answered(fallback)
+  }
+
+  /** A secret has no safe default: always fail loud. */
+  secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
+    return unanswered(request.message)
+  }
+
+  /** Answer a single choice from its initial value, or fail loud. */
+  select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
+    if (request.initialValue === undefined) return unanswered(request.message)
+    return answered(request.initialValue)
+  }
+
+  /** Answer a multi-choice from its initial values, or fail loud when required. */
+  multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
+    const initial = request.initialValues ?? []
+    if (request.required && initial.length === 0) return unanswered(request.message)
+    return answered(initial)
+  }
+
+  /** Answer a confirmation from its initial value, or fail loud. */
+  confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
+    if (request.initialValue === undefined) return unanswered(request.message)
+    return answered(request.initialValue)
+  }
+
+  /** Nested feature selection has no scalar default: always fail loud. */
+  nestedMultiselect<TValue, TChoice>(
+    request: NestedMultiSelectRequest<TValue, TChoice>,
+  ): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
+    return unanswered(request.message)
+  }
+}

+ 95 - 0
packages/sdk/helper/tests/headless-prompt-port.spec.ts

@@ -0,0 +1,95 @@
+import { describe, expect, it } from 'vitest'
+import { HeadlessPromptError, HeadlessPromptPort } from '../src/questions/headless-prompt-port.ts'
+
+/** Unwrap an answered outcome or fail the test. */
+async function answered<T>(promise: Promise<{ status: 'answered'; value: T } | { status: 'cancelled' }>): Promise<T> {
+  const outcome = await promise
+  if (outcome.status !== 'answered') throw new Error('expected an answered outcome')
+  return outcome.value
+}
+
+describe('HeadlessPromptError', () => {
+  it('names the unanswered prompt', () => {
+    const error = new HeadlessPromptError('DeepSeek API key')
+    expect(error).toBeInstanceOf(Error)
+    expect(error.name).toBe('HeadlessPromptError')
+    expect(error.prompt).toBe('DeepSeek API key')
+    expect(error.message).toContain('DeepSeek API key')
+  })
+})
+
+describe('HeadlessPromptPort', () => {
+  const port = new HeadlessPromptPort()
+
+  describe('text', () => {
+    it('takes the initial value when present', async () => {
+      expect(await answered(port.text({ message: 'name', initialValue: 'agent' }))).toBe('agent')
+    })
+
+    it('falls back to the default value', async () => {
+      expect(await answered(port.text({ message: 'dir', defaultValue: 'my-agent' }))).toBe('my-agent')
+    })
+
+    it('prefers the initial value over the default value', async () => {
+      expect(await answered(port.text({ message: 'dir', initialValue: 'given', defaultValue: 'my-agent' }))).toBe('given')
+    })
+
+    it('fails loud when no default exists', async () => {
+      await expect(port.text({ message: 'base URL' })).rejects.toThrow(HeadlessPromptError)
+    })
+
+    it('fails loud when the default is invalid', async () => {
+      await expect(port.text({
+        message: 'name',
+        defaultValue: '',
+        validate: value => value.length === 0 ? 'required' : undefined,
+      })).rejects.toThrow(/required/)
+    })
+  })
+
+  describe('secret', () => {
+    it('always fails loud', async () => {
+      await expect(port.secret({ message: 'API key' })).rejects.toThrow(HeadlessPromptError)
+    })
+  })
+
+  describe('select', () => {
+    it('takes the initial value when present', async () => {
+      expect(await answered(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }], initialValue: 'npm' }))).toBe('npm')
+    })
+
+    it('fails loud without an initial value', async () => {
+      await expect(port.select({ message: 'pm', options: [{ value: 'npm', label: 'npm' }] })).rejects.toThrow(HeadlessPromptError)
+    })
+  })
+
+  describe('multiselect', () => {
+    it('returns the initial values', async () => {
+      expect(await answered(port.multiselect({ message: 'x', options: [], initialValues: ['a', 'b'] }))).toEqual(['a', 'b'])
+    })
+
+    it('returns an empty selection when none are supplied and none are required', async () => {
+      expect(await answered(port.multiselect({ message: 'x', options: [] }))).toEqual([])
+    })
+
+    it('fails loud when required and nothing is preselected', async () => {
+      await expect(port.multiselect({ message: 'x', options: [], required: true })).rejects.toThrow(HeadlessPromptError)
+    })
+  })
+
+  describe('confirm', () => {
+    it('takes the initial value when present', async () => {
+      expect(await answered(port.confirm({ message: 'install?', initialValue: false }))).toBe(false)
+    })
+
+    it('fails loud without an initial value', async () => {
+      await expect(port.confirm({ message: 'apply?' })).rejects.toThrow(HeadlessPromptError)
+    })
+  })
+
+  describe('nestedMultiselect', () => {
+    it('always fails loud', async () => {
+      await expect(port.nestedMultiselect({ message: 'Select features', options: [] })).rejects.toThrow(HeadlessPromptError)
+    })
+  })
+})

+ 31 - 0
packages/sdk/helper/tests/questions.spec.ts

@@ -450,4 +450,35 @@ describe('feature configurator', () => {
     await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile))
       .rejects.toThrow('has no default option')
   })
+
+  it('configures fully from prefilled options, values, and secrets without prompting', async () => {
+    const registry = createBuiltinRegistry(profile)
+    const port = new QueuePromptPort([])
+    const result = await new FeatureConfigurator(port).configure(
+      registry.get(featureId('provider')),
+      profile,
+      undefined,
+      ['custom'],
+      { apiKey: 'prefilled-key' },
+      { baseURL: 'https://prefilled' },
+    )
+    expect(result).toMatchObject({
+      options: ['custom'],
+      values: { baseURL: 'https://prefilled' },
+      secrets: { apiKey: 'prefilled-key' },
+    })
+    expect(port.requests).toEqual([])
+  })
+
+  it('rejects a non-string prefilled feature value', async () => {
+    const registry = createBuiltinRegistry(profile)
+    await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(
+      registry.get(featureId('provider')),
+      profile,
+      undefined,
+      ['custom'],
+      { apiKey: 'k' },
+      { baseURL: 123 },
+    )).rejects.toThrow('must be a string')
+  })
 })

+ 66 - 42
packages/sdk/scripts/src/config/config-workflow.ts

@@ -28,6 +28,17 @@ export interface ConfigWorkflowResult {
   installError?: Error
 }
 
+/**
+ * Non-interactive desired end-state for a config run: the complete set of enabled
+ * features, with options and any secrets/values a newly installed feature needs.
+ * Features not listed are reconciled to disabled, exactly as an interactive tree
+ * selection would be. Custom (non-feature) cordis plugins keep their current state;
+ * toggling them headlessly is not yet supported.
+ */
+export interface ConfigPlan {
+  features: readonly FeatureSelection[]
+}
+
 function featureTarget(feature: Feature): string {
   return `feature:${feature.id}`
 }
@@ -66,48 +77,58 @@ export class ConfigWorkflow {
   }
 
   /** Select desired state, reconcile the working copy, review, and apply. */
-  async run(project: SdkProject, registry: FeatureRegistry): Promise<ConfigWorkflowResult> {
+  async run(project: SdkProject, registry: FeatureRegistry, plan?: ConfigPlan): Promise<ConfigWorkflowResult> {
     const edit = project.edit(registry)
     const configurator = new FeatureConfigurator(this.port)
     const features = registry.all().filter(feature => feature.isApplicable(project.profile))
     const inspections = new Map(edit.inspections().map(item => [item.id, item]))
     const custom = edit.cordisConfigEntries().filter(entry => !registry.ownerOfPackage(entry.name, project.profile))
-    const desired = requireAnswer(await this.port.nestedMultiselect<string, string>({
-      message: 'Configure the project',
-      showChanges: true,
-      options: [
-        ...features.map((feature) => {
-          const installation = inspections.get(feature.id)
-          /* v8 ignore next -- inspections() is built from this exact feature registry */
-          if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`)
-          const inconsistent = installation.state === 'inconsistent'
-          const selectedOptions = new Set(installation.options.length > 0
-            ? installation.options
-            : feature.defaultOptions(project.profile))
-          return {
-            value: featureTarget(feature),
-            label: feature.summary,
-            required: feature.required,
-            default: feature.required || installation.state === 'enabled' || inconsistent,
-            disabled: inconsistent,
-            ...inconsistent ? { warning: installation.diagnostics.join('; ') } : {},
-            ...feature.mode === 'single' ? {} : {
-              choiceMode: feature.mode,
-              choices: feature.options.map(option => ({
-                value: option.id,
-                label: option.label,
-                default: selectedOptions.has(option.id),
-              })),
-            },
-          }
-        }),
-        ...custom.map(entry => ({
-          value: pluginTarget(entry.id),
-          label: `${entry.name} [custom]`,
-          default: !entry.disabled,
+    const desired = plan
+      ? [
+        ...plan.features.map(selection => ({
+          value: featureTarget(registry.get(selection.id)),
+          choices: selection.options,
         })),
-      ],
-    }))
+        ...custom
+          .filter(entry => !entry.disabled)
+          .map(entry => ({ value: pluginTarget(entry.id), choices: [] as readonly string[] })),
+      ]
+      : requireAnswer(await this.port.nestedMultiselect<string, string>({
+        message: 'Configure the project',
+        showChanges: true,
+        options: [
+          ...features.map((feature) => {
+            const installation = inspections.get(feature.id)
+            /* v8 ignore next -- inspections() is built from this exact feature registry */
+            if (!installation) throw new Error(`feature inspection is missing: ${feature.id}`)
+            const inconsistent = installation.state === 'inconsistent'
+            const selectedOptions = new Set(installation.options.length > 0
+              ? installation.options
+              : feature.defaultOptions(project.profile))
+            return {
+              value: featureTarget(feature),
+              label: feature.summary,
+              required: feature.required,
+              default: feature.required || installation.state === 'enabled' || inconsistent,
+              disabled: inconsistent,
+              ...inconsistent ? { warning: installation.diagnostics.join('; ') } : {},
+              ...feature.mode === 'single' ? {} : {
+                choiceMode: feature.mode,
+                choices: feature.options.map(option => ({
+                  value: option.id,
+                  label: option.label,
+                  default: selectedOptions.has(option.id),
+                })),
+              },
+            }
+          }),
+          ...custom.map(entry => ({
+            value: pluginTarget(entry.id),
+            label: `${entry.name} [custom]`,
+            default: !entry.disabled,
+          })),
+        ],
+      }))
     const desiredByTarget = new Map(desired.map(item => [item.value, item]))
     const targetProfile = {
       ...project.profile,
@@ -117,6 +138,9 @@ export class ConfigWorkflow {
       if (!feature.isApplicable(targetProfile)) desiredByTarget.delete(featureTarget(feature))
     }
 
+    const plannedById = new Map<FeatureSelection['id'], FeatureSelection>(
+      (plan?.features ?? []).map(selection => [selection.id, selection]),
+    )
     for (const feature of features) {
       const installation = inspections.get(feature.id)
       /* v8 ignore next -- inspections() is built from this exact feature registry */
@@ -124,7 +148,7 @@ export class ConfigWorkflow {
       if (installation.state === 'inconsistent') continue
       const choice = desiredByTarget.get(featureTarget(feature))
       if (!choice && !feature.required) continue
-      await this.enableOrConfigure(feature, installation, choice, project, edit, configurator)
+      await this.enableOrConfigure(feature, installation, choice, project, edit, configurator, plannedById.get(feature.id))
     }
 
     for (const feature of [...features].reverse()) {
@@ -176,6 +200,7 @@ export class ConfigWorkflow {
     project: SdkProject,
     edit: ReturnType<SdkProject['edit']>,
     configurator: FeatureConfigurator,
+    planned?: FeatureSelection,
   ): Promise<void> {
     const options = choice?.choices.length
       ? choice.choices
@@ -183,7 +208,9 @@ export class ConfigWorkflow {
         ? installation.options
         : feature.defaultOptions(project.profile)
     if (installation.state === 'absent') {
-      const selection = await configurator.configure(feature, project.profile, undefined, options)
+      const selection = await configurator.configure(
+        feature, project.profile, undefined, options, planned?.secrets ?? {}, planned?.values ?? {},
+      )
       edit.installFeature(feature, selection)
       return
     }
@@ -191,10 +218,7 @@ export class ConfigWorkflow {
     if (!installation.selection) throw new Error(`feature ${feature.id} has no readable selection`)
     if (!sameOptions(installation.options, options)) {
       const selection: FeatureSelection = await configurator.configure(
-        feature,
-        project.profile,
-        installation.selection,
-        options,
+        feature, project.profile, installation.selection, options, planned?.secrets ?? {}, planned?.values ?? {},
       )
       edit.configureFeature(feature, selection)
     }

+ 24 - 1
packages/sdk/scripts/tests/scripts.spec.ts

@@ -5,6 +5,7 @@ import { PassThrough, Writable } from 'node:stream'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
 import {
+  HeadlessPromptPort,
   LocalPluginBlueprint,
   NpmPackageManager,
   SdkProject,
@@ -29,7 +30,7 @@ import { parseDshSdkArgs, parseSdkBootArgs } from '../src/args.ts'
 import { PluginBuild, ProjectBuild, runProjectBuild } from '../src/build.ts'
 import { runDshSdkCommand, type DshSdkCommandContext } from '../src/command.ts'
 import { runConfigCommand } from '../src/config.ts'
-import { ConfigWorkflow } from '../src/config/config-workflow.ts'
+import { ConfigWorkflow, type ConfigPlan } from '../src/config/config-workflow.ts'
 import { initialize, resolve as resolveLocalPlugin } from '../src/local-plugin-loader-hooks.ts'
 
 const temporary: string[] = []
@@ -399,6 +400,28 @@ describe('ConfigWorkflow', () => {
     expect(output.read()).toContain('Disable feature: todo')
   })
 
+  it('reconciles a headless plan without prompting and preserves custom plugins', async () => {
+    const project = await committedProject([], [new LocalPluginBlueprint('plugin', 'plugin')])
+    const registry = createBuiltinRegistry(project.profile)
+    const output = outputBuffer()
+    let installs = 0
+    const plan: ConfigPlan = {
+      features: [
+        { id: featureId('bash'), options: ['local'] },
+        { id: featureId('persistence'), options: ['jsonl'] },
+        { id: featureId('todo'), options: ['default'] },
+        { id: featureId('web'), options: ['exa'], secrets: { apiKey: 'exa-key' } },
+      ],
+    }
+    const result = await new ConfigWorkflow(
+      new HeadlessPromptPort(), output.stream, async () => { installs += 1 },
+    ).run(project, registry, plan)
+    expect(result.commit?.project.cordis.entry('tool-todo')).toBeDefined()
+    // the unlisted custom local plugin keeps its enabled state (not nuked by the plan)
+    expect(result.commit?.project.cordis.entry('plugin')?.disabled).toBeFalsy()
+    expect(installs).toBe(1)
+  })
+
   it('installs once after NPM dependency changes and keeps committed files on install failure', async () => {
     const project = await committedProject()
     const registry = createBuiltinRegistry(project.profile)