omdsh-plugins
GitHub
公约 · 九条规则Conventions · the nine rules

omdsh 插件约定omdsh plugin conventions

这个集合里的插件共同遵守的约定,目的只有一个:让 omdsh-plughub完全不认识某个插件的前提下,也能把它列出来、装上去、配起来。 What a plugin in this collection agrees to, so that omdsh-plughub can list it, install it, and configure it without knowing anything about it in particular.

规则很短,因为它们几乎全都是"用 harness 已经有的那个缝"。如果插件中心需要每个插件教它一点东西,那每来一个新插件就得改一次插件中心;而现在它什么都不用改 —— 它渲染的每一样东西,都是从插件自己已经声明的内容里读出来的。 The rules are short because they are almost all "use the seam the harness already has". A plugin hub that needed each plugin to teach it something would need editing before every new plugin could appear; this one needs nothing, because everything it renders is read from what the plugin already declares.

rule 1

可配置项 = 一个 settings 命名空间Configuration is a settings namespace

有配置项的插件注册一个 settings 命名空间,名字取自它去掉 scope 的包名,并附上一份 schemastery schema: A plugin with anything to configure registers one settings namespace, named for its unscoped package name, with a schemastery schema:

export const SETTINGS_NAMESPACE = 'omdsh-shortcuts'

export const Config: Schema<ShortcutConfig, Required<ShortcutConfig>> = Schema.object({ /* … */ })

ctx.inject?.(['settings'], (sctx) => {
  const settings = sctx.get?.('settings') as SettingsLike | undefined
  if (settings === undefined) return
  const scope = settings.register(SETTINGS_NAMESPACE, Config, {
    base: config,          // the composition entry stays the layer underneath
    applies: 'live',       // or 'restart', see rule 4
    validate: value => { /* cross-field checks the schema cannot express */ },
  })
  adopt(scope.get())
  sctx.effect(() => scope.watch(next => { adopt(next) }))
})

命名空间必须匹配 ^[a-z][a-z0-9-]*$,settings 服务不接受别的形状。 The namespace must match ^[a-z][a-z0-9-]*$ — the settings service refuses anything else.

有三处细节是承重的:Three details are load-bearing:

  • base: config cordis patch 里的配置成为用户改动之下的一层,于是"在 profile 里配置过的插件"仍然按那份配置跑,面板也能显示哪些字段是人真正动过的。 The cordis patch entry becomes the layer under the user's edits, so a profile that configures a plugin keeps configuring it and the panel shows which fields a person has actually changed.
  • ctx.inject(['settings'], …) 这段注册挂在一个受限 fiber 上,因此一个没有 settings provider 的组装(headless、测试台)照旧按 entry config 运行。可配置性是加法,不是前提。 The registration rides a scoped fiber, so a composition with no settings provider — headless, a test bench — runs on the entry config exactly as before. Configurability is additive, never required.
  • 按名字解析服务。Resolve the service by NAME. 在 harness 之外编译的包,会把浏览器半边和 host 半边当成同一个程序来类型检查,于是 ctx.settings 是编译器先看到的那份 Context 声明。要用 ctx.get('settings') 加一个结构化类型。 A package compiled outside the harness typechecks its browser and host halves as one program, so ctx.settings is whichever Context declaration the compiler saw first. Use ctx.get('settings') and a structural type.

除此之外不需要任何东西。omdsh-plughub 的 host 半边用 ctx.settings.describe({ redactSecrets: true }) 读到这个命名空间,转运给自己的面板,按 schema 渲染表单,再用 ctx.settings.mutate 写回去。校验、持久化、base/user 分层、密钥脱敏、并发冲突、热生效 —— 全是 harness 的,已经写好了。 Nothing else is needed. omdsh-plughub's host half reads the namespace with ctx.settings.describe({ redactSecrets: true }), carries it to its panel, renders a form from the schema, and writes back with ctx.settings.mutate. Validation, persistence, the base/user layering, secret redaction, revision conflicts, and hot commits are all the harness's, already written.

插件中心用的是自己的一条路由,而不是 harness 的 settings.describe RPC,因为那条 RPC 被一张写死的命名空间名单挡住了,任何 out-of-tree 插件都进不去。那是插件中心要操心的事,不是你的 —— 上面那段代码不会因此有任何不同。 The hub carries it over a route of its own rather than the harness's settings.describe RPC, because that RPC is gated by a hard-coded allowlist of namespace names that no out-of-tree plugin can be in. That is the hub's problem, not yours — nothing in the code above changes either way.

rule 2

schema 自带文案The schema carries its own words

每个字段都用 .description() 写说明,并用 .i18n({ zh: { … } }) 本地化: Label every field with .description(), and localize the schema with .i18n({ zh: { … } }):

Schema.object({
  bindings: Schema.dict(Schema.string())
    .description('Keyboard shortcut per command id, as an Electron accelerator.'),
}).i18n({
  zh: { bindings: '每个命令 id 对应的快捷键,写作 Electron accelerator。' },
})

.i18n() 会把说明序列化成一张以 '' 为默认值的 locale 映射表,插件中心按当前语言取用。没有插件需要向插件中心注册字典,插件中心里也没有哪本字典会随着插件数量增长 —— 正是这一条,让明年才写出来的插件在装上的当天就能在中英文下都显示正确的字段名。 .i18n() serializes a description as a locale map with '' for the default, and the hub resolves it against the active locale. No plugin registers a dictionary with the hub, and no dictionary in the hub grows an entry per plugin — which is the property that lets a plugin written next year get correct labels in both languages on the day it is installed.

description 请写成完整的句子。插件中心会用属性名生成控件标题(maxReposMax repos),把 description 放在它下面,和 harness 自带的设置行读起来一样。 Write descriptions as sentences. The hub titles each control from its property name (maxReposMax repos) and puts the description underneath, the way the harness's own settings rows read.

.comment() 是控件下方的补充说明,.link() 是控件旁边的文档链接,.hidden() 让表单跳过这个字段,数字字段还有 .min() / .max() / .step() Use .comment() for a note under the control, .link() for a documentation link beside it, .hidden() for a field a form should not offer, and .min()/.max()/.step() on numbers.

rule 3

密钥要声明,不能指望Secrets are declared, not hoped for

存放凭据的字段加 .role('secret')。线上会把它从每一个响应里剥掉,只报告"有没有存过值",插件中心据此渲染成只写控件。没有声明的密钥,会以明文发给每一个打开面板的浏览器。 A field holding a credential gets .role('secret'). The wire strips it from every response and reports only whether a value is stored, and the hub renders a write-only control. A secret that is not declared travels in plaintext to every browser that opens the panel.

rule 4

老实说明什么时候生效Say when a change takes effect

applies: 'live'(默认)表示提交即生效;applies: 'restart' 表示不是。插件中心会在卡片上标出"重启后生效",免得人改完之后不明白为什么没反应。 applies: 'live' (the default) means a change is acted on as it commits; applies: 'restart' means it is not. The hub marks a restart-only plugin on its card, so a person is not left wondering why nothing happened.

尽量用 live。它通常只是多一个 watch 回调,把从配置推导出来的东西重算一遍 —— omdsh-shortcuts 就是重建菜单文档,然后推给已经开着的那几条流 —— 而这正是"一个设置项"和"一个改完要重启的东西"之间的区别。 Prefer live. It usually costs one watch callback that recomputes whatever the config derived — omdsh-shortcuts rebuilds its menu document and pushes it down the streams that are already open — and it is the difference between a setting and a thing you have to restart for.

rule 5

在 package.json 里声明展示元数据Declare display metadata in package.json

"dsh": {
  "bundle": { "patch": "./cordis.patch.yml" },
  "plughub": {
    "displayName": { "": "Shortcuts", "zh": "快捷键" },
    "summary":     { "": "One chord per command.", "zh": "为每个命令绑定一个快捷键。" },
    "category": "input",
    "settings": ["omdsh-shortcuts"],
    "docs": "https://github.com/omdsh-plugins/omdsh-shortcuts#readme",
    "order": 10
  }
}

每个字段都是可选的。什么都不声明的插件照样会出现,只是用包名当标题;settings 缺省时回落到去掉 scope 的包名 —— 所以只做到第 1 条就已经可配置了 Every field is optional. A plugin that declares nothing still appears, under its package name, and settings falls back to the unscoped package name — so following rule 1 alone is enough to be configurable.

这个仓库里每个插件都声明 displayName,而且写法统一。英文一律 Title Case,每个词首字母大写:Remote ControlSide PanelsUsage。词要写全,不用包名里的缩写 —— 包名是拿来敲的,所以会缩(omdsh-remctrl);标题是拿来读的,所以不缩(Remote Control)。写它是什么,而不是把归档用的代号再念一遍:叫 Chat Mode,不叫 Justchat。中文照着旁边 summary 的规矩一起翻。插件中心不再把标题折成小写了,所以 manifest 里写的就是面板上显示的。 Every plugin here declares a displayName, and they are all written the same way. Title Case, every word capitalized: Remote Control, Side Panels, Usage. Spelled out rather than abbreviated — a package name is typed, so it contracts (omdsh-remctrl); a title is read, so it does not (Remote Control). Say what the plugin is rather than repeating what it is filed under: Chat Mode, not Justchat. Translate it the way the summary beside it is translated. The hub no longer folds the case, so what the manifest says is what the panel shows.

回落仍然留着,给这个仓库之外的插件和 harness 自带的 bundle 用:什么都没声明,卡片就用 npm 里的那个包名当标题(dsh-web-app)。这才是老实的渲染 —— 没人给它们起过标题,标识符就该长成标识符的样子。 The fallback is still there, for plugins from outside this repo and for the harness's own bundles: nothing declared, so the card is titled with the package name as npm spells it (dsh-web-app). That is the honest rendering — nobody wrote those a title, and an identifier should look like one.

只有当插件持有的命名空间不叫自己的名字、或者持有不止一个时,才需要显式写 settings Declare settings explicitly when a plugin owns a namespace that is not its own name, or owns more than one.

dsh.bundle.patch 是一个包能被安装的前提:dsh plugin 只在依赖的 manifest 声明了它的时候,才把这个依赖加进 profile 的层栈。 dsh.bundle.patch is what makes a package installable at all: dsh plugin adds a dependency to the profile's layer stack exactly when its manifest declares one.

rule 6

表单画不出来的控件,用卡片A control the form cannot draw gets a card

通用表单能画字符串、数字、布尔、闭合枚举、字符串列表、字符串字典和嵌套对象。除此之外它拒绝渲染而不是去猜 —— 猜出来的控件会写进错误的形状,还能通过校验,但含义已经变了。 The generic form draws strings, numbers, booleans, closed unions, string lists, string dictionaries, and nested objects. It refuses anything else rather than guessing — a guessed control writes the wrong shape and passes validation while meaning something else.

当插件需要一个表单画不出来的控件时(比如"按一下捕获快捷键",而不是往输入框里敲 Ctrl+K),它的浏览器半边往插件中心的 slot 里注册一张卡片,id 用包名 When a plugin needs a control the form cannot draw (capturing a keystroke rather than typing Ctrl+K into a box), its browser half registers a card in the hub's slot, under its package name as the id:

ctx.slots.inject('omdsh.plugin.card', () => ctx.slots.register({
  name: 'omdsh.plugin.card',
  id: '@omdsh-plugins/omdsh-shortcuts',
  inject: () => ({ /* your own face */ }),
}, ChordCaptureCard))

这张卡片会代替该插件的通用表单。插件仍然持有自己的 settings 命名空间,仍然通过 settings.mutate 写入 —— 这个逃生舱换掉的只是控件长什么样,不是值存在哪里。 The card renders instead of the generic form for that plugin. The plugin still owns its settings namespace and still writes through settings.mutate — the escape hatch changes what the control looks like, never where the value lives.

rule 7

version 用 semver,发布时记得升Keep version semver, and bump it on release

插件中心的更新按钮只由一次比较点亮:目录来源声明的版本,对上磁盘上那个包的版本,按 semver 排序。所以 version 字段不是流水账 —— 它是别人能收到的唯一一个"有新版本了"的信号。 The hub's Update button is lit by one comparison: the version the catalog source advertises against the version of the package on disk, ordered by semver. So a plugin's version field is not bookkeeping — it is the only signal anybody gets that a new release exists.

由此有三件事。Three things follow.

  • 发布时要升。Bump it when you publish. 同一个版本号推新代码,等于每个已安装的副本都报"已是最新",没有人会被提示这次改动。 Pushing new code at the same version means every installed copy reports "up to date" and nobody is offered the change.
  • 要能解析。Keep it parseable. 2024.03latest1.2 都不是 semver,比较因此没有答案,插件中心会报 unknown 而不是猜一个方向。预发布版没问题,而且顺序是对的:1.0.0-rc.21.0.0-rc.10 之前,两者都在 1.0.0 之前。 2024.03, latest, and 1.2 are not semver, so the comparison has no answer and the hub reports unknown rather than guessing a direction. A prerelease is fine and ordered correctly: 1.0.0-rc.2 is behind 1.0.0-rc.10, and both are behind 1.0.0.
  • 别指望它对签出目录安装有用。Do not expect it to matter for a checkout install. dsh plugin add <路径> 记的是 link: 依赖,装进去的文件就是那个签出目录,两个版本号是同一个文件。插件中心会把这种情况报成 linked 而不是"已是最新",因为从来就没有什么可拉的。 dsh plugin add <path> records a link: dependency, so the installed files ARE the checkout and the two versions are the same file. The hub reports that as linked instead of "up to date", because there was never anything to fetch.
  • 别忘了同步目录里的那一行。Move the catalog row with it. 插件中心的默认上游是 omdsh-plugins 账号,而它在那里优先采用的来源是 omdsh-plugins/registry 里的策展清单 —— 所以别人拿来比较的版本是那个文件声明的版本,不是你默认分支上的。清单由这些 package.json 生成,所以一次发布是两次推送:先推插件,再 node registry/build.mjs 后推 registry。 The hub's default upstream is the omdsh-plugins account, and the source it prefers there is the curated manifest in omdsh-plugins/registry — so the version an installation compares against is the one THAT file advertises, not the one on your default branch. The manifest is generated from these package.json files, so a release is two pushes: the plugin, then node registry/build.mjs and the registry.
rule 8

三件不能做的事Three things a plugin here never does

  • deepseek-harnessEdit deepseek-harness. 它是一份保持干净、以便跟随上游的 fork;在那里打的补丁下次同步时会丢失或冲突。所有功能都以 out-of-tree bundle 交付。如果缺一个缝,就从这个目录里的某个插件上取。 It is a tracked fork kept clean so it can follow upstream; a local patch there is lost or conflicts on the next sync. Every feature ships as an out-of-tree bundle. If a seam is missing, take it from a plugin in this directory instead.
  • 提交 link: 依赖。Commit a link: specifier. pnpm 会相对声明它的 manifest 解析,于是把某一台机器的目录结构写死了 —— 而且它静默失败:悬空软链、"安装成功",然后每一个 harness import 都 TS2307。提交的应该是 registry 版本号,再用 harness:local <path> / harness:npm 脚本切换,外加一个只要还链着就失败的 check:harness-pin pnpm resolves it against the declaring manifest, so it hard-codes one machine's layout — and it fails SILENTLY: dangling symlink, "successful" install, then tsc TS2307 on every harness import. Commit the registry pin and switch with a harness:local <path> / harness:npm script, plus a check:harness-pin that fails while anything is linked.
  • 在浏览器半边 value-import 另一个插件。Import another plugin's values in the browser half. client bundle 的纯度栅栏禁止这件事:跨插件的 value import 要么内联了另一个插件运行时的第二份副本,要么向冻结的模块表要一个它答不上来的 specifier。协作要走 cordis 服务和 slot;type-only import 会被擦除,没有问题。至于怎么依赖一个服务,见第 9 条。 The client bundle purity gate forbids it: a cross-plugin value import either inlines a second copy of that plugin's runtime or asks the frozen module table for a specifier it cannot answer. Collaborate through cordis services and slots; type-only imports are erased and are fine. Rule 9 is how you depend on one.
rule 9

别的插件发布的服务,绝不写进你的 injectAnother plugin's service never goes in your inject

harness 自己的服务 —— slotssessionsworkspacesworkspaceRegistrylocaleconnectionwebServerwebRuntimesettingssessionProjectionsinvariants —— 是由 dsh-base 和 surface bundle 组装的,所以顶层 inject 写它们总能解析。 The harness's own services — slots, sessions, workspaces, workspaceRegistry, locale, connection, webServer, webRuntime, settings, sessionProjections, invariants — are composed by dsh-base and the surface bundle, so a top-level inject naming them always resolves.

判据是这个名字由谁组装,而不是它在不在上面这份名单里:webRuntime 来自 web-app 这个 surface bundle,所以写它的插件是在声明自己需要那个界面 —— 对一个有浏览器半边的插件来说,这是诚实的说法。 The test is where a name is composed, not whether it appears in this list: webRuntime comes from the web-app surface bundle, so a plugin that names it is declaring it needs that surface — which is the honest thing for a plugin that has a browser half.

而由这个集合里另一个插件发布的服务(omdsh-base 的 sessionModes、omdsh-shortcuts 的 shortcut、omdsh-remdev 的 remdev)是另一类事实:它在不在,是 profile 的属性,而 profile 是一个人用 dsh plugin add 一条一条装出来的。 A service published by another plugin in this collection (sessionModes from omdsh-base, shortcut from omdsh-shortcuts, remdev from omdsh-remdev) is a different kind of fact: whether it exists is a property of the PROFILE, and a profile is assembled by a person one dsh plugin add at a time.

代价the cost

cordis 对被注入的服务会无限期等待,于是一个声明了"没人组装的服务"的 entry 会永远停在 pending —— 而两侧的启动审计都会因为任何非 active 的 entry 让整个应用失败(host 侧的 assertEntriesActivated,浏览器侧 dsh-client-web 在插件树静默后的那一遍扫描): cordis's inject wait has no timeout, so an entry naming a service nobody composed sits at pending forever — and both boot audits fail the whole app for any entry that is not active (the host's assertEntriesActivated, and the browser's post-settle sweep in dsh-client-web):

web boot: 1 entry did not activate
@omdsh-plugins/omdsh-code: pending (waiting for service: sessionModes)

这是一个死掉的界面,不是一个被关掉的功能 —— 少装一个配套插件,整个页面陪葬,连跟它毫无关系的插件也一起没了。 That is a dead UI, not a disabled feature — one missing companion plugin takes the whole page down, including every plugin that had nothing to do with it.

所以:别的插件的服务要在 apply 里面取,绝不写进 inject两种写法,仓库里都已经有: So reach for another plugin's service from inside apply, never from inject. Two shapes, both already in the tree:

受限 fiber,当这个依赖有生命周期时 —— 它在就挂载,它走就卸载: A restricted fiber, when the dependency has a lifetime — mount while it is there, unmount when it goes:

export const inject = ['slots', 'sessions', 'locale']   // harness services only

export function apply(ctx: ClientContext): void {
  ctx.inject([SESSION_MODES], (mctx) => {
    const modes = mctx.get(SESSION_MODES) as SessionModes | undefined
    // Reachable when the name is provided by a fiber that is not active.
    if (modes === undefined) return
    mountMode(mctx, modes)
  })
}

apply 里起的 fiber 不是 loader entry,所以永远等下去也没有代价。effect 要挂在 mctx 而不是 ctx 上,这样提供方在运行时卸载,会把你的注册一并带走。 A fiber started inside apply is not a loader entry, so waiting forever costs nothing. Hang the effects on mctx rather than ctx, and a provider that unloads at runtime withdraws your registrations with it.

惰性读取,当你只在某件事发生的那一刻才需要它时:ctx.get('remdev') as RemdevFace | undefined,取不到就自己给出答案。 A lazy read, when you only need the service at the moment something is called: ctx.get('remdev') as RemdevFace | undefined, and answer for yourself when it is undefined.

这就是第 1 条里 ctx.inject(['settings'], …) 的推广,理由也一样:依赖另一个插件是加法,不是前提。在 README 里说清楚关闭状态是什么样 —— 并且确保它是"什么都不做",而不是"炸掉"。 This is rule 1's ctx.inject(['settings'], …) generalized, and it holds for the same reason: depending on another plugin is additive, never a precondition. Say in your README what the off state is — and make sure it is inert, not fatal.

自查表checklist

新插件自查表Checklist for a new plugin

  • package.json 声明了 dsh.bundle.patch;有浏览器半边的还要声明 dsh.clientpackage.json declares dsh.bundle.patch, and dsh.client if it has a browser half
  • package.json 声明了 dsh.plughub 展示元数据package.json declares dsh.plughub display metadata
  • host 半边导出 schemastery Config,每个字段都有本地化的 descriptionThe host half exports a schemastery Config with a localized description per field
  • 通过 ctx.inject(['settings'], …) 注册命名空间,base 设为组装层的 entry configIt registers its settings namespace through ctx.inject(['settings'], …) with base set to the composition entry
  • 凭据字段标了 .role('secret')Credentials carry .role('secret')
  • applies 说的是实话applies is honest
  • 顶层 inject 里没有任何"别的插件发布的服务";没装它的 profile 能正常启动,README 里写清楚了关闭状态是什么样No service another plugin publishes appears in a top-level inject; the profile without it boots, and the README says what the off state is
  • version 是 semver,发布时会升version is semver and gets bumped on release
  • harness 依赖是提交下来的 registry 版本号,不是 link:Harness dependencies are the committed registry pin, not link:
  • 裸 clone 下 pnpm install && pnpm run build && pnpm test && pnpm run typecheck 能跑通pnpm install && pnpm run build && pnpm test && pnpm run typecheck from a bare clone
参照实现worked example

一个完整的例子Worked example

omdsh-shortcuts 是参照实现。它的配置正好沿着第 1 条画出的那条线分成两半: omdsh-shortcuts is the reference implementation. Its configuration splits along the line rule 1 draws:

  • items —— 有哪些命令、显示成什么、由谁执行。属于组装层的事实,对表单 .hidden(),在 profile 的 patch 文件里改。 — which commands exist, what they read as, who performs them. A composition fact, .hidden() from the form, edited in a profile's patch file.
  • bindings —— 哪个键触发哪个命令。属于人的事实,一张扁平的 dict(string),在插件中心里改。 — which key reaches which command. A person's fact, a flat dict(string), edited in the hub.

读一遍 src/bindings.tssrc/index.ts 末尾那段 settings 注册,是给另一个插件写出同样东西的最短路径。 Reading src/bindings.ts and the settings block at the end of src/index.ts is the shortest route to writing the same thing for another plugin.

这一页是集合仓库里 CONVENTIONS.md / CONVENTIONS.zh.md 的发布版本。两份文档是同一份内容,以仓库里的为准。 This page is the published rendering of CONVENTIONS.md / CONVENTIONS.zh.md in the collection repository, which is the authority when the two disagree.

回到集合首页Back to the collection