Merge remote-tracking branch 'origin/master' into worktree/multimodal-ui

This commit is contained in:
creatixchu
2026-08-11 18:04:23 +08:00
318 changed files with 1408 additions and 15137 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: 257ed4647c74ef45973d717278cdaa420968b3f6
README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a
README.md: aea083505cf84207a12086361e5d7f41176c0241
README.zh.md: 013806e802f524b34757bb2de073625eb8b0f768

View File

@@ -46,7 +46,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable API |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API |
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable API |
| [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable API |
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable API |
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable API |
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable API |

View File

@@ -46,7 +46,7 @@ npm scope 为 `@deepseek-ai/dsh-*`Cordis `Service` 子类和函数插件通
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` 提供方 | 产品:稳定接口 |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定接口 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定接口 |
| [`scaffold/`](scaffold/README.md) | 创建启动驱动项目的工具helper、启动器、初始化器、带两端的通信协议、启动器 telemetry | 产品:稳定接口 |
| [`sdk/`](sdk/README.md) | 进程外运行时 SDKJSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定接口 |
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定接口 |
| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、用户问答工具 | 产品:稳定接口 |
| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/boot/README.md
README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14
README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e
README.md: cdf551729567a7ad4be9dbd99861db4ad57cd5d7
README.zh.md: f775e9aac291ce176516c20eca773d6520050e23

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/`](../scaffold/README.md) launcher, and the [`examples/`](../examples/README.md) demo bins all consume it.
The channel-neutral boot library shared by `apps/cli` and the [`examples/`](../examples/README.md) demo bins.
| Package | Role | ctx key |
|---|---|---|

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
各 app bin 共享的、与渠道无关的启动库:`apps/cli`、[`scaffold/`](../scaffold/README.md) 启动器与 [`examples/`](../examples/README.md) demo bin 都消费它
`apps/cli` [`examples/`](../examples/README.md) demo bin 共享、与渠道无关的启动库
| 包 | 职责 | ctx 键 |
|---|---|---|

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 7c835deb58db149710495f97a2553c3de58d99da
README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09
README.md: 69634d4ca577e9fa5c508a5fb2b50333290154b1
README.zh.md: 9e03cc1903b5e9dc1d13e07bf8394a6e7aee9209

View File

@@ -33,6 +33,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.
## Pending queue projection
`ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.

View File

@@ -33,6 +33,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list``host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用与任何 `running: true` 状态帧翻为 false每次列表重拉重新对齐。列表界面隐藏 blank 行store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。
## 待处理队列投影
`ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering中途引导不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted``claimed``discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果claim 竞态则会返回 `queue-item-not-found`

View File

@@ -324,8 +324,9 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error'
* - `engaging`: a first prompt was attempted, but no accepted turn or other
* authoritative activity signal has arrived — the UI keeps the composer
* visible through admission and error frames.
* - `active`: the session is non-blank beyond its pending first prompt, is
* running, or owns a pending interaction — the ordinary conversation view.
* - `active`: the session is non-blank beyond its pending first prompt,
* contains visible non-command Chat content, is running, or owns a pending
* interaction — the ordinary conversation view.
*
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; returning to the hero would discard the error context).

View File

@@ -741,7 +741,8 @@ export class Session implements SessionFace {
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
(!this.blankBit && !this.firstPromptPendingTurn)
hasVisibleConversationContent(chat)
|| (!this.blankBit && !this.firstPromptPendingTurn)
|| this.running
|| this.pendingCache.value.length > 0,
this.promptAttempted,
@@ -774,13 +775,18 @@ function conversationInput(entry: HistoryEntry): ConversationEventInput {
return { event: entry.event, view: entry.view }
}
/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */
function hasVisibleConversationContent(chat: ChatSnapshot): boolean {
return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command')
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). A failed first prompt
* stays engaging until an authoritative accepted-turn, running, or pending
* signal arrives (retry semantics — see ComposerPhase).
* @param hasContent - authoritative non-blank activity beyond a pending first
* prompt, a running turn, or a pending interaction.
* prompt, visible non-command Chat content, a running turn, or a pending interaction.
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/

View File

@@ -8,6 +8,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {} from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import type {
@@ -132,7 +133,11 @@ const TEST_EVENT_DEFINITION: ConversationNodeDefinition<TestEventState> = {
if (context.state === undefined || context.start === undefined) return null
return {
key: context.key,
kind: 'runtime-test-event',
kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal'
? 'command-input'
: context.start.event.type === 'command/run' || context.start.event.type === 'command/done'
? 'command'
: 'runtime-test-event',
id: context.id,
target: 'chat',
anchorSeq: context.start.event.seq,
@@ -272,6 +277,24 @@ describe('live event path', () => {
expect(snapshot.composerPhase).toBe('blank')
})
it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => {
const { session } = await opened([])
session.handleBlank(true)
const feed = (event: SessionEvent) => {
session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
}
feed(ev.commandRun(0, 'cmd-goal', 'goal', ' '))
feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.'))
expect(session.getSnapshot()).toMatchObject({
blank: true,
composerPhase: 'active',
})
expect(session.getSnapshot().chat.order.map(
key => session.getSnapshot().chat.nodes.get(key)?.kind,
)).toContain('command-input')
})
it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => {
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
README.md: f0446aa0637bc181f7fdc22e5d0d3192e0ac20cf
README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108
README.md: c79d6f5a68f1b4b40f4b57f5745feeed63a25fcd
README.zh.md: c2d000dd8141a989c67f2e8dc6786ed2b5067a6b

View File

@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
The plugin separately projects each durable `/goal` `command/run` through its own Conversation Definition. It builds a `command-input` Chat Node before the generic command result Node and registers that Node's keyed renderer as a right-aligned 14px/22px monospace user-style bubble with the localized group name `Command input` / `命令输入` and no timestamp, copy, or branch actions. The visible non-command Node activates fresh Chat; reload reconstructs it from the run, while a history window containing only `command/done` keeps only the generic result row. This projection never creates `user/message` or a model turn.
The `/client` exports are the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
## Model Experience

View File

@@ -4,6 +4,8 @@
Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片order 10位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`ctx.remote.goals` 调用——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
该插件还会通过自有 Conversation Definition 投影每条持久 `/goal` `command/run`。它在通用命令结果 Node 之前构建一个 `command-input` Chat Node并为该 Node 注册 keyed rendererrenderer 将其呈现为右对齐、使用 14px/22px 等宽字体的用户样式气泡,使用本地化分组名称 `Command input``命令输入`,且不含时间戳、复制或分支操作。可见的非命令 Node 会激活新 Chat重新加载时会根据 run 重建该 Node而仅包含 `command/done` 的历史窗口只保留通用结果行。该投影绝不会创建 `user/message` 或模型轮次。
`/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
## 模型体验

View File

@@ -52,6 +52,7 @@
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
@@ -65,6 +66,7 @@
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@testing-library/react": "^16.1.0",

View File

@@ -0,0 +1,25 @@
.row {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.stack {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 0;
max-width: min(525px, 82%);
}
.bubble {
max-width: 100%;
padding: 10px 16px;
overflow-wrap: anywhere;
border-radius: 22px;
background: var(--dsw-specific-bubble);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-markdown-code);
white-space: pre-wrap;
}

View File

@@ -0,0 +1,30 @@
import { memo } from 'react'
import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { GoalCommandInputData } from './goal-command-input.ts'
import css from './GoalCommandInputView.module.css'
type GoalCommandInputViewProps =
PropsRuntime<'conversation.chat.node', 'command-input'>
& PropsLocale<'goal'>
/** Right-aligned `/goal` input bubble without ordinary message actions. */
export const GoalCommandInputView = memo(function GoalCommandInputView({
node, t,
}: GoalCommandInputViewProps) {
const data: GoalCommandInputData = node.data
return (
<div
className={css.row}
data-command-input=""
role="group"
aria-label={t('commandInput.aria')}
>
<div className={css.stack}>
<div className={css.bubble}>
<MessageText text={data.text} />
</div>
</div>
</div>
)
})

View File

@@ -0,0 +1,71 @@
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {} from '@deepseek-ai/dsh-commands/types'
import type {
ConversationNodeDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
/** Goal-owned human command input projected independently of model messages. */
export interface GoalCommandInputData {
readonly commandId: CommandId
readonly text: string
readonly time: number
}
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Human-entered `/goal` command input. */
'command-input': GoalCommandInputData
}
}
interface GoalCommandInputState extends GoalCommandInputData {
readonly seq: number
}
/**
* Derive the visible command line from its structured durable run.
* @param event - `/goal` command run.
* @returns command text with trailing parser whitespace removed.
*/
export function goalCommandText(event: SessionEvent<'command/run'>): string {
return `/${event.data.name}${(event.data.args ?? '').trimEnd()}`
}
/** Goal-owned command input projection; the generic command Definition retains the result row. */
export const goalCommandInputDefinition: ConversationNodeDefinition<GoalCommandInputState> = {
kind: 'goal-command-input',
target: 'chat',
match: event => event.type === 'command/run' && event.data.name === 'goal'
? { id: String(event.data.commandId), role: 'start' }
: null,
start: (_context, match) => {
if (match.event.type !== 'command/run') {
throw new Error('goal-command-input start requires command/run')
}
return {
commandId: match.event.data.commandId,
seq: match.event.seq,
time: match.event.time,
text: goalCommandText(match.event),
}
},
update: context => context.state,
buildViewNode: (context) => {
if (context.state === undefined) return null
return {
key: context.key,
kind: 'command-input',
id: context.id,
target: 'chat',
anchorSeq: context.state.seq - 0.1,
location: context.start?.location ?? { kind: 'unresolved' },
visibility: 'visible',
data: {
commandId: context.state.commandId,
text: context.state.text,
time: context.state.time,
},
}
},
}

View File

@@ -19,6 +19,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client'
import type { GoalActionResult, GoalBarActions } from './slots.ts'
import { GoalDock } from './GoalBar.tsx'
import { GoalCommandInputView } from './GoalCommandInputView.tsx'
import { goalCommandInputDefinition } from './goal-command-input.ts'
import { en, zh, type GoalKey } from './locales.ts'
export { GoalBar, GoalDock } from './GoalBar.tsx'
@@ -35,8 +37,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Dictionary namespace owned by this plugin. */
const NS = 'goal'
/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale']
/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */
export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents']
/** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */
async function settle(invoke: () => Promise<unknown>): Promise<GoalActionResult> {
@@ -68,8 +70,15 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.conversationEvents.register(goalCommandInputDefinition)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries')
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'command-input',
locale: NS,
}, GoalCommandInputView))
const sessions = ctx.sessions
/** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */

View File

@@ -6,6 +6,7 @@ export const zh = {
'phase.paused': '已暂停的目标',
'phase.blocked': '受阻的目标',
'objective.aria': '目标内容',
'commandInput.aria': '命令输入',
'action.save': '保存目标',
'action.cancel': '取消编辑',
'action.pause': '暂停目标',
@@ -23,6 +24,7 @@ export const en = {
'phase.paused': 'Paused Goal',
'phase.blocked': 'Blocked Goal',
'objective.aria': 'Goal objective',
'commandInput.aria': 'Command input',
'action.save': 'Save goal',
'action.cancel': 'Cancel edit',
'action.pause': 'Pause goal',

View File

@@ -15,6 +15,7 @@ import { describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-runtime/src/client/conversation/event-registry.ts'
import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -52,6 +53,7 @@ async function bench(options: {
} = {}) {
const ctx = new Context()
const calls: { method: string; args: unknown[] }[] = []
const conversationEvents = new ConversationEventRegistry(ctx)
function answer<T>(method: string, value: T) {
return (...args: unknown[]) => {
calls.push({ method, args })
@@ -85,7 +87,10 @@ async function bench(options: {
})
await ctx.plugin(SlotsService).await()
ctx.slots.register({
name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
name: 'root', children: {
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.chat.node': { kind: 'keyed', scope: 'session' },
},
} as never, (() => null) as never)
ctx.provide('locale', new LocaleService(ctx))
ctx.provide('sessions', {
@@ -103,6 +108,7 @@ async function bench(options: {
ctx,
fiber,
calls,
definitions: () => conversationEvents.entries(),
remountGoals: () => { activeGoals = goals('remounted-goals') },
unmountGoals: () => { activeGoals = undefined },
entry: () => {
@@ -114,15 +120,19 @@ async function bench(options: {
inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined,
}
},
chatEntry: () => ctx.slots.entries('conversation.chat.node')[0],
}
}
describe('ui-goal browser plugin', () => {
it('registers the GoalBar dock entry with the documented id and order', async () => {
it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => {
const b = await bench()
await b.fiber.await()
expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
expect(b.entry()?.inject).toBeTypeOf('function')
expect(b.definitions().map(definition => definition.kind)).toEqual(['goal-command-input'])
expect(b.chatEntry()?.options).toMatchObject({ key: 'command-input' })
expect(b.chatEntry()?.locale).toBe('goal')
})
it('verbs read the CAS ref from the current projected value at call time', async () => {
@@ -199,8 +209,12 @@ describe('ui-goal browser plugin', () => {
const b = await bench()
await b.fiber.await()
expect(b.entry()).toBeDefined()
expect(b.chatEntry()).toBeDefined()
expect(b.definitions()).toHaveLength(1)
await b.fiber.dispose()
expect(b.entry()).toBeUndefined()
expect(b.chatEntry()).toBeUndefined()
expect(b.definitions()).toHaveLength(0)
})
})

View File

@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { cleanup, render, within } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type {
ChatConversationViewNode, ChatSnapshot, ConversationEventInput,
ConversationNodeDefinition, ConversationViewDefinition,
} from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts'
import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts'
import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx'
import {
goalCommandInputDefinition, goalCommandText,
} from '../src/client/goal-command-input.ts'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
class TestEventDefinitions {
entries(): readonly ConversationNodeDefinition[] {
return [commandDefinition, goalCommandInputDefinition]
}
fallbackEntry(): undefined {
return undefined
}
}
class TestViewDefinitions {
entries(): readonly ConversationViewDefinition[] {
return [chatViewDefinition]
}
}
function entry(seq: number, type: string, data: unknown): ConversationEventInput {
return {
event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'],
view: undefined,
}
}
function snapshot(entries: readonly ConversationEventInput[], hasMore = false): ChatSnapshot {
const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
assembler.replaceWindow(entries, hasMore)
assembler.flush()
const value = assembler.snapshot('chat') as ChatSnapshot | undefined
if (value === undefined) throw new Error('chat view was not registered')
return value
}
function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
return value.nodes.values().find(candidate => candidate.kind === kind)
}
describe('goal command input projection', () => {
it('builds a separate input Node before the generic command result and restores it on replay', () => {
const run = entry(1, 'command/run', {
commandId: 'command-goal', name: 'goal', args: ' ', source: { kind: 'user' },
})
const done = entry(2, 'command/done', {
commandId: 'command-goal', kind: 'success', text: 'No goal is currently set.',
})
const value = snapshot([run, done])
expect(value.order.map(key => value.nodes.get(key)?.kind)).toEqual(['command-input', 'command'])
expect(node(value, 'command-input')).toMatchObject({
anchorSeq: 0.9,
data: { commandId: 'command-goal', text: '/goal' },
})
expect(node(value, 'command')?.data).toMatchObject({
name: 'goal', args: ' ', outcome: { kind: 'success', text: 'No goal is currently set.' },
})
const doneOnly = snapshot([done], true)
expect(node(doneOnly, 'command-input')).toBeUndefined()
expect(node(doneOnly, 'command')?.data).toMatchObject({ name: null, args: null })
})
it('ignores other commands and preserves internal multiline arguments', () => {
const plan = entry(1, 'command/run', {
commandId: 'command-plan', name: 'plan', args: '', source: { kind: 'user' },
})
const goal = entry(2, 'command/run', {
commandId: 'command-goal', name: 'goal', args: '\nfirst line\nsecond line \n', source: { kind: 'user' },
})
expect(goalCommandInputDefinition.match(plan.event)).toBeNull()
expect(goalCommandText(goal.event as SessionEvent<'command/run'>))
.toBe('/goal\nfirst line\nsecond line')
})
it('keeps the Definition total across required interface and window fallback paths', () => {
const run = entry(3, 'command/run', {
commandId: 'command-goal', name: 'goal', source: { kind: 'user' },
})
const match = {
...run,
role: 'start' as const,
location: { kind: 'session' as const },
}
const state = goalCommandInputDefinition.start({} as never, match, {} as never)
expect(state.text).toBe('/goal')
expect(goalCommandInputDefinition.update({ state } as never, match)).toBe(state)
expect(goalCommandInputDefinition.buildViewNode!({ state: undefined } as never)).toBeNull()
expect(goalCommandInputDefinition.buildViewNode!({
key: 'goal-command-input', id: 'command-goal', state, start: undefined,
} as never)).toMatchObject({ location: { kind: 'unresolved' } })
const done = entry(4, 'command/done', { commandId: 'command-goal', kind: 'success' })
expect(() => goalCommandInputDefinition.start({} as never, {
...done, role: 'start', location: { kind: 'session' },
} as never, {} as never)).toThrow('goal-command-input start requires command/run')
})
it('renders the user-style command bubble without ordinary message actions', () => {
const t = makeTranslate(zh, commonZh)
const props = {
node: {
key: 'goal-command-input:one',
data: { commandId: 'command-goal', text: '/goal ship it', time: 1_700_000_000_000 },
},
t,
} as unknown as Parameters<typeof GoalCommandInputView>[0]
const view = render(<GoalCommandInputView {...props} />)
const bubble = view.getByRole('group', { name: '命令输入' })
expect(bubble.textContent).toBe('/goal ship it')
expect(within(bubble).queryByRole('button')).toBeNull()
})
})

View File

@@ -29,6 +29,9 @@
{
"path": "../ui-slots"
},
{
"path": "../../interaction/commands"
},
{
"path": "../../goal/goal"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
README.md: cab9961a6d703d600a856e71339cda7062d20d62
README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739
README.md: df4d5e0370962bf6f2a8ac0a7b88d669225dc5c5
README.zh.md: e0f614645a16b44e374e450bb8dd1e3c5805ea42

View File

@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary.
When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `<body>` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeService and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates.
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default.

View File

@@ -4,6 +4,8 @@
主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好`light``dark``system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOMui-layout 的呈现器会应用解析后的快照(`html { color-scheme }``body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。
当主机组合包含 HTTP 服务器时,主机侧紧接 `<body>` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme``body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响插件树激活后ThemeService 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css``design-platform.css``scrollbar.css``gradient-shadow-text.css``shiki.css``scrollbar.css``--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
滚动条重新绑定约定:`scrollbar.css``body` 上把 `--dsh-scrollbar-thumb``--dsh-scrollbar-thumb-hover` 绑定到 l1基础表面token两条渲染路径都读取这一组变量。高层级表面菜单、浮层、对话框在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)``--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-theme",
"description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row",
"description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeService for light/dark/system state; --dsw-* token styles and Appearance settings row",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
@@ -48,6 +48,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
@@ -58,6 +59,7 @@
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",

View File

@@ -0,0 +1,40 @@
/**
* Host-rendered theme bootstrap for the browser's pre-plugin interval. Each
* index response embeds the current durable built-in preference; the browser
* resolves only `system`, then writes the same DOM fields ui-layout's
* ThemePresenter owns after the client plugin tree activates.
*/
import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts'
/** Build the inline script for one schema-validated built-in preference. */
function bootThemeScript(preference: ThemePreference): string {
return `<script>(() => {
const preference = ${JSON.stringify(preference)}
const systemDark = preference === 'system'
&& typeof matchMedia !== 'undefined'
&& matchMedia('(prefers-color-scheme: dark)').matches
const dark = preference === 'dark' || systemDark
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
document.body.toggleAttribute('data-ds-dark-theme', dark)
})()</script>`
}
/**
* Insert the theme bootstrap immediately after the opening body tag, before
* the shell mount and module script. Body-less fragments receive it at the
* end, where the HTML parser has already synthesized a body.
* @param html - Raw application index HTML.
* @param preference - Current Host-backed built-in preference.
* @returns HTML containing the theme bootstrap.
*/
export function injectBootTheme(
html: string,
preference: ThemePreference = DEFAULT_PREFERENCE,
): string {
const script = bootThemeScript(preference)
const body = /<body(?:\s[^>]*)?>/i.exec(html)
if (body === null) return `${html}${script}`
const at = body.index + body[0].length
return `${html.slice(0, at)}${script}${html.slice(at)}`
}

View File

@@ -1,23 +1,43 @@
/** Host registration for the browser theme preference. */
/** Host registration for the browser theme preference and pre-plugin palette. */
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-host-webserver'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts'
import { injectBootTheme } from './boot-theme.ts'
import {
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema,
type ThemePreference, type ThemeSettings,
} from './theme-settings.ts'
export {
DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE,
type ThemePreference, type ThemeSettings,
} from './theme-settings.ts'
const THEME_NAMESPACE = settingsNamespace(THEME_SETTINGS_NAMESPACE)
/** Read the registered preference or use the schema default without a settings provider. */
function readPreference(ctx: Context): ThemePreference {
const settings = ctx.get('settings')
if (settings === undefined) return DEFAULT_PREFERENCE
const section = settings.get(THEME_NAMESPACE) as ThemeSettings | undefined
if (section === undefined) return DEFAULT_PREFERENCE
return section.preference
}
/**
* Register the durable theme section when a settings provider exists.
* @param ctx - Host context whose optional settings service owns the section.
* Register the durable theme section and initial-theme index transform when
* their optional Host services are composed.
* @param ctx - Host context that may acquire settings and HTTP services.
*/
export function apply(ctx: Context): void {
ctx.inject(['settings'], (settingsCtx) => {
settingsCtx.settings.register(
settingsNamespace(THEME_SETTINGS_NAMESPACE),
ThemeSettingsSchema,
settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema)
})
ctx.inject(['httpServer'], (httpCtx) => {
httpCtx.effect(
() => httpCtx.httpServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))),
'client-ui-theme: initial theme bootstrap',
)
})
}

View File

@@ -0,0 +1,71 @@
// @vitest-environment jsdom
/** Host index injection and the resulting pre-plugin browser theme. */
import { runInNewContext } from 'node:vm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { injectBootTheme } from '../src/boot-theme.ts'
import type { ThemePreference } from '../src/theme-settings.ts'
const DARK_ATTRIBUTE = 'data-ds-dark-theme'
function mockSystemDark(matches: boolean): void {
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList))
}
function executeBootstrap(
preference?: ThemePreference,
html = '<html><body><div id="root"></div><script type="module"></script></body></html>',
): string {
const injected = injectBootTheme(html, preference)
const source = /<script>([\s\S]*?)<\/script>/.exec(injected)?.[1]
if (source === undefined) throw new Error('theme bootstrap script missing')
runInNewContext(source, { document, matchMedia: globalThis.matchMedia })
return injected
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
document.documentElement.style.removeProperty('color-scheme')
document.body.removeAttribute(DARK_ATTRIBUTE)
})
describe('theme boot index transform', () => {
it('runs immediately inside the body before the shell mount', () => {
mockSystemDark(false)
const html = executeBootstrap('dark', '<html><body class="app"><div id="root"></div></body></html>')
expect(html.indexOf('<script>')).toBeGreaterThan(html.indexOf('<body class="app">'))
expect(html.indexOf('<script>')).toBeLessThan(html.indexOf('<div id="root">'))
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true)
})
it('lets durable light override a dark OS and clears stale dark state', () => {
document.body.setAttribute(DARK_ATTRIBUTE, '')
mockSystemDark(true)
executeBootstrap('light')
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it.each([
[true, 'dark', true],
[false, 'light', false],
] as const)('resolves system=%s to %s', (matches, colorScheme, dark) => {
mockSystemDark(matches)
executeBootstrap('system')
expect(document.documentElement.style.colorScheme).toBe(colorScheme)
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(dark)
})
it('defaults to system and falls back to light when matchMedia is unavailable', () => {
vi.stubGlobal('matchMedia', undefined)
executeBootstrap()
expect(document.documentElement.style.colorScheme).toBe('light')
expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false)
})
it('appends the script to a body-less fragment', () => {
const html = injectBootTheme('<main>loading</main>', 'dark')
expect(html.startsWith('<main>loading</main><script>')).toBe(true)
})
})

View File

@@ -1,5 +1,6 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import {
DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, apply,
@@ -27,4 +28,38 @@ describe('ui-theme host', () => {
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
})
it('renders the current durable preference and disposes the index transform', async () => {
const ctx = new Context()
await ctx.plugin(MemorySettings).await()
let transform: ((html: string) => string) | undefined
let disposed = false
ctx.provide('httpServer', {
tapIndex: (next: (html: string) => string) => {
transform = next
return () => { disposed = true }
},
} as HttpServerService)
const fiber = ctx.plugin({ apply })
await fiber.await()
expect(transform?.('<body></body>')).toContain('const preference = "system"')
await ctx.settings.update(settingsNamespace(THEME_SETTINGS_NAMESPACE), { preference: 'dark' })
expect(transform?.('<body></body>')).toContain('const preference = "dark"')
await fiber.dispose()
expect(disposed).toBe(true)
expect(transform?.('<body></body>')).toContain('const preference = "system"')
})
it('uses the system preference when only an HTTP server exists', async () => {
const ctx = new Context()
let transform: ((html: string) => string) | undefined
ctx.provide('httpServer', {
tapIndex: (next: (html: string) => string) => {
transform = next
return () => undefined
},
} as HttpServerService)
await ctx.plugin({ apply }).await()
expect(transform?.('<body></body>')).toContain('const preference = "system"')
})
})

View File

@@ -15,7 +15,7 @@ describe('invariant companion', () => {
await expect(ctx.plugin(ThemeInvariant).await()).resolves.toBeDefined()
})
it('node-half waits for an optional settings provider', () => {
it('node-half waits for optional Host services', () => {
nodeApply(new Context())
expect(true).toBe(true)
})

View File

@@ -20,6 +20,9 @@
{
"path": "../ui-slots"
},
{
"path": "../../host/webserver"
},
{
"path": "../../../vendor/cordis"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/examples/jsonrpc-demo/README.md
README.md: 40ced3ee1fe2d3eac69b82501d417130267d7634
README.zh.md: 451bdf7428f8265750082af240418d4653bb8595
README.md: b2035b4caf2cadcbd5e93b27ac4aad2bdadcdb4d
README.zh.md: 40867d03c31cb44ed7b2cc20643b3838de294b92

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative.
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../sdk/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative.
## Config discovery

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。
只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../sdk/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。
## 配置发现

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/README.md
README.md: 8fe77ae845390359e2c1e3302400c08828a186a1
README.zh.md: 68e13be7c55e64305bbe0b87ede8d37e55a7cd36
README.md: 4640bf015723d03257eae3a360201c8cbe122e63
README.zh.md: c7b9cd8f30ba12cf7c7ef3affc24e4ca72010b25

View File

@@ -14,4 +14,4 @@ The services and plugins through which a human collaborates with a running agent
These packages integrate through existing agent and session contracts rather than changing the loop. Interactive applications provide the concrete command, approval, and question adapters; automation uses [`acp/`](../acp/README.md), and runnable demo bundles live under [`examples/`](../examples/README.md). The product [`dsh`](../../apps/cli/README.md) CLI composes these packages directly.
The subsystem references: [approval.md](../../docs/subsystems/approval.md), [permission.md](../../docs/subsystems/permission.md), [user-interaction.md](../../docs/subsystems/user-interaction.md), and [commands.md](../../docs/subsystems/commands.md). The automation-only ACP transport is [`acp/`](../acp/README.md), the SDK's JSON-RPC server half [`scaffold/server`](../scaffold/README.md), and the shared bin boot glue [`boot/`](../boot/README.md).
The subsystem references: [approval.md](../../docs/subsystems/approval.md), [permission.md](../../docs/subsystems/permission.md), [user-interaction.md](../../docs/subsystems/user-interaction.md), and [commands.md](../../docs/subsystems/commands.md). The automation-only ACP transport is [`acp/`](../acp/README.md), the SDK's JSON-RPC server half is [`sdk/server`](../sdk/README.md), and the shared bin boot glue is [`boot/`](../boot/README.md).

View File

@@ -14,4 +14,4 @@
这些包通过现有的 agent智能体和会话约定集成而不改变循环。交互式应用提供具体的命令、审批和提问适配器自动化使用 [`acp/`](../acp/README.md),可运行的演示组合包位于 [`examples/`](../examples/README.md)。产品 [`dsh`](../../apps/cli/README.md) CLI命令行界面直接组合这些包。
子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission.md](../../docs/subsystems/permission.md)、[user-interaction.md](../../docs/subsystems/user-interaction.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输 [`acp/`](../acp/README.md)SDK 的 JSON-RPC 服务器一半 [`scaffold/server`](../scaffold/README.md),共享 bin 启动胶水 [`boot/`](../boot/README.md)。
子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission.md](../../docs/subsystems/permission.md)、[user-interaction.md](../../docs/subsystems/user-interaction.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输 [`acp/`](../acp/README.md)SDK 的 JSON-RPC 服务器一半 [`sdk/server`](../sdk/README.md),共享 bin 启动胶水 [`boot/`](../boot/README.md)。

View File

@@ -1,17 +0,0 @@
# scaffold/ — create, launch, and drive projects from outside
English | [中文](README.zh.md)
This group contains developer tooling for Harness projects and the client stack for driving a Harness runtime from another process. Folders are role-named; npm names converge on `dsh-sdk-*` through the FIXME-tracked renames in the [regrouping Agent Note](../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md).
| Package | Role |
|---|---|
| [`helper/`](helper/README.md) | Provides the shared project-editing domain |
| [`scripts/`](scripts/README.md) | Provides the `dsh-sdk` project commands |
| [`create-sdk/`](create-sdk/README.md) | Creates new SDK projects |
| [`protocol/`](protocol/README.md) | Defines the SDK runtime wire protocol |
| [`client/`](client/README.md) | Drives a Harness runtime through the TypeScript client API |
| [`server/`](server/README.md) | Serves out-of-process SDK clients over stdio JSON-RPC |
| [`telemetry/`](telemetry/README.md) | Provides launcher telemetry, consent, and redaction primitives |
`@deepseek-ai/create-sdk` follows npm's scoped initializer naming convention; the other packages follow the repository's `@deepseek-ai/dsh-*` convention. See the [developer-project workflow](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md), [project-editing architecture](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md), and [TypeScript SDK design](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md).

View File

@@ -1,17 +0,0 @@
# scaffold/:从外部创建、启动、驱动项目
[English](README.md) | 中文
本组包含 Harness 项目的开发者工具,以及从另一进程驱动 Harness 运行时的客户端栈。目录按角色命名npm 名则经由[重新分组 Agent Noteagent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md)中 FIXME 跟踪的改名收敛为 `dsh-sdk-*`
| 包 | 职责 |
|---|---|
| [`helper/`](helper/README.md) | 提供共享的项目编辑领域 |
| [`scripts/`](scripts/README.md) | 提供 `dsh-sdk` 项目命令 |
| [`create-sdk/`](create-sdk/README.md) | 创建新的 SDK 项目 |
| [`protocol/`](protocol/README.md) | 定义 SDK 运行时通信协议 |
| [`client/`](client/README.md) | 通过 TypeScript 客户端 API 驱动 Harness 运行时 |
| [`server/`](server/README.md) | 通过 stdio JSON-RPC 为进程外 SDK 客户端提供服务 |
| [`telemetry/`](telemetry/README.md) | 提供启动器 telemetry、同意与脱敏原语 |
`@deepseek-ai/create-sdk` 遵循 npm 的 scoped initializer 命名约定;其余包遵循仓库的 `@deepseek-ai/dsh-*` 约定。参见[开发者项目工作流](../../.agents/notes/proposed/feature/2026-07-14-sdk-developer-projects.md)、[项目编辑架构](../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md)与 [TypeScript SDK 设计](../../.agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)。

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/scaffold/create-sdk/README.md
README.md: 7d0d9415c74a4c3b4514bb90ab81c5895b9e347f
README.zh.md: a4685e66bc3eb7924dd247a7b6b420f6bcc70990

View File

@@ -1,25 +0,0 @@
# `@deepseek-ai/create-sdk`
English | [中文](README.zh.md)
Interactive initializer for `npm create @deepseek-ai/sdk [directory]`. Directory/name/description have visible editable defaults. A tree picker selects features and configures finite options with Right/Left navigation; secret text follows only for selected options. Local plugin creation is one none/plugin/tool choice.
The supported package entry point is the `create-sdk` bin. The package root exports no symbols, and workflow, bin, source, and package-manifest subpaths are not exported.
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`, `--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; the headless `--config-json` + `--json` interface additionally lets an agent create a project end to end and react to `action-required` events.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **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.

View File

@@ -1,25 +0,0 @@
# `@deepseek-ai/create-sdk`
[English](README.md) | 中文
用于 `npm create @deepseek-ai/sdk [directory]` 的交互式初始化器。目录/名称/描述都提供可见且可编辑的默认值。树形选择器用于选择功能,并通过 RightLeft 导航配置取值有限的选项;只有选中相应选项后才会询问密钥文本。本地插件创建提供 noneplugintool 三选一。
受支持的包接口是 `create-sdk` bin。包根不导出任何符号也不导出 workflow、bin、source 或 package-manifest 子路径。
初始化器拒绝任何已经存在的目标路径,创建一个 `SdkProject` 编辑会话,验证并提交该会话,然后询问是否安装 NPM 依赖并构建。安装或构建失败时会保留生成的项目,并打印重试命令。
公开标志包括 `[directory]``--description``--provider``--base-url``--api-key``--model``--interface``--pm``--install``--no-install`,以及无头模式标志 `--config <path>``--config-json <json>``--json`。交互式标志会预填对应问题;无头 spec`--config``--config-json`)会预先提供所有答案和功能方案,因此创建过程无需 TTY并通过 `HeadlessPromptPort` 驱动;若缺少任何必填答案,该端口会明确失败。`--json` 会发送 NDJSON 生命周期事件(`done``action-required``error`),使 agent智能体能够补充其中点名的缺失输入并重新运行。
提供方可以选择 DeepSeek也可以选择由 `llm-pi-ai` 支持的自定义端点。选择 DeepSeek 时只询问 API key并使用公共端点与 `deepseek-v4-flash`;自定义端点还会询问 base URL。密钥为空时必须确认系统会在 `.env` 中创建一个被注释掉的空变量,从而使提供方在为该变量填入值之前启动时明确失败。现有插件的默认值会被省略;必填 SDK 预设仍按所属包的 Config 保持类型约束。
## 模型体验
通过生成的项目组合及其所选运行时插件间接提供;此外,无头 `--config-json` + `--json` 接口允许 agent 端到端创建项目,并响应 `action-required` 事件。
#### KV Cache 影响
不会直接导致 KV Cache 失效;由具名消费方负责请求前缀变更。
## 已知限制与暂缓事项
- **无头本地插件**:无头 spec 会提供项目答案和功能方案;目前还不能在 spec 中表达本地插件脚手架(交互式 noneplugintool 选择),默认使用 none。

View File

@@ -1,49 +0,0 @@
{
"name": "@deepseek-ai/create-sdk",
"description": "Create a DeepSeek Harness SDK project with npm create @deepseek-ai/sdk",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/scaffold/create-sdk"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"create-sdk": "lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
}
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/bin.js",
"lib/assets",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-helper": "workspace:^",
"commander": "^15.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -1,96 +0,0 @@
/**
* Commander adapter for the create-sdk command interface.
*
* @module @deepseek-ai/create-sdk/args
*/
import { Command, Option } from 'commander'
import type { PackageManagerName, RunInterface } from '@deepseek-ai/dsh-helper'
/** Parsed create command flags before interactive resolution. */
export interface CreateArgs {
directory?: string
description?: string
provider?: 'deepseek-official' | 'custom'
baseURL?: string
apiKey?: string
model?: string
runInterface?: RunInterface
packageManager?: PackageManagerName
install?: boolean
linkWorkspace?: boolean
config?: string
configJson?: string
json?: boolean
help: boolean
}
interface CommanderCreateOptions {
description?: string
provider?: 'deepseek-official' | 'custom'
baseUrl?: string
apiKey?: string
model?: string
interface?: RunInterface
pm?: PackageManagerName
install?: boolean
linkWorkspace?: boolean
config?: string
configJson?: string
json?: boolean
help?: boolean
}
function createProgram(): Command {
return new Command()
.name('create-sdk')
.description('Create a DeepSeek Harness SDK project')
.helpOption(false)
.showHelpAfterError(false)
.exitOverride()
.configureOutput({
/* v8 ignore next -- the command wrapper renders the package-owned usage template */
writeOut: () => {},
/* v8 ignore next -- Commander output is deliberately suppressed; errors are returned to the bin wrapper */
writeErr: () => {},
})
.argument('[directory]')
.option('-h, --help')
.option('--description <text>')
.addOption(new Option('--provider <name>').choices(['deepseek-official', 'custom']))
.option('--base-url <url>')
.option('--api-key <key>')
.option('--model <name>')
.addOption(new Option('--interface <name>').choices(['acp', 'embed']))
.addOption(new Option('--pm <name>').choices(['npm', 'pnpm', 'yarn']))
.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. */
export function parseCreateArgs(argv: readonly string[]): CreateArgs {
const program = createProgram()
program.parse([...argv], { from: 'user' })
const options = program.opts<CommanderCreateOptions>()
const directory = program.processedArgs[0] as string | undefined
return {
...directory === undefined ? {} : { directory },
...options.description === undefined ? {} : { description: options.description },
...options.provider === undefined ? {} : { provider: options.provider },
...options.baseUrl === undefined ? {} : { baseURL: options.baseUrl },
...options.apiKey === undefined ? {} : { apiKey: options.apiKey },
...options.model === undefined ? {} : { model: options.model },
...options.interface === undefined ? {} : { runInterface: options.interface },
...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,
}
}

View File

@@ -1,10 +0,0 @@
#!/usr/bin/env node
/**
* Self-executing create-sdk command.
*
* @module @deepseek-ai/create-sdk/bin
*/
import { runCreateCommand } from './command.ts'
process.exitCode = await runCreateCommand()

View File

@@ -1,144 +0,0 @@
/**
* Internal create-sdk command composition used by the package bin.
*
* @module @deepseek-ai/create-sdk/command
*/
import { readFile } from 'node:fs/promises'
import {
ClackPromptPort,
HeadlessPromptError,
HeadlessPromptPort,
NodeCommandRunner,
PromptCancelledError,
type PackageManagerVersionProbe,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
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'
/** Process and terminal slice used by the initializer. */
export interface CreateCommandContext {
cwd: string
stdin: NodeJS.ReadStream
stdout: NodeJS.WriteStream
stderr: NodeJS.WriteStream
releaseVersion?: string
versionProbe?: PackageManagerVersionProbe
port?: PromptPort
setup?: (request: ResolvedCreateRequest) => Promise<void>
}
/** Read this initializer package's release version in source and built layouts. */
export async function readCreateSdkVersion(): Promise<string> {
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { version?: unknown }
/* v8 ignore next -- this package's checked-in manifest always carries its version */
if (typeof manifest.version !== 'string') throw new Error('create-sdk package version is missing')
return manifest.version
}
/** Resolve, write, optionally install, and build one new project. */
export async function createProject(
argv: readonly string[],
context: CreateCommandContext,
): Promise<ScaffoldResult | undefined> {
const args = parseCreateArgs(argv)
// Under --json, stdout carries only NDJSON events: human-readable progress
// and package-manager child output move to stderr.
const progress = args.json === true ? context.stderr : context.stdout
if (args.help) {
context.stdout.write(CREATE_TEMPLATES.usage.render({}))
return undefined
}
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: headless ? headless.args : args,
/* v8 ignore next -- production TTY wiring is exercised by the built-bin smoke */
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)
progress.write(CREATE_TEMPLATES.created.render({
name: resolved.request.name,
directory: resolved.directory,
}))
if (resolved.install) {
try {
if (context.setup) await context.setup(resolved)
else {
const runner = args.json === true ? new NodeCommandRunner(context.stderr) : new NodeCommandRunner()
await resolved.request.packageManager.install(resolved.directory, runner)
await resolved.request.packageManager.build(resolved.directory, runner)
}
} catch (error) {
context.stderr.write(CREATE_TEMPLATES.setupFailure.render({
directory: resolved.directory,
error: String(error),
...packageManagerTemplateModel(resolved.request.packageManager),
}))
throw error
}
}
progress.write(CREATE_TEMPLATES.nextSteps.render({
directory: resolved.directory,
setupRequired: !resolved.install,
...packageManagerTemplateModel(resolved.request.packageManager),
}))
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),
context: CreateCommandContext = {
cwd: process.cwd(),
stdin: process.stdin,
stdout: process.stdout,
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) {
if (json) emit({ type: 'error', reason: 'cancelled' })
else context.stderr.write('create-sdk: cancelled\n')
return 1
}
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
}
}

View File

@@ -1,204 +0,0 @@
/**
* Static create-sdk question sequence; dynamic feature/plugin loops remain
* in the wizard orchestrator.
*
* @module @deepseek-ai/create-sdk/create-questions
*/
import { existsSync } from 'node:fs'
import { basename, resolve } from 'node:path'
import {
ConfirmQuestion,
SecretQuestion,
SelectQuestion,
TextQuestion,
requireAnswer,
type PromptPort,
type Question,
type RunInterface,
} from '@deepseek-ai/dsh-helper'
import type { CreateArgs } from './args.ts'
/** Answers that establish project identity and feature applicability. */
export interface ProjectAnswers {
directory: string
name: string
description: string
provider: 'deepseek-official' | 'custom'
baseURL: string
apiKey: string
model: string
runInterface: RunInterface
}
interface ProjectAnswerState extends Partial<ProjectAnswers> {
readonly args: CreateArgs
readonly cwd: string
}
interface WizardStep<TState> {
run(port: PromptPort, state: TState): Promise<void>
}
function questionStep<TState, TValue>(options: {
question: (state: TState) => Question<TValue>
when?: (state: TState) => boolean
prefilled?: (state: TState) => TValue | undefined
apply: (state: TState, value: TValue) => void
}): WizardStep<TState> {
return {
async run(port, state) {
if (options.when && !options.when(state)) return
const value = requireAnswer(await options.question(state).resolve(port, options.prefilled?.(state)))
options.apply(state, value)
},
}
}
/** Validate one required text answer. */
function nonEmpty(value: string): string | undefined {
return value.trim().length === 0 ? 'A value is required' : undefined
}
function packageName(value: string): string | undefined {
if (!/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value)) {
return 'Use a lowercase npm package name'
}
return undefined
}
function projectDirectory(value: string, cwd: string): string | undefined {
const empty = nonEmpty(value)
if (empty) return empty
return existsSync(resolve(cwd, value)) ? 'Target already exists' : undefined
}
const API_KEY_STEP: WizardStep<ProjectAnswerState> = {
async run(port, state) {
let prefilled = state.args.apiKey
while (true) {
const apiKey = requireAnswer(await new SecretQuestion({
id: 'apiKey',
message: state.provider === 'custom' ? 'Custom provider API key' : 'DeepSeek API key',
}).resolve(port, prefilled))
if (apiKey.length > 0) {
state.apiKey = apiKey
return
}
const keepEmpty = requireAnswer(await new ConfirmQuestion({
id: 'apiKey.empty',
message: 'Keep the API key empty and fill .env later?',
initialValue: false,
tone: 'warning',
}).resolve(port))
if (keepEmpty) {
state.apiKey = ''
return
}
prefilled = undefined
}
},
}
const PROJECT_QUESTION_STEPS: readonly WizardStep<ProjectAnswerState>[] = [
questionStep({
question: state => new TextQuestion({
id: 'directory',
message: 'Where should the project be created?',
placeholder: 'my-agent',
defaultValue: 'my-agent',
validate: value => projectDirectory(value, state.cwd),
}),
prefilled: state => state.args.directory,
apply: (state, value) => { state.directory = resolve(state.cwd, value) },
}),
questionStep({
question: (state) => {
/* v8 ignore next -- the preceding directory step always populates this state */
if (!state.directory) throw new Error('directory must resolve before package name')
return new TextQuestion({
id: 'name',
message: 'Package name',
placeholder: basename(state.directory),
defaultValue: basename(state.directory),
validate: packageName,
})
},
apply: (state, value) => { state.name = value },
}),
questionStep({
question: (state) => {
/* v8 ignore next -- the preceding package-name step always populates this state */
if (!state.name) throw new Error('package name must resolve before description')
return new TextQuestion({
id: 'description',
message: 'Project description',
placeholder: `A DeepSeek Harness agent named ${state.name}`,
defaultValue: `A DeepSeek Harness agent named ${state.name}`,
validate: nonEmpty,
})
},
prefilled: state => state.args.description,
apply: (state, value) => { state.description = value },
}),
questionStep({
question: () => new SelectQuestion<'deepseek-official' | 'custom'>({
id: 'provider',
message: 'Model provider',
options: [
{ value: 'deepseek-official', label: 'DeepSeek' },
{ value: 'custom', label: 'Custom endpoint (pi-ai)' },
],
initialValue: 'deepseek-official',
}),
prefilled: state => state.args.provider,
apply: (state, value) => { state.provider = value },
}),
questionStep({
question: () => new TextQuestion({
id: 'baseURL', message: 'Custom provider base URL', validate: nonEmpty,
}),
when: state => state.provider === 'custom' || state.args.baseURL !== undefined,
prefilled: state => state.args.baseURL,
apply: (state, value) => { state.baseURL = value },
}),
API_KEY_STEP,
questionStep({
question: () => new SelectQuestion<RunInterface>({
id: 'interface',
message: 'Run interface',
options: [
{ value: 'acp', label: 'ACP automation server' },
{ value: 'embed', label: 'Embedded context' },
],
initialValue: 'acp',
}),
prefilled: state => state.args.runInterface,
apply: (state, value) => { state.runInterface = value },
}),
]
function completeAnswers(state: ProjectAnswerState): ProjectAnswers {
const keys = ['directory', 'name', 'description', 'provider', 'baseURL', 'apiKey', 'model', 'runInterface'] as const
for (const key of keys) {
/* v8 ignore next -- the fixed step list above populates every key or throws/cancels first */
if (state[key] === undefined) throw new Error(`create question did not resolve ${key}`)
}
return state as ProjectAnswerState & ProjectAnswers
}
/** Run the fixed project-context sequence in declaration order. */
export async function collectProjectAnswers(
port: PromptPort,
args: CreateArgs,
cwd: string,
): Promise<ProjectAnswers> {
const state: ProjectAnswerState = {
args,
cwd,
baseURL: args.baseURL ?? '',
model: args.model ?? 'deepseek-v4-flash',
}
for (const step of PROJECT_QUESTION_STEPS) await step.run(port, state)
return completeAnswers(state)
}

View File

@@ -1,233 +0,0 @@
/**
* Declarative create questions with dynamic feature and plugin orchestration.
*
* @module @deepseek-ai/create-sdk/create-wizard
*/
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
FeatureConfigurator,
ConfirmQuestion,
LocalPluginBlueprint,
NpmPackageManager,
SelectQuestion,
featureId,
createBuiltinRegistry,
createPackageManager,
inferPackageManagerName,
probePackageManagerVersion,
requireAnswer,
type FeatureRegistry,
type FeatureSelection,
type LocalPluginKind,
type PackageManager,
type PackageManagerName,
type PackageManagerVersionProbe,
type ProjectCreationRequest,
type ProjectProfile,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
import type { CreateArgs } from './args.ts'
import { collectProjectAnswers, type ProjectAnswers } from './create-questions.ts'
import { CREATE_TEMPLATES, packageManagerTemplateModel } from './templates/create-templates.ts'
/** Fully resolved initializer request and post-create choice. */
export interface ResolvedCreateRequest {
directory: string
request: ProjectCreationRequest
install: boolean
}
/** Create-specific orchestration around declarative questions and dynamic selections. */
export class CreateWizard {
private readonly args: CreateArgs
private readonly port: PromptPort
private readonly cwd: string
private readonly releaseVersion: string
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: {
args: CreateArgs
port: PromptPort
cwd?: string
releaseVersion: string
versionProbe?: PackageManagerVersionProbe
userAgent?: string
features?: readonly FeatureSelection[]
}) {
this.args = options.args
this.port = options.port
this.cwd = resolve(options.cwd ?? process.cwd())
this.releaseVersion = options.releaseVersion
this.versionProbe = options.versionProbe ?? probePackageManagerVersion
/* v8 ignore next -- pnpm supplies npm_config_user_agent while direct invocations may omit it */
this.userAgent = options.userAgent ?? process.env.npm_config_user_agent ?? ''
this.linkWorkspaceRoot = options.args.linkWorkspace
? fileURLToPath(new URL('../../../../', import.meta.url))
: undefined
this.featurePlan = options.features
}
/** Collect all answers before constructing any project files. */
async run(): Promise<ResolvedCreateRequest> {
const answers = await this.collectProjectAnswers()
const profile = this.provisionalProfile(answers)
const registry = createBuiltinRegistry(profile)
const features = await this.collectFeatures(profile, registry, answers)
const localPlugins = await this.collectPlugins()
const { manager, install } = await this.collectPackageManager()
return {
directory: answers.directory,
install,
request: {
name: answers.name,
description: answers.description,
runtime: { model: answers.model },
packageManager: manager,
releaseVersion: this.releaseVersion,
...this.linkWorkspaceRoot ? { linkWorkspaceRoot: this.linkWorkspaceRoot } : {},
features,
localPlugins,
},
}
}
private async collectProjectAnswers(): Promise<ProjectAnswers> {
return collectProjectAnswers(this.port, this.args, this.cwd)
}
private provisionalProfile(answers: ProjectAnswers): ProjectProfile {
return {
name: answers.name,
description: answers.description,
runtime: { model: answers.model },
runInterface: answers.runInterface,
packageManager: new NpmPackageManager('10.0.0'),
releaseVersion: this.releaseVersion,
...this.linkWorkspaceRoot ? { linkWorkspaceRoot: this.linkWorkspaceRoot } : {},
}
}
private async collectFeatures(
profile: ProjectProfile,
registry: FeatureRegistry,
answers: ProjectAnswers,
): Promise<FeatureSelection[]> {
const configurator = new FeatureConfigurator(this.port)
const selections: FeatureSelection[] = [
{
id: featureId('provider'),
options: [answers.provider],
...answers.baseURL ? { values: { baseURL: answers.baseURL } } : {},
secrets: { apiKey: answers.apiKey },
},
{ id: featureId('spine'), options: ['default'] },
{ id: featureId('app'), options: [answers.runInterface] },
]
const configurable = registry.all().filter(feature => feature.id === 'bash'
|| feature.id === 'persistence'
|| (!feature.required && feature.isApplicable(profile)))
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) })
}
}
}
const fixed = new Set(selections.map(selection => selection.id))
const choices = new Map<FeatureSelection['id'], readonly string[] | undefined>()
for (const feature of registry.all()) {
if (feature.required && feature.isApplicable(profile) && !fixed.has(feature.id)) {
choices.set(feature.id, feature.defaultOptions(profile))
}
}
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
}
private async collectPlugins(): Promise<LocalPluginBlueprint[]> {
const kind = requireAnswer(await new SelectQuestion<LocalPluginKind | 'none'>({
id: 'plugins.kind',
message: 'Local plugin',
options: [
{ value: 'none', label: 'No local plugin' },
{ value: 'plugin', label: 'Cordis plugin' },
{ value: 'tool', label: 'Model-facing tool' },
],
initialValue: 'none',
}).resolve(this.port))
return kind === 'none' ? [] : [new LocalPluginBlueprint(kind, kind)]
}
private async collectPackageManager(): Promise<{ manager: PackageManager; install: boolean }> {
const inferred = inferPackageManagerName(this.args.packageManager, this.userAgent)
const name = requireAnswer(await new SelectQuestion<PackageManagerName>({
id: 'packageManager',
message: 'Package manager',
options: [
{ value: 'npm', label: 'npm' },
{ value: 'pnpm', label: 'pnpm' },
{ value: 'yarn', label: 'Yarn' },
],
initialValue: inferred ?? 'npm',
}).resolve(this.port, inferred))
const manager = createPackageManager(name, await this.versionProbe(name, this.cwd))
const install = requireAnswer(await new ConfirmQuestion({
id: 'install',
message: CREATE_TEMPLATES.installQuestion.render(packageManagerTemplateModel(manager)).trimEnd(),
initialValue: true,
}).resolve(this.port, this.args.install))
return { manager, install }
}
}

View File

@@ -1,98 +0,0 @@
/**
* 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.
*/
interface HeadlessCreateSpec {
directory?: string
description?: string
provider?: 'deepseek-official' | '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. */
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 hook 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 }
}

View File

@@ -1,7 +0,0 @@
/**
* The create-sdk package is a CLI initializer; its library entry exports no symbols.
*
* @module @deepseek-ai/create-sdk
*/
export {}

View File

@@ -1,30 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/create-sdk`.
* @module @deepseek-ai/create-sdk/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/create-sdk'
/** Cordis companion plugin name. */
export const name = 'create-sdk-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
* generated output and consumer tests cover its contract.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -1,41 +0,0 @@
/**
* Project creation use case over the shared SDK aggregate and edit session.
*
* @module @deepseek-ai/create-sdk/project-scaffolder
*/
import { stat } from 'node:fs/promises'
import {
SdkProject,
createBuiltinRegistry,
type ChangeSet,
type ProjectCreationRequest,
} from '@deepseek-ai/dsh-helper'
/** Result of writing one new SDK project. */
export interface ScaffoldResult {
project: SdkProject
changes: ChangeSet
}
/** Create a project entirely in memory, then validate and commit it once. */
export async function scaffoldProject(root: string, request: ProjectCreationRequest): Promise<ScaffoldResult> {
let targetExists = true
try {
await stat(root)
} catch (error) {
/* v8 ignore else -- the other arm requires a filesystem permission/IO fault from stat */
if ((error as NodeJS.ErrnoException).code === 'ENOENT') targetExists = false
/* v8 ignore next -- paired with the ignored defensive stat-error arm above */
else throw error
}
if (targetExists) throw new Error(`target already exists: ${root}`)
const project = SdkProject.create(root, request)
const registry = createBuiltinRegistry(project.profile)
const edit = project.edit(registry)
for (const selection of request.features) {
edit.installFeature(registry.get(selection.id), selection)
}
for (const plugin of request.localPlugins) edit.addPlugin(plugin)
return edit.commit()
}

View File

@@ -1 +0,0 @@
Created {{name}} in {{directory}}

View File

@@ -1 +0,0 @@
Run {{packageManager}} {{installArgs}} and then build the project?

View File

@@ -1,5 +0,0 @@
{{#if setupRequired}}
Next: cd {{directory}} && {{packageManager}} {{installArgs}} && {{packageManager}} {{buildArgs}} && {{packageManager}} start
{{else}}
Next: cd {{directory}} && {{packageManager}} start
{{/if}}

View File

@@ -1,2 +0,0 @@
Project files are ready, but setup failed: {{error}}
Retry: cd {{directory}} && {{packageManager}} {{installArgs}} && {{packageManager}} {{buildArgs}}

View File

@@ -1,14 +0,0 @@
Usage: create-sdk [directory] [options]
Options:
--description <text>
--provider <deepseek-official|custom>
--base-url <url>
--api-key <key>
--model <name>
--interface <acp|embed>
--pm <npm|pnpm|yarn>
--install / --no-install
--config <path>
--config-json <json>
--json

View File

@@ -1,59 +0,0 @@
/**
* Package-owned terminal templates for create-sdk.
*
* @module @deepseek-ai/create-sdk/templates/create-templates
*/
import {
TextTemplate,
type PackageManager,
type PackageManagerName,
} from '@deepseek-ai/dsh-helper'
interface CreatedTemplateModel {
name: string
directory: string
}
interface NextStepsTemplateModel extends PackageManagerTemplateModel {
directory: string
setupRequired: boolean
}
interface SetupFailureTemplateModel extends PackageManagerTemplateModel {
directory: string
error: string
}
/** Package-manager execution data consumed by create-sdk templates. */
export interface PackageManagerTemplateModel {
packageManager: PackageManagerName
installArgs: string
buildArgs: string
}
/**
* Map package-manager execution data into terminal-template fields.
* @param manager - selected package-manager strategy.
* @returns executable name and operation arguments.
*/
export function packageManagerTemplateModel(manager: PackageManager): PackageManagerTemplateModel {
return {
packageManager: manager.name,
installArgs: manager.installCommand().join(' '),
buildArgs: manager.buildCommand().join(' '),
}
}
/** Compiled create-sdk terminal templates. */
export const CREATE_TEMPLATES = {
usage: TextTemplate.fromFile<Record<string, never>>(new URL('./assets/usage.txt.tpl', import.meta.url)),
created: TextTemplate.fromFile<CreatedTemplateModel>(new URL('./assets/created.txt.tpl', import.meta.url)),
nextSteps: TextTemplate.fromFile<NextStepsTemplateModel>(new URL('./assets/next-steps.txt.tpl', import.meta.url)),
setupFailure: TextTemplate.fromFile<SetupFailureTemplateModel>(
new URL('./assets/setup-failure.txt.tpl', import.meta.url),
),
installQuestion: TextTemplate.fromFile<PackageManagerTemplateModel>(
new URL('./assets/install-question.txt.tpl', import.meta.url),
),
} as const

View File

@@ -1,28 +0,0 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
const execFileAsync = promisify(execFile)
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const createBin = join(repoRoot, 'packages/scaffold/create-sdk/lib/bin.js')
const scriptsBin = join(repoRoot, 'packages/scaffold/scripts/lib/bin.js')
describe.skipIf(!existsSync(createBin) || !existsSync(scriptsBin))(
'SDK built artifacts',
() => {
it('runs the published dsh-sdk bin help path under plain Node', async () => {
const result = await execFileAsync(process.execPath, [scriptsBin, '--help'], { encoding: 'utf8' })
expect(result.stdout).toContain('Usage: dsh-sdk <command>')
expect(result.stderr).toBe('')
})
it('runs the published create-sdk bin help path under plain Node', async () => {
const result = await execFileAsync(process.execPath, [createBin, '--help'], { encoding: 'utf8' })
expect(result.stdout).toContain('Usage: create-sdk [directory]')
expect(result.stderr).toBe('')
})
},
)

View File

@@ -1,391 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
featureId,
createPackageManager,
type NestedMultiSelectValue,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
import type {
ConfirmPromptRequest,
MultiSelectPromptRequest,
NestedMultiSelectRequest,
PromptOutcome,
SecretPromptRequest,
SelectPromptRequest,
TextPromptRequest,
} from '../../helper/src/questions/prompt-port.ts'
import { parseCreateArgs } from '../src/args.ts'
import { CreateWizard } from '../src/create-wizard.ts'
import { CREATE_TEMPLATES, packageManagerTemplateModel } from '../src/templates/create-templates.ts'
class RecordingPort implements PromptPort {
readonly transcript: unknown[] = []
readonly #answers: unknown[]
constructor(answers: unknown[]) { this.#answers = [...answers] }
answer<T>(record: unknown): Promise<PromptOutcome<T>> {
this.transcript.push(record)
return Promise.resolve({ status: 'answered', value: this.#answers.shift() as T })
}
text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
return this.answer({
kind: 'text',
message: request.message,
defaultValue: request.defaultValue,
initialValue: request.initialValue,
})
}
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
return this.answer({ kind: 'secret', message: request.message })
}
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
return this.answer({
kind: 'select', message: request.message, options: request.options.map(option => option.label),
initialValue: request.initialValue,
})
}
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
return this.answer({
kind: 'multiselect', message: request.message, options: request.options.map(option => option.label),
initialValues: request.initialValues,
})
}
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
return this.answer({ kind: 'confirm', message: request.message, initialValue: request.initialValue })
}
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return this.answer({
kind: 'nested-multiselect',
message: request.message,
options: request.options.map(option => ({
label: option.label,
required: option.required,
default: option.default,
choices: option.choices?.map(choice => choice.label),
})),
})
}
}
describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', () => {
it('renders package-manager-specific setup commands', () => {
const model = packageManagerTemplateModel(createPackageManager('yarn', '4.0.0'))
expect(CREATE_TEMPLATES.installQuestion.render(model)).toBe('Run yarn install and then build the project?\n')
expect(CREATE_TEMPLATES.setupFailure.render({
directory: '/workspace/agent',
error: 'offline',
...model,
})).toContain('yarn install && yarn build')
})
it('pins the full unresolved question order and completion messages', async () => {
const port = new RecordingPort([
'my-agent',
'my-agent',
'Snapshot agent',
'deepseek-official',
'secret-key',
'acp',
[
{ value: featureId('persistence'), choices: ['jsonl'] },
{ value: featureId('hmr'), choices: [] },
{ value: featureId('web'), choices: ['exa'] },
{ value: featureId('workflow'), choices: [] },
],
true,
'exa-key',
'none',
'npm',
false,
])
const resolved = await new CreateWizard({
args: parseCreateArgs([]),
port,
cwd: '/workspace',
releaseVersion: '0.0.1',
userAgent: '',
versionProbe: async () => '10.0.0',
}).run()
expect({
prompts: port.transcript,
result: {
directory: resolved.directory,
name: resolved.request.name,
manager: resolved.request.packageManager.name,
install: resolved.install,
features: resolved.request.features.map(item => ({ id: item.id, options: item.options })),
},
messages: {
created: CREATE_TEMPLATES.created.render({
name: resolved.request.name,
directory: resolved.directory,
}),
next: CREATE_TEMPLATES.nextSteps.render({
directory: resolved.directory,
setupRequired: false,
...packageManagerTemplateModel(resolved.request.packageManager),
}),
failure: CREATE_TEMPLATES.setupFailure.render({
directory: resolved.directory,
error: String(new Error('offline')),
...packageManagerTemplateModel(resolved.request.packageManager),
}),
},
}).toMatchInlineSnapshot(`
{
"messages": {
"created": "Created my-agent in /workspace/my-agent
",
"failure": "Project files are ready, but setup failed: Error: offline
Retry: cd /workspace/my-agent && npm install && npm run build
",
"next": "Next: cd /workspace/my-agent && npm start
",
},
"prompts": [
{
"defaultValue": "my-agent",
"initialValue": undefined,
"kind": "text",
"message": "Where should the project be created?",
},
{
"defaultValue": "my-agent",
"initialValue": undefined,
"kind": "text",
"message": "Package name",
},
{
"defaultValue": "A DeepSeek Harness agent named my-agent",
"initialValue": undefined,
"kind": "text",
"message": "Project description",
},
{
"initialValue": "deepseek-official",
"kind": "select",
"message": "Model provider",
"options": [
"DeepSeek",
"Custom endpoint (pi-ai)",
],
},
{
"kind": "secret",
"message": "DeepSeek API key",
},
{
"initialValue": "acp",
"kind": "select",
"message": "Run interface",
"options": [
"ACP automation server",
"Embedded context",
],
},
{
"kind": "nested-multiselect",
"message": "Select features",
"options": [
{
"choices": [
"Local executor",
"Sandboxed executor",
],
"default": true,
"label": "Command execution",
"required": true,
},
{
"choices": [
"JSONL files",
"SQLite database",
],
"default": true,
"label": "Durable session storage",
"required": true,
},
{
"choices": undefined,
"default": true,
"label": "Hot-module reload",
"required": false,
},
{
"choices": undefined,
"default": true,
"label": "Read, write, and edit local files",
"required": false,
},
{
"choices": undefined,
"default": true,
"label": "Model-facing task tracking",
"required": false,
},
{
"choices": undefined,
"default": true,
"label": "Local skill discovery",
"required": false,
},
{
"choices": [
"DeepSeek search",
"Exa search",
"Perplexity search",
"Fetch only",
],
"default": false,
"label": "Web search and fetch tools",
"required": false,
},
{
"choices": [
"Fresh child agent",
"Fork parent history",
],
"default": false,
"label": "Delegate work to child agents",
"required": false,
},
{
"choices": undefined,
"default": false,
"label": "Scripted multi-agent workflows",
"required": false,
},
{
"choices": undefined,
"default": false,
"label": "Automatic context compaction",
"required": false,
},
{
"choices": [
"Claude Code hooks",
"Codex hooks",
],
"default": false,
"label": "Run Claude Code or Codex hooks",
"required": false,
},
{
"choices": undefined,
"default": false,
"label": "Loop-hygiene reminders",
"required": false,
},
{
"choices": undefined,
"default": false,
"label": "Tool timeout policy",
"required": false,
},
],
},
{
"initialValue": true,
"kind": "confirm",
"message": "Add the recommended tool timeout policy for web search and fetch tools?",
},
{
"kind": "secret",
"message": "Exa API key",
},
{
"initialValue": "none",
"kind": "select",
"message": "Local plugin",
"options": [
"No local plugin",
"Cordis plugin",
"Model-facing tool",
],
},
{
"initialValue": "npm",
"kind": "select",
"message": "Package manager",
"options": [
"npm",
"pnpm",
"Yarn",
],
},
{
"initialValue": true,
"kind": "confirm",
"message": "Run npm install and then build the project?",
},
],
"result": {
"directory": "/workspace/my-agent",
"features": [
{
"id": "provider",
"options": [
"deepseek-official",
],
},
{
"id": "spine",
"options": [
"default",
],
},
{
"id": "app",
"options": [
"acp",
],
},
{
"id": "bash",
"options": [
"local",
],
},
{
"id": "persistence",
"options": [
"jsonl",
],
},
{
"id": "hmr",
"options": [
"default",
],
},
{
"id": "web",
"options": [
"exa",
],
},
{
"id": "workflow",
"options": [
"workerthread",
],
},
{
"id": "timeout-policy",
"options": [
"default",
],
},
],
"install": false,
"manager": "npm",
"name": "my-agent",
},
}
`)
})
})

View File

@@ -1,678 +0,0 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough, Writable } from 'node:stream'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
HeadlessPromptPort,
LocalPluginBlueprint,
featureId,
NodeCommandRunner,
NpmPackageManager,
type FeatureSelection,
type NestedMultiSelectValue,
type PromptPort,
} from '@deepseek-ai/dsh-helper'
import type {
ConfirmPromptRequest,
MultiSelectPromptRequest,
NestedMultiSelectRequest,
PromptOutcome,
SecretPromptRequest,
SelectPromptRequest,
TextPromptRequest,
} from '../../helper/src/questions/prompt-port.ts'
import { parseCreateArgs } from '../src/args.ts'
import {
createProject,
readCreateSdkVersion,
runCreateCommand,
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 {
readonly requests: string[] = []
readonly #answers: unknown[]
constructor(answers: unknown[]) {
this.#answers = [...answers]
}
answer<T>(message: string): Promise<PromptOutcome<T>> {
this.requests.push(message)
const value = this.#answers.shift()
return Promise.resolve(value === ScriptedPort.cancel
? { status: 'cancelled' }
: { status: 'answered', value: value as T })
}
async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
const outcome = await this.answer<string>(request.message)
if (outcome.status === 'cancelled') return outcome
const value = outcome.value || request.defaultValue || ''
const diagnostic = request.validate?.(value)
if (diagnostic) throw new Error(diagnostic)
return { status: 'answered', value }
}
secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> { return this.answer(request.message) }
select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> { return this.answer(request.message) }
multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
return this.answer(request.message)
}
confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> { return this.answer(request.message) }
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return this.answer(request.message)
}
static readonly cancel = Symbol('cancel')
}
const temporary: string[] = []
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
interface GeneratedPackageManifest {
scripts?: Record<string, string>
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
interface GeneratedTsConfig {
compilerOptions: {
types?: readonly string[]
}
}
function parseGeneratedPackageManifest(text: string): GeneratedPackageManifest {
return JSON.parse(text) as GeneratedPackageManifest
}
function parseGeneratedTsConfig(text: string): GeneratedTsConfig {
return JSON.parse(text) as GeneratedTsConfig
}
function commandContext(
cwd: string,
port?: PromptPort,
setup?: CreateCommandContext['setup'],
): CreateCommandContext & { readStdout: () => string; readStderr: () => string } {
let stdout = ''
let stderr = ''
const input = Object.assign(new PassThrough(), { isTTY: true }) as unknown as NodeJS.ReadStream
const output = Object.assign(new Writable({
write(chunk, _encoding, callback) { stdout += String(chunk); callback() },
}), { isTTY: true }) as unknown as NodeJS.WriteStream
const error = new Writable({
write(chunk, _encoding, callback) { stderr += String(chunk); callback() },
}) as unknown as NodeJS.WriteStream
return {
cwd,
stdin: input,
stdout: output,
stderr: error,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
...port ? { port } : {},
...setup ? { setup } : {},
readStdout: () => stdout,
readStderr: () => stderr,
}
}
afterEach(async () => {
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
describe('create arguments', () => {
it('parses public options and the private repository link mode', () => {
expect(parseCreateArgs([
'agent', '--description=demo', '--provider', 'deepseek-official', '--base-url=https://api.example',
'--api-key', 'key', '--model=m', '--interface', 'acp', '--pm=pnpm', '--no-install',
'--link-workspace',
])).toEqual({
directory: 'agent',
description: 'demo',
provider: 'deepseek-official',
baseURL: 'https://api.example',
apiKey: 'key',
model: 'm',
runInterface: 'acp',
packageManager: 'pnpm',
install: false,
linkWorkspace: true,
help: false,
})
expect(parseCreateArgs(['--link-workspace']).linkWorkspace).toBe(true)
expect(() => parseCreateArgs(['--link-packages-workspace'])).toThrow("unknown option '--link-packages-workspace'")
expect(parseCreateArgs(['--provider=custom']).provider).toBe('custom')
expect(parseCreateArgs(['--help']).help).toBe(true)
expect(() => parseCreateArgs(['--interface=bad'])).toThrow('Allowed choices are acp, embed')
expect(() => parseCreateArgs(['--unknown'])).toThrow("unknown option '--unknown'")
expect(() => parseCreateArgs(['one', 'two'])).toThrow('too many arguments')
})
it('validates empty directories and package names', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-validation-'))
temporary.push(root)
await expect(new CreateWizard({
args: parseCreateArgs(['']), port: new ScriptedPort([]), cwd: root,
releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
}).run()).rejects.toThrow('A value is required')
await expect(new CreateWizard({
args: parseCreateArgs(['agent']), port: new ScriptedPort(['Invalid Name']), cwd: root,
releaseVersion: '0.0.1', versionProbe: async () => '10.0.0',
}).run()).rejects.toThrow('lowercase npm package name')
})
it('rejects an existing target before asking project questions', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-existing-target-'))
temporary.push(cwd)
await mkdir(join(cwd, 'taken'))
const port = new ScriptedPort([])
const wizard = new CreateWizard({
args: parseCreateArgs(['taken']),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
})
await expect(wizard.run()).rejects.toThrow('directory: Target already exists')
expect(port.requests).toEqual([])
})
})
describe('CreateWizard and scaffolder', () => {
it('asks only unresolved questions in requirement-safe order', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-wizard-'))
temporary.push(cwd)
const port = new ScriptedPort([
'my-agent',
[
{ value: featureId('persistence'), choices: ['sqlite'] },
{ value: featureId('hmr'), choices: [] },
{ value: featureId('fs'), choices: [] },
{ value: featureId('web'), choices: ['exa'] },
],
false,
'exa-key',
'tool',
])
const args = parseCreateArgs([
'my-agent',
'--description=demo',
'--provider=deepseek-official',
'--api-key=deepseek-key',
'--model=deepseek-v4-flash',
'--interface=acp',
'--pm=npm',
'--no-install',
'--link-workspace',
])
const resolved = await new CreateWizard({
args,
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
}).run()
expect(port.requests).toEqual([
'Package name',
'Select features',
'Add the recommended tool timeout policy for web search and fetch tools?',
'Exa API key',
'Local plugin',
])
expect(resolved.install).toBe(false)
expect(resolved.request.packageManager.name).toBe('npm')
expect(resolved.request.linkWorkspaceRoot).toBe(repoRoot)
expect(resolved.request.localPlugins[0]).toMatchObject({ name: 'tool', kind: 'tool' })
expect(resolved.request.features.find(item => item.id === 'web')).toMatchObject({
options: ['exa'], secrets: { apiKey: 'exa-key' },
})
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-official', '--api-key=deepseek-key',
'--model=deepseek-v4-flash', '--interface=acp', '--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-official', '--api-key=k',
'--model=m', '--interface=acp', '--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)
const request = {
name: 'agent',
description: 'demo',
runtime: { model: 'deepseek-v4-flash' },
packageManager: new NpmPackageManager('10.0.0'),
releaseVersion: '0.0.1',
features: [
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'key' } },
{ id: featureId('bash'), options: ['local'] },
{ id: featureId('app'), options: ['embed'] },
{ id: featureId('persistence'), options: ['jsonl'] },
],
localPlugins: [new LocalPluginBlueprint('plugin', 'plugin')],
}
const target = join(root, 'project')
const result = await scaffoldProject(target, request)
expect(result.changes.changedFiles).toContain('README.md')
const index = await readFile(join(target, 'index.ts'), 'utf8')
expect(index).toContain('SdkBootContext')
expect(index).toContain('ctx.agents.create')
expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }')
expect(index).not.toContain('AgentId')
const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8'))
const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8'))
expect(tsconfig.compilerOptions.types).toEqual(['node'])
expect(manifest.scripts).toEqual({
dev: 'dsh-sdk dev index.ts',
build: 'dsh-sdk build',
typecheck: 'tsc -b',
start: 'dsh-sdk start index.js',
config: 'dsh-sdk config',
})
expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
expect(manifest.devDependencies?.['@types/node']).toBe('^22.20.0')
expect(await readFile(join(target, 'plugins/plugin/src/index.ts'), 'utf8')).toContain('export function apply')
const cordis = await readFile(join(target, 'cordis.yml'), 'utf8')
expect(cordis).toMatch(/^- id:/)
expect(cordis).not.toMatch(/^\[/)
const occupied = join(root, 'occupied')
await mkdir(occupied)
await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
await writeFile(join(occupied, 'keep'), 'x')
await expect(scaffoldProject(occupied, request)).rejects.toThrow('already exists')
})
it('installs workflow requirements before validating the next feature', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-workflow-requires-'))
temporary.push(cwd)
const port = new ScriptedPort([
'workflow-agent',
[
{ value: featureId('persistence'), choices: ['jsonl'] },
{ value: featureId('workflow'), choices: [] },
],
'none',
])
const resolved = await new CreateWizard({
args: parseCreateArgs([
'workflow-agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
'--interface=embed', '--pm=npm', '--no-install',
]),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
}).run()
const result = await scaffoldProject(resolved.directory, resolved.request)
expect(result.project.cordis.entry('subagent-spawn')).toBeDefined()
expect(result.project.cordis.entry('tool-subagent')).toBeDefined()
})
it('confirms an empty provider key and leaves a documented .env placeholder', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-empty-key-'))
temporary.push(cwd)
const port = new ScriptedPort([
'empty-key-agent',
'',
true,
[{ value: featureId('persistence'), choices: ['jsonl'] }],
'none',
])
const resolved = await new CreateWizard({
args: parseCreateArgs([
'empty-key-agent', '--description=test', '--provider=deepseek-official',
'--interface=embed', '--pm=npm', '--no-install',
]),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
}).run()
await scaffoldProject(resolved.directory, resolved.request)
expect(await readFile(join(resolved.directory, '.env'), 'utf8')).toBe(
'# Required before the first model request.\nDEEPSEEK_API_KEY=\n',
)
expect(port.requests).toContain('Keep the API key empty and fill .env later?')
})
it('collects custom provider inputs, retries an empty key, and accepts a recommendation', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-custom-inputs-'))
temporary.push(cwd)
const port = new ScriptedPort([
'custom-agent',
'test custom provider',
'custom',
'https://provider.example/v1',
'', false, 'custom-key',
'embed',
[
{ value: featureId('persistence'), choices: ['jsonl'] },
{ value: featureId('web'), choices: ['deepseek-official'] },
],
true,
'none',
'npm',
false,
])
const resolved = await new CreateWizard({
args: parseCreateArgs(['custom-agent']),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
userAgent: '',
}).run()
expect(resolved.request.features.find(item => item.id === 'provider')).toMatchObject({
options: ['custom'], values: { baseURL: 'https://provider.example/v1' }, secrets: { apiKey: 'custom-key' },
})
expect(resolved.request.features.some(item => item.id === 'timeout-policy')).toBe(true)
})
it('does not re-suggest an already selected feature', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'create-selected-suggestion-'))
temporary.push(cwd)
const port = new ScriptedPort([
'agent',
[
{ value: featureId('persistence'), choices: ['jsonl'] },
{ value: featureId('web'), choices: ['deepseek-official'] },
{ value: featureId('timeout-policy'), choices: ['default'] },
],
'none',
])
const resolved = await new CreateWizard({
args: parseCreateArgs([
'agent', '--description=test', '--provider=deepseek-official', '--api-key=key',
'--interface=embed', '--pm=npm', '--no-install',
]),
port,
cwd,
releaseVersion: '0.0.1',
versionProbe: async () => '10.0.0',
}).run()
expect(resolved.request.features.filter(item => item.id === 'timeout-policy')).toHaveLength(1)
})
it('uses process defaults when constructor infrastructure is omitted', async () => {
const name = `default-infra-${String(process.pid)}`
const port = new ScriptedPort([
name, [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
])
const resolved = await new CreateWizard({
args: parseCreateArgs([
name, '--description=test', '--provider=deepseek-official', '--api-key=key',
'--interface=embed', '--pm=npm', '--no-install',
]),
port,
releaseVersion: '0.0.1',
}).run()
expect(resolved.request.packageManager.name).toBe('npm')
})
it('reads the release batch from the initializer package', async () => {
// The version tracks the release, including a prerelease such as 0.0.1-rc.1,
// so the expectation comes from the manifest rather than a literal.
const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }
await expect(readCreateSdkVersion()).resolves.toBe(manifest.version)
})
})
describe('create command composition', () => {
const argv = (directory: string, install: boolean): string[] => [
directory, '--description=test', '--provider=deepseek-official', '--api-key=key',
'--interface=embed', '--pm=npm', install ? '--install' : '--no-install',
]
it('prints help before requiring a TTY and rejects non-interactive creation', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-help-'))
temporary.push(root)
const context = commandContext(root)
context.stdin.isTTY = false
context.stdout.isTTY = false
await expect(createProject(['--help'], context)).resolves.toBeUndefined()
expect(context.readStdout()).toContain('Usage: create-sdk')
expect(context.readStdout()).toContain('--config-json <json>')
expect(context.readStdout()).not.toContain('--link-workspace')
await expect(createProject(argv('agent', false), context)).rejects.toThrow('interactive TTY')
context.stdin.isTTY = true
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-official', 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-official', apiKey: 'key', features: [] })
await expect(runCreateCommand(['--config-json', okSpec, '--json'], ok)).resolves.toBe(0)
expect(ok.readStdout()).toContain('{"type":"done"}')
// stdout stays pure NDJSON: every line parses, human progress goes to stderr
for (const line of ok.readStdout().split('\n').filter(line => line.length > 0)) {
expect(() => { JSON.parse(line) }).not.toThrow()
}
expect(ok.readStderr()).toContain('Created done-agent')
expect(ok.readStderr()).toContain('Next: cd')
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)
const port = new ScriptedPort([
'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
])
let setupDirectory = ''
const context = commandContext(root, port, async (request) => { setupDirectory = request.directory })
const result = await createProject(argv('agent', true), context)
expect(result?.project.root).toBe(join(root, 'agent'))
expect(setupDirectory).toBe(join(root, 'agent'))
expect(context.readStdout()).toContain('Created agent')
expect(context.readStdout()).toContain('Next: cd')
const noInstall = commandContext(root, new ScriptedPort([
'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
]))
await expect(createProject(argv('next', false), noInstall)).resolves.toBeDefined()
expect(noInstall.readStdout()).toContain('npm install && npm run build && npm start')
})
it('uses the package manager setup path when no setup override is supplied', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-default-setup-'))
temporary.push(root)
const port = new ScriptedPort([
'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
])
const install = vi.spyOn(NpmPackageManager.prototype, 'install').mockResolvedValue()
const build = vi.spyOn(NpmPackageManager.prototype, 'build').mockResolvedValue()
const context = commandContext(root, port)
delete context.releaseVersion
delete context.versionProbe
await createProject(argv('agent', true), context)
expect(install).toHaveBeenCalledOnce()
expect(build).toHaveBeenCalledOnce()
const spec = JSON.stringify({
directory: 'json-agent', description: 'test', provider: 'deepseek-official', apiKey: 'key',
model: 'deepseek-v4-flash', interface: 'embed', pm: 'npm', install: true, features: [],
})
const json = commandContext(root)
json.stdin.isTTY = false
json.stdout.isTTY = false
await createProject(['--config-json', spec, '--json'], json)
// json mode hands install/build a runner that redirects child output to stderr
expect(install).toHaveBeenCalledTimes(2)
expect(install.mock.calls[1]?.[1]).toBeInstanceOf(NodeCommandRunner)
install.mockRestore()
build.mockRestore()
})
it('reports setup failures after preserving generated files', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-failure-'))
temporary.push(root)
const port = new ScriptedPort([
'agent', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
])
const context = commandContext(root, port, async () => { throw new Error('offline') })
await expect(createProject(argv('agent', true), context)).rejects.toThrow('offline')
expect(context.readStderr()).toContain('Project files are ready, but setup failed')
expect(context.readStderr()).toContain('npm install && npm run build')
const stringFailure = commandContext(root, new ScriptedPort([
'next', [{ value: featureId('persistence'), choices: ['jsonl'] }], 'none',
]), async () => { throw 'offline-string' })
await expect(runCreateCommand(argv('next', true), stringFailure)).resolves.toBe(1)
expect(stringFailure.readStderr()).toContain('offline-string')
})
it('maps cancellation and ordinary errors to command exit codes', async () => {
const root = await mkdtemp(join(tmpdir(), 'create-command-exit-'))
temporary.push(root)
const cancelled = commandContext(root, new ScriptedPort([ScriptedPort.cancel]))
await expect(runCreateCommand([], cancelled)).resolves.toBe(1)
expect(cancelled.readStderr()).toContain('cancelled')
const invalid = commandContext(root)
await expect(runCreateCommand(['--unknown'], invalid)).resolves.toBe(1)
expect(invalid.readStderr()).toContain('unknown option')
const help = commandContext(root)
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()
})
})

View File

@@ -1,126 +0,0 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import {
LocalPluginBlueprint,
featureId,
createPackageManager,
type PackageManagerName,
} from '@deepseek-ai/dsh-helper'
import { scrubEnvironment } from '../../helper/src/package-managers/package-manager.ts'
import { scaffoldProject } from '../src/project-scaffolder.ts'
const execFileAsync = promisify(execFile)
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const builtScripts = join(repoRoot, 'packages/scaffold/scripts/lib/bin.js')
const temporary: string[] = []
function resolveCorepackHome(): string {
return process.env.COREPACK_HOME ?? join(
process.env.XDG_CACHE_HOME
?? process.env.LOCALAPPDATA
?? join(homedir(), process.platform === 'win32' ? 'AppData/Local' : '.cache'),
'node/corepack',
)
}
afterEach(async () => {
await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true })))
})
async function managerVersion(name: PackageManagerName): Promise<string | undefined> {
try {
return (await execFileAsync(name, ['--version'], { encoding: 'utf8' })).stdout.trim()
} catch {
// An unavailable optional manager skips only its own live-link case.
return undefined
}
}
const managers: PackageManagerName[] = ['npm', 'pnpm', 'yarn']
describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () => {
for (const name of managers) {
it(`${name}: installs the local closure and resolves plugin TypeScript in dev`, async (context) => {
const version = await managerVersion(name)
if (!version) {
context.skip()
return
}
const parent = await mkdtemp(join(tmpdir(), `dsh-link-${name}-`))
const root = join(parent, 'project')
temporary.push(parent)
const manager = createPackageManager(name, version)
await scaffoldProject(root, {
name: `linked-${name}`,
description: 'link e2e',
runtime: { model: 'deepseek-v4-flash' },
packageManager: manager,
releaseVersion: '0.0.1',
linkWorkspaceRoot: repoRoot,
features: [
{ id: featureId('provider'), options: ['deepseek-official'], secrets: { apiKey: 'test-key' } },
{ id: featureId('bash'), options: ['local'] },
{ id: featureId('app'), options: ['embed'] },
{ id: featureId('persistence'), options: ['jsonl'] },
],
localPlugins: [new LocalPluginBlueprint('probe', 'plugin')],
})
await writeFile(join(root, 'plugins/probe/src/index.ts'), `
import { writeFileSync } from 'node:fs'
import type { Context } from '@deepseek-ai/cordis'
export const name = 'probe'
export function apply(_ctx: Context): void {
writeFileSync(new URL('../../../plugin-loaded', import.meta.url), 'loaded\\n')
}
`)
const cacheRoot = join(tmpdir(), 'dsh-sdk-link-cache', name)
const pnpmStore = name === 'pnpm'
? (await execFileAsync(name, ['store', 'path', '--silent'], { encoding: 'utf8' })).stdout.trim()
: undefined
const commandEnvironment = {
...scrubEnvironment(),
COREPACK_HOME: resolveCorepackHome(),
...name === 'pnpm' ? {} : { XDG_CACHE_HOME: join(cacheRoot, 'cache') },
XDG_DATA_HOME: join(cacheRoot, 'data'),
npm_config_cache: join(cacheRoot, 'npm'),
...pnpmStore === undefined ? {} : { pnpm_config_store_dir: pnpmStore },
// A generated project has no lockfile yet; ambient CI must not make its first Yarn install immutable.
...name === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {},
}
await execFileAsync(name, manager.installCommand(), {
cwd: root,
env: commandEnvironment,
encoding: 'utf8',
timeout: 120_000,
})
await execFileAsync(name, manager.buildCommand(), {
cwd: root,
env: commandEnvironment,
encoding: 'utf8',
timeout: 120_000,
})
expect(existsSync(join(root, 'index.js'))).toBe(true)
expect(existsSync(join(root, 'plugins/probe/lib/index.js'))).toBe(true)
const dshSdk = join(root, 'node_modules/@deepseek-ai/dsh-scripts/lib/bin.js')
const run = await execFileAsync(process.execPath, [dshSdk, 'dev', 'index.ts'], {
cwd: root,
env: { ...commandEnvironment, DEEPSEEK_API_KEY: 'test-key' },
encoding: 'utf8',
timeout: 30_000,
})
expect(run.stderr).not.toContain('without inject')
expect(await readFile(join(root, 'plugin-loaded'), 'utf8')).toBe('loaded\n')
const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as {
dependencies: Record<string, string>
}
expect(manifest.dependencies['@deepseek-ai/cordis']).toMatch(name === 'npm' ? /^file:/ : name === 'pnpm' ? /^link:/ : /^portal:/)
expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin')
}, 180_000)
}
})

View File

@@ -1,19 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../helper"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,14 +0,0 @@
import { defineConfig } from 'tsdown'
/** Bundle the library and create bin, then mirror package-owned terminal templates. */
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
copy: [{ from: 'src/templates/assets/*', to: 'lib/assets' }],
})

View File

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/scaffold/helper/README.md
README.md: 416fcab9815e50ca662333eb6925cc37eb0c41c4
README.zh.md: c50ec7aafa03377bd11759c50eeb2422a12fab68

View File

@@ -1,29 +0,0 @@
# `@deepseek-ai/dsh-helper`
English | [中文](README.zh.md)
Shared project domain and infrastructure for `create-sdk` and `dsh-sdk config`. `SdkProject` is a read-only snapshot; `ProjectEditSession` is the only mutation and commit boundary. The [SDK architecture Agent Note](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) owns the rationale.
The package owns the builtin typed-spec catalog, provider/app behavior entities, structured project file objects, helper-owned project templates, the shared typed `TextTemplate` renderer, package-manager strategies, local-plugin blueprints, typed questions, and the clack prompt adapter. It never boots a Cordis application.
All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts.
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, and timeout policy. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes only the automation bridge; interactive services belong to host compositions.
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`, but rejects a config that references the removed `@deepseek-ai/dsh-tui` root or a subpath. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically.
`.env.example` follows the currently selected features. `.env` is append-only: helper may add a missing differently named variable, but never updates or removes existing content.
The package root explicitly exports only the objects consumed by `create-sdk` and `dsh-scripts`; internal modules have no `src/*` or package-manifest subpath export.
## Model Experience
None, as the project domain edits files and never mounts a live agent or model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Commit is not transactional across files** — external edits are detected before each write, but a later failure does not roll back files already written.

View File

@@ -1,29 +0,0 @@
# `@deepseek-ai/dsh-helper`
[English](README.md) | 中文
`create-sdk``dsh-sdk config` 共用的项目领域和基础设施。`SdkProject` 是只读快照;`ProjectEditSession` 是唯一的变更与提交边界。设计理由由 [SDK 架构 Agent Noteagent 决策记录)](../../../.agents/notes/proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) 负责。
该包package负责内置的类型化 spec 目录、提供方应用行为实体、结构化项目文件对象、helper 自有项目模板、共享的类型化 `TextTemplate` 渲染器、包管理器策略、本地插件蓝图、类型化问题,以及 clack 交互提示适配器。它绝不会启动 Cordis 应用。
所有业务验证与文档验证都会在提交写入任何受影响文件前完成。提交会检测编辑会话打开后发生的外部修改,但在开始写入后,有意不提供跨文件回滚。
内置功能包括提供方、bash、app、持久化、HMR热模块替换、filesystem、todo、skill技能、web、subagent、工作流、压缩compaction、钩子、repeat-tool guard 和 timeout policy。目录负责功能选项、必需和非默认 Cordis 插件配置、功能依赖、资源贡献与往返标记create 与 config 使用同一注册表和配置器。ACPAgent Client Protocol应用选项只贡献自动化桥交互式服务属于宿主组合。
`SdkProject.open()` 只要求根目录下的 `package.json``cordis.yml` 可读,但会拒绝引用已移除的 `@deepseek-ai/dsh-tui` 包根或其子路径的配置。Cordis 配置项用于锚定功能安装;如果某个包只存在于链接的 NPM 依赖闭包中,则该功能仍视为不存在。一旦所属的 Cordis 配置项存在,资源结构不完整就是 `inconsistent`,无法自动修改。
`.env.example` 跟随当前所选功能。`.env` 仅追加helper 可以补充缺失且名称不同的变量,但绝不会更新或删除现有内容。
包根明确只导出 `create-sdk``dsh-scripts` 使用的对象;内部模块不提供 `src/*` 或 package-manifest 子路径导出。
## 模型体验
无。项目领域只编辑文件,绝不会挂载运行中的 agent智能体也不会发起模型请求。
#### KV Cache 影响
无;此包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **提交不具备跨文件事务性**:每次写入前都会检测外部修改,但后续失败不会回滚已经写入的文件。

View File

@@ -1,59 +0,0 @@
{
"name": "@deepseek-ai/dsh-helper",
"description": "Domain model and infrastructure for creating and editing DeepSeek Harness SDK projects",
"version": "0.0.1-rc.1",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/scaffold/helper"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
}
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/assets",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {
"@clack/core": "^1.4.3",
"@clack/prompts": "^1.7.0",
"handlebars": "^4.7.9",
"jsonc-parser": "^3.3.1",
"yaml": "^2.9.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-hooks-claude": "workspace:^",
"@deepseek-ai/dsh-hooks-codex": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -1,190 +0,0 @@
/**
* Comment-preserving Cordis YAML document and `!!js` expression value.
*
* @module @deepseek-ai/dsh-helper/documents/cordis-yaml-file
*/
import {
Document, isMap, isSeq, parseDocument, visit, YAMLMap, YAMLSeq,
type ScalarTag,
} from 'yaml'
import { ProjectFile, withTrailingNewline } from './project-file.ts'
/** Explicit JavaScript expression serialized with Cordis' `!!js` YAML tag. */
export class JsExpression {
/** Expression source evaluated by the Cordis include loader. */
readonly source: string
/** Create an expression value. */
constructor(source: string) {
if (source.trim().length === 0) throw new Error('JavaScript expression must not be empty')
this.source = source
}
/** Return expression source for YAML scalar stringification. */
toString(): string {
return this.source
}
}
const JS_EXPRESSION_TAG: ScalarTag = {
tag: 'tag:yaml.org,2002:js',
identify: value => value instanceof JsExpression,
resolve: value => new JsExpression(value),
stringify: item => String(item.value),
}
/** Plain domain representation of one top-level Cordis config entry. */
export interface CordisConfigEntry {
id: string
name: string
config?: Record<string, unknown>
disabled?: boolean
}
function parseYaml(text: string): Document.Parsed {
const document = parseDocument(text, {
customTags: [JS_EXPRESSION_TAG],
keepSourceTokens: true,
prettyErrors: true,
})
if (document.errors.length > 0) {
throw new Error(`invalid cordis.yml: ${document.errors.map(error => error.message).join('; ')}`)
}
if (!isSeq(document.contents)) throw new Error('invalid cordis.yml: root must be a sequence')
visit(document, { Collection: (_key, collection) => { collection.flow = false } })
return document
}
function entryFromValue(value: unknown): CordisConfigEntry {
/* v8 ignore next -- entries() calls this only after requiring a YAMLMap, whose JSON value is an object */
if (value === null || Array.isArray(value) || typeof value !== 'object') {
throw new Error('invalid cordis.yml entry: expected an object')
}
const entry = value as Record<string, unknown>
if (typeof entry.id !== 'string' || entry.id.length === 0) {
throw new Error('invalid cordis.yml entry: id must be a non-empty string')
}
if (typeof entry.name !== 'string' || entry.name.length === 0) {
throw new Error(`invalid cordis.yml entry ${entry.id}: name must be a non-empty string`)
}
if (entry.config !== undefined
&& (entry.config === null || Array.isArray(entry.config) || typeof entry.config !== 'object')) {
throw new Error(`invalid cordis.yml entry ${entry.id}: plugin config must be an object`)
}
if (entry.disabled !== undefined && typeof entry.disabled !== 'boolean') {
throw new Error(`invalid cordis.yml entry ${entry.id}: disabled must be boolean`)
}
return {
id: entry.id,
name: entry.name,
...entry.config !== undefined ? { config: entry.config as Record<string, unknown> } : {},
...entry.disabled !== undefined ? { disabled: entry.disabled } : {},
}
}
/** Editable top-level cordis.yml using YAML's document API. */
export class CordisYamlFile extends ProjectFile {
private readonly document: Document.Parsed
private constructor(document: Document.Parsed, originalText?: string) {
super('cordis.yml', originalText)
this.document = document
}
/** Create an empty Cordis config entry list. */
static create(): CordisYamlFile {
return new CordisYamlFile(parseYaml('[]\n'))
}
/** Parse an existing cordis.yml while retaining comments and scalar styles. */
static parse(text: string): CordisYamlFile {
return new CordisYamlFile(parseYaml(text), text)
}
/** Clone through YAML text so the edit session owns an independent AST. */
override clone(): CordisYamlFile {
return new CordisYamlFile(parseYaml(this.serialize()), this.originalText)
}
private sequence(): YAMLSeq {
/* v8 ignore next -- parseYaml and create both establish a sequence root */
if (!isSeq(this.document.contents)) throw new Error('cordis.yml root is not a sequence')
return this.document.contents
}
private entryNode(id: string): YAMLMap | undefined {
for (const item of this.sequence().items) {
if (!isMap(item)) continue
if (item.get('id') === id) return item
}
return undefined
}
/** Return defensive plain entry values in file order. */
entries(): CordisConfigEntry[] {
return this.sequence().items.map((item) => {
if (!isMap(item)) throw new Error('invalid cordis.yml: every entry must be a mapping')
return entryFromValue(item.toJSON())
})
}
/** Find one entry by stable id. */
entry(id: string): CordisConfigEntry | undefined {
return this.entries().find(entry => entry.id === id)
}
/** Add one new top-level entry, rejecting duplicate ids. */
addEntry(entry: CordisConfigEntry, commentedExample?: string): void {
if (this.entryNode(entry.id)) throw new Error(`Cordis config entry already exists: ${entry.id}`)
const node = this.document.createNode(entry)
if (commentedExample) node.comment = commentedExample.split('\n').map(line => ` ${line}`).join('\n')
this.sequence().items.push(node)
}
/** Remove an entry by id and report whether it existed. */
removeEntry(id: string): boolean {
const sequence = this.sequence()
const index = sequence.items.findIndex(item => isMap(item) && item.get('id') === id)
if (index < 0) return false
sequence.items.splice(index, 1)
return true
}
/** Enable or disable an entry through the Loader-native field. */
setDisabled(id: string, disabled: boolean): void {
const node = this.entryNode(id)
if (!node) throw new Error(`Cordis config entry does not exist: ${id}`)
if (disabled) node.set('disabled', true)
else node.delete('disabled')
}
/** Replace only owned plugin config keys while retaining unknown user keys. */
updateOwnedConfig(id: string, ownedKeys: readonly string[], next: Record<string, unknown>): void {
const entry = this.entryNode(id)
if (!entry) throw new Error(`Cordis config entry does not exist: ${id}`)
let config: unknown = entry.get('config', true)
if (config === undefined || config === null) {
config = new YAMLMap()
entry.set('config', config)
}
if (!isMap(config)) throw new Error(`Cordis config entry ${id} plugin config is not a mapping`)
for (const key of ownedKeys) config.delete(key)
for (const [key, value] of Object.entries(next)) config.set(key, this.document.createNode(value))
if (config.items.length === 0) entry.delete('config')
}
/** Validate ids, names, plugin config maps, and id uniqueness. */
override validate(): void {
const seen = new Set<string>()
for (const entry of this.entries()) {
if (seen.has(entry.id)) throw new Error(`duplicate Cordis config entry id: ${entry.id}`)
seen.add(entry.id)
}
}
/** Serialize through the YAML document while retaining untouched trivia. */
override serialize(): string {
return withTrailingNewline(this.document.toString({ lineWidth: 0 }))
}
}

View File

@@ -1,111 +0,0 @@
/**
* Ownership-aware, line-preserving dotenv document.
*
* @module @deepseek-ai/dsh-helper/documents/env-file
*/
import { ProjectFile, withTrailingNewline } from './project-file.ts'
interface ParsedVariable {
index: number
value: string
}
const VARIABLE = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/
const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
/** `.env` appends missing variables; `.env.example` supports managed replacement and removal. */
export class EnvFile extends ProjectFile {
private readonly lines: string[]
private constructor(relativePath: '.env' | '.env.example', lines: string[], originalText?: string) {
super(relativePath, originalText, relativePath === '.env' ? 0o600 : undefined)
this.lines = [...lines]
}
/** Create an empty environment file. */
static create(relativePath: '.env' | '.env.example'): EnvFile {
return new EnvFile(relativePath, [])
}
/** Parse an existing environment file without rewriting unknown lines. */
static parse(relativePath: '.env' | '.env.example', text: string): EnvFile {
const normalized = text.replace(/\n$/, '')
return new EnvFile(relativePath, normalized.length === 0 ? [] : normalized.split('\n'), text)
}
/** Clone the current line model. */
override clone(): EnvFile {
return new EnvFile(this.relativePath as '.env' | '.env.example', this.lines, this.originalText)
}
private variables(): Map<string, ParsedVariable[]> {
const values = new Map<string, ParsedVariable[]>()
this.lines.forEach((line, index) => {
const match = VARIABLE.exec(line)
if (!match) return
const name = match[1]
const value = match[2]
/* v8 ignore next -- both captures are mandatory in VARIABLE */
if (name === undefined || value === undefined) return
const occurrences = values.get(name) ?? []
occurrences.push({ index, value })
values.set(name, occurrences)
})
return values
}
/** Read the effective value; append-only `.env` accepts duplicates and uses the last declaration. */
get(name: string): string | undefined {
const occurrences = this.variables().get(name) ?? []
if (this.relativePath === '.env.example' && occurrences.length > 1) {
throw new Error(`${this.relativePath} contains duplicate variable ${name}`)
}
return occurrences.at(-1)?.value
}
/** Add or replace one SDK-managed `.env.example` variable while preserving unrelated lines. */
set(name: string, value: string): void {
if (this.relativePath !== '.env.example') throw new Error('.env is append-only')
if (!VARIABLE_NAME.test(name)) throw new Error(`invalid environment variable name: ${name}`)
const occurrences = this.variables().get(name) ?? []
if (occurrences.length > 1) throw new Error(`${this.relativePath} contains duplicate variable ${name}`)
const line = `${name}=${value}`
if (occurrences[0]) this.lines[occurrences[0].index] = line
else this.lines.push(line)
}
/** Append a missing `.env` variable and optional comment without changing any existing declaration. */
append(name: string, value: string, comment?: string): boolean {
if (this.relativePath !== '.env') throw new Error('.env.example is SDK-managed')
if (!VARIABLE_NAME.test(name)) throw new Error(`invalid environment variable name: ${name}`)
if (comment !== undefined && (!comment || comment.includes('\n'))) {
throw new Error('environment comment must be one non-empty line')
}
if (this.variables().has(name)) return false
if (comment) this.lines.push(`# ${comment}`)
this.lines.push(`${name}=${value}`)
return true
}
/** Remove one SDK-managed `.env.example` variable while retaining every other line. */
remove(name: string): void {
if (this.relativePath !== '.env.example') throw new Error('.env is append-only')
const occurrences = this.variables().get(name) ?? []
if (occurrences.length > 1) throw new Error(`${this.relativePath} contains duplicate variable ${name}`)
if (occurrences[0]) this.lines.splice(occurrences[0].index, 1)
}
/** Validate the managed placeholder file; append-only `.env` accepts duplicate declarations. */
override validate(): void {
if (this.relativePath === '.env') return
for (const [name, occurrences] of this.variables()) {
if (occurrences.length > 1) throw new Error(`${this.relativePath} contains duplicate variable ${name}`)
}
}
/** Serialize all retained lines with one trailing newline. */
override serialize(): string {
return withTrailingNewline(this.lines.join('\n'))
}
}

View File

@@ -1,169 +0,0 @@
/**
* Structured package.json document owned by an SDK project.
*
* @module @deepseek-ai/dsh-helper/documents/package-json-file
*/
import { ProjectFile, withTrailingNewline } from './project-file.ts'
/** NPM dependency sections managed by the SDK. */
export type NpmDependencySection = 'dependencies' | 'devDependencies'
/** JSON shape retained by {@link PackageJsonFile}. */
export interface PackageManifest {
name?: string
version?: string
private?: boolean
description?: string
type?: string
packageManager?: string
scripts?: Record<string, string>
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
workspaces?: string[]
resolutions?: Record<string, string>
[key: string]: unknown
}
function parseManifest(text: string): PackageManifest {
let value: unknown
try {
value = JSON.parse(text)
} catch (error) {
throw new Error(`invalid package.json: ${String(error)}`)
}
if (value === null || Array.isArray(value) || typeof value !== 'object') {
throw new Error('invalid package.json: root must be an object')
}
return value as PackageManifest
}
function sortedRecord(value: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)))
}
/** Editable, deterministic package.json representation. */
export class PackageJsonFile extends ProjectFile {
private readonly manifest: PackageManifest
private constructor(manifest: PackageManifest, originalText?: string) {
super('package.json', originalText)
this.manifest = structuredClone(manifest)
}
/** Create a new package manifest from a complete rendered template. */
static create(text: string): PackageJsonFile {
return new PackageJsonFile(parseManifest(text))
}
/** Parse an existing package.json document. */
static parse(text: string): PackageJsonFile {
return new PackageJsonFile(parseManifest(text), text)
}
/** Clone this document and its nested manifest data. */
override clone(): PackageJsonFile {
return new PackageJsonFile(this.manifest, this.originalText)
}
/** Return a defensive copy of the manifest. */
value(): Readonly<PackageManifest> {
return structuredClone(this.manifest)
}
/** Set one package script. */
setScript(name: string, command: string): void {
this.manifest.scripts ??= {}
this.manifest.scripts[name] = command
}
/** Read one package script. */
script(name: string): string | undefined {
return this.manifest.scripts?.[name]
}
/** Remove one package script. */
removeScript(name: string): void {
delete this.manifest.scripts?.[name]
}
/** Set one NPM dependency in its runtime or development section. */
setNpmDependency(section: NpmDependencySection, name: string, spec: string): void {
this.manifest[section] ??= {}
this.manifest[section][name] = spec
}
/** Remove one NPM dependency from a section. */
removeNpmDependency(section: NpmDependencySection, name: string): void {
delete this.manifest[section]?.[name]
}
/** Read an NPM dependency spec from either managed section. */
npmDependency(name: string): { section: NpmDependencySection; spec: string } | undefined {
for (const section of ['dependencies', 'devDependencies'] as const) {
const spec = this.manifest[section]?.[name]
if (spec !== undefined) return { section, spec }
}
return undefined
}
/** Return all managed NPM dependency names. */
npmDependencyNames(): string[] {
return [...new Set([
...Object.keys(this.manifest.dependencies ?? {}),
...Object.keys(this.manifest.devDependencies ?? {}),
])].sort()
}
/** Add a package-manager workspace glob. */
addWorkspace(pattern: string): void {
const workspaces = this.manifest.workspaces ??= []
if (!workspaces.includes(pattern)) workspaces.push(pattern)
}
/** Set or remove the packageManager field. */
setPackageManager(value: string | undefined): void {
if (value === undefined) delete this.manifest.packageManager
else this.manifest.packageManager = value
}
/** Pin a Yarn resolution used by live-link projects. */
setResolution(name: string, spec: string): void {
this.manifest.resolutions ??= {}
this.manifest.resolutions[name] = spec
}
/** Validate the fields the SDK relies on. */
override validate(): void {
if (!this.manifest.name || typeof this.manifest.name !== 'string') {
throw new Error('package.json name must be a non-empty string')
}
for (const section of ['scripts', 'dependencies', 'devDependencies'] as const) {
const value: unknown = this.manifest[section]
if (value === undefined) continue
if (value === null || Array.isArray(value) || typeof value !== 'object') {
throw new Error(`package.json ${section} must be an object`)
}
for (const [key, item] of Object.entries(value)) {
if (typeof item !== 'string' || item.length === 0) {
throw new Error(`package.json ${section}.${key} must be a non-empty string`)
}
}
}
if (this.manifest.workspaces !== undefined
&& (!Array.isArray(this.manifest.workspaces) || this.manifest.workspaces.some(item => typeof item !== 'string'))) {
throw new Error('package.json workspaces must be an array of strings')
}
}
/** Serialize with deterministic managed maps and two-space JSON formatting. */
override serialize(): string {
const value: PackageManifest = structuredClone(this.manifest)
if (this.manifest.scripts) value.scripts = sortedRecord(this.manifest.scripts)
if (this.manifest.dependencies) value.dependencies = sortedRecord(this.manifest.dependencies)
if (this.manifest.devDependencies) value.devDependencies = sortedRecord(this.manifest.devDependencies)
if (this.manifest.workspaces) value.workspaces = [...this.manifest.workspaces].sort()
if (this.manifest.resolutions) value.resolutions = sortedRecord(this.manifest.resolutions)
return withTrailingNewline(JSON.stringify(value, null, 2))
}
}

View File

@@ -1,96 +0,0 @@
/**
* Structured pnpm workspace configuration for generated SDK projects.
*
* @module @deepseek-ai/dsh-helper/documents/pnpm-workspace-file
*/
import {
isMap, isScalar, isSeq, parseDocument,
type Document, type Scalar, type YAMLMap, type YAMLSeq,
} from 'yaml'
import { ProjectFile, withTrailingNewline } from './project-file.ts'
function parseYaml(text: string): Document.Parsed {
const document = parseDocument(text, { keepSourceTokens: true, prettyErrors: true })
if (document.errors.length > 0) {
throw new Error(`invalid pnpm-workspace.yaml: ${document.errors.map(error => error.message).join('; ')}`)
}
if (!isMap(document.contents)) throw new Error('pnpm-workspace.yaml root must be an object')
return document
}
/** Generated pnpm-workspace.yaml model. */
export class PnpmWorkspaceFile extends ProjectFile {
private readonly document: Document.Parsed
private constructor(document: Document.Parsed, originalText?: string) {
super('pnpm-workspace.yaml', originalText)
this.document = document
}
/** Create a pnpm workspace document. */
static create(): PnpmWorkspaceFile {
const document = new PnpmWorkspaceFile(parseYaml('{}\n'))
document.mapping().set('packages', document.document.createNode([]))
document.mapping().set('allowBuilds', document.document.createNode({ esbuild: true }))
return document
}
/** Parse the workspace fields the SDK owns while retaining all other YAML. */
static parse(text: string): PnpmWorkspaceFile {
const document = new PnpmWorkspaceFile(parseYaml(text), text)
document.packageSequence()
const autoInstallPeers = document.mapping().get('autoInstallPeers')
if (autoInstallPeers !== undefined && typeof autoInstallPeers !== 'boolean') {
throw new Error('pnpm-workspace.yaml autoInstallPeers must be boolean')
}
return document
}
/** Clone the complete comment-preserving workspace document. */
override clone(): PnpmWorkspaceFile {
return new PnpmWorkspaceFile(parseYaml(this.serialize()), this.originalText)
}
/** Add one package workspace glob. */
addPackage(pattern: string): void {
const packages = this.packageSequence()
if (packages.items.some(item => item.value === pattern)) return
packages.add(this.document.createNode(pattern))
}
/** Disable registry peer auto-installation for live-link projects. */
disableAutoInstallPeers(): void {
this.mapping().set('autoInstallPeers', false)
}
/** Validate workspace globs. */
override validate(): void {
for (const pattern of this.packageValues()) {
if (pattern.trim().length === 0) throw new Error('pnpm workspace pattern must not be empty')
}
}
/** Serialize the workspace while retaining unknown settings and comments. */
override serialize(): string {
return withTrailingNewline(this.document.toString({ lineWidth: 0 }))
}
private mapping(): YAMLMap {
/* v8 ignore next -- parseYaml and create both establish a mapping root */
if (!isMap(this.document.contents)) throw new Error('pnpm-workspace.yaml root must be an object')
return this.document.contents
}
private packageSequence(): YAMLSeq<Scalar<string>> {
const packages = this.mapping().get('packages', true)
if (!isSeq(packages) || packages.items.some(item => !isScalar(item) || typeof item.value !== 'string')) {
throw new Error('pnpm-workspace.yaml packages must be an array of strings')
}
return packages as YAMLSeq<Scalar<string>>
}
private packageValues(): string[] {
return this.packageSequence().items.map(item => item.value)
}
}

View File

@@ -1,64 +0,0 @@
/**
* Base abstraction for one file in an SDK project snapshot.
*
* @module @deepseek-ai/dsh-helper/documents/project-file
*/
/** Return text with exactly one trailing newline. */
export function withTrailingNewline(text: string): string {
return text.replace(/\n*$/, '') + '\n'
}
/** One cloneable, validatable project file. */
export abstract class ProjectFile {
/** Project-relative POSIX path. */
readonly relativePath: string
/** Text observed when the document entered the snapshot; absent for a new file. */
readonly originalText: string | undefined
/** Permission bits used only when the file is first created. */
readonly createMode: number | undefined
protected constructor(relativePath: string, originalText?: string, createMode?: number) {
if (relativePath.startsWith('/') || relativePath.split('/').includes('..')) {
throw new Error(`project document path must stay inside the project: ${relativePath}`)
}
this.relativePath = relativePath
this.originalText = originalText
this.createMode = createMode
}
/** Clone the document for an isolated edit session. */
abstract clone(): ProjectFile
/** Validate the document's complete current state. */
abstract validate(): void
/** Serialize the complete current file. */
abstract serialize(): string
}
/** Immutable complete-text file used by one-shot artifacts. */
export class TextProjectFile extends ProjectFile {
private readonly text: string
/** Create a complete-text project document. */
constructor(relativePath: string, text: string, originalText?: string) {
super(relativePath, originalText)
this.text = withTrailingNewline(text)
}
/** Clone this immutable document. */
override clone(): TextProjectFile {
return new TextProjectFile(this.relativePath, this.text, this.originalText)
}
/** Complete text artifacts have no extra structural validation. */
override validate(): void {}
/** Return the complete artifact text. */
override serialize(): string {
return this.text
}
}

View File

@@ -1,89 +0,0 @@
/**
* Comment-preserving root tsconfig editor for local plugin references.
*
* @module @deepseek-ai/dsh-helper/documents/tsconfig-file
*/
import { applyEdits, modify, parse, type ParseError } from 'jsonc-parser'
import { ProjectFile, withTrailingNewline } from './project-file.ts'
const FORMAT = { insertSpaces: true, tabSize: 2, eol: '\n' }
function parseConfig(text: string): Record<string, unknown> {
const errors: ParseError[] = []
const value: unknown = parse(text, errors, { allowTrailingComma: true, disallowComments: false })
if (errors.length > 0 || value === null || Array.isArray(value) || typeof value !== 'object') {
throw new Error('tsconfig.json is not a valid JSONC object')
}
return value as Record<string, unknown>
}
/** Root tsconfig document edited with jsonc-parser patches. */
export class TsConfigFile extends ProjectFile {
private text: string
private constructor(text: string, originalText?: string) {
super('tsconfig.json', originalText)
this.text = withTrailingNewline(text)
}
/** Create the root project-reference config. */
static create(): TsConfigFile {
return new TsConfigFile(JSON.stringify({
extends: './tsconfig.base.json',
compilerOptions: { noEmit: true },
include: ['index.ts'],
references: [],
}, null, 2))
}
/** Parse an existing root tsconfig. */
static parse(text: string): TsConfigFile {
parseConfig(text)
return new TsConfigFile(text, text)
}
/** Clone the current JSONC text. */
override clone(): TsConfigFile {
return new TsConfigFile(this.text, this.originalText)
}
/** Add one project reference while retaining comments and formatting. */
addReference(path: string): void {
const value = parseConfig(this.text)
const references = value.references
if (references !== undefined && !Array.isArray(references)) {
throw new Error('tsconfig.json references must be an array')
}
const typed = (references ?? []) as unknown[]
for (const item of typed) {
if (item === null || Array.isArray(item) || typeof item !== 'object' || typeof (item as { path?: unknown }).path !== 'string') {
throw new Error('tsconfig.json references must contain { path: string } objects')
}
}
if (typed.some(item => (item as { path: string }).path === path)) return
this.text = applyEdits(this.text, modify(
this.text,
['references', typed.length],
{ path },
{ formattingOptions: FORMAT, isArrayInsertion: true },
))
}
/** Validate JSONC and the project-reference fields. */
override validate(): void {
const value = parseConfig(this.text)
if (value.references === undefined) return
if (!Array.isArray(value.references)) throw new Error('tsconfig.json references must be an array')
for (const item of value.references) {
if (item === null || Array.isArray(item) || typeof item !== 'object' || typeof (item as { path?: unknown }).path !== 'string') {
throw new Error('tsconfig.json references must contain { path: string } objects')
}
}
}
/** Return patched JSONC text. */
override serialize(): string {
return withTrailingNewline(this.text)
}
}

View File

@@ -1,100 +0,0 @@
/**
* Required run-interface app feature.
*
* @module @deepseek-ai/dsh-helper/features/builtin/app
*/
import { featureId } from '../../ids.ts'
import type { ProjectProfile, RunInterface } from '../../project/types.ts'
import {
createAppPackageScripts,
createAppProjectArtifacts,
createProjectTemplateContext,
} from '../../templates/project-template.ts'
import {
FeatureOption,
ExclusiveOptionFeature,
} from '../feature.ts'
import { ProjectContribution, type ProjectResource } from '../resources.ts'
import {
npmCordisConfigEntry,
ownedTextFile,
packageScript,
requiredString,
} from './helpers.ts'
const ID = featureId('app')
function appProjectResources(
profile: ProjectProfile,
runInterface: RunInterface,
): readonly ProjectResource[] {
const context = createProjectTemplateContext(profile, runInterface)
const scripts = createAppPackageScripts()
return [
...createAppProjectArtifacts(context).map(document => (
ownedTextFile(ID, document.relativePath, document.serialize())
)),
packageScript(ID, 'dev', scripts.dev),
packageScript(ID, 'start', scripts.start),
]
}
class AppOption extends FeatureOption {
override readonly id: RunInterface
override readonly label: string
constructor(id: RunInterface, label: string) {
super()
this.id = id
this.label = label
}
/** Identify external options by their run interface, not the shared interaction service. */
override markerConfigEntries(): readonly { id: string; name: string }[] {
switch (this.id) {
case 'acp': return [{ id: 'acp', name: '@deepseek-ai/dsh-acp' }]
case 'embed': return []
}
}
/** Embed is identified by the configured loop and absence of an external entry point. */
override matchesConfigEntries(entries: readonly { id: string; name: string }[], profile: ProjectProfile): boolean {
if (this.id !== 'embed') return super.matchesConfigEntries(entries, profile)
return entries.some(entry => entry.id === 'agent-loop' && entry.name === '@deepseek-ai/dsh-agent-loop')
&& !entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')
}
override contribution(profile: ProjectProfile): ProjectContribution {
switch (this.id) {
case 'acp':
return new ProjectContribution([
...appProjectResources(profile, this.id),
...npmCordisConfigEntry(ID, {
id: 'acp',
name: '@deepseek-ai/dsh-acp',
config: { model: profile.runtime.model },
}, ['model'], config => requiredString(config, 'model')),
])
case 'embed':
return new ProjectContribution(appProjectResources(profile, this.id))
}
}
}
/** Required app selection represented by ACP or embed options. */
export class AppFeature extends ExclusiveOptionFeature {
override readonly id = ID
override readonly summary = 'Run interface'
override readonly required = true
override readonly requires = [featureId('spine')]
override readonly options = [
new AppOption('acp', 'ACP automation server'),
new AppOption('embed', 'Embedded context'),
]
/** Default to the profile's already selected run interface. */
override defaultOptions(profile: ProjectProfile): readonly string[] {
return [profile.runInterface]
}
}

View File

@@ -1,122 +0,0 @@
/**
* Small resource constructors shared by builtin feature modules.
*
* @module @deepseek-ai/dsh-helper/features/builtin/helpers
*/
import type { CordisConfigEntry } from '../../documents/cordis-yaml-file.ts'
import { TextProjectFile } from '../../documents/project-file.ts'
import { resourceKey } from '../../ids.ts'
import type {
CordisConfigEntryResource,
EnvironmentResource,
OwnedFileResource,
NpmDependencyResource,
PackageScriptResource,
} from '../resources.ts'
/** Return the installable package name for a bare package or package subpath. */
function installablePackageName(specifier: string): string {
const segments = specifier.split('/')
const expectedSegments = specifier.startsWith('@') ? 2 : 1
if (segments.length < expectedSegments || segments.slice(0, expectedSegments).some(segment => segment.length === 0)) {
throw new Error(`invalid bare package specifier: ${JSON.stringify(specifier)}`)
}
return segments.slice(0, expectedSegments).join('/')
}
/** Create a runtime NPM dependency resource. */
function npmDependency(_owner: string, specifier: string): NpmDependencyResource {
const name = installablePackageName(specifier)
return {
kind: 'npm-dependency',
key: resourceKey(`npm-dependency:${name}`),
name,
section: 'dependencies',
}
}
/** Create a feature-owned package script that is replaceable only while unchanged. */
export function packageScript(_owner: string, name: string, command: string): PackageScriptResource {
return {
kind: 'package-script',
key: resourceKey(`package-script:${name}`),
name,
command,
removeOnlyWhenUnchanged: true,
}
}
/** Create a Cordis config entry resource with explicitly owned config keys. */
export function cordisConfigEntry(
_owner: string,
value: CordisConfigEntry,
ownedConfigKeys: readonly string[] = Object.keys(value.config ?? {}),
validateConfig?: CordisConfigEntryResource['validateConfig'],
): CordisConfigEntryResource {
return {
kind: 'cordis-config-entry',
key: resourceKey(`cordis-config-entry:${value.id}`),
entry: value,
ownedConfigKeys,
...validateConfig ? { validateConfig } : {},
}
}
/** Couple one bare-package or subpath Cordis entry to its installable NPM package. */
export function npmCordisConfigEntry(
owner: string,
value: CordisConfigEntry,
ownedConfigKeys: readonly string[] = Object.keys(value.config ?? {}),
validateConfig?: CordisConfigEntryResource['validateConfig'],
): readonly [NpmDependencyResource, CordisConfigEntryResource] {
return [
npmDependency(owner, value.name),
cordisConfigEntry(owner, value, ownedConfigKeys, validateConfig),
]
}
/** Create a secret/environment binding resource. */
export function environment(
_owner: string,
name: string,
value: string | undefined,
comment?: string,
): EnvironmentResource {
return {
kind: 'environment',
key: resourceKey(`environment:${name}`),
name,
...value === undefined ? {} : { value },
exampleValue: '',
...comment === undefined ? {} : { comment },
}
}
/** Create an owned complete-text file that is removable only while unchanged. */
export function ownedTextFile(_owner: string, path: string, text: string): OwnedFileResource {
return {
kind: 'owned-file',
key: resourceKey(`file:${path}`),
document: new TextProjectFile(path, text),
removeOnlyWhenUnchanged: true,
}
}
/** Validate a config key as a string when present. */
export function optionalString(config: Readonly<Record<string, unknown>>, key: string): string[] {
return config[key] === undefined || typeof config[key] === 'string' ? [] : [`${key} must be a string`]
}
/** Validate a config key as a non-empty string when required. */
export function requiredString(config: Readonly<Record<string, unknown>>, key: string): string[] {
return typeof config[key] === 'string' && config[key].length > 0 ? [] : [`${key} must be a non-empty string`]
}
/** Validate a config key as an array of strings. */
export function stringArray(config: Readonly<Record<string, unknown>>, key: string): string[] {
const value = config[key]
return Array.isArray(value) && value.every(item => typeof item === 'string')
? []
: [`${key} must be an array of strings`]
}

View File

@@ -1,368 +0,0 @@
/**
* Ordered builtin feature catalog: behavior entities only where project
* context changes the contribution, typed specs everywhere else.
*
* @module @deepseek-ai/dsh-helper/features/builtin
*/
import type { Config as ClaudeHooksConfig } from '@deepseek-ai/dsh-hooks-claude'
import type { Config as CodexHooksConfig } from '@deepseek-ai/dsh-hooks-codex'
import type { Config as JsonlConfig } from '@deepseek-ai/dsh-session-persistence-jsonl'
import type { Config as SqliteConfig } from '@deepseek-ai/dsh-session-persistence-sqlite'
import type { Config as ToolSubagentConfig } from '@deepseek-ai/dsh-tool-subagent'
import type { Config as ToolTodoConfig } from '@deepseek-ai/dsh-tool-todo'
import type { Config as ToolWebConfig } from '@deepseek-ai/dsh-tool-web'
import type { ProjectProfile } from '../../project/types.ts'
import { defineFeatures } from '../define-feature.ts'
import { FeatureRegistry } from '../registry.ts'
import { AppFeature } from './app.ts'
import { ProviderFeature } from './provider.ts'
import { SpineFeature } from './spine.ts'
/**
* Build and definition-check the complete builtin set for one project profile.
* @param profile - project context used to validate conditional contributions.
* @returns ordered builtin feature registry.
*/
export function createBuiltinRegistry(profile: ProjectProfile): FeatureRegistry {
return new FeatureRegistry(defineFeatures([
new ProviderFeature(),
new SpineFeature(),
{
id: 'bash',
summary: 'Command execution',
mode: 'exclusive',
required: true,
baseResources: [
{ kind: 'npm-cordis-config-entry', id: 'subprocess', package: '@deepseek-ai/dsh-subprocess-local' },
{ kind: 'npm-cordis-config-entry', id: 'bash-env', package: '@deepseek-ai/dsh-bash-env' },
{ kind: 'npm-cordis-config-entry', id: 'tool-bash', package: '@deepseek-ai/dsh-tool-bash' },
],
options: [
{
id: 'local',
label: 'Local executor',
default: true,
resources: [{ kind: 'npm-cordis-config-entry', id: 'bash', package: '@deepseek-ai/dsh-bash-local' }],
},
{
id: 'sandbox',
label: 'Sandboxed executor',
resources: [
{ kind: 'npm-cordis-config-entry', id: 'sandbox', package: '@deepseek-ai/dsh-sandbox-local' },
{
kind: 'npm-cordis-config-entry',
id: 'bash',
package: '@deepseek-ai/dsh-bash-sandbox',
commentedExample: `Uncomment to allow writes under the project workspace.
config:
mode: workspace-write
workspaceRoot: !!js process.cwd()`,
},
],
},
],
},
new AppFeature(),
{
id: 'persistence',
summary: 'Durable session storage',
mode: 'exclusive',
required: true,
options: [
{
id: 'jsonl',
label: 'JSONL files',
default: true,
resources: [{
kind: 'npm-cordis-config-entry',
id: 'session-persistence',
package: '@deepseek-ai/dsh-session-persistence-jsonl',
config: { root: './.sessions' } satisfies JsonlConfig,
}],
},
{
id: 'sqlite',
label: 'SQLite database',
resources: [{
kind: 'npm-cordis-config-entry',
id: 'session-persistence',
package: '@deepseek-ai/dsh-session-persistence-sqlite',
config: { path: './.sessions/sessions.sqlite' } satisfies SqliteConfig,
}],
},
],
},
{
id: 'hmr',
summary: 'Hot-module reload',
mode: 'single',
options: [{
id: 'default',
label: 'Cordis HMR',
default: true,
resources: [{ kind: 'npm-cordis-config-entry', id: 'hmr', package: '@deepseek-ai/cordis-plugin-hmr' }],
}],
},
{
id: 'fs',
summary: 'Read, write, and edit local files',
mode: 'single',
options: [{
id: 'local',
label: 'Local filesystem',
default: true,
resources: [
{ kind: 'npm-cordis-config-entry', id: 'fs-local', package: '@deepseek-ai/dsh-fs-local' },
{ kind: 'npm-cordis-config-entry', id: 'fs-policy', package: '@deepseek-ai/dsh-fs-policy' },
{ kind: 'npm-cordis-config-entry', id: 'tool-fs', package: '@deepseek-ai/dsh-tool-fs' },
],
}],
},
{
id: 'todo',
summary: 'Model-facing task tracking',
mode: 'single',
options: [{
id: 'default',
label: 'todo_write tool',
default: true,
resources: [{
kind: 'npm-cordis-config-entry',
id: 'tool-todo',
package: '@deepseek-ai/dsh-tool-todo',
config: { allowParallelInProgress: true } satisfies ToolTodoConfig,
}],
}],
},
{
id: 'skill',
summary: 'Local skill discovery',
mode: 'single',
options: [{
id: 'default',
label: 'Local skills and skill tool',
default: true,
resources: [
{ kind: 'npm-cordis-config-entry', id: 'skill', package: '@deepseek-ai/dsh-skill' },
{ kind: 'npm-cordis-config-entry', id: 'skill-local', package: '@deepseek-ai/dsh-skill-local' },
{ kind: 'npm-cordis-config-entry', id: 'tool-skill', package: '@deepseek-ai/dsh-tool-skill' },
],
}],
},
{
id: 'web',
summary: 'Web search and fetch tools',
mode: 'exclusive',
suggests: ['timeout-policy'],
baseResources: [
{ kind: 'npm-cordis-config-entry', id: 'web', package: '@deepseek-ai/dsh-web' },
{ kind: 'npm-cordis-config-entry', id: 'web-fetch-local', package: '@deepseek-ai/dsh-web-fetch-local' },
],
options: [
{
id: 'deepseek-official',
label: 'DeepSeek search',
default: true,
markers: [{ id: 'web-search-deepseek', name: '@deepseek-ai/dsh-web-search-deepseek' }],
resources: [
{ kind: 'npm-cordis-config-entry', id: 'web-search-deepseek', package: '@deepseek-ai/dsh-web-search-deepseek' },
{ kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' },
],
},
{
id: 'exa',
label: 'Exa search',
secrets: [{ id: 'apiKey', environment: 'EXA_API_KEY', message: 'Exa API key', required: true }],
markers: [{ id: 'web-search-exa', name: '@deepseek-ai/dsh-web-search-exa' }],
resources: [
{ kind: 'npm-cordis-config-entry', id: 'web-search-exa', package: '@deepseek-ai/dsh-web-search-exa' },
{ kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' },
],
},
{
id: 'perplexity',
label: 'Perplexity search',
secrets: [{
id: 'apiKey',
environment: 'PERPLEXITY_API_KEY',
message: 'Perplexity API key',
required: true,
}],
markers: [{ id: 'web-search-perplexity', name: '@deepseek-ai/dsh-web-search-perplexity' }],
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'web-search-perplexity',
package: '@deepseek-ai/dsh-web-search-perplexity',
},
{ kind: 'npm-cordis-config-entry', id: 'tool-web', package: '@deepseek-ai/dsh-tool-web' },
],
},
{
id: 'fetch-only',
label: 'Fetch only',
markers: [{ id: 'tool-web', name: '@deepseek-ai/dsh-tool-web', config: { search: false } }],
resources: [{
kind: 'npm-cordis-config-entry',
id: 'tool-web',
package: '@deepseek-ai/dsh-tool-web',
config: { search: false } satisfies ToolWebConfig,
}],
},
],
},
{
id: 'subagent',
summary: 'Delegate work to child agents',
mode: 'multiple',
// In-process options select continuable background delegation; the
// follow-up adapter remains an independently loadable global tool.
baseResources: [
{ kind: 'npm-cordis-config-entry', id: 'tasks', package: '@deepseek-ai/dsh-tasks-local' },
{ kind: 'npm-cordis-config-entry', id: 'tool-tasks', package: '@deepseek-ai/dsh-tool-tasks' },
{ kind: 'npm-cordis-config-entry', id: 'subagent', package: '@deepseek-ai/dsh-subagent' },
{ kind: 'npm-cordis-config-entry', id: 'tool-subagent-control', package: '@deepseek-ai/dsh-tool-subagent-control' },
],
options: [
{
id: 'spawn',
label: 'Fresh child agent',
default: true,
resources: [
{ kind: 'npm-cordis-config-entry', id: 'subagent-spawn', package: '@deepseek-ai/dsh-subagent-spawn' },
{
kind: 'npm-cordis-config-entry',
id: 'tool-subagent',
package: '@deepseek-ai/dsh-tool-subagent',
config: { provider: 'spawn', backgroundMode: 'continuable' } satisfies ToolSubagentConfig,
},
],
},
{
id: 'fork',
label: 'Fork parent history',
resources: [
{ kind: 'npm-cordis-config-entry', id: 'subagent-fork', package: '@deepseek-ai/dsh-subagent-fork' },
{
kind: 'npm-cordis-config-entry',
id: 'tool-subagent-fork',
package: '@deepseek-ai/dsh-tool-subagent',
config: {
provider: 'fork',
toolName: 'subagent_fork',
backgroundMode: 'continuable',
} satisfies ToolSubagentConfig,
},
],
},
],
},
{
id: 'workflow',
summary: 'Scripted multi-agent workflows',
mode: 'single',
options: [{
id: 'workerthread',
label: 'Worker thread engine',
default: true,
requires: [{ id: 'subagent', options: ['spawn'] }],
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'workflow-workerthread',
package: '@deepseek-ai/dsh-workflow-workerthread',
},
{ kind: 'npm-cordis-config-entry', id: 'tool-workflow', package: '@deepseek-ai/dsh-tool-workflow' },
],
}],
},
{
id: 'compact',
summary: 'Automatic context compaction',
mode: 'single',
options: [{
id: 'basic',
label: 'Basic compaction',
default: true,
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'token-meter',
package: '@deepseek-ai/dsh-token-meter',
},
{
kind: 'npm-cordis-config-entry',
id: 'compact-basic',
package: '@deepseek-ai/dsh-compact-basic',
},
],
}],
},
{
id: 'hooks',
summary: 'Run Claude Code or Codex hooks',
mode: 'multiple',
requires: [{ id: 'bash' }],
options: [
{
id: 'claude',
label: 'Claude Code hooks',
default: true,
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'hooks-claude',
package: '@deepseek-ai/dsh-hooks-claude',
config: { configPath: './hooks.json' } satisfies ClaudeHooksConfig,
},
{ kind: 'owned-file', path: 'hooks.json', text: '{}' },
],
},
{
id: 'codex',
label: 'Codex hooks',
resources: [
{
kind: 'npm-cordis-config-entry',
id: 'hooks-codex',
package: '@deepseek-ai/dsh-hooks-codex',
config: { configPath: './codex-hooks.json' } satisfies CodexHooksConfig,
},
{ kind: 'owned-file', path: 'codex-hooks.json', text: '{}' },
],
},
],
},
{
id: 'guard',
summary: 'Loop-hygiene reminders',
mode: 'single',
options: [{
id: 'repeat-tool',
label: 'Repeat-tool reminders',
default: true,
resources: [{
kind: 'npm-cordis-config-entry',
id: 'repeat-tool-guard',
package: '@deepseek-ai/dsh-repeat-tool-guard',
}],
}],
},
{
id: 'timeout-policy',
summary: 'Tool timeout policy',
mode: 'single',
options: [{
id: 'default',
label: 'Timeout policy',
default: true,
resources: [{
kind: 'npm-cordis-config-entry',
id: 'timeout-policy',
package: '@deepseek-ai/dsh-timeout-policy',
}],
}],
},
]), profile)
}

View File

@@ -1,108 +0,0 @@
/**
* Required direct-fetch DeepSeek and custom pi-ai provider behavior.
*
* @module @deepseek-ai/dsh-helper/features/builtin/provider
*/
import { featureId } from '../../ids.ts'
import type { FeatureSelection, ProjectProfile } from '../../project/types.ts'
import {
FeatureOption,
ExclusiveOptionFeature,
type FeatureProjectView,
} from '../feature.ts'
import { ProjectContribution } from '../resources.ts'
import { npmCordisConfigEntry, environment } from './helpers.ts'
const ID = featureId('provider')
const DEFAULT_MODEL = 'deepseek-v4-flash'
const API_KEY_COMMENT = 'Required before the first model request.'
class DeepSeekOption extends FeatureOption {
override readonly id = 'deepseek-official'
override readonly label = 'DeepSeek'
override readonly secrets = [{
id: 'apiKey',
environment: 'DEEPSEEK_API_KEY',
message: 'DeepSeek API key',
required: true,
}]
override contribution(_profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution {
return new ProjectContribution([
...npmCordisConfigEntry(ID, {
id: 'llm-deepseek',
name: '@deepseek-ai/dsh-llm-deepseek',
}, ['baseURL', 'models']),
environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT),
])
}
}
class CustomOption extends FeatureOption {
override readonly id = 'custom'
override readonly label = 'Custom endpoint (pi-ai)'
override readonly secrets = [{
id: 'apiKey',
environment: 'DEEPSEEK_API_KEY',
message: 'Custom provider API key',
required: true,
}]
override readonly inputs = [{
id: 'baseURL',
message: 'Custom provider base URL',
}]
override contribution(_profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution {
return new ProjectContribution([
...npmCordisConfigEntry(ID, {
id: 'llm-pi-ai',
name: '@deepseek-ai/dsh-llm-pi-ai',
}, ['baseURL', 'models']),
environment(ID, 'DEEPSEEK_API_KEY', secrets.apiKey, API_KEY_COMMENT),
])
}
}
/** Required provider feature with DeepSeek and custom pi-ai options. */
export class ProviderFeature extends ExclusiveOptionFeature {
override readonly id = ID
override readonly summary = 'Model provider'
override readonly required = true
override readonly options = [new DeepSeekOption(), new CustomOption()]
/** Prefer the direct-fetch adapter and its public endpoint defaults. */
override defaultOptions(): readonly string[] {
return ['deepseek-official']
}
/** Recover literal endpoint overrides from either provider entry. */
override readSelection(project: FeatureProjectView, selection: FeatureSelection): FeatureSelection {
const base = super.readSelection(project, selection)
const entry = project.cordisConfigEntries().find(item => item.id === 'llm-deepseek' || item.id === 'llm-pi-ai')
const baseURL = entry?.config?.baseURL
return typeof baseURL === 'string' ? { ...base, values: { baseURL } } : base
}
/** Apply explicit endpoint/model overrides while omitting provider defaults. */
override contribution(selection: FeatureSelection, profile: ProjectProfile): ProjectContribution {
const contribution = super.contribution(selection, profile)
const baseURL = selection.values?.baseURL
if (baseURL !== undefined && typeof baseURL !== 'string') throw new Error('provider baseURL must be a string')
return new ProjectContribution(contribution.resources.map((resource) => {
if (resource.kind !== 'cordis-config-entry' || (resource.entry.id !== 'llm-deepseek'
&& resource.entry.id !== 'llm-pi-ai')) return resource
return {
...resource,
entry: {
...resource.entry,
config: {
...resource.entry.config,
...baseURL ? { baseURL } : {},
...profile.runtime.model === DEFAULT_MODEL ? {} : { models: [profile.runtime.model] },
},
},
}
}))
}
}

View File

@@ -1,59 +0,0 @@
/**
* Required agent-spine feature expressed as top-level Cordis config entries.
*
* @module @deepseek-ai/dsh-helper/features/builtin/spine
*/
import { featureId } from '../../ids.ts'
import type { ProjectProfile } from '../../project/types.ts'
import { loadHelperTemplate } from '../../templates/template-assets.ts'
import { FeatureOption, FixedFeature } from '../feature.ts'
import { ProjectContribution } from '../resources.ts'
import { cordisConfigEntry, npmCordisConfigEntry, requiredString } from './helpers.ts'
const ID = featureId('spine')
const PERSONA = loadHelperTemplate<Record<string, never>>('persona.txt.tpl').render({}).trimEnd()
function emptyAgentsDiagnostics(config: Readonly<Record<string, unknown>>): string[] {
const agents = config.agents
if (!Array.isArray(agents)) return ['agents must be an array']
return agents.length === 0 ? [] : ['agents must be empty']
}
class SpineOption extends FeatureOption {
override readonly id = 'default'
override readonly label = 'Default agent spine'
override contribution(_profile: ProjectProfile): ProjectContribution {
return new ProjectContribution([
...npmCordisConfigEntry(ID, { id: 'timer', name: '@deepseek-ai/cordis-plugin-timer' }),
...npmCordisConfigEntry(ID, { id: 'llm', name: '@deepseek-ai/dsh-llm' }),
...npmCordisConfigEntry(ID, { id: 'session', name: '@deepseek-ai/dsh-session' }),
...npmCordisConfigEntry(ID, {
id: 'system-prompt',
name: '@deepseek-ai/dsh-system-prompt',
config: { persona: PERSONA },
}, ['persona'], config => requiredString(config, 'persona')),
...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []),
...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }),
...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }),
cordisConfigEntry(ID, { id: 'session-invariant', name: '@deepseek-ai/dsh-session/invariant' }),
cordisConfigEntry(ID, { id: 'agent-invariant', name: '@deepseek-ai/dsh-agent/invariant' }),
...npmCordisConfigEntry(ID, { id: 'scope-invariant', name: '@deepseek-ai/dsh-scope/invariant' }),
cordisConfigEntry(ID, { id: 'agent-loop-invariant', name: '@deepseek-ai/dsh-agent-loop/invariant' }),
...npmCordisConfigEntry(ID, {
id: 'agent-loop',
name: '@deepseek-ai/dsh-agent-loop',
config: { agents: [] },
}, ['agents'], emptyAgentsDiagnostics),
])
}
}
/** Required providerless agent spine without a composition bundle entry. */
export class SpineFeature extends FixedFeature {
override readonly id = ID
override readonly summary = 'Agent runtime spine'
override readonly required = true
override readonly options = [new SpineOption()]
}

View File

@@ -1,286 +0,0 @@
/**
* Typed declarative definitions for features whose behavior is entirely
* the shared resource lifecycle.
*
* @module @deepseek-ai/dsh-helper/features/define-feature
*/
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import { TextProjectFile } from '../documents/project-file.ts'
import { featureId, resourceKey, type FeatureId } from '../ids.ts'
import type { FeatureSelection, ProjectProfile, RunInterface } from '../project/types.ts'
import {
Feature,
FeatureOption,
type FeatureRequirement,
type FeatureSecret,
} from './feature.ts'
import { ProjectContribution, type ProjectResource } from './resources.ts'
/** Static NPM dependency in a declarative feature. */
interface NpmDependencySpec {
kind: 'npm-dependency'
name: string
section?: 'dependencies' | 'devDependencies'
}
/** Bare-package Cordis config entry that also contributes its NPM dependency. */
interface NpmCordisConfigEntrySpec {
kind: 'npm-cordis-config-entry'
id: string
package: string
config?: Readonly<Record<string, unknown>>
ownedConfigKeys?: readonly string[]
commentedExample?: string
}
/** Relative or absolute file Cordis config entry with no NPM dependency. */
interface FileCordisConfigEntrySpec {
kind: 'file-cordis-config-entry'
id: string
path: string
config?: Readonly<Record<string, unknown>>
ownedConfigKeys?: readonly string[]
commentedExample?: string
}
/** Static complete file owned by one feature option. */
interface OwnedFileSpec {
kind: 'owned-file'
path: string
text: string
removeOnlyWhenUnchanged?: boolean
}
/** Resource forms that require no feature-specific imperative code. */
type FeatureResourceSpec =
| NpmDependencySpec
| NpmCordisConfigEntrySpec
| FileCordisConfigEntrySpec
| OwnedFileSpec
/** Cordis config entry identity and optional plugin-config subset that identifies an option. */
interface FeatureOptionMarkerSpec {
id: string
name: string
config?: Readonly<Record<string, unknown>>
}
/** Declarative requirement converted to branded domain identity at the boundary. */
interface FeatureRequirementSpec {
id: string
options?: readonly string[]
}
/** One static option inside a typed feature definition. */
interface FeatureOptionSpec {
id: string
label: string
default?: boolean
resources: readonly FeatureResourceSpec[]
secrets?: readonly FeatureSecret[]
markers?: readonly FeatureOptionMarkerSpec[]
requires?: readonly FeatureRequirementSpec[]
}
/** Complete declarative feature definition. */
export interface FeatureSpec {
id: string
summary: string
mode: 'single' | 'exclusive' | 'multiple'
options: readonly FeatureOptionSpec[]
baseResources?: readonly FeatureResourceSpec[]
required?: boolean
requires?: readonly FeatureRequirementSpec[]
suggests?: readonly string[]
supportedInterfaces?: readonly RunInterface[]
}
function sameShape(expected: unknown, actual: unknown): boolean {
if (expected === null || actual === null) return expected === actual
if (Array.isArray(expected)) {
return Array.isArray(actual) && (expected.length === 0 || actual.every(item => sameShape(expected[0], item)))
}
if (typeof expected !== 'object') return typeof expected === typeof actual
if (typeof actual !== 'object' || Array.isArray(actual)) return false
return Object.entries(expected as Record<string, unknown>).every(
([key, value]) => sameShape(value, (actual as Record<string, unknown>)[key]),
)
}
function configDiagnostics(
expected: Readonly<Record<string, unknown>> | undefined,
): ((config: Readonly<Record<string, unknown>>) => readonly string[]) | undefined {
if (!expected || Object.keys(expected).length === 0) return undefined
return config => Object.entries(expected).flatMap(([key, value]) => sameShape(value, config[key])
? []
: [`${key} has fields or value types that do not match the expected config`])
}
function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] {
switch (spec.kind) {
case 'npm-dependency':
return [{
kind: 'npm-dependency',
key: resourceKey(`npm-dependency:${spec.name}`),
name: spec.name,
section: spec.section ?? 'dependencies',
}]
case 'npm-cordis-config-entry':
case 'file-cordis-config-entry': {
const config = spec.config ? { ...spec.config } : undefined
const validateConfig = configDiagnostics(config)
const name = spec.kind === 'npm-cordis-config-entry' ? spec.package : spec.path
return [
...spec.kind === 'npm-cordis-config-entry'
? [{
kind: 'npm-dependency' as const,
key: resourceKey(`npm-dependency:${spec.package}`),
name: spec.package,
section: 'dependencies' as const,
}]
: [],
{
kind: 'cordis-config-entry',
key: resourceKey(`cordis-config-entry:${spec.id}`),
entry: {
id: spec.id,
name,
...config ? { config } : {},
},
ownedConfigKeys: spec.ownedConfigKeys ?? Object.keys(config ?? {}),
...spec.commentedExample ? { commentedExample: spec.commentedExample } : {},
...validateConfig ? { validateConfig } : {},
},
]
}
case 'owned-file':
return [{
kind: 'owned-file',
key: resourceKey(`file:${spec.path}`),
document: new TextProjectFile(spec.path, spec.text),
removeOnlyWhenUnchanged: spec.removeOnlyWhenUnchanged ?? true,
}]
}
}
function isSubset(expected: Readonly<Record<string, unknown>>, actual: Readonly<Record<string, unknown>>): boolean {
return Object.entries(expected).every(([key, value]) => Object.is(actual[key], value))
}
class DefinedFeatureOption extends FeatureOption {
override readonly id: string
override readonly label: string
override readonly secrets: readonly FeatureSecret[]
private readonly spec: FeatureOptionSpec
constructor(spec: FeatureOptionSpec) {
super()
this.spec = spec
this.id = spec.id
this.label = spec.label
this.secrets = spec.secrets ?? []
}
override contribution(_profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution {
return new ProjectContribution([
...this.spec.resources.flatMap(resourcesFromSpec),
...this.secrets.map(secret => ({
kind: 'environment' as const,
key: resourceKey(`environment:${secret.environment}`),
name: secret.environment,
...secrets[secret.id] === undefined ? {} : { value: secrets[secret.id] },
exampleValue: '',
})),
])
}
override markerConfigEntries(): readonly Pick<CordisConfigEntry, 'id' | 'name'>[] {
const markers = this.spec.markers ?? this.spec.resources.flatMap((resource) => {
switch (resource.kind) {
case 'npm-cordis-config-entry': return [{ id: resource.id, name: resource.package }]
case 'file-cordis-config-entry': return [{ id: resource.id, name: resource.path }]
default: return []
}
})
return markers.map(marker => ({ id: marker.id, name: marker.name }))
}
override matchesConfigEntries(entries: readonly CordisConfigEntry[]): boolean {
const markers = this.spec.markers
if (!markers) return this.markerConfigEntries().some(marker => entries.some(
entry => entry.id === marker.id && entry.name === marker.name,
))
return markers.some(marker => entries.some(entry => entry.id === marker.id
&& entry.name === marker.name
&& (!marker.config || isSubset(marker.config, entry.config ?? {}))))
}
}
/** Feature entity backed by a typed static definition. */
class DefinedFeature extends Feature {
override readonly id: FeatureId
override readonly summary: string
override readonly mode: FeatureSpec['mode']
override readonly options: readonly FeatureOption[]
override readonly required: boolean
override readonly requires: readonly FeatureId[]
override readonly suggests: readonly FeatureId[]
override readonly supportedInterfaces: readonly RunInterface[]
private readonly spec: FeatureSpec
/** Validate and materialize one declarative definition. */
constructor(spec: FeatureSpec) {
super()
this.spec = spec
this.id = featureId(spec.id)
this.summary = spec.summary
this.mode = spec.mode
this.options = spec.options.map(option => new DefinedFeatureOption(option))
const defaultCount = spec.options.filter(option => option.default).length
if (spec.mode === 'single' && (spec.options.length !== 1 || defaultCount !== 1)) {
throw new Error(`single feature ${spec.id} requires one default option`)
}
if (spec.mode === 'exclusive' && defaultCount !== 1) {
throw new Error(`exclusive feature ${spec.id} requires exactly one default option`)
}
if (spec.mode === 'multiple' && defaultCount === 0) {
throw new Error(`multiple feature ${spec.id} requires at least one default option`)
}
this.required = spec.required ?? false
this.requires = (spec.requires ?? []).map(requirement => featureId(requirement.id))
this.suggests = (spec.suggests ?? []).map(featureId)
this.supportedInterfaces = spec.supportedInterfaces ?? ['acp', 'embed']
}
override defaultOptions(): readonly string[] {
return this.spec.options.filter(option => option.default).map(option => option.id)
}
override baseContribution(): ProjectContribution {
return new ProjectContribution((this.spec.baseResources ?? []).flatMap(resourcesFromSpec))
}
override requirements(selection: FeatureSelection): readonly FeatureRequirement[] {
const selected = new Set(selection.options)
return [
...(this.spec.requires ?? []),
...this.spec.options.filter(option => selected.has(option.id)).flatMap(option => option.requires ?? []),
].map(requirement => ({
id: featureId(requirement.id),
...requirement.options ? { options: requirement.options } : {},
}))
}
}
/** Construct the shared lifecycle entity from a typed declarative definition. */
export function defineFeature(spec: FeatureSpec): Feature {
return new DefinedFeature(spec)
}
/** Materialize one ordered catalog containing static specs and behavior entities. */
export function defineFeatures(definitions: readonly (Feature | FeatureSpec)[]): Feature[] {
return definitions.map(definition => definition instanceof Feature
? definition
: defineFeature(definition))
}

View File

@@ -1,111 +0,0 @@
/**
* Shared option and secret question flow for create and config.
*
* @module @deepseek-ai/dsh-helper/features/feature-configurator
*/
import type { Feature } from './feature.ts'
import type { FeatureSelection, ProjectProfile } from '../project/types.ts'
import type { PromptPort } from '../questions/prompt-port.ts'
import { requireAnswer } from '../questions/prompt-port.ts'
import { MultiSelectQuestion, SecretQuestion, SelectQuestion, TextQuestion } from '../questions/question.ts'
/** Resolve one feature selection without knowing which workflow requested it. */
export class FeatureConfigurator {
private readonly port: PromptPort
/** Bind the configurator to the shared prompt boundary. */
constructor(port: PromptPort) {
this.port = port
}
/**
* Ask option and input questions, preserving current secrets on empty input.
* @param feature - feature whose options and inputs are collected.
* @param profile - target project context.
* @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(
feature: Feature,
profile: ProjectProfile,
current?: FeatureSelection,
prefilledOptions?: readonly string[],
prefilledSecrets: Readonly<Record<string, string>> = {},
prefilledValues: Readonly<Record<string, unknown>> = {},
): Promise<FeatureSelection> {
let options: readonly string[]
switch (feature.mode) {
case 'single':
options = feature.defaultOptions(profile)
break
case 'exclusive': {
const initialValue = current?.options[0] ?? feature.defaultOptions(profile)[0]
if (initialValue === undefined) throw new Error(`feature ${feature.id} has no default option`)
const question = new SelectQuestion({
id: `${feature.id}.option`,
message: `Choose ${feature.summary.toLowerCase()}`,
options: feature.options.map(option => ({ value: option.id, label: option.label })),
initialValue,
})
const prefilled = prefilledOptions?.[0]
options = [requireAnswer(await question.resolve(this.port, prefilled))]
break
}
case 'multiple': {
const question = new MultiSelectQuestion({
id: `${feature.id}.options`,
message: `Choose ${feature.summary.toLowerCase()}`,
options: feature.options.map(option => ({ value: option.id, label: option.label })),
initialValues: current?.options ?? feature.defaultOptions(profile),
required: true,
})
options = requireAnswer(await question.resolve(this.port, prefilledOptions))
break
}
}
const selected: FeatureSelection = {
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]
if (existing !== undefined && typeof existing !== 'string') {
throw new Error(`${feature.id}.${input.id} current value must be a string`)
}
const question = new TextQuestion({
id: `${feature.id}.${input.id}`,
message: input.message,
...existing === undefined ? {} : { initialValue: existing },
validate: value => value.trim().length === 0 ? 'A value is required' : undefined,
})
values[input.id] = requireAnswer(await question.resolve(this.port, coercedPrefilled[input.id]))
}
const base: FeatureSelection = Object.keys(values).length === 0
? selected
: { ...selected, values }
const secrets = { ...current?.secrets }
for (const secret of feature.secrets(base, profile)) {
const existing = secrets[secret.id]
const question = new SecretQuestion({
id: `${feature.id}.${secret.id}`,
message: existing === undefined ? secret.message : `${secret.message} (leave empty to keep current)`,
validate: value => secret.required && existing === undefined && value.length === 0
? 'A value is required'
: undefined,
})
const answer = requireAnswer(await question.resolve(this.port, prefilledSecrets[secret.id]))
if (answer.length > 0) secrets[secret.id] = answer
}
return Object.keys(secrets).length === 0 ? base : { ...base, secrets }
}
}

View File

@@ -1,345 +0,0 @@
/**
* Stateful builtin feature and option domain objects.
*
* @module @deepseek-ai/dsh-helper/features/feature
*/
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import type { PackageManifest } from '../documents/package-json-file.ts'
import type { FeatureId } from '../ids.ts'
import type { FeatureSelection, ProjectProfile, RunInterface } from '../project/types.ts'
import { ProjectContribution, type CordisConfigEntryResource, type ProjectResource } from './resources.ts'
/** Read-only project surface used by feature inspection. */
export interface FeatureProjectView {
readonly profile: ProjectProfile
cordisConfigEntries(): readonly CordisConfigEntry[]
packageManifest(): Readonly<PackageManifest>
hasDocument(path: string): boolean
readEnvironment(path: '.env' | '.env.example', name: string): string | undefined
}
/** Installation state visible to create/config workflows. */
type FeatureInstallationState = 'absent' | 'enabled' | 'disabled' | 'inconsistent'
/** Result of round-tripping one feature from a project snapshot. */
export interface FeatureInstallation {
id: FeatureId
state: FeatureInstallationState
options: readonly string[]
selection?: FeatureSelection
diagnostics: readonly string[]
}
/** One final-state requirement on another builtin feature. */
export interface FeatureRequirement {
id: FeatureId
options?: readonly string[]
}
/** One secret captured into an environment binding rather than Cordis plugin config. */
export interface FeatureSecret {
id: string
environment: string
message: string
required: boolean
}
/** One visible string value requested only by options that own it. */
export interface FeatureValueInput {
id: string
message: string
}
/** One selectable behavior option owned by a feature. */
export abstract class FeatureOption {
abstract readonly id: string
abstract readonly label: string
readonly secrets: readonly FeatureSecret[] = []
readonly inputs: readonly FeatureValueInput[] = []
/** Contribute this option's project resources. */
abstract contribution(profile: ProjectProfile, secrets: Readonly<Record<string, string>>): ProjectContribution
/** Every Cordis config entry package owned by this option during inspection. */
ownedConfigEntries(profile: ProjectProfile): readonly Pick<CordisConfigEntry, 'id' | 'name'>[] {
return this.contribution(profile, {}).resources
.filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry')
.map(resource => ({ id: resource.entry.id, name: resource.entry.name }))
}
/** Cordis config entry identities that distinguish this option during inspection. */
markerConfigEntries(profile: ProjectProfile): readonly Pick<CordisConfigEntry, 'id' | 'name'>[] {
return this.ownedConfigEntries(profile)
}
/** Whether current owned Cordis config entries identify this option. */
matchesConfigEntries(entries: readonly CordisConfigEntry[], profile: ProjectProfile): boolean {
return this.markerConfigEntries(profile).some(marker => entries.some(
entry => entry.id === marker.id && entry.name === marker.name,
))
}
}
/** How a feature's options compose. */
export type FeatureOptionMode = 'single' | 'exclusive' | 'multiple'
function packageNames(resources: readonly ProjectResource[]): Set<string> {
return new Set(resources
.filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry')
.map(resource => resource.entry.name))
}
function configDiagnostics(resource: CordisConfigEntryResource, entry: CordisConfigEntry): string[] {
/* v8 ignore next -- entries without validators have no diagnostics to compute */
if (!resource.validateConfig) return []
return [...resource.validateConfig(entry.config ?? {})].map(message => `${entry.id}: ${message}`)
}
/** A behavior-owning builtin feature with shallow option composition. */
export abstract class Feature {
/** Stable registry identity. */
abstract readonly id: FeatureId
/** User-facing feature summary. */
abstract readonly summary: string
/** Option-selection rule. */
abstract readonly mode: FeatureOptionMode
/** Available behavior options. */
abstract readonly options: readonly FeatureOption[]
/** Whether every valid project must enable this feature. */
readonly required: boolean = false
/** Unconditional feature requirements. */
readonly requires: readonly FeatureId[] = []
/** Features recommended during creation. */
readonly suggests: readonly FeatureId[] = []
/** Run interfaces under which this feature is meaningful. */
readonly supportedInterfaces: readonly RunInterface[] = ['acp', 'embed']
/**
* Options selected when installation has no override.
* @param profile - project context controlling applicable defaults.
* @returns selected option ids.
*/
abstract defaultOptions(profile: ProjectProfile): readonly string[]
/**
* Shared resources present for every installed option set.
* @param _profile - project context available to behavior features.
* @returns shared project contribution.
*/
baseContribution(_profile: ProjectProfile): ProjectContribution {
return new ProjectContribution([])
}
/**
* Additional final-state requirements depending on selected options.
* @param _selection - normalized feature selection.
* @returns required features and option constraints.
*/
requirements(_selection: FeatureSelection): readonly FeatureRequirement[] {
return this.requires.map(id => ({ id }))
}
/**
* Whether the feature may be selected for this project run interface.
* @param profile - project context to check.
* @returns whether the feature applies.
*/
isApplicable(profile: ProjectProfile): boolean {
return this.supportedInterfaces.includes(profile.runInterface)
}
/**
* Validate and normalize one requested option set.
* @param selection - requested feature and options.
* @param profile - project context for applicability and defaults.
* @returns deduplicated, sorted selection.
*/
normalizeSelection(selection: FeatureSelection, profile: ProjectProfile): FeatureSelection {
if (selection.id !== this.id) throw new Error(`selection ${selection.id} does not belong to feature ${this.id}`)
if (!this.isApplicable(profile)) {
throw new Error(`feature ${this.id} is not available for ${profile.runInterface}`)
}
const available = new Set(this.options.map(option => option.id))
const options = [...new Set(selection.options.length > 0 ? selection.options : this.defaultOptions(profile))]
for (const option of options) {
if (!available.has(option)) throw new Error(`unknown ${this.id} option: ${option}`)
}
if (this.mode === 'single' && (options.length !== 1 || this.options.length !== 1)) {
throw new Error(`feature ${this.id} has one fixed option`)
}
if (this.mode === 'exclusive' && options.length !== 1) {
throw new Error(`feature ${this.id} requires exactly one option`)
}
if (this.mode === 'multiple' && options.length === 0) {
throw new Error(`feature ${this.id} requires at least one option`)
}
return { ...selection, options: options.sort() }
}
/**
* Build the complete selected resource contribution.
* @param selection - selected options and captured inputs.
* @param profile - target project context.
* @returns merged base and option resources.
*/
contribution(selection: FeatureSelection, profile: ProjectProfile): ProjectContribution {
const normalized = this.normalizeSelection(selection, profile)
const selected = this.selectedOptions(normalized)
.map(option => option.contribution(profile, normalized.secrets ?? {}))
return ProjectContribution.merge(this.baseContribution(profile), ...selected)
}
/**
* All secret definitions required by one selected option set.
* @param selection - selected options.
* @param profile - target project context.
* @returns selected secret definitions.
*/
secrets(selection: FeatureSelection, profile: ProjectProfile): readonly FeatureSecret[] {
const normalized = this.normalizeSelection(selection, profile)
return this.selectedOptions(normalized).flatMap(option => option.secrets)
}
/**
* All visible value definitions required by one selected option set.
* @param selection - selected options.
* @param profile - target project context.
* @returns selected visible-input definitions.
*/
valueInputs(selection: FeatureSelection, profile: ProjectProfile): readonly FeatureValueInput[] {
const normalized = this.normalizeSelection(selection, profile)
return this.selectedOptions(normalized).flatMap(option => option.inputs)
}
private selectedOptions(selection: FeatureSelection): readonly FeatureOption[] {
return selection.options.map((id) => {
const option = this.options.find(candidate => candidate.id === id)
/* v8 ignore next -- normalizeSelection already membership-checks every selected id */
if (!option) throw new Error(`unknown ${this.id} option: ${id}`)
return option
})
}
/**
* Recover input and secret values after structural inspection.
* @param project - project snapshot being inspected.
* @param selection - structurally detected selection.
* @returns selection enriched with readable values.
*/
readSelection(project: FeatureProjectView, selection: FeatureSelection): FeatureSelection {
const secrets = Object.fromEntries(this.secrets(selection, project.profile).flatMap((secret) => {
const value = project.readEnvironment('.env', secret.environment)
return value === undefined ? [] : [[secret.id, value]]
}))
return Object.keys(secrets).length === 0 ? selection : { ...selection, secrets }
}
/**
* Inspect current files and reject any partial or ambiguous owned file set.
* @param project - project snapshot to inspect.
* @returns installation state, selection, and diagnostics.
*/
inspect(project: FeatureProjectView): FeatureInstallation {
const profile = project.profile
const allPackages = new Set<string>()
for (const option of this.options) {
for (const entry of option.ownedConfigEntries(profile)) allPackages.add(entry.name)
}
for (const name of packageNames(this.baseContribution(profile).resources)) allPackages.add(name)
const configEntries = project.cordisConfigEntries()
const ownedConfigEntries = configEntries.filter(entry => allPackages.has(entry.name))
const options = this.options
.filter(option => option.matchesConfigEntries(configEntries, profile))
.map(option => option.id)
if (ownedConfigEntries.length === 0 && options.length === 0) {
return { id: this.id, state: 'absent', options: [], diagnostics: [] }
}
let selection: FeatureSelection
try {
selection = this.normalizeSelection({ id: this.id, options }, profile)
} catch (error) {
return { id: this.id, state: 'inconsistent', options, diagnostics: [String(error)] }
}
selection = this.readSelection(project, selection)
const expected = this.contribution(selection, profile)
const expectedEntries = expected.resources
.filter((resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry')
const diagnostics: string[] = []
for (const resource of expectedEntries) {
const actual = ownedConfigEntries.find(entry => entry.id === resource.entry.id && entry.name === resource.entry.name)
if (!actual) diagnostics.push(`missing Cordis config entry ${resource.entry.id} (${resource.entry.name})`)
else diagnostics.push(...configDiagnostics(resource, actual))
}
for (const actual of ownedConfigEntries) {
if (!expectedEntries.some(resource => resource.entry.id === actual.id && resource.entry.name === actual.name)) {
diagnostics.push(`unexpected owned Cordis config entry ${actual.id} (${actual.name})`)
}
}
const manifest = project.packageManifest()
for (const resource of expected.resources) {
switch (resource.kind) {
case 'npm-dependency':
if (!manifest[resource.section]?.[resource.name]) {
diagnostics.push(`missing package.json ${resource.section} entry ${resource.name}`)
}
break
case 'package-script':
if (!manifest.scripts?.[resource.name]) {
diagnostics.push(`missing package.json script ${resource.name}`)
}
break
case 'owned-file':
if (!project.hasDocument(resource.document.relativePath)) diagnostics.push(`missing owned file ${resource.document.relativePath}`)
break
case 'environment':
try {
if (project.readEnvironment('.env.example', resource.name) === undefined) {
diagnostics.push(`missing .env.example variable ${resource.name}`)
}
} catch (error) {
diagnostics.push(String(error))
}
break
case 'cordis-config-entry': break
}
}
const disabled = ownedConfigEntries.map(entry => entry.disabled === true)
if (disabled.some(Boolean) && disabled.some(value => !value)) {
diagnostics.push('owned Cordis config entries have mixed enabled states')
}
if (diagnostics.length > 0) {
return { id: this.id, state: 'inconsistent', options, diagnostics }
}
return {
id: this.id,
state: ownedConfigEntries.length > 0 && disabled.every(Boolean) ? 'disabled' : 'enabled',
options,
selection,
diagnostics: [],
}
}
}
/** Fixed one-option feature base. */
export abstract class FixedFeature extends Feature {
override readonly mode = 'single'
/** Select the sole option. */
override defaultOptions(): readonly string[] {
const option = this.options[0]
if (!option) throw new Error(`simple feature ${this.id} has no option`)
return [option.id]
}
}
/** Mutually exclusive option feature base. */
export abstract class ExclusiveOptionFeature extends Feature {
override readonly mode = 'exclusive'
}
/** Additive multi-option feature base. */
export abstract class MultiOptionFeature extends Feature {
override readonly mode = 'multiple'
}

View File

@@ -1,87 +0,0 @@
/**
* Builtin feature registry and definition-time conflict checks.
*
* @module @deepseek-ai/dsh-helper/features/registry
*/
import type { FeatureId, ResourceKey } from '../ids.ts'
import type { ProjectProfile } from '../project/types.ts'
import type { Feature, FeatureProjectView } from './feature.ts'
import type { CordisConfigEntryResource } from './resources.ts'
/** Compile-time builtin feature collection. */
export class FeatureRegistry {
private readonly features = new Map<FeatureId, Feature>()
/** Register and validate a complete builtin set. */
constructor(features: readonly Feature[], validationProfile: ProjectProfile) {
const owners = new Map<ResourceKey, FeatureId>()
for (const feature of features) {
if (this.features.has(feature.id)) throw new Error(`duplicate feature id: ${feature.id}`)
this.features.set(feature.id, feature)
const validationInterface = feature.supportedInterfaces[0]
if (!validationInterface) throw new Error(`feature ${feature.id} supports no run interface`)
const selections = feature.options.map(option => ({ id: feature.id, options: [option.id] }))
for (const selection of selections) {
const contribution = feature.contribution(selection, {
...validationProfile,
runInterface: validationInterface,
})
for (const resource of contribution.resources) {
const owner = owners.get(resource.key)
if (owner && owner !== feature.id) {
throw new Error(`resource ${resource.key} is declared by both ${owner} and ${feature.id}`)
}
owners.set(resource.key, feature.id)
}
}
}
}
/**
* Return all builtins in display order.
* @returns all registered features.
*/
all(): readonly Feature[] {
return [...this.features.values()]
}
/**
* Resolve one builtin or fail loud.
* @param id - stable feature identity.
* @returns registered feature.
*/
get(id: FeatureId): Feature {
const feature = this.features.get(id)
if (!feature) throw new Error(`unknown feature: ${id}`)
return feature
}
/**
* Inspect every applicable builtin in display order.
* @param project - project view to inspect.
* @returns installation snapshots for applicable features.
*/
inspect(project: FeatureProjectView): ReturnType<Feature['inspect']>[] {
return this.all()
.filter(feature => feature.isApplicable(project.profile))
.map(feature => feature.inspect(project))
}
/**
* Resolve the builtin that owns a Cordis package name for this profile.
* @param name - Loader package name.
* @param profile - project context controlling applicability.
* @returns owning feature, if the package is builtin-owned.
*/
ownerOfPackage(name: string, profile: ProjectProfile): Feature | undefined {
return this.all().find((feature) => {
if (!feature.isApplicable(profile)) return false
const selections = feature.options.map(option => ({ id: feature.id, options: [option.id] }))
return selections.some(selection => feature.contribution(selection, profile).resources.some(
(resource): resource is CordisConfigEntryResource => resource.kind === 'cordis-config-entry'
&& resource.entry.name === name,
))
})
}
}

View File

@@ -1,97 +0,0 @@
/**
* Resource vocabulary contributed by builtin SDK features.
*
* @module @deepseek-ai/dsh-helper/features/resources
*/
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import type { ProjectFile } from '../documents/project-file.ts'
import type { ResourceKey } from '../ids.ts'
/** Runtime or development NPM dependency contribution. */
export interface NpmDependencyResource {
kind: 'npm-dependency'
key: ResourceKey
name: string
section: 'dependencies' | 'devDependencies'
}
/** Feature-owned package script. */
export interface PackageScriptResource {
kind: 'package-script'
key: ResourceKey
name: string
command: string
removeOnlyWhenUnchanged: boolean
}
/** Owned Cordis config entry plus the config keys safe to update in place. */
export interface CordisConfigEntryResource {
kind: 'cordis-config-entry'
key: ResourceKey
entry: CordisConfigEntry
ownedConfigKeys: readonly string[]
commentedExample?: string
validateConfig?: (config: Readonly<Record<string, unknown>>) => readonly string[]
}
/** Environment variable reference and dotenv material. */
export interface EnvironmentResource {
kind: 'environment'
key: ResourceKey
name: string
value?: string
exampleValue: string
comment?: string
}
/** Feature-exclusive complete file. */
export interface OwnedFileResource {
kind: 'owned-file'
key: ResourceKey
document: ProjectFile
removeOnlyWhenUnchanged: boolean
}
/** Any resource a feature can add to a project. */
export type ProjectResource =
| NpmDependencyResource
| PackageScriptResource
| CordisConfigEntryResource
| EnvironmentResource
| OwnedFileResource
/** Complete resource contribution for one selected feature state. */
export class ProjectContribution {
readonly resources: readonly ProjectResource[]
/** Validate and retain one feature-owned resource set. */
constructor(resources: readonly ProjectResource[]) {
const seen = new Set<ResourceKey>()
for (const resource of resources) {
if (seen.has(resource.key)) throw new Error(`duplicate contribution resource key: ${resource.key}`)
seen.add(resource.key)
}
this.resources = resources
}
/** Merge base and option contributions by stable key. */
static merge(...contributions: readonly ProjectContribution[]): ProjectContribution {
const resources = new Map<ResourceKey, ProjectResource>()
for (const contribution of contributions) {
for (const resource of contribution.resources) {
const previous = resources.get(resource.key)
if (previous && JSON.stringify(previous) !== JSON.stringify(resource)) {
throw new Error(`resource ${resource.key} has conflicting definitions inside one feature`)
}
resources.set(resource.key, resource)
}
}
return new ProjectContribution([...resources.values()])
}
/** Index resources by stable key. */
byKey(): ReadonlyMap<ResourceKey, ProjectResource> {
return new Map(this.resources.map(resource => [resource.key, resource]))
}
}

View File

@@ -1,31 +0,0 @@
/**
* Branded identities owned by the SDK project domain.
*
* @module @deepseek-ai/dsh-helper/ids
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Stable identity of a builtin SDK feature. */
export type FeatureId = Branded<'FeatureId'>
/**
* Construct a feature identity from its registry key.
* @param value - lowercase kebab-case registry key.
* @returns branded feature identity.
*/
export function featureId(value: string): FeatureId {
if (!/^[a-z][a-z0-9-]*$/.test(value)) {
throw new Error(`invalid feature id: ${JSON.stringify(value)}`)
}
return value as FeatureId
}
/** Stable identity of a resource contributed to an SDK project. */
export type ResourceKey = Branded<'ResourceKey'>
/** Construct a resource key from its owner-qualified value. */
export function resourceKey(value: string): ResourceKey {
if (value.length === 0) throw new Error('resource key must not be empty')
return value as ResourceKey
}

View File

@@ -1,50 +0,0 @@
/**
* Shared domain and infrastructure for DeepSeek Harness SDK project tooling.
*
* FIXME: rename to `@deepseek-ai/dsh-sdk-helper` before the first tagged release —
* the current name is indefensibly generic as a published name
* ([regrouping Agent Note](../../../../.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md)).
*
* @module @deepseek-ai/dsh-helper
*/
export { featureId } from './ids.ts'
export { TextTemplate } from './templates/text-template.ts'
export type {
FeatureSelection,
ProjectCreationRequest,
ProjectProfile,
RunInterface,
} from './project/types.ts'
export type { ChangeSet, ProjectCommitResult } from './project/change-set.ts'
export { SdkProject } from './project/sdk-project.ts'
export {
NodeCommandRunner,
NpmPackageManager,
createPackageManager,
inferPackageManagerName,
probePackageManagerVersion,
} from './package-managers/package-manager.ts'
export type {
CommandRunner,
PackageManager,
PackageManagerName,
PackageManagerVersionProbe,
} from './package-managers/package-manager.ts'
export { LocalPluginBlueprint } from './plugins/local-plugin-blueprint.ts'
export type { LocalPluginKind } from './plugins/local-plugin-blueprint.ts'
export type { Feature, FeatureInstallation } from './features/feature.ts'
export type { FeatureRegistry } from './features/registry.ts'
export { FeatureConfigurator } from './features/feature-configurator.ts'
export { createBuiltinRegistry } from './features/builtin/index.ts'
export { PromptCancelledError, requireAnswer } from './questions/prompt-port.ts'
export type { NestedMultiSelectValue, PromptPort } from './questions/prompt-port.ts'
export {
ConfirmQuestion,
SecretQuestion,
SelectQuestion,
TextQuestion,
} 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'

View File

@@ -1,30 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-helper`.
* @module @deepseek-ai/dsh-helper/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-helper'
/** Cordis companion plugin name. */
export const name = 'helper-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this SDK build-time package owns no live event stream or mutable data;
* generated output and consumer tests cover its contract.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -1,170 +0,0 @@
/**
* Repository package discovery and NPM dependency-closure rewriting for live links.
*
* @module @deepseek-ai/dsh-helper/package-managers/link-workspace
*/
import { readFile, readdir } from 'node:fs/promises'
import { existsSync, realpathSync } from 'node:fs'
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
import type { PackageJsonFile, PackageManifest } from '../documents/package-json-file.ts'
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
import type { ProjectFile } from '../documents/project-file.ts'
import type { PackageManager } from './package-manager.ts'
interface WorkspacePackage {
directory: string
manifest: PackageManifest
}
function posixPath(path: string): string {
return path.split(sep).join('/')
}
function canonicalPath(path: string): string {
let existing = resolve(path)
const suffix: string[] = []
while (!existsSync(existing)) {
const parent = dirname(existing)
/* v8 ignore next -- every absolute path reaches the existing filesystem root */
if (parent === existing) throw new Error(`cannot resolve an existing ancestor for ${path}`)
suffix.unshift(basename(existing))
existing = parent
}
return resolve(realpathSync(existing), ...suffix)
}
async function packageDirectories(root: string): Promise<string[]> {
const result: string[] = []
for (const vendor of await readdir(join(root, 'vendor'), { withFileTypes: true })) {
if (vendor.isDirectory()) result.push(join(root, 'vendor', vendor.name))
}
for (const group of await readdir(join(root, 'packages'), { withFileTypes: true })) {
if (!group.isDirectory()) continue
for (const pkg of await readdir(join(root, 'packages', group.name), { withFileTypes: true })) {
if (pkg.isDirectory()) result.push(join(root, 'packages', group.name, pkg.name))
}
}
return result
}
/** Index of repository packages used by `--link-workspace`. */
export class LinkWorkspace {
readonly root: string
private readonly packages: Map<string, WorkspacePackage>
private constructor(root: string, packages: Map<string, WorkspacePackage>) {
this.root = root
this.packages = packages
}
/** Scan vendor and package workspaces from a repository root. */
static async open(root: string): Promise<LinkWorkspace> {
const absolute = resolve(root)
const packages = new Map<string, WorkspacePackage>()
for (const directory of await packageDirectories(absolute)) {
let manifest: PackageManifest
try {
manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as PackageManifest
} catch (error) {
throw new Error(`cannot read linked package at ${directory}: ${String(error)}`)
}
if (!manifest.name || typeof manifest.name !== 'string') continue
if (packages.has(manifest.name)) throw new Error(`duplicate linked package name: ${manifest.name}`)
packages.set(manifest.name, { directory, manifest })
}
if (!packages.has('@deepseek-ai/cordis') || !packages.has('@deepseek-ai/dsh-scripts')) {
throw new Error(`not a DeepSeek Harness repository root: ${absolute}`)
}
return new LinkWorkspace(absolute, packages)
}
/** Expand direct NPM dependencies through all repository-local NPM dependency edges. */
closure(names: Iterable<string>): string[] {
const pending = [...names]
const result = new Set<string>()
while (pending.length > 0) {
const name = pending.pop()
if (!name || result.has(name)) continue
const pkg = this.packages.get(name)
/* v8 ignore next -- closure() only returns names present in this package map */
if (!pkg) continue
result.add(name)
const edges = {
...pkg.manifest.dependencies,
...pkg.manifest.peerDependencies as Record<string, string> | undefined,
}
for (const dependencyName of Object.keys(edges)) {
if (this.packages.has(dependencyName) && !result.has(dependencyName)) pending.push(dependencyName)
}
}
return [...result].sort()
}
/** Rewrite the full local closure to manager-specific live-link specs. */
apply(
projectRoot: string,
manifest: PackageJsonFile,
manager: PackageManager,
documents: readonly ProjectFile[],
): void {
const canonicalProjectRoot = canonicalPath(projectRoot)
const names = this.closure(manifest.npmDependencyNames())
for (const name of names) {
const pkg = this.packages.get(name)
/* v8 ignore next -- closure() only returns names present in this package map */
if (!pkg) continue
const relativePath = posixPath(relative(canonicalProjectRoot, realpathSync(pkg.directory)))
const spec = manager.linkSpec(relativePath)
const current = manifest.npmDependency(name)
manifest.setNpmDependency(current?.section ?? 'dependencies', name, spec)
if (manager.name === 'yarn') manifest.setResolution(name, spec)
}
if (manager.name === 'pnpm') {
const workspace = documents.find((item): item is PnpmWorkspaceFile => item instanceof PnpmWorkspaceFile)
if (!workspace) throw new Error('pnpm link mode requires pnpm-workspace.yaml')
workspace.disableAutoInstallPeers()
}
}
/** Resolve a package directory for diagnostics and tests. */
packageDirectory(name: string): string | undefined {
const directory = this.packages.get(name)?.directory
return directory
? resolve(dirname(directory), directory.split(sep).at(-1) as string)
: undefined
}
/**
* Rewrite one nested generated manifest's local dependencies to live-link specs.
*
* A generated workspace member resolves its own dependencies, so every local
* name it declares must point into this repository as well: none of them —
* the harness packages or the rescoped framework — exists on a public
* registry, so a semver spec there fails the install outright.
* `peerDependencies` keeps its range because a peer states what the consumer
* must supply, and package managers reject a link spec in that section.
* @param projectRoot - Absolute root of the generated project.
* @param manifestPath - The nested manifest's project-relative POSIX path.
* @param text - The nested manifest's complete current text.
* @param manager - Package manager whose link-spec form applies.
* @returns The manifest text with every resolved local dependency relinked.
*/
relinkNestedManifest(projectRoot: string, manifestPath: string, text: string, manager: PackageManager): string {
const manifest = JSON.parse(text) as Record<string, unknown>
const manifestDirectory = resolve(canonicalPath(projectRoot), dirname(manifestPath))
let changed = false
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
const dependencies = manifest[section]
if (typeof dependencies !== 'object' || dependencies === null) continue
for (const [name] of Object.entries(dependencies as Record<string, string>)) {
const pkg = this.packages.get(name)
if (!pkg) continue
const relativePath = posixPath(relative(manifestDirectory, realpathSync(pkg.directory)))
;(dependencies as Record<string, string>)[name] = manager.linkSpec(relativePath)
changed = true
}
}
return changed ? `${JSON.stringify(manifest, null, 2)}\n` : text
}
}

View File

@@ -1,332 +0,0 @@
/**
* Package-manager strategies for SDK project workspaces and child commands.
*
* @module @deepseek-ai/dsh-helper/package-managers/package-manager
*/
import { execFile, spawn } from 'node:child_process'
import { scrubbedParentEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
import { promisify } from 'node:util'
import type { PackageJsonFile } from '../documents/package-json-file.ts'
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
import type { ProjectFile } from '../documents/project-file.ts'
/** Supported generated-project package managers. */
export type PackageManagerName = 'npm' | 'pnpm' | 'yarn'
/** Result from one child package-manager process. */
export interface CommandResult {
exitCode: number | null
signal: NodeJS.Signals | null
}
/** Injectable subprocess boundary used by package-manager strategies. */
export interface CommandRunner {
/** Run one executable without a shell and await process exit. */
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult>
}
/** Injectable package-manager version probe used by project creation. */
export type PackageManagerVersionProbe = (name: PackageManagerName, cwd: string) => Promise<string>
const execFileAsync = promisify(execFile)
/**
* Read a manager version without forwarding ambient credentials.
* @param name - package-manager executable.
* @param cwd - working directory used for resolution.
* @returns trimmed version output.
*/
export async function probePackageManagerVersion(name: PackageManagerName, cwd: string): Promise<string> {
try {
const { stdout } = await execFileAsync(name, ['--version'], {
cwd,
env: scrubEnvironment(),
encoding: 'utf8',
})
const version = stdout.trim()
if (!version) throw new Error('empty version output')
return version
} catch (error) {
throw new Error(`cannot run ${name} --version: ${String(error)}`)
}
}
/**
* Remove credential-shaped environment variables from spawned commands.
* @param environment - source environment (injectable for tests); the default
* path shares the subprocess seam's scrub so every harness spawner drops the
* same names.
*/
export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
if (environment === undefined) return scrubbedParentEnv()
return Object.fromEntries(Object.entries(environment).filter(([name]) => !SENSITIVE_ENV_PATTERN.test(name)))
}
/** Node child-process command runner with inherited stdio and quiescent completion. */
export class NodeCommandRunner implements CommandRunner {
private readonly output: NodeJS.WritableStream | undefined
/**
* @param output - redirect target for child stdout+stderr; the child inherits
* this process's stdio when absent. Callers whose own stdout carries a machine
* protocol (create-sdk --json NDJSON) redirect child output to keep the
* protocol stream pure.
*/
constructor(output?: NodeJS.WritableStream) {
this.output = output
}
/** Spawn one child and settle only after exit, with redirected stdio drained. */
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const output = this.output
if (output === undefined) {
const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false })
child.once('error', reject)
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
return
}
const child = spawn(command, [...args], {
cwd,
env: scrubEnvironment(),
stdio: ['inherit', 'pipe', 'pipe'],
shell: false,
})
child.stdout.pipe(output, { end: false })
child.stderr.pipe(output, { end: false })
child.once('error', reject)
child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) })
})
}
}
function major(version: string): number {
const match = /^(\d+)/.exec(version)
if (!match?.[1]) throw new Error(`invalid package manager version: ${JSON.stringify(version)}`)
return Number(match[1])
}
/** Behavior owned by one generated-project package manager. */
export abstract class PackageManager {
/** Manager executable and project identity. */
abstract readonly name: PackageManagerName
/** Detected concrete manager version. */
readonly version: string
constructor(version: string) {
this.version = version
}
/** Validate the detected version against this SDK's supported floor. */
abstract validateVersion(): void
/**
* Configure root manifest fields and return manager-specific files.
* @param manifest - generated root manifest to update.
* @returns manager-specific companion documents.
*/
abstract configureWorkspace(manifest: PackageJsonFile): ProjectFile[]
/**
* Build the NPM dependency spec for a local workspace plugin.
* @returns manager-specific local NPM dependency spec.
*/
abstract localPluginSpec(): string
/**
* Resolve a repository live-link NPM dependency.
* @param relativePath - relative path from generated project to package.
* @returns manager-specific NPM dependency spec.
*/
abstract linkSpec(relativePath: string): string
/**
* Build install command arguments.
* @returns arguments following the manager executable.
*/
installCommand(): readonly string[] {
return ['install']
}
/**
* Build project-build command arguments.
* @returns arguments following the manager executable.
*/
buildCommand(): readonly string[] {
return ['run', 'build']
}
/**
* Run NPM dependency installation and fail on non-zero or signalled exit.
* @param cwd - generated project directory.
* @param runner - optional subprocess boundary.
*/
async install(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
await this.runChecked(runner, this.installCommand(), cwd, 'install')
}
/**
* Run the project build and fail on non-zero or signalled exit.
* @param cwd - generated project directory.
* @param runner - optional subprocess boundary.
*/
async build(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
await this.runChecked(runner, this.buildCommand(), cwd, 'build')
}
/**
* Build add-dependency command arguments for one already-normalized source spec.
* @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`).
* @returns arguments following the manager executable.
*/
addCommand(spec: string): readonly string[] {
return ['add', spec]
}
/**
* Add one dependency from a native source spec and fail on non-zero or signalled exit.
* @param spec - a package-manager-native dependency source.
* @param cwd - project directory.
* @param runner - optional subprocess boundary.
*/
async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
await this.runChecked(runner, this.addCommand(spec), cwd, 'add')
}
private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise<void> {
const result = await runner.run(this.name, args, cwd)
if (result.signal !== null) {
throw new Error(`${this.name} ${operation} was killed by ${result.signal}`)
}
if (result.exitCode !== 0) {
throw new Error(`${this.name} ${operation} exited with code ${String(result.exitCode)}`)
}
}
}
/** npm workspace behavior. */
export class NpmPackageManager extends PackageManager {
override readonly name = 'npm'
/** npm 10 is the supported floor at the repository's Node floor. */
override validateVersion(): void {
if (major(this.version) < 10) throw new Error(`npm >=10 is required, got ${this.version}`)
}
/** Configure package.json workspaces; npm needs no companion file. */
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
manifest.addWorkspace('plugins/*')
manifest.setPackageManager(undefined)
return []
}
/** npm resolves workspace packages through its ordinary wildcard. */
override localPluginSpec(): string {
return '*'
}
/** npm live links use file NPM dependencies. */
override linkSpec(relativePath: string): string {
return `file:${relativePath}`
}
/** npm adds a dependency through `install <spec>` rather than an `add` verb. */
override addCommand(spec: string): readonly string[] {
return ['install', spec]
}
}
/** pnpm workspace behavior. */
export class PnpmPackageManager extends PackageManager {
override readonly name = 'pnpm'
/** pnpm 10 is the supported floor for strict NPM dependency-build policy. */
override validateVersion(): void {
if (major(this.version) < 10) throw new Error(`pnpm >=10 is required, got ${this.version}`)
}
/** Configure packageManager and a structured pnpm workspace file. */
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
manifest.setPackageManager(`pnpm@${this.version}`)
const workspace = PnpmWorkspaceFile.create()
workspace.addPackage('plugins/*')
return [workspace]
}
/** pnpm uses its explicit workspace protocol. */
override localPluginSpec(): string {
return 'workspace:*'
}
/** pnpm live links use link NPM dependencies. */
override linkSpec(relativePath: string): string {
return `link:${relativePath}`
}
}
/** Yarn Berry-compatible workspace behavior. */
export class YarnPackageManager extends PackageManager {
override readonly name = 'yarn'
/** Yarn classic is excluded because the generated project relies on modern workspaces. */
override validateVersion(): void {
if (major(this.version) < 2) throw new Error(`Yarn >=2 is required, got ${this.version}`)
}
/** Configure packageManager and package.json workspaces. */
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
manifest.addWorkspace('plugins/*')
manifest.setPackageManager(`yarn@${this.version}`)
return []
}
/** Modern Yarn uses the workspace protocol. */
override localPluginSpec(): string {
return 'workspace:*'
}
/** Yarn live links use portal NPM dependencies to preserve package identity. */
override linkSpec(relativePath: string): string {
return `portal:${relativePath}`
}
/** Yarn runs scripts without the `run` token. */
override buildCommand(): readonly string[] {
return ['build']
}
}
/**
* Construct and validate one package-manager strategy.
* @param name - selected manager.
* @param version - detected concrete version.
* @returns validated strategy.
*/
export function createPackageManager(name: PackageManagerName, version: string): PackageManager {
let manager: PackageManager
switch (name) {
case 'npm': manager = new NpmPackageManager(version); break
case 'pnpm': manager = new PnpmPackageManager(version); break
case 'yarn': manager = new YarnPackageManager(version); break
}
manager.validateVersion()
return manager
}
/**
* Infer a package manager from an explicit choice or npm user-agent value.
* @param explicit - explicit CLI selection.
* @param userAgent - npm-compatible user-agent string.
* @returns selected or inferred manager name.
*/
export function inferPackageManagerName(
explicit: PackageManagerName | undefined,
userAgent: string | undefined = process.env.npm_config_user_agent,
): PackageManagerName | undefined {
if (explicit) return explicit
const token = userAgent?.split(' ')[0]?.split('/')[0]
if (token === 'npm' || token === 'pnpm' || token === 'yarn') return token
return undefined
}

View File

@@ -1,123 +0,0 @@
/**
* Source blueprints for local Cordis plugins generated under `plugins/*`.
*
* @module @deepseek-ai/dsh-helper/plugins/local-plugin-blueprint
*/
import { TextProjectFile } from '../documents/project-file.ts'
import type { CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import { resolveNpmDependency } from '../project/npm-dependency-policy.ts'
import { loadHelperTemplate } from '../templates/template-assets.ts'
/** Supported generated local-plugin shapes. */
export type LocalPluginKind = 'plugin' | 'tool'
function kebab(value: string): string {
const result = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
if (!result || !/^[a-z]/.test(result)) throw new Error(`invalid local plugin name: ${JSON.stringify(value)}`)
return result
}
function packageName(projectName: string, pluginName: string): string {
if (projectName.startsWith('@')) {
const separator = projectName.indexOf('/')
if (separator > 1 && separator < projectName.length - 1) {
return `${projectName.slice(0, separator)}/${projectName.slice(separator + 1)}-${pluginName}`
}
}
return `${projectName}-${pluginName}`
}
interface LocalPluginTemplateContext {
pluginName: string
toolName: string
toolTitle: string
}
const PLUGIN_SOURCE = loadHelperTemplate<LocalPluginTemplateContext>('local-plugin.ts.tpl')
const TOOL_SOURCE = loadHelperTemplate<LocalPluginTemplateContext>('local-tool.ts.tpl')
const PLUGIN_TSDOWN = loadHelperTemplate<LocalPluginTemplateContext>('local-plugin-tsdown.config.ts.tpl')
/** One local plugin's derived package, source, build, and runtime entry. */
export class LocalPluginBlueprint {
/** Normalized local package and Cordis config entry name. */
readonly name: string
/** Generated plugin source shape. */
readonly kind: LocalPluginKind
/** Normalize and validate one local plugin request. */
constructor(name: string, kind: LocalPluginKind) {
this.name = kebab(name)
this.kind = kind
}
/** Root-relative plugin directory. */
get directory(): string {
return `plugins/${this.name}`
}
/**
* Derive an npm package name from the root project identity.
* @param projectName - generated root package name.
* @returns local plugin package name.
*/
packageName(projectName: string): string {
return packageName(projectName, this.name)
}
/**
* Build the runtime Cordis config entry for this local package.
* @param projectName - generated root package name.
* @returns Loader entry referencing the local package.
*/
cordisConfigEntry(projectName: string): CordisConfigEntry {
return { id: this.name, name: this.packageName(projectName) }
}
/**
* Render the complete local package files.
* @param projectName - generated root package name.
* @param releaseVersion - SDK dependency version.
* @returns local manifest, configs, and source documents.
*/
documents(projectName: string, releaseVersion: string): TextProjectFile[] {
const name = this.packageName(projectName)
const toolName = this.name.replaceAll('-', '_')
const cordisSpec = resolveNpmDependency('@deepseek-ai/cordis', 'devDependencies', releaseVersion).spec
const manifest = {
name,
version: '0.0.0',
private: true,
type: 'module',
main: 'lib/index.js',
types: 'lib/index.d.ts',
exports: { '.': { types: './lib/index.d.ts', default: './lib/index.js' } },
peerDependencies: {
...this.kind === 'tool' ? { '@deepseek-ai/dsh-tools': `^${releaseVersion}` } : {},
'@deepseek-ai/cordis': cordisSpec,
},
devDependencies: {
'@deepseek-ai/cordis': cordisSpec,
},
}
const tsconfig = {
extends: '../../tsconfig.base.json',
compilerOptions: { rootDir: 'src', outDir: 'lib/types' },
include: ['src'],
}
const context: LocalPluginTemplateContext = {
pluginName: this.name,
toolName,
toolTitle: toolName.replaceAll('_', ' '),
}
return [
new TextProjectFile(`${this.directory}/package.json`, JSON.stringify(manifest, null, 2)),
new TextProjectFile(`${this.directory}/tsconfig.json`, JSON.stringify(tsconfig, null, 2)),
new TextProjectFile(`${this.directory}/tsdown.config.ts`, PLUGIN_TSDOWN.render(context)),
new TextProjectFile(
`${this.directory}/src/index.ts`,
(this.kind === 'tool' ? TOOL_SOURCE : PLUGIN_SOURCE).render(context),
),
]
}
}

View File

@@ -1,26 +0,0 @@
/**
* Result summary for one SDK project edit session.
*
* @module @deepseek-ai/dsh-helper/project/change-set
*/
import type { FeatureId } from '../ids.ts'
/** Immutable description of committed or pending project changes. */
export interface ChangeSet {
addedFeatures: readonly FeatureId[]
enabledFeatures: readonly FeatureId[]
disabledFeatures: readonly FeatureId[]
configuredFeatures: readonly FeatureId[]
addedPlugins: readonly string[]
enabledPlugins: readonly string[]
disabledPlugins: readonly string[]
changedFiles: readonly string[]
npmDependenciesChanged: boolean
}
/** Result of committing one project edit session. */
export interface ProjectCommitResult<TProject> {
project: TProject
changes: ChangeSet
}

View File

@@ -1,62 +0,0 @@
/**
* NPM dependency baseline and version policy for generated SDK projects.
*
* @module @deepseek-ai/dsh-helper/project/npm-dependency-policy
*/
import type { NpmDependencySection } from '../documents/package-json-file.ts'
/** One NPM dependency spec selected by the SDK release policy. */
export interface ResolvedNpmDependency {
section: NpmDependencySection
spec: string
}
/** NPM dependency maps rendered into a newly created root package.json. */
export interface BaselineNpmDependencies {
dependencies: Readonly<Record<string, string>>
devDependencies: Readonly<Record<string, string>>
}
const EXTERNAL_NPM_DEPENDENCY_SPECS: Readonly<Record<string, string>> = {
'@deepseek-ai/cordis-plugin-hmr': '^1.0.15',
'@deepseek-ai/cordis-plugin-timer': '^1.1.2',
'@types/node': '^22.20.0',
'@deepseek-ai/cordis': '^4.0.0-rc.7',
tsdown: '0.22.2',
tsx: '^4.22.4',
typescript: '^6.0.3',
}
const BASELINE_NPM_DEPENDENCY_NAMES: Readonly<Record<NpmDependencySection, readonly string[]>> = {
dependencies: ['@deepseek-ai/dsh-scripts', '@deepseek-ai/cordis'],
devDependencies: ['@types/node', 'tsdown', 'tsx', 'typescript'],
}
/** Resolve one package to its generated-project section and version spec. */
export function resolveNpmDependency(
name: string,
requestedSection: NpmDependencySection,
releaseVersion: string,
): ResolvedNpmDependency {
if (name.startsWith('@deepseek-ai/dsh-')) {
return { section: requestedSection, spec: `^${releaseVersion}` }
}
const spec = EXTERNAL_NPM_DEPENDENCY_SPECS[name]
if (spec) return { section: requestedSection, spec }
throw new Error(`no generated-project NPM dependency policy for ${name}`)
}
/** Build the root package.json NPM dependency maps from the shared version policy. */
export function baselineNpmDependencies(releaseVersion: string): BaselineNpmDependencies {
return {
dependencies: Object.fromEntries(BASELINE_NPM_DEPENDENCY_NAMES.dependencies.map((name) => {
const dependency = resolveNpmDependency(name, 'dependencies', releaseVersion)
return [name, dependency.spec]
})),
devDependencies: Object.fromEntries(BASELINE_NPM_DEPENDENCY_NAMES.devDependencies.map((name) => {
const dependency = resolveNpmDependency(name, 'devDependencies', releaseVersion)
return [name, dependency.spec]
})),
}
}

View File

@@ -1,628 +0,0 @@
/**
* Isolated domain-command and commit boundary for SDK project changes.
*
* @module @deepseek-ai/dsh-helper/project/project-edit-session
*/
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import type {
Feature,
FeatureInstallation,
FeatureProjectView,
FeatureRequirement,
} from '../features/feature.ts'
import type { FeatureRegistry } from '../features/registry.ts'
import type { ProjectResource } from '../features/resources.ts'
import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import { EnvFile } from '../documents/env-file.ts'
import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts'
import { ProjectFile, TextProjectFile } from '../documents/project-file.ts'
import { TsConfigFile } from '../documents/tsconfig-file.ts'
import { featureId, type FeatureId, type ResourceKey } from '../ids.ts'
import { LinkWorkspace } from '../package-managers/link-workspace.ts'
import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
import type { FeatureSelection, ProjectProfile } from './types.ts'
import { resolveNpmDependency } from './npm-dependency-policy.ts'
import type { ChangeSet, ProjectCommitResult } from './change-set.ts'
import type { SdkProject } from './sdk-project.ts'
interface MutableFeatureState {
selection?: FeatureSelection
state: FeatureInstallation['state']
}
function sameText(left: ProjectFile, right: ProjectFile | undefined): boolean {
return right !== undefined && left.serialize() === right.serialize()
}
function npmDependencyShape(manifest: Readonly<PackageManifest>): string {
return JSON.stringify({
/* v8 ignore next -- generated manifests always carry the managed dependency maps */
dependencies: manifest.dependencies ?? {},
/* v8 ignore next -- generated manifests always carry the managed dependency maps */
devDependencies: manifest.devDependencies ?? {},
})
}
function asError(error: unknown): Error {
/* v8 ignore else -- node:fs promise APIs reject Error objects */
if (error instanceof Error) return error
/* v8 ignore next -- node:fs promise APIs reject Error objects */
return new Error(String(error))
}
function canUpdateResource(previous: ProjectResource, next: ProjectResource): boolean {
if (previous.kind !== next.kind) return false
switch (previous.kind) {
case 'npm-dependency': return previous.name === (next as typeof previous).name
case 'package-script': return previous.name === (next as typeof previous).name
case 'cordis-config-entry': {
const candidate = next as typeof previous
return previous.entry.name === candidate.entry.name
}
case 'environment': return previous.name === (next as typeof previous).name
case 'owned-file': return previous.document.relativePath === (next as typeof previous).document.relativePath
}
}
/** Mutable working copy that applies feature and local-plugin domain commands. */
export class ProjectEditSession implements FeatureProjectView {
readonly profile: ProjectProfile
private readonly source: SdkProject
private readonly registry: FeatureRegistry
private readonly documents: Map<string, ProjectFile>
private readonly removed = new Map<string, ProjectFile>()
private readonly states = new Map<FeatureId, MutableFeatureState>()
private readonly added = new Set<FeatureId>()
private readonly enabled = new Set<FeatureId>()
private readonly disabled = new Set<FeatureId>()
private readonly configured = new Set<FeatureId>()
private readonly addedPlugins = new Set<string>()
private readonly enabledPlugins = new Set<string>()
private readonly disabledPlugins = new Set<string>()
private committed = false
/** Clone one project snapshot into an isolated working copy. */
constructor(source: SdkProject, registry: FeatureRegistry) {
this.source = source
this.registry = registry
this.profile = source.profile
this.documents = source.cloneDocuments()
for (const feature of registry.all()) {
/* v8 ignore next -- no current built-in feature is interface-specific */
if (!feature.isApplicable(this.profile)) continue
const installation = feature.inspect(this)
this.states.set(feature.id, {
state: installation.state,
...installation.selection ? { selection: installation.selection } : {},
})
}
}
/** Root manifest value for feature inspection. */
packageManifest(): Readonly<PackageManifest> {
return this.manifest().value()
}
/** Cordis config entries for feature and custom-plugin inspection. */
cordisConfigEntries(): readonly CordisConfigEntry[] {
return this.cordis().entries()
}
/** Whether one managed document exists in the working copy. */
/* jscpd:ignore-start -- FeatureProjectView deliberately has symmetric snapshot/edit implementations. */
hasDocument(path: string): boolean {
return this.documents.has(path)
}
/** Read one unique working-copy environment variable. */
readEnvironment(path: '.env' | '.env.example', name: string): string | undefined {
const document = this.documents.get(path)
if (!document) return undefined
if (!(document instanceof EnvFile)) throw new Error(`${path} is not an environment document`)
return document.get(name)
}
/* jscpd:ignore-end */
/** Inspect every applicable builtin against the current working copy. */
inspections(): readonly FeatureInstallation[] {
return this.registry.inspect(this)
}
/** Install a builtin and recursively satisfy its declared requirements. */
installFeature(feature: Feature, selection: FeatureSelection): void {
this.assertOpen()
this.installFeatureRecursive(feature, selection, new Set())
}
/** Replace one installed builtin's feature-option and captured-input selection. */
configureFeature(feature: Feature, selection: FeatureSelection): void {
this.assertOpen()
const current = this.state(feature)
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
if (current.state === 'absent' || !current.selection) {
this.installFeature(feature, selection)
return
}
const normalized = feature.normalizeSelection(selection, this.profile)
this.ensureRequirements(feature, normalized, new Set([feature.id]))
this.replaceContribution(
feature.contribution(current.selection, this.profile),
feature.contribution(normalized, this.profile),
)
current.selection = normalized
current.state = current.state === 'disabled' ? 'disabled' : 'enabled'
if (current.state === 'disabled') this.setFeatureDisabled(feature, normalized, true)
this.assertFeatureConsistent(feature)
this.configured.add(feature.id)
}
/** Enable all entries owned by one installed feature. */
enableFeature(feature: Feature): void {
this.assertOpen()
const current = this.state(feature)
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
if (current.state === 'absent' || !current.selection) {
throw new Error(`feature ${feature.id} is not installed`)
}
this.setFeatureDisabled(feature, current.selection, false)
current.state = 'enabled'
this.assertFeatureConsistent(feature)
this.disabled.delete(feature.id)
this.enabled.add(feature.id)
}
/** Disable an optional feature without removing its configuration. */
disableFeature(feature: Feature): void {
this.assertOpen()
if (feature.required) throw new Error(`required feature ${feature.id} cannot be disabled`)
const current = this.state(feature)
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
if (current.state === 'absent' || !current.selection) {
throw new Error(`feature ${feature.id} is not installed`)
}
const dependent = this.registry.all().find((candidate) => {
const state = this.states.get(candidate.id)
return state?.state === 'enabled' && state.selection
&& candidate.requirements(state.selection).some(requirement => requirement.id === feature.id)
})
if (dependent) throw new Error(`feature ${feature.id} is required by ${dependent.id}`)
this.setFeatureDisabled(feature, current.selection, true)
current.state = 'disabled'
this.assertFeatureConsistent(feature)
this.enabled.delete(feature.id)
this.disabled.add(feature.id)
}
/** Add a generated local plugin and all four of its project registrations. */
addPlugin(blueprint: LocalPluginBlueprint): void {
this.assertOpen()
const manifest = this.manifest()
const cordis = this.cordis()
const tsconfig = this.documents.get('tsconfig.json')
if (!(tsconfig instanceof TsConfigFile)) {
throw new Error('adding a local plugin requires a valid tsconfig.json')
}
const packageName = blueprint.packageName(this.profile.name)
if (manifest.npmDependency(packageName)) throw new Error(`root NPM dependency already exists: ${packageName}`)
const entry = blueprint.cordisConfigEntry(this.profile.name)
if (cordis.entry(entry.id)) throw new Error(`Cordis config entry already exists: ${entry.id}`)
const documents = blueprint.documents(this.profile.name, this.profile.releaseVersion)
for (const document of documents) {
if (this.documents.has(document.relativePath)) {
throw new Error(`local plugin file already exists: ${document.relativePath}`)
}
}
for (const document of documents) this.documents.set(document.relativePath, document)
manifest.setNpmDependency('dependencies', packageName, this.profile.packageManager.localPluginSpec())
tsconfig.addReference(`./${blueprint.directory}`)
cordis.addEntry(entry)
this.addedPlugins.add(entry.id)
}
/**
* Mount a Cordis entry for an external dependency the package manager has already
* added (github or npm), without generating files or re-adding the dependency.
* @param id - stable Cordis config entry id.
* @param packageName - the installed dependency's package name.
*/
addExternalPlugin(id: string, packageName: string): void {
this.assertOpen()
if (!this.manifest().npmDependency(packageName)) {
throw new Error(`external plugin dependency is not installed: ${packageName}`)
}
const cordis = this.cordis()
if (cordis.entry(id)) throw new Error(`Cordis config entry already exists: ${id}`)
cordis.addEntry({ id, name: packageName })
this.addedPlugins.add(id)
}
/** Enable or disable one custom/manual Cordis config entry by stable id. */
setCustomPluginDisabled(id: string, disabled: boolean): void {
this.assertOpen()
const entry = this.cordis().entry(id)
if (!entry) throw new Error(`Cordis config entry does not exist: ${id}`)
if (this.registry.ownerOfPackage(entry.name, this.profile)) {
throw new Error(`Cordis config entry ${id} belongs to a builtin feature`)
}
this.cordis().setDisabled(id, disabled)
if (disabled) {
this.enabledPlugins.delete(id)
this.disabledPlugins.add(id)
} else {
this.disabledPlugins.delete(id)
this.enabledPlugins.add(id)
}
}
/** Summarize all pending domain and file changes. */
changes(): ChangeSet {
const changedFiles = new Set<string>()
for (const [path, document] of this.documents) {
if (this.source.origin === 'create' || !sameText(document, this.source.document(path))) changedFiles.add(path)
}
for (const path of this.removed.keys()) changedFiles.add(path)
return {
addedFeatures: [...this.added].sort(),
enabledFeatures: [...this.enabled].sort(),
disabledFeatures: [...this.disabled].sort(),
configuredFeatures: [...this.configured].sort(),
addedPlugins: [...this.addedPlugins].sort(),
enabledPlugins: [...this.enabledPlugins].sort(),
disabledPlugins: [...this.disabledPlugins].sort(),
changedFiles: [...changedFiles].sort(),
npmDependenciesChanged: npmDependencyShape(this.manifest().value())
!== npmDependencyShape(this.source.packageManifest()),
}
}
/** Validate, detect external edits, write affected files, and return a fresh snapshot. */
async commit(): Promise<ProjectCommitResult<SdkProject>> {
this.assertOpen()
if (this.profile.linkWorkspaceRoot) {
const workspace = await LinkWorkspace.open(this.profile.linkWorkspaceRoot)
workspace.apply(
this.source.root,
this.manifest(),
this.profile.packageManager,
[...this.documents.values()],
)
// Generated workspace members resolve their own dependencies, so the root
// manifest's links are not enough: relink every nested manifest as well.
for (const [path, document] of this.documents) {
if (path === 'package.json' || !path.endsWith('/package.json')) continue
const relinked = workspace.relinkNestedManifest(
this.source.root,
path,
document.serialize(),
this.profile.packageManager,
)
this.documents.set(path, new TextProjectFile(path, relinked, document.originalText))
}
}
this.validateFinalState()
const changes = this.changes()
await this.assertUnchanged(changes.changedFiles)
await mkdir(this.source.root, { recursive: true })
for (const path of changes.changedFiles) {
const document = this.documents.get(path)
const absolute = resolve(this.source.root, path)
if (!document) {
await unlink(absolute)
continue
}
await mkdir(dirname(absolute), { recursive: true })
await writeFile(absolute, document.serialize(), {
encoding: 'utf8',
...document.createMode === undefined ? {} : { mode: document.createMode },
})
}
this.committed = true
return { project: await this.source.reopen(), changes }
}
private installFeatureRecursive(
feature: Feature,
selection: FeatureSelection,
stack: Set<FeatureId>,
): void {
if (stack.has(feature.id)) throw new Error(`cyclic feature requirement involving ${feature.id}`)
const current = this.state(feature)
if (current.state === 'inconsistent') throw new Error(`feature ${feature.id} is inconsistent`)
if (current.state !== 'absent' && current.selection) {
this.configureFeature(feature, selection)
if (current.state === 'disabled') this.enableFeature(feature)
return
}
const normalized = feature.normalizeSelection(selection, this.profile)
const nextStack = new Set(stack).add(feature.id)
this.ensureRequirements(feature, normalized, nextStack)
this.replaceContribution(undefined, feature.contribution(normalized, this.profile))
current.selection = normalized
current.state = 'enabled'
this.assertFeatureConsistent(feature)
this.added.add(feature.id)
}
private ensureRequirements(feature: Feature, selection: FeatureSelection, stack: Set<FeatureId>): void {
for (const requirement of feature.requirements(selection)) {
const required = this.registry.get(requirement.id)
const state = this.state(required)
if (state.state === 'inconsistent') throw new Error(`required feature ${required.id} is inconsistent`)
if (state.state === 'absent' || !state.selection) {
this.installFeatureRecursive(required, {
id: required.id,
options: requirement.options ?? required.defaultOptions(this.profile),
}, stack)
} else {
const next = this.selectionWithRequiredOptions(required, state.selection, requirement)
if (next !== state.selection) this.configureFeature(required, next)
if (state.state === 'disabled') this.enableFeature(required)
}
}
}
private selectionWithRequiredOptions(
feature: Feature,
selection: FeatureSelection,
requirement: FeatureRequirement,
): FeatureSelection {
if (!requirement.options || requirement.options.every(option => selection.options.includes(option))) {
return selection
}
if (feature.mode !== 'multiple') {
throw new Error(`${feature.id} does not satisfy the option requirement from another feature`)
}
return { ...selection, options: [...new Set([...selection.options, ...requirement.options])] }
}
private replaceContribution(
previous: ReturnType<Feature['contribution']> | undefined,
next: ReturnType<Feature['contribution']>,
): void {
const previousByKey = previous?.byKey() ?? new Map<ResourceKey, ProjectResource>()
const nextByKey = next.byKey()
for (const [key, resource] of previousByKey) {
const replacement = nextByKey.get(key)
if (!replacement || !canUpdateResource(resource, replacement)) this.removeResource(resource)
}
for (const [key, resource] of nextByKey) {
const previousResource = previousByKey.get(key)
this.applyResource(
resource,
previousResource && canUpdateResource(previousResource, resource) ? previousResource : undefined,
)
}
}
private applyResource(resource: ProjectResource, previous: ProjectResource | undefined): void {
switch (resource.kind) {
case 'npm-dependency': {
const dependency = resolveNpmDependency(resource.name, resource.section, this.profile.releaseVersion)
this.manifest().setNpmDependency(dependency.section, resource.name, dependency.spec)
return
}
case 'package-script': {
const manifest = this.manifest()
const current = manifest.script(resource.name)
if (!previous || previous.kind !== 'package-script') {
if (current !== undefined) throw new Error(`feature-owned package script already exists: ${resource.name}`)
manifest.setScript(resource.name, resource.command)
return
}
if (current === resource.command) return
if (current !== previous.command) {
throw new Error(`feature-owned package script was modified: ${resource.name}`)
}
manifest.setScript(resource.name, resource.command)
return
}
case 'cordis-config-entry': {
const current = this.cordis().entry(resource.entry.id)
if (!current) this.cordis().addEntry(resource.entry, resource.commentedExample)
else {
if (current.name !== resource.entry.name) {
throw new Error(`Cordis config entry ${resource.entry.id} is owned by ${current.name}, not ${resource.entry.name}`)
}
this.cordis().updateOwnedConfig(
resource.entry.id,
resource.ownedConfigKeys,
resource.entry.config ?? {},
)
this.cordis().setDisabled(resource.entry.id, false)
}
return
}
case 'environment': {
this.environment('.env.example').set(resource.name, resource.exampleValue)
/* v8 ignore else -- an omitted secret intentionally materializes only its example placeholder */
if (resource.value !== undefined) {
const environment = this.environment('.env')
environment.append(
resource.name,
resource.value,
resource.value === '' ? resource.comment : undefined,
)
}
return
}
case 'owned-file': {
const existing = this.documents.get(resource.document.relativePath)
if (!existing) {
this.documents.set(resource.document.relativePath, resource.document.clone())
this.removed.delete(resource.document.relativePath)
return
}
if (!previous || previous.kind !== 'owned-file') {
throw new Error(`feature-owned file already exists: ${resource.document.relativePath}`)
}
if (previous.document.serialize() === resource.document.serialize()) return
if (existing.serialize() !== previous.document.serialize()) {
throw new Error(`feature-owned file was modified: ${resource.document.relativePath}`)
}
this.documents.set(resource.document.relativePath, resource.document.clone())
this.removed.delete(resource.document.relativePath)
return
}
}
}
private removeResource(resource: ProjectResource): void {
switch (resource.kind) {
case 'npm-dependency':
this.manifest().removeNpmDependency(resource.section, resource.name)
return
case 'package-script': {
const manifest = this.manifest()
const current = manifest.script(resource.name)
if (current === undefined) throw new Error(`owned package script is missing: ${resource.name}`)
if (resource.removeOnlyWhenUnchanged && current !== resource.command) {
throw new Error(`feature-owned package script was modified: ${resource.name}`)
}
manifest.removeScript(resource.name)
return
}
case 'cordis-config-entry': {
const entry = this.cordis().entry(resource.entry.id)
if (!entry || entry.name !== resource.entry.name) {
throw new Error(`cannot confirm old Cordis resource ${resource.entry.id}`)
}
this.cordis().removeEntry(resource.entry.id)
return
}
case 'environment':
this.environment('.env.example').remove(resource.name)
return
case 'owned-file': {
const document = this.documents.get(resource.document.relativePath)
if (!document) throw new Error(`owned file is missing: ${resource.document.relativePath}`)
if (resource.removeOnlyWhenUnchanged && document.serialize() !== resource.document.serialize()) {
throw new Error(`owned file was modified: ${resource.document.relativePath}`)
}
this.documents.delete(resource.document.relativePath)
if (this.source.document(resource.document.relativePath)) {
this.removed.set(resource.document.relativePath, document)
}
}
}
}
private setFeatureDisabled(feature: Feature, selection: FeatureSelection, disabled: boolean): void {
for (const resource of feature.contribution(selection, this.profile).resources) {
if (resource.kind === 'cordis-config-entry') this.cordis().setDisabled(resource.entry.id, disabled)
}
}
private validateFinalState(): void {
for (const document of this.documents.values()) document.validate()
const profile = this.finalProfile()
const view = this.projectView(profile)
for (const feature of this.registry.all()) {
const state = this.states.get(feature.id)
/* v8 ignore next 5 -- no current built-in feature is interface-specific */
if (!feature.isApplicable(profile)) {
if (state?.state === 'enabled') {
throw new Error(`feature ${feature.id} is not available for ${profile.runInterface}`)
}
continue
}
const installation = feature.inspect(view)
/* v8 ignore next 3 -- public domain commands assert feature consistency before final validation */
if (installation.state === 'inconsistent') {
throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`)
}
/* v8 ignore next 3 -- required features are installed by creation and cannot be disabled by public commands */
if (feature.required && installation.state !== 'enabled') {
throw new Error(`required feature ${feature.id} must be installed and enabled`)
}
if (installation.state !== 'enabled' || !installation.selection) continue
for (const requirement of feature.requirements(installation.selection)) {
const required = this.registry.get(requirement.id).inspect(view)
/* v8 ignore next 3 -- ensureRequirements establishes enabled requirements before contributions change */
if (required.state !== 'enabled') {
throw new Error(`feature ${feature.id} requires enabled ${requirement.id}`)
}
for (const option of requirement.options ?? []) {
/* v8 ignore next 3 -- selectionWithRequiredOptions establishes required options before commit */
if (!required.options.includes(option)) {
throw new Error(`feature ${feature.id} requires ${requirement.id} option ${option}`)
}
}
}
}
}
private assertFeatureConsistent(feature: Feature): void {
const installation = feature.inspect(this)
/* v8 ignore next 3 -- resource application either succeeds completely or throws at the owning operation */
if (installation.state === 'inconsistent') {
throw new Error(`feature ${feature.id} is inconsistent: ${installation.diagnostics.join('; ')}`)
}
}
private finalProfile(): ProjectProfile {
const runInterface = this.states.get(featureId('app'))?.selection?.options[0]
if (runInterface !== 'acp' && runInterface !== 'embed') return this.profile
return { ...this.profile, runInterface }
}
private projectView(profile: ProjectProfile): FeatureProjectView {
return {
profile,
cordisConfigEntries: () => this.cordisConfigEntries(),
packageManifest: () => this.packageManifest(),
hasDocument: path => this.hasDocument(path),
readEnvironment: (path, name) => this.readEnvironment(path, name),
}
}
private async assertUnchanged(paths: readonly string[]): Promise<void> {
for (const path of paths) {
const source = this.source.document(path)
const absolute = resolve(this.source.root, path)
try {
const current = await readFile(absolute, 'utf8')
if (source?.originalText === undefined || current !== source.originalText) {
throw new Error(`project file changed outside this edit session: ${path}`)
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT' && source?.originalText === undefined) continue
if (error instanceof Error && error.message.startsWith('project file changed outside')) throw error
throw new Error(`cannot verify project file ${path}: ${asError(error).message}`)
}
}
}
private state(feature: Feature): MutableFeatureState {
const state = this.states.get(feature.id)
if (!state) throw new Error(`feature ${feature.id} is not applicable to this project`)
return state
}
private manifest(): PackageJsonFile {
const document = this.documents.get('package.json')
if (!(document instanceof PackageJsonFile)) throw new Error('project package.json is missing')
return document
}
private cordis(): CordisYamlFile {
const document = this.documents.get('cordis.yml')
if (!(document instanceof CordisYamlFile)) throw new Error('project cordis.yml is missing')
return document
}
private environment(path: '.env' | '.env.example'): EnvFile {
const existing = this.documents.get(path)
if (existing instanceof EnvFile) return existing
if (existing) throw new Error(`${path} is not an environment document`)
const document = EnvFile.create(path)
this.documents.set(path, document)
return document
}
private assertOpen(): void {
if (this.committed) throw new Error('project edit session has already committed')
}
}

View File

@@ -1,309 +0,0 @@
/**
* Read-only aggregate for one generated or existing SDK project.
*
* @module @deepseek-ai/dsh-helper/project/sdk-project
*/
import { access, readFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts'
import { EnvFile } from '../documents/env-file.ts'
import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts'
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
import { ProjectFile, TextProjectFile } from '../documents/project-file.ts'
import { TsConfigFile } from '../documents/tsconfig-file.ts'
import {
createPackageManager,
type PackageManager,
type PackageManagerName,
} from '../package-managers/package-manager.ts'
import {
createBaselineProjectArtifacts,
createPackageJsonDoc,
createProjectTemplateContext,
} from '../templates/project-template.ts'
import type { ProjectCreationRequest, ProjectProfile, RunInterface } from './types.ts'
import type { FeatureRegistry } from '../features/registry.ts'
import { ProjectEditSession } from './project-edit-session.ts'
/** Whether a project snapshot describes uncommitted creation or files on disk. */
export type ProjectOrigin = 'create' | 'disk'
const OPTIONAL_DOCUMENTS = [
'.env',
'.env.example',
'tsconfig.json',
'pnpm-workspace.yaml',
'hooks.json',
'codex-hooks.json',
'README.md',
'index.ts',
] as const
function runInterface(entries: readonly CordisConfigEntry[]): RunInterface {
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-tui'
|| entry.name.startsWith('@deepseek-ai/dsh-tui/'))) {
throw new Error('unsupported run interface: @deepseek-ai/dsh-tui has been removed')
}
if (entries.some(entry => entry.name === '@deepseek-ai/dsh-acp')) return 'acp'
return 'embed'
}
function runtimeModel(entries: readonly CordisConfigEntry[]): string {
const acp = entries.find(entry => entry.name === '@deepseek-ai/dsh-acp')
if (typeof acp?.config?.model === 'string' && acp.config.model.length > 0) return acp.config.model
const provider = entries.find(entry => entry.name === '@deepseek-ai/dsh-llm-deepseek'
|| entry.name === '@deepseek-ai/dsh-llm-pi-ai')
const models = provider?.config?.models
if (Array.isArray(models) && typeof models[0] === 'string') return models[0]
return 'deepseek-v4-flash'
}
function releaseVersion(manifest: Readonly<PackageManifest>): string {
const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts']
const match = spec && /(?:^|[^0-9])(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(spec)
return match?.[1] ?? '0.0.1'
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch (error) {
/* v8 ignore else -- the other arm requires a filesystem permission/IO fault from access */
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
/* v8 ignore next -- paired with the ignored defensive access-error arm above */
throw error
}
}
async function detectPackageManager(root: string, manifest: Readonly<PackageManifest>): Promise<PackageManager> {
let name: PackageManagerName = 'npm'
let version = '10.0.0'
const field = manifest.packageManager
if (field) {
const match = /^(npm|pnpm|yarn)@(.+)$/.exec(field)
if (!match?.[1] || !match[2]) throw new Error(`invalid packageManager field: ${field}`)
name = match[1] as PackageManagerName
version = match[2]
} else if (await pathExists(resolve(root, 'pnpm-lock.yaml'))) {
name = 'pnpm'
version = '10.0.0'
} else if (await pathExists(resolve(root, 'yarn.lock'))) {
name = 'yarn'
version = '2.0.0'
}
return createPackageManager(name, version)
}
function linkedRepositoryRoot(root: string, manifest: Readonly<PackageManifest>): string | undefined {
const spec = manifest.dependencies?.['@deepseek-ai/dsh-scripts']
const match = /^(?:file|link|portal):(.+)\/packages\/scaffold\/scripts\/?$/.exec(spec ?? '')
return match?.[1] ? resolve(root, match[1]) : undefined
}
function parseOptionalDocument(path: string, text: string): ProjectFile {
try {
switch (path) {
case '.env': return EnvFile.parse('.env', text)
case '.env.example': return EnvFile.parse('.env.example', text)
case 'tsconfig.json': return TsConfigFile.parse(text)
case 'pnpm-workspace.yaml': return PnpmWorkspaceFile.parse(text)
default: return new TextProjectFile(path, text, text)
}
} catch {
// Optional malformed resources do not invalidate the project aggregate;
// an operation that needs their structure checks the concrete document type.
return new TextProjectFile(path, text, text)
}
}
/** A project snapshot whose documents can only be changed through {@link ProjectEditSession}. */
export class SdkProject {
/** Absolute project directory. */
readonly root: string
/** Whether this snapshot is an uncommitted blueprint or disk state. */
readonly origin: ProjectOrigin
/** Project identity, runtime, interface, and package-manager context. */
readonly profile: ProjectProfile
private readonly documents: ReadonlyMap<string, ProjectFile>
private constructor(
root: string,
origin: ProjectOrigin,
profile: ProjectProfile,
documents: ReadonlyMap<string, ProjectFile>,
) {
this.root = resolve(root)
this.origin = origin
this.profile = profile
this.documents = documents
}
/**
* Build an in-memory project blueprint without touching the target directory.
* @param root - target project directory.
* @param request - complete creation request.
* @returns uncommitted project snapshot.
*/
static create(root: string, request: ProjectCreationRequest): SdkProject {
const app = request.features.find(selection => selection.id === 'app')
const selectedInterface = app?.options[0]
if (selectedInterface !== 'acp' && selectedInterface !== 'embed') {
throw new Error('project creation requires one app feature option')
}
const profile: ProjectProfile = {
name: request.name,
description: request.description,
runtime: request.runtime,
runInterface: selectedInterface,
packageManager: request.packageManager,
releaseVersion: request.releaseVersion,
...request.linkWorkspaceRoot ? { linkWorkspaceRoot: resolve(request.linkWorkspaceRoot) } : {},
}
const templates = createProjectTemplateContext(profile)
const manifest = createPackageJsonDoc(templates)
const documents = new Map<string, ProjectFile>()
documents.set(manifest.relativePath, manifest)
documents.set('cordis.yml', CordisYamlFile.create())
documents.set('.env.example', EnvFile.create('.env.example'))
documents.set('tsconfig.json', TsConfigFile.create())
for (const document of request.packageManager.configureWorkspace(manifest)) {
documents.set(document.relativePath, document)
}
for (const document of createBaselineProjectArtifacts(templates)) {
documents.set(document.relativePath, document)
}
return new SdkProject(root, 'create', profile, documents)
}
/**
* Load an existing project from required and SDK-managed optional files.
* @param root - existing project directory.
* @returns disk-backed project snapshot.
* @throws When the config references the removed `@deepseek-ai/dsh-tui` root or a subpath.
*/
static async open(root: string): Promise<SdkProject> {
const absolute = resolve(root)
const [manifestText, cordisText] = await Promise.all([
readFile(resolve(absolute, 'package.json'), 'utf8'),
readFile(resolve(absolute, 'cordis.yml'), 'utf8'),
])
const manifest = PackageJsonFile.parse(manifestText)
const cordis = CordisYamlFile.parse(cordisText)
const value = manifest.value()
const manager = await detectPackageManager(absolute, value)
const entries = cordis.entries()
const linkWorkspaceRoot = linkedRepositoryRoot(absolute, value)
const profile: ProjectProfile = {
name: value.name ?? basename(absolute),
description: typeof value.description === 'string' ? value.description : '',
runtime: { model: runtimeModel(entries) },
runInterface: runInterface(entries),
packageManager: manager,
releaseVersion: releaseVersion(value),
...linkWorkspaceRoot ? { linkWorkspaceRoot } : {},
}
const documents = new Map<string, ProjectFile>([
['package.json', manifest],
['cordis.yml', cordis],
])
await Promise.all(OPTIONAL_DOCUMENTS.map(async (path) => {
try {
const text = await readFile(resolve(absolute, path), 'utf8')
documents.set(path, parseOptionalDocument(path, text))
} catch (error) {
/* v8 ignore next -- optional-file reads fail normally only with ENOENT; other IO faults surface */
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}))
return new SdkProject(absolute, 'disk', profile, documents)
}
/**
* Read the root package manifest defensively.
* @returns cloned manifest value.
*/
packageManifest(): Readonly<PackageManifest> {
return this.packageJson.value()
}
/**
* Read Cordis config entries defensively in file order.
* @returns cloned Cordis config entries.
*/
cordisConfigEntries(): readonly CordisConfigEntry[] {
return this.cordis.entries()
}
/**
* Check whether this snapshot contains one managed document.
* @param path - project-relative document path.
* @returns whether the document is loaded.
*/
hasDocument(path: string): boolean {
return this.documents.has(path)
}
/**
* Read one environment variable from a loaded dotenv document.
* @param path - environment file to read.
* @param name - variable name.
* @returns variable value when present.
*/
readEnvironment(path: '.env' | '.env.example', name: string): string | undefined {
const document = this.documents.get(path)
if (!document) return undefined
if (!(document instanceof EnvFile)) throw new Error(`${path} is not an environment document`)
return document.get(name)
}
/** Read the root package document. */
get packageJson(): PackageJsonFile {
const document = this.documents.get('package.json')
if (!(document instanceof PackageJsonFile)) throw new Error('project package.json is missing or invalid')
return document
}
/** Read the root Cordis document. */
get cordis(): CordisYamlFile {
const document = this.documents.get('cordis.yml')
if (!(document instanceof CordisYamlFile)) throw new Error('project cordis.yml is missing or invalid')
return document
}
/**
* Return one managed document without exposing the aggregate map.
* @param path - project-relative document path.
* @returns loaded document when present.
*/
document(path: string): ProjectFile | undefined {
return this.documents.get(path)
}
/**
* Create the only mutable boundary for this snapshot.
* @param registry - feature catalog governing edits.
* @returns isolated edit session.
*/
edit(registry: FeatureRegistry): ProjectEditSession {
return new ProjectEditSession(this, registry)
}
/**
* Clone every managed document for an isolated edit session.
* @returns project-relative document map.
*/
cloneDocuments(): Map<string, ProjectFile> {
return new Map([...this.documents].map(([path, document]) => [path, document.clone()]))
}
/**
* Reload this aggregate from committed disk state.
* @returns fresh disk-backed snapshot.
*/
reopen(): Promise<SdkProject> {
return SdkProject.open(this.root)
}
}

View File

@@ -1,48 +0,0 @@
/**
* Shared creation and project-profile values for SDK project editing.
*
* @module @deepseek-ai/dsh-helper/project/types
*/
import type { PackageManager } from '../package-managers/package-manager.ts'
import type { LocalPluginBlueprint } from '../plugins/local-plugin-blueprint.ts'
import type { FeatureId } from '../ids.ts'
/** Run interface selected for a generated project. */
export type RunInterface = 'acp' | 'embed'
/** Values shared by the required provider and app features. */
interface ProjectRuntimeOptions {
model: string
}
/** Selected options and captured secrets for one feature. */
export interface FeatureSelection {
id: FeatureId
options: readonly string[]
values?: Readonly<Record<string, unknown>>
secrets?: Readonly<Record<string, string>>
}
/** Stable context available to project and feature objects. */
export interface ProjectProfile {
name: string
description: string
runtime: ProjectRuntimeOptions
runInterface: RunInterface
packageManager: PackageManager
releaseVersion: string
linkWorkspaceRoot?: string
}
/** Fully collected create request; it contains intent, never rendered file text. */
export interface ProjectCreationRequest {
name: string
description: string
runtime: ProjectRuntimeOptions
packageManager: PackageManager
releaseVersion: string
linkWorkspaceRoot?: string
features: readonly FeatureSelection[]
localPlugins: readonly LocalPluginBlueprint[]
}

View File

@@ -1,304 +0,0 @@
/**
* Tree-shaped Clack picker for root checkboxes with finite child options.
*
* @module @deepseek-ai/dsh-helper/questions/clack-nested-multiselect
*/
import { styleText } from 'node:util'
import type { Readable, Writable } from 'node:stream'
import { Prompt, isCancel } from '@clack/core'
import {
S_BAR,
S_BAR_END,
S_CHECKBOX_ACTIVE,
S_CHECKBOX_INACTIVE,
S_CHECKBOX_SELECTED,
S_RADIO_ACTIVE,
S_RADIO_INACTIVE,
symbol,
symbolBar,
} from '@clack/prompts'
import type {
NestedMultiSelectOption,
NestedMultiSelectRequest,
NestedMultiSelectValue,
PromptOutcome,
} from './prompt-port.ts'
interface NestedPromptOptions<TValue, TChoice> extends NestedMultiSelectRequest<TValue, TChoice> {
input: Readable
output: Writable
}
class NestedPrompt<TValue, TChoice> extends Prompt<readonly NestedMultiSelectValue<TValue, TChoice>[]> {
readonly options: readonly NestedMultiSelectOption<TValue, TChoice>[]
private readonly selected = new Set<TValue>()
private readonly selectedChoices = new Map<TValue, Set<TChoice>>()
private readonly initialSelected: Set<TValue>
private readonly initialChoices: Map<TValue, Set<TChoice>>
private readonly showChanges: boolean
private layer: 'root' | 'choices' = 'root'
private rootCursor = 0
private choiceCursor = 0
constructor(options: NestedPromptOptions<TValue, TChoice>) {
super({
input: options.input,
output: options.output,
validate: value => NestedPrompt.validate(options.options, value),
render(this: Prompt<readonly NestedMultiSelectValue<TValue, TChoice>[]>) {
return (this as NestedPrompt<TValue, TChoice>).renderFrame(options.message)
},
}, false)
this.options = options.options
this.showChanges = options.showChanges ?? false
for (const option of options.options) {
if (option.required || option.default) this.selected.add(option.value)
this.selectedChoices.set(option.value, new Set(
option.choices?.filter(choice => choice.default).map(choice => choice.value) ?? [],
))
}
this.initialSelected = new Set(this.selected)
this.initialChoices = new Map([...this.selectedChoices].map(([value, choices]) => [
value, new Set(choices),
]))
this.updateValue()
this.on('cursor', (action) => { this.handleAction(action) })
}
private static validate<TValue, TChoice>(
options: readonly NestedMultiSelectOption<TValue, TChoice>[],
value: readonly NestedMultiSelectValue<TValue, TChoice>[] | undefined,
): string | undefined {
/* v8 ignore next -- NestedPrompt initializes its value before submission validation */
const selected = new Map(value?.map(item => [item.value, item.choices]) ?? [])
for (const option of options) {
if (option.disabled) continue
/* v8 ignore next -- required options initialize selected and cannot be toggled off */
if (option.required && !selected.has(option.value)) return `${option.label} is required`
if (!selected.has(option.value) || !option.choiceMode) continue
const choices = selected.get(option.value)
/* v8 ignore next -- selected.has above guarantees the map value exists */
if (!choices) continue
const count = choices.length
if (option.choiceMode === 'exclusive' && count !== 1) return `Choose one ${option.label} option`
if (option.choiceMode === 'multiple' && count === 0) return `Choose at least one ${option.label} option`
}
return undefined
}
protected override _shouldSubmit(): boolean {
if (this.layer === 'choices') {
this.leaveChoices()
return false
}
return true
}
private handleAction(action: string | undefined): void {
if (this.layer === 'root') this.handleRootAction(action)
else this.handleChoiceAction(action)
this.updateValue()
}
private handleRootAction(action: string | undefined): void {
if (action === 'up') this.rootCursor = this.move(this.rootCursor, -1, this.options.length)
if (action === 'down') this.rootCursor = this.move(this.rootCursor, 1, this.options.length)
const option = this.options[this.rootCursor]
/* v8 ignore next -- Clack cannot emit a cursor action when the option list is empty */
if (!option) return
if (action === 'space' && !option.required && !option.disabled) {
if (this.selected.has(option.value)) this.selected.delete(option.value)
else this.selected.add(option.value)
}
if (action === 'right' && !option.disabled && option.choices && option.choices.length > 0) {
this.selected.add(option.value)
this.layer = 'choices'
const selected = this.selectedChoices.get(option.value)
const selectedIndex = option.choices.findIndex(choice => selected?.has(choice.value))
this.choiceCursor = Math.max(selectedIndex, 0)
}
}
private handleChoiceAction(action: string | undefined): void {
const rootOption = this.options[this.rootCursor]
/* v8 ignore next -- the choices layer is entered only from a concrete root option */
if (!rootOption) return
/* v8 ignore next -- the choices layer is entered only for a non-empty choices array */
const choices = rootOption.choices ?? []
if (action === 'left') {
this.leaveChoices()
return
}
if (action === 'up') this.choiceCursor = this.move(this.choiceCursor, -1, choices.length)
if (action === 'down') this.choiceCursor = this.move(this.choiceCursor, 1, choices.length)
if ((action === 'up' || action === 'down') && rootOption.choiceMode === 'exclusive') {
const choice = choices[this.choiceCursor]
/* v8 ignore else -- a cursor in the non-empty choices layer always addresses a choice */
if (choice) this.selectedChoices.set(rootOption.value, new Set([choice.value]))
}
if (action !== 'space' && action !== 'right') return
const choice = choices[this.choiceCursor]
/* v8 ignore next -- the choices layer requires a non-empty choice list */
if (!choice) return
/* v8 ignore next -- every root option initializes its choice set in the constructor */
const selected = this.selectedChoices.get(rootOption.value) ?? new Set<TChoice>()
if (rootOption.choiceMode === 'exclusive') {
selected.clear()
selected.add(choice.value)
} else if (selected.has(choice.value)) selected.delete(choice.value)
else selected.add(choice.value)
this.selectedChoices.set(rootOption.value, selected)
}
private move(cursor: number, offset: number, length: number): number {
/* v8 ignore next -- cursor movement is emitted only for a non-empty displayed list */
if (length === 0) return 0
return (cursor + offset + length) % length
}
private updateValue(): void {
this._setValue(this.options.filter(option => this.selected.has(option.value)).map(option => ({
value: option.value,
/* v8 ignore next -- every root option initializes its choice set in the constructor */
choices: [...this.selectedChoices.get(option.value) ?? []],
})))
}
private renderFrame(message: string): string {
const header = `${symbolBar(this.state)} ${message}`
if (this.state === 'submit') {
/* v8 ignore next -- NestedPrompt initializes its value before it can submit */
const summary = (this.value ?? []).map(item => this.options.find(option => option.value === item.value)?.label)
.filter(Boolean).join(', ') || 'none'
return `${symbol(this.state)} ${message}\n${styleText('gray', S_BAR)} ${styleText('dim', summary)}`
}
if (this.state === 'cancel') return `${symbol(this.state)} ${message}`
const body = this.layer === 'root' ? this.renderRoot() : this.renderChoices()
const instructions = this.layer === 'root'
? `${styleText('dim', '↑/↓')} navigate ${styleText('dim', 'Space')} select ${styleText('dim', '→')} configure ${styleText('dim', 'Enter')} confirm`
: `${styleText('dim', '↑/↓')} navigate ${styleText('dim', 'Space/→')} select ${styleText('dim', '←/Enter')} back`
const error = this.state === 'error' ? `\n${styleText('yellow', `${S_BAR_END} ${this.error}`)}` : ''
return `${header}\n${styleText('cyan', S_BAR)} ${body.join(`\n${styleText('cyan', S_BAR)} `)}\n${styleText('cyan', S_BAR_END)} ${instructions}${error}`
}
private renderRoot(): string[] {
return this.options.map((option, index) => {
const active = index === this.rootCursor
const selected = this.selected.has(option.value)
const focus = active ? styleText('cyan', '') : ' '
const checkbox = selected
? styleText('green', S_CHECKBOX_SELECTED)
: styleText('dim', active ? S_CHECKBOX_ACTIVE : S_CHECKBOX_INACTIVE)
const choices = option.choices?.filter(choice => this.selectedChoices.get(option.value)?.has(choice.value))
.map(choice => choice.label).join(', ')
const suffix = option.choices?.length
? ` ${styleText('dim', `* →${choices ? ` ${choices}` : ''}`)}`
: ''
const required = option.required ? ` ${styleText('yellow', '(required)')}` : ''
const issue = this.choiceIssue(option)
const warningText = option.warning ?? issue
const warning = warningText ? ` ${styleText('yellow', `${warningText}`)}` : ''
const changed = this.optionChanged(option)
const change = changed ? ` ${styleText('yellow', '● changed')}` : ''
const label = active
? styleText('cyan', option.label)
: changed
? styleText('yellow', option.label)
: selected ? styleText('green', option.label) : styleText('dim', option.label)
const line = `${focus} ${checkbox} ${label}${required}${suffix}${warning}${change}`
return option.disabled ? styleText('gray', line) : line
})
}
private renderChoices(): string[] {
const rootOption = this.options[this.rootCursor]
/* v8 ignore next -- renderChoices runs only after entering from a concrete root option */
if (!rootOption) return []
/* v8 ignore next -- every root option initializes its choice set in the constructor */
const selected = this.selectedChoices.get(rootOption.value) ?? new Set<TChoice>()
const issue = this.choiceIssue(rootOption)
const changed = this.optionChanged(rootOption)
const header = styleText('dim', `${rootOption.label} options`)
+ (issue ? ` ${styleText('yellow', `${issue}`)}` : '')
+ (changed ? ` ${styleText('yellow', '● changed')}` : '')
const choices = rootOption.choices
/* v8 ignore next -- the choices layer is entered only for a non-empty choices array */
if (!choices) return [header]
return [
header,
...choices.map((choice, index) => {
const active = index === this.choiceCursor
const checked = selected.has(choice.value)
const choiceChanged = this.choiceChanged(rootOption.value, choice.value)
const focus = active ? styleText('cyan', '') : ' '
const marker = rootOption.choiceMode === 'exclusive'
? checked ? styleText('green', S_RADIO_ACTIVE) : styleText('dim', S_RADIO_INACTIVE)
: checked ? styleText('green', S_CHECKBOX_SELECTED) : styleText('dim', S_CHECKBOX_INACTIVE)
const label = active
? styleText('cyan', choice.label)
: choiceChanged
? styleText('yellow', choice.label)
: checked ? styleText('green', choice.label) : styleText('dim', choice.label)
const change = choiceChanged ? ` ${styleText('yellow', '●')}` : ''
return `${focus} ${marker} ${label}${change}`
}),
]
}
private optionChanged(option: NestedMultiSelectOption<TValue, TChoice>): boolean {
if (!this.showChanges) return false
const selected = this.selected.has(option.value)
const initiallySelected = this.initialSelected.has(option.value)
if (selected !== initiallySelected) return true
if (!selected) return false
/* v8 ignore next -- every root option initializes both current and baseline option sets */
const current = this.selectedChoices.get(option.value) ?? new Set<TChoice>()
/* v8 ignore next -- every root option initializes both current and baseline option sets */
const initial = this.initialChoices.get(option.value) ?? new Set<TChoice>()
return current.size !== initial.size || [...current].some(value => !initial.has(value))
}
private choiceChanged(value: TValue, choice: TChoice): boolean {
if (!this.showChanges) return false
return this.selectedChoices.get(value)?.has(choice) !== this.initialChoices.get(value)?.has(choice)
}
private choiceIssue(option: NestedMultiSelectOption<TValue, TChoice>): string | undefined {
if (option.disabled || !this.selected.has(option.value) || !option.choiceMode) return undefined
/* v8 ignore next -- every root option initializes its choice set in the constructor */
const count = this.selectedChoices.get(option.value)?.size ?? 0
if (option.choiceMode === 'exclusive' && count !== 1) return 'choose one'
if (option.choiceMode === 'multiple' && count === 0) return 'choose at least one'
return undefined
}
private leaveChoices(): boolean {
const option = this.options[this.rootCursor]
/* v8 ignore next -- leaveChoices runs only after entering from a concrete root option */
if (!option) return false
const issue = this.choiceIssue(option)
if (issue) {
this.error = `${option.label}: ${issue}`
this.state = 'error'
return false
}
this.error = ''
this.layer = 'root'
return true
}
}
/** Run the nested picker with Clack's standard cancellation symbol. */
export async function clackNestedMultiselect<TValue, TChoice>(
request: NestedPromptOptions<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
const value = await new NestedPrompt(request).prompt()
return isCancel(value)
? { status: 'cancelled' }
: {
status: 'answered',
/* v8 ignore next -- NestedPrompt initializes its value before it can submit */
value: value ?? [],
}
}

View File

@@ -1,127 +0,0 @@
/**
* Thin @clack/prompts adapter for the shared prompt port.
*
* @module @deepseek-ai/dsh-helper/questions/clack-prompt-port
*/
import type { Readable, Writable } from 'node:stream'
import { styleText } from 'node:util'
import {
confirm,
isCancel,
multiselect,
password,
select,
text,
S_WARN,
} from '@clack/prompts'
import type { Option } from '@clack/prompts'
import type {
ConfirmPromptRequest,
MultiSelectPromptRequest,
NestedMultiSelectRequest,
NestedMultiSelectValue,
PromptOutcome,
PromptPort,
SecretPromptRequest,
SelectPromptRequest,
TextPromptRequest,
} from './prompt-port.ts'
import { clackNestedMultiselect } from './clack-nested-multiselect.ts'
function outcome<T>(value: T | symbol): PromptOutcome<T> {
return isCancel(value) ? { status: 'cancelled' } : { status: 'answered', value }
}
function clackOptions<T>(values: readonly import('./prompt-port.ts').PromptOption<T>[]): Option<T>[] {
return values.map(value => ({
value: value.value,
label: value.label,
...value.hint === undefined ? {} : { hint: value.hint },
...value.disabled === undefined ? {} : { disabled: value.disabled },
})) as Option<T>[]
}
/** Clack-backed prompt adapter with injectable streams for snapshots and tests. */
export class ClackPromptPort implements PromptPort {
private readonly input: Readable
private readonly output: Writable
/** Bind all prompts to one input/output pair. */
constructor(input: Readable = process.stdin, output: Writable = process.stdout) {
this.input = input
this.output = output
}
/** Ask for visible text through clack. */
async text(request: TextPromptRequest): Promise<PromptOutcome<string>> {
return outcome(await text({
message: request.message,
...request.placeholder === undefined ? {} : { placeholder: request.placeholder },
...request.initialValue === undefined ? {} : { initialValue: request.initialValue },
...request.defaultValue === undefined ? {} : { defaultValue: request.defaultValue },
...request.validate === undefined
? {}
: {
/* v8 ignore next -- value/default precedence is exercised through the adapter contract tests */
validate: value => request.validate?.(value || request.defaultValue || ''),
},
input: this.input,
output: this.output,
}))
}
/** Ask for a masked secret through clack. */
async secret(request: SecretPromptRequest): Promise<PromptOutcome<string>> {
return outcome(await password({
message: request.message,
...request.validate === undefined ? {} : {
/* v8 ignore next -- @clack/password always calls validation with a string; fallback is defensive */
validate: value => request.validate?.(value ?? ''),
},
input: this.input,
output: this.output,
}))
}
/** Ask for one option through clack. */
async select<T>(request: SelectPromptRequest<T>): Promise<PromptOutcome<T>> {
return outcome(await select({
...request,
options: clackOptions(request.options),
input: this.input,
output: this.output,
}))
}
/** Ask for multiple options through clack. */
async multiselect<T>(request: MultiSelectPromptRequest<T>): Promise<PromptOutcome<readonly T[]>> {
return outcome(await multiselect({
message: request.message,
options: clackOptions(request.options),
...request.initialValues === undefined ? {} : { initialValues: [...request.initialValues] },
...request.required === undefined ? {} : { required: request.required },
input: this.input,
output: this.output,
}))
}
/** Ask for confirmation through clack. */
async confirm(request: ConfirmPromptRequest): Promise<PromptOutcome<boolean>> {
return outcome(await confirm({
message: request.tone === 'warning'
? styleText('yellow', `${S_WARN} ${request.message}`)
: request.message,
...request.initialValue === undefined ? {} : { initialValue: request.initialValue },
input: this.input,
output: this.output,
}))
}
/** Select root values and finite child options in one tree prompt. */
nestedMultiselect<TValue, TChoice>(
request: NestedMultiSelectRequest<TValue, TChoice>,
): Promise<PromptOutcome<readonly NestedMultiSelectValue<TValue, TChoice>[]>> {
return clackNestedMultiselect({ ...request, input: this.input, output: this.output })
}
}

Some files were not shown because too many files have changed in this diff Show More