# dsh-settings-plus 设置注册 SDK 契约(docs/sdk-contract.md) 本文档是 `@deepseek-ai/dsh-settings-plus` 的开放注册接口契约:其他插件如何通过 `src/sdk.ts`(导出面 `@deepseek-ai/dsh-settings-plus/sdk`)在宿主机上注册自己的 自定义设置项(settings namespace)。宿主的设置服务、crawler 自动发现与 UI 渲染都围绕 这些已注册的 namespace 展开;本文档定义插件侧注册的规则、生命周期与去重语义。 配套参考:`docs/dsh-plugin-contracts.md`(模板级插件契约)、`src/crawler.ts` (自动发现枚举面)、`src/service.ts`(dshSettingsPlus 服务面)。 ## 一、SDK 是什么 `src/sdk.ts` 是薄 helper,不引入宿主包 `@deepseek-ai/dsh-settings`——它通过最小本地 契约(与 `src/crawler.ts` 相同的风格)消费宿主 `ctx.settings` 的 `register` 面。 SDK 不提供插件生命周期管理(无 install/enable/disable,那是 plugin-registry 的职责)。 导出面 `@deepseek-ai/dsh-settings-plus/sdk`(package.json exports 的 `./sdk`)包含: | 导出 | 形态 | 说明 | | --- | --- | --- | | `registerUserSettings(ctx, ns, schema, options?)` | 函数 | 实注册;返回显式移除 disposer | | `defineSettingsSection(ns, schema, options?)` | 函数 | 声明式描述;无副作用,不注册 | | `settingsNamespace(value)` | 品牌函数 | 命名校验(`^[a-z][a-z0-9-]*$`),非法即抛 `TypeError` | | `SettingsNamespace` / `SettingsApplies` / `SettingsSchemaLike` / `SettingsRegisterOptionsLike` / `SettingsScopeLike` / `SettingsSection` | 类型 | 本地契约类型(官方 `SettingsScope`/`SettingsRegisterOptions` 的镜像) | ## 二、快速开始 在插件 `apply` 中注册一个 namespace,并用 `ctx.effect` 包裹以实现 fiber 自动回收: ```ts import { Context } from 'cordis' import z from 'schemastery' import { registerUserSettings } from '@deepseek-ai/dsh-settings-plus/sdk' export const name = 'my-plugin' export const inject = ['settings'] const MySection = z.object({ host: z.string().default('localhost'), token: z.string().role('secret'), }) export function apply(ctx: Context) { // 注册是调用方 fiber 的 effect;用 ctx.effect 包裹,fiber 回收时自动移除 ctx.effect(() => registerUserSettings(ctx, 'my-plugin', MySection, { applies: 'live', base: { host: 'default-host' }, })) } ``` 注册完成后,namespace 立即进入宿主的设置面:crawler 的 `ctx.dshSettingsPlus.listNamespaces()` 会包含它,配置 UI 自动渲染其 schema。 ## 三、API 清单 ### registerUserSettings ```ts registerUserSettings( ctx: Context, ns: string, schema: SettingsSchemaLike, options?: SettingsRegisterOptionsLike, ): () => void ``` - 内部等价于 `ctx.settings.register(settingsNamespace(ns), schema, { base, applies, validate })`, 逐字转发,不做任何改写。 - 返回 `() => void` disposer:显式调用时移除该注册(调用提供 `dispose` 钩子的 provider 时移除注册;官方宿主 scope 无移除钩子,此时移除由 fiber 回收完成——见 “生命周期”一节)。disposer 必须显式调用才会触发;SDK 不会在调用方背后自行移除。 - 错误在调用点同步抛出,不做包装: - `ns` 不合命名规范 → `TypeError`(品牌函数抛出); - 宿主 seam 缺失 → `Error`(“requires the `settings` service”); - namespace 重复 → 宿主 `register` 的 duplicate 错误原样传播(见“去重语义”)。 ### defineSettingsSection ```ts defineSettingsSection( ns: string, schema: SettingsSchemaLike, options?: SettingsRegisterOptionsLike, ): SettingsSection ``` 声明式描述一个设置分区(`{ ns, schema, options? }`),**不产生任何注册副作用**。 用于“引用分区而不拥有注册”的场景——例如与自动发现协同:把分区声明作为发现结果的 权威来源引用,或在注册前先声明以便校验命名。ns 同样经过品牌校验。 ### settingsNamespace ```ts settingsNamespace(value: string): SettingsNamespace ``` 官方命名品牌函数(`^[a-z][a-z0-9-]*$`)。SDK 内所有 ns 入口都先经过它;插件自己 构造已品牌化命名空间时也用它。非法名(大写、前导连字符、数字开头、空格、下划线等) 立即抛 `TypeError`。 ## 四、namespace 命名规范 - 命名规则:`^[a-z][a-z0-9-]*$`——小写 kebab-case,与插件 short name 一致 (如 `session`、`gateway-2`、`my-settings-ns`)。 - 每个 namespace 在宿主内全局唯一;同一 ns 由多个注册方声明是配置错误(见“去重语义”)。 - 命名空间是插件的对外身份:选择稳定、可读、不易与宿主自带 namespace (如 `session`)冲突的名字;建议以插件名开头(`-`)。 ## 五、secret 字段规则 - 敏感字段用 schemastery 的 `role('secret')` 声明,如 `z.string().role('secret')`。 - SDK 本身不读取、不接触任何值:它只转发 schema 与选项。secret 语义由宿主设置服务 执行——宿主的 `describe({ redactSecrets: true })` 会剥离 `role('secret')` 字段并 枚举其位置(descriptor.secrets);crawler 与 dshSettingsPlus 的所有读取面都已 强制 redact,插件无需自行脱敏,但也不要把非 secret 机制(如明文口令字段)声明成 普通字段。 - `role('secret')` 字段仍由宿主的配置 UI 正常渲染编辑(带遮盖与显式提交语义)。 ## 六、生命周期:注册随调用方 fiber 回收 - 宿主的 `ctx.settings.register` 把注册建模为**调用方 fiber 的 effect**:该 fiber 被 dispose 时,namespace 与它的观察者一并移除(宿主契约,官方实现如此)。 - SDK 返回的 disposer 是显式移除句柄:**必须显式调用才会移除**。官方宿主 scope 没有 `dispose` 方法,因此对官方宿主而言显式移除不可通过 seam 完成——移除由 fiber 回收 完成。提供 `dispose` 钩子的 provider(含测试替身)会收到该调用。 - 推荐模式(fiber 自动回收): ```ts ctx.effect(() => registerUserSettings(ctx, 'my-plugin', MySection)) ``` `ctx.effect` 的 cleanup 即 SDK 返回的 disposer;fiber dispose 时 cleanup 运行, 注册被回收。不要只用裸调用并丢弃 disposer——那意味着注册只能靠宿主 fiber effect 兜底回收,无法在 fiber 存活期间提前移除。 ## 七、去重语义(SDK 显式声明覆盖自动发现) Metis C-6 的去重结论,诚实表述如下: 1. **宿主是唯一仲裁者**:`ctx.settings.register` 对已注册 namespace 立即抛 duplicate 错误。SDK 不做注册表仲裁、不模拟复杂的覆盖合并——重复声明的结果是 响亮报错,而不是静默的 schema 争抢。 2. **SDK 注册是权威来源**:通过 SDK 注册的 namespace 会进入宿主的注册表,因而被 C1 crawler 自动发现(`listNamespaces()` 枚举宿主注册表)。对自动发现而言, “已注册即被发现”是唯一的真相来源:SDK 注册的 schema/options 就是配置 UI 渲染的 schema/options,不存在另一份自动发现 schema 与之竞争。 3. **重复注册 = 配置错误**:同一 namespace 被 SDK 与(理论上)自动发现或其他插件 同时注册时,先到者胜、后到者在调用点同步收到宿主的 duplicate 错误并向上传播。 修复方式是移除冲突声明,而不是让 SDK 悄悄覆盖。 4. **命名失败提前拦截**:不合规范的 ns 在 SDK 层(品牌函数)即抛错,不会进入宿主。 ## 八、与自动发现(crawler)的关系 - C1 crawler(`src/crawler.ts`)枚举宿主注册表:任何通过官方机制(含本 SDK)注册的 namespace 都会被 `ctx.dshSettingsPlus.listNamespaces()` 自动发现——插件无需额外 登记。 - `defineSettingsSection` 的声明式形态与自动发现协同:声明可以先行(校验命名、供 引用),注册仍以 `registerUserSettings` 为准;两者用同一命名与 schema 即保持同步。 - 发现结果是 redacted 快照:`role('secret')` 值永不出现,`secrets` 数组枚举其位置。 ## 九、本地契约与宿主边界 - SDK 不 import 宿主包;类型是官方 `SettingsScope`/`SettingsRegisterOptions` 的本地 镜像(`SettingsScopeLike` 额外带可选 `dispose` 钩子,用于显式移除,官方宿主缺席时 文档化降级)。schema 参数为结构化 `SettingsSchemaLike`(schemastery schema 天然 满足)。 - 宿主 seam 缺失或 `register` 不是函数时调用点即抛错(fail loud),与 crawler 的 `resolveSettings` 同风格。 - SDK 不提供插件生命周期管理(无 install/enable/disable)——那是 plugin-registry 的职责;本契约只管“设置项的声明、注册、回收”。