feat(sdk): add developer project tooling

docs(rfc): propose SDK developer project tooling

feat: rename / docs

ci: fix windows gates

docs: revert
This commit is contained in:
imccyu
2026-07-15 18:17:38 +08:00
parent 5a8466b774
commit 42b07a7022
115 changed files with 11315 additions and 27 deletions
+453
View File
@@ -0,0 +1,453 @@
import { PassThrough, Writable } from 'node:stream'
import { stripVTControlCharacters } from 'node:util'
import { S_CHECKBOX_SELECTED, S_RADIO_ACTIVE, S_WARN } from '@clack/prompts'
import { describe, expect, it } from 'vitest'
import { createBuiltinRegistry } from '../src/features/builtin/index.ts'
import { FeatureConfigurator } from '../src/features/feature-configurator.ts'
import { FeatureOption, ExclusiveOptionFeature } from '../src/features/feature.ts'
import { ProjectContribution } from '../src/features/resources.ts'
import { featureId } from '../src/ids.ts'
import { NpmPackageManager } from '../src/package-managers/package-manager.ts'
import { ClackPromptPort } from '../src/questions/clack-prompt-port.ts'
import {
PromptCancelledError,
requireAnswer,
type ConfirmPromptRequest,
type MultiSelectPromptRequest,
type NestedMultiSelectRequest,
type NestedMultiSelectValue,
type PromptOutcome,
type PromptPort,
type SecretPromptRequest,
type SelectPromptRequest,
type TextPromptRequest,
} from '../src/questions/prompt-port.ts'
import {
ConfirmQuestion,
MultiSelectQuestion,
SecretQuestion,
SelectQuestion,
TextQuestion,
} from '../src/questions/question.ts'
import type { ProjectProfile } from '../src/project/types.ts'
import { clackNestedMultiselect } from '../src/questions/clack-nested-multiselect.ts'
function validateString(
outcome: PromptOutcome<string>,
validate: ((value: string) => string | undefined) | undefined,
): PromptOutcome<string> {
if (outcome.status === 'answered') {
const diagnostic = validate?.(outcome.value)
if (diagnostic) throw new Error(diagnostic)
}
return outcome
}
class QueuePromptPort implements PromptPort {
readonly answers: unknown[]
readonly requests: string[] = []
constructor(answers: unknown[]) {
this.answers = [...answers]
}
next<T>(message: string): PromptOutcome<T> {
this.requests.push(message)
const value = this.answers.shift()
return value === QueuePromptPort.cancel ? { status: 'cancelled' } : { status: 'answered', value: value as T }
}
async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
return validateString(this.next<string>(request.message), request.validate)
}
async secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
return validateString(this.next<string>(request.message), request.validate)
}
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
return Promise.resolve(this.next(request.message))
}
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
return Promise.resolve(this.next(request.message))
}
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
return Promise.resolve(this.next(request.message))
}
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return Promise.resolve(this.next(request.message))
}
static readonly cancel = Symbol('cancel')
}
describe('typed questions', () => {
it('uses and validates prefilled answers without prompting', async () => {
const port = new QueuePromptPort([])
const text = new TextQuestion({ id: 'name', message: 'Name', validate: value => value ? undefined : 'required' })
await expect(text.resolve(port, 'demo')).resolves.toEqual({ status: 'answered', value: 'demo' })
await expect(text.resolve(port, '')).rejects.toThrow('name: required')
const select = new SelectQuestion({
id: 'choice', message: 'Choice', options: [{ value: 'a', label: 'A' }], initialValue: 'a',
})
await expect(select.resolve(port, 'b')).rejects.toThrow('unknown or disabled option')
await expect(select.resolve(port, 'a')).resolves.toMatchObject({ value: 'a' })
const disabled = new SelectQuestion({
id: 'disabled', message: 'Disabled', options: [{ value: 'a', label: 'A', disabled: true }],
})
await expect(disabled.resolve(port, 'a')).rejects.toThrow('disabled option')
const multi = new MultiSelectQuestion({
id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }], required: true,
})
await expect(multi.resolve(port, [])).rejects.toThrow('choose at least one')
await expect(multi.resolve(port, ['missing'])).rejects.toThrow('unknown or disabled option')
await expect(new MultiSelectQuestion({
id: 'disabled-many', message: 'Disabled many', options: [{ value: 'a', label: 'A', disabled: true }],
}).resolve(port, ['a'])).rejects.toThrow('disabled option')
await expect(new MultiSelectQuestion({
id: 'optional', message: 'Optional', options: [{ value: 'a', label: 'A' }],
}).resolve(port, [])).resolves.toMatchObject({ value: [] })
const secret = new SecretQuestion({ id: 'secret', message: 'Secret', validate: value => value ? undefined : 'required' })
await expect(secret.resolve(port, 'value')).resolves.toMatchObject({ value: 'value' })
await expect(secret.resolve(port, '')).rejects.toThrow('secret: required')
await expect(new ConfirmQuestion({ id: 'confirm', message: 'Confirm' }).resolve(port, false))
.resolves.toEqual({ status: 'answered', value: false })
expect(port.requests).toEqual([])
})
it('delegates each interaction shape and propagates cancellation', async () => {
const port = new QueuePromptPort(['text', 'secret', 'a', ['a'], true, QueuePromptPort.cancel])
await expect(new TextQuestion({ id: 't', message: 'Text' }).resolve(port)).resolves.toMatchObject({ value: 'text' })
await expect(new SecretQuestion({ id: 's', message: 'Secret' }).resolve(port)).resolves.toMatchObject({ value: 'secret' })
await expect(new SelectQuestion({
id: 'one', message: 'One', options: [{ value: 'a', label: 'A' }],
}).resolve(port)).resolves.toMatchObject({ value: 'a' })
await expect(new MultiSelectQuestion({
id: 'many', message: 'Many', options: [{ value: 'a', label: 'A' }],
}).resolve(port)).resolves.toMatchObject({ value: ['a'] })
await expect(new ConfirmQuestion({ id: 'yes', message: 'Yes?' }).resolve(port)).resolves.toMatchObject({ value: true })
const cancelled = await new ConfirmQuestion({ id: 'cancel', message: 'Cancel?' }).resolve(port)
expect(() => requireAnswer(cancelled)).toThrow(PromptCancelledError)
const optionsPort = new QueuePromptPort(['full', 'a', ['a']])
await new TextQuestion({
id: 'full', message: 'Full', placeholder: 'p', initialValue: 'i', defaultValue: 'd', validate: () => undefined,
}).resolve(optionsPort)
await new SelectQuestion({
id: 'initial', message: 'Initial', options: [{ value: 'a', label: 'A' }], initialValue: 'a',
}).resolve(optionsPort)
await new MultiSelectQuestion({
id: 'initial-many', message: 'Initial many', options: [{ value: 'a', label: 'A' }],
initialValues: ['a'], required: true,
}).resolve(optionsPort)
})
it('accepts a visible placeholder default before required validation', async () => {
const input = new PassThrough()
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
const pending = new ClackPromptPort(input, output).text({
message: 'Directory',
placeholder: 'my-agent',
defaultValue: 'my-agent',
validate: value => value ? undefined : 'required',
})
setTimeout(() => input.write('\r'), 0)
await expect(pending).resolves.toEqual({ status: 'answered', value: 'my-agent' })
})
it('renders warning confirmations with a yellow warning marker', async () => {
const input = new PassThrough()
let screen = ''
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
const pending = new ClackPromptPort(input, output).confirm({
message: 'Keep empty?',
initialValue: true,
tone: 'warning',
})
setTimeout(() => input.write('\r'), 0)
await expect(pending).resolves.toEqual({ status: 'answered', value: true })
expect(stripVTControlCharacters(screen)).toContain(`${S_WARN} Keep empty?`)
})
it('adapts secret, select, multiselect, nested, and cancellation prompts', async () => {
const run = async <T>(
start: (port: ClackPromptPort) => Promise<PromptOutcome<T>>,
keys: string,
): Promise<PromptOutcome<T>> => {
const input = new PassThrough()
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
const pending = start(new ClackPromptPort(input, output))
setTimeout(() => input.write(keys), 0)
return pending
}
await expect(run(port => port.secret({ message: 'Secret', validate: value => value ? undefined : 'required' }), 'key\r'))
.resolves.toEqual({ status: 'answered', value: 'key' })
await expect(run(port => port.secret({ message: 'Secret' }), 'plain\r'))
.resolves.toEqual({ status: 'answered', value: 'plain' })
await expect(run(port => port.text({ message: 'Text', initialValue: 'seed' }), '\r'))
.resolves.toEqual({ status: 'answered', value: 'seed' })
let validated = 'unset'
await expect(run(port => port.text({
message: 'Empty', validate: (value) => { validated = value; return undefined },
}), '\r')).resolves.toEqual({ status: 'answered', value: '' })
expect(validated).toBe('')
await expect(run(port => port.select({
message: 'Select', options: [{ value: 'a', label: 'A', hint: 'hint' }, { value: 'b', label: 'B', disabled: true }],
initialValue: 'a',
}), '\r')).resolves.toEqual({ status: 'answered', value: 'a' })
await expect(run(port => port.multiselect({
message: 'Many', options: [{ value: 'a', label: 'A' }], initialValues: ['a'], required: true,
}), '\r')).resolves.toEqual({ status: 'answered', value: ['a'] })
await expect(run(port => port.multiselect({
message: 'Many', options: [{ value: 'a', label: 'A' }],
}), ' \r')).resolves.toEqual({ status: 'answered', value: ['a'] })
await expect(run(port => port.nestedMultiselect({
message: 'Nested', options: [{ value: 'a', label: 'A', default: true }],
}), '\r')).resolves.toEqual({ status: 'answered', value: [{ value: 'a', choices: [] }] })
await expect(run(port => port.confirm({ message: 'Cancel' }), '\u0003')).resolves.toEqual({ status: 'cancelled' })
expect(new ClackPromptPort()).toBeInstanceOf(ClackPromptPort)
})
})
describe('nested Clack picker', () => {
it('navigates root options, ignores disabled rows, and toggles optional rows', async () => {
const input = new PassThrough()
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
const pending = clackNestedMultiselect({
message: 'Features', showChanges: true, input, output,
options: [
{ value: 'required', label: 'Required', required: true },
{ value: 'optional', label: 'Optional', default: true },
{ value: 'added', label: 'Added' },
{ value: 'disabled', label: 'Disabled', disabled: true, warning: 'disabled warning' },
],
})
setTimeout(() => input.write('\x1b[A \x1b[B\x1b[B \x1b[B \x1b[A\r'), 0)
await expect(pending).resolves.toEqual({
status: 'answered',
value: [{ value: 'required', choices: [] }, { value: 'added', choices: [] }],
})
})
it('cancels from the root layer', async () => {
const input = new PassThrough()
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
const pending = clackNestedMultiselect({
message: 'Features', input, output, options: [{ value: 'one', label: 'One' }],
})
setTimeout(() => input.write('\u0003'), 0)
await expect(pending).resolves.toEqual({ status: 'cancelled' })
})
it('enters an exclusive child with Right and commits the selected option', async () => {
const input = new PassThrough()
let screen = ''
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
const pending = clackNestedMultiselect({
message: 'Features',
showChanges: true,
input,
output,
options: [
{
value: 'persistence',
label: 'Session storage',
required: true,
default: true,
choiceMode: 'exclusive',
choices: [
{ value: 'jsonl', label: 'JSONL', default: true },
{ value: 'sqlite', label: 'SQLite' },
],
},
{ value: 'fs', label: 'Filesystem', default: true },
],
})
setTimeout(() => input.write('\x1b[C\x1b[B\x1b[A\x1b[B\x1b[C\r\r'), 0)
await expect(pending).resolves.toEqual({
status: 'answered',
value: [
{ value: 'persistence', choices: ['sqlite'] },
{ value: 'fs', choices: [] },
],
})
const rendered = stripVTControlCharacters(screen)
expect(rendered).toContain(`› ${S_CHECKBOX_SELECTED} Session storage`)
expect(rendered).toContain(`› ${S_RADIO_ACTIVE} SQLite`)
expect(rendered).toContain('● changed')
})
it('highlights and blocks a selected multiple feature with no child option', async () => {
const input = new PassThrough()
let screen = ''
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
const pending = clackNestedMultiselect({
message: 'Features',
input,
output,
options: [{
value: 'hooks',
label: 'Hooks',
default: true,
choiceMode: 'multiple',
choices: [
{ value: 'claude', label: 'Claude', default: true },
{ value: 'codex', label: 'Codex' },
],
}],
})
setTimeout(() => input.write('\x1b[C \x1b[D \x1b[D\r'), 0)
await expect(pending).resolves.toEqual({
status: 'answered',
value: [{ value: 'hooks', choices: ['claude'] }],
})
expect(stripVTControlCharacters(screen)).toContain('▲ choose at least one')
})
it('blocks root submission for an exclusive feature with no selected option', async () => {
const input = new PassThrough()
let screen = ''
const output = new Writable({ write(chunk, _encoding, callback) { screen += String(chunk); callback() } })
const pending = clackNestedMultiselect({
message: 'Features', input, output,
options: [{
value: 'provider', label: 'Provider', default: true, choiceMode: 'exclusive',
choices: [{ value: 'one', label: 'One' }],
}],
})
setTimeout(() => input.write('\r\x1b[C\x1b[C\r\r'), 0)
await expect(pending).resolves.toEqual({
status: 'answered', value: [{ value: 'provider', choices: ['one'] }],
})
expect(stripVTControlCharacters(screen)).toContain('Choose one Provider option')
})
it('blocks root submission for a multiple feature with no selected option', async () => {
const input = new PassThrough()
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
const pending = clackNestedMultiselect({
message: 'Features', input, output,
options: [{
value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple',
choices: [{ value: 'one', label: 'One' }],
}],
})
setTimeout(() => input.write('\r\x1b[C \r\r'), 0)
await expect(pending).resolves.toEqual({
status: 'answered', value: [{ value: 'hooks', choices: ['one'] }],
})
})
it('renders an unchanged checked option while another child is focused', async () => {
const input = new PassThrough()
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
const pending = clackNestedMultiselect({
message: 'Features', input, output,
options: [{
value: 'hooks', label: 'Hooks', default: true, choiceMode: 'multiple',
choices: [
{ value: 'one', label: 'One', default: true },
{ value: 'two', label: 'Two', default: true },
],
}],
})
setTimeout(() => input.write('\x1b[C\x1b[B\x1b[D\r'), 0)
await expect(pending).resolves.toEqual({
status: 'answered', value: [{ value: 'hooks', choices: ['one', 'two'] }],
})
})
it('submits an empty optional selection', async () => {
const input = new PassThrough()
const output = new Writable({ write(_chunk, _encoding, callback) { callback() } })
const pending = clackNestedMultiselect({
message: 'Features', input, output, options: [{ value: 'one', label: 'One' }],
})
setTimeout(() => input.write('\r'), 0)
await expect(pending).resolves.toEqual({ status: 'answered', value: [] })
})
})
describe('feature configurator', () => {
const profile: ProjectProfile = {
name: 'demo',
description: 'demo',
runtime: { model: 'deepseek-v4-flash' },
runInterface: 'stdio',
packageManager: new NpmPackageManager('10.0.0'),
releaseVersion: '0.0.1',
}
it('shares exclusive, multiple, fixed, and secret behavior', async () => {
const registry = createBuiltinRegistry(profile)
const port = new QueuePromptPort(['sqlite', ['spawn', 'fork'], 'deepseek', 'new-key'])
const configurator = new FeatureConfigurator(port)
await expect(configurator.configure(registry.get(featureId('persistence')), profile)).resolves.toMatchObject({
options: ['sqlite'],
})
await expect(configurator.configure(registry.get(featureId('subagent')), profile)).resolves.toMatchObject({
options: ['spawn', 'fork'],
})
await expect(configurator.configure(
registry.get(featureId('provider')),
profile,
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'old-key' } },
)).resolves.toMatchObject({ secrets: { apiKey: 'new-key' } })
expect(port.requests).toEqual([
'Choose durable session storage',
'Choose delegate work to child agents',
'Choose model provider',
'DeepSeek API key (leave empty to keep current)',
])
})
it('validates feature values, defaults, and retained secrets', async () => {
const registry = createBuiltinRegistry(profile)
const fixed = new FeatureConfigurator(new QueuePromptPort([]))
await expect(fixed.configure(registry.get(featureId('bash')), profile, undefined, ['local'])).resolves.toMatchObject({
options: ['local'],
})
const requiredSecret = new FeatureConfigurator(new QueuePromptPort([]))
await expect(requiredSecret.configure(
registry.get(featureId('provider')), profile, undefined, ['deepseek'], { apiKey: '' },
)).rejects.toThrow('required')
const keep = new FeatureConfigurator(new QueuePromptPort(['deepseek', '']))
await expect(keep.configure(
registry.get(featureId('provider')),
profile,
{ id: featureId('provider'), options: ['deepseek'], secrets: { apiKey: 'old' } },
)).resolves.toMatchObject({ secrets: { apiKey: 'old' } })
const custom = registry.get(featureId('provider'))
await expect(new FeatureConfigurator(new QueuePromptPort(['custom'])).configure(
custom,
profile,
{ id: featureId('provider'), options: ['custom'], values: { baseURL: 1 }, secrets: { apiKey: 'old' } },
)).rejects.toThrow('current value must be a string')
await expect(new FeatureConfigurator(new QueuePromptPort(['custom', ''])).configure(
custom, profile, undefined,
)).rejects.toThrow('required')
await expect(new FeatureConfigurator(new QueuePromptPort(['custom', 'https://next', ''])).configure(
custom,
profile,
{
id: featureId('provider'), options: ['custom'],
values: { baseURL: 'https://old' }, secrets: { apiKey: 'old' },
},
)).resolves.toMatchObject({ values: { baseURL: 'https://next' }, secrets: { apiKey: 'old' } })
class EmptyExclusive extends ExclusiveOptionFeature {
override readonly id = featureId('empty-exclusive')
override readonly summary = 'Empty'
override readonly options = [new (class extends FeatureOption {
override readonly id = 'one'
override readonly label = 'One'
override contribution(): ProjectContribution { return new ProjectContribution([]) }
})()]
override defaultOptions(): readonly string[] { return [] }
}
await expect(new FeatureConfigurator(new QueuePromptPort([])).configure(new EmptyExclusive(), profile))
.rejects.toThrow('has no default option')
})
})