create-extension
从零创建一个新插件(extension)时使用。当任务是新建内置插件、照 numbered-bookmarks/ai/git 的样子起新扩展、加 commands/menus/keybindings/configuration 贡献点、activate 里注册命令/状态栏/装饰/SCM、插件不激活/被扫描器跳过、或 manifest 文案本地化(package.nls)时使用。
SKILL.md.claude/skills/create-extension/SKILL.md
GitHub从零创建一个新插件(extension)
本 skill 面向仓库内内置扩展(
extensions/<name>/,in-workspace)。仓库外第三方作者走 npm 脚手架npm create @universe-editor/extension(packages/create-extension),不要照本 skill 的外部目录套路手工搭建。
本仓库的扩展模型完整对标 VSCode:一个插件是 extensions/<name>/ 下的一个目录,由 manifest(package.json)声明贡献点 + 激活时机,可选地带 运行时代码(src/extension.ts) 在激活时通过 @universe-editor/extension-api 门面注册命令/菜单/键位/配置/provider。放进 extensions/ 目录即被扫描器自动发现,无需在主程序手动注册。
⚠️ 第一原则:先判断你要的是"纯声明"还是"有代码"。很多需求(给某 JSON 配 schema、给已有命令加键位、挂个菜单项)只需一个
package.json,不写一行 TS、不需要构建(见claude-helper)。只有当激活时要跑逻辑(注册命令处理器、装饰、状态栏、SCM、provider)才需要src/extension.ts+ esbuild。别默认从代码起步。⚠️ 第二原则:
engines.universe必须能满足当前 host API 版本。host API 版本 =packages/extension-api/src/index.ts的version常量(现为0.12.0)。范围写不对,扫描器会静默跳过整个插件(只在扩展输出通道console.error一行),表现为"插件完全不生效"。统一写">=0.1.0 <1.0.0",别用^0.1.0(0.x 下 caret 锁 minor,0.12.0会被挡下)。原因见文末"engines 兼容红线"。
两种形态 → 选哪种
| 你的需求 | 形态 | 需要的文件 | 范例 |
|---|---|---|---|
| 只贡献静态声明:jsonValidation、给已有命令配键位、挂菜单项 | 纯声明 | 仅 package.json |
extensions/claude-helper/ |
| 激活时要跑代码:注册命令处理器 / 状态栏 / 装饰 / SCM / provider | 有代码 | manifest + esbuild + src/extension.ts + 测试 |
extensions/ai/(最简)、numbered-bookmarks/(完整)、git/(全贡献点) |
若要做的是语言能力 provider(definition/hover/completion/诊断…)→ 用 skill
extend-language-plugin;webview/自定义编辑器预览 → 见apps/editor/src/renderer/workbench/webview/CLAUDE.md(新预览扩展范例照抄extensions-external/pdf/CLAUDE.md);只接 JSON schema → 用 skilladd-json-schema-association。这些是本套路的特化场景,已有专门文档。
纯声明型:只需一个 package.json
照抄 extensions/claude-helper/package.json。没有 main、没有 activationEvents、没有 scripts/devDependencies——扫描器 mainPath 为 undefined 即视为 declaration-only。
{
"name": "@universe-editor/<name>",
"version": "0.0.1",
"private": true,
"type": "module",
"displayName": "Human Readable Name",
"description": "...",
"engines": { "universe": ">=0.1.0 <1.0.0" },
"contributes": {
"jsonValidation": [
{ "fileMatch": ["**/foo.config.json"], "url": "https://.../schema.json" }
]
}
}
有代码型:起骨架(照抄 extensions/ai,最简代码插件)
extensions/<name>/
package.json manifest + main:"dist/extension.js" + activationEvents + files:["dist"] + scripts + devDependencies
esbuild.config.mjs 从 ai/numbered-bookmarks 逐字复制,仅改日志 label(bundle→dist/extension.js,注入 createRequire banner)
tsconfig.json extends "@universe-editor/config-ts/node",noEmit,types:["node"],include:["src","esbuild.config.mjs"]
vitest.config.ts defineConfig({})(空,默认 node 环境)
src/extension.ts export activate(context) + deactivate()
src/*.ts (可选)把可测逻辑抽成纯模块
src/__tests__/*.test.ts (可选)vi.mock extension-api + 动态 import 被测模块
package.json 相比纯声明型多出的字段(scripts / devDependencies 六件套逐字复制 ai 的):
"main": "dist/extension.js",
"files": ["dist"],
"activationEvents": ["onCommand:<name>.someCommand"],
"scripts": {
"build": "node esbuild.config.mjs",
"dev": "node esbuild.config.mjs --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint src",
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
},
"devDependencies": {
"@universe-editor/config-ts": "workspace:*",
"@universe-editor/extension-api": "workspace:*",
"@types/node": "catalog:", "esbuild": "catalog:", "eslint": "catalog:",
"typescript": "catalog:", "vitest": "catalog:"
}
src/extension.ts:activate / deactivate / context
import { commands, type ExtensionContext } from '@universe-editor/extension-api' // 门面 namespace
import { doThing } from './thing.js' // 相对 import 必带 .js 后缀(ESM/NodeNext)
export function activate(context: ExtensionContext): void { // 可同步,也可 async 返回 Promise
context.subscriptions.push( // 一切注册结果都 push 到 subscriptions,host 在停用时统一 dispose
commands.registerCommand('<name>.someCommand', (arg) => doThing(arg)),
)
}
export function deactivate(): void {} // 通常空实现——subscriptions 已托管
ExtensionContext:subscriptions: Disposable[]、extensionPath: string、globalState: Memento(跨工作区 KV)、workspaceState: Memento(工作区级 KV)。Memento=get<T>(key, default?)/update(key, value): Promise<void>。持久化状态用workspaceState,绝不往工作区写 json 文件(见 numbered-bookmarks)。- 日志走
console.error——stdout 是 host↔renderer 的 RPC 通道,普通日志只能走 stderr(约定加[<name>] ...前缀)。 activate只接线:把有逻辑的部分抽成纯模块(commitMessage.ts/bookmarks.ts…)再单测,入口本身一般不测。
extension-api 门面(packages/extension-api/src/index.ts 末尾的 namespace 常量)
| namespace | 能力 | 备注 |
|---|---|---|
commands |
registerCommand / executeCommand<T> |
|
window |
消息框 / showQuickPick / showInputBox / createStatusBarItem / createOutputChannel / getActiveTextEditor / onDidChangeActiveTextEditor / createTextEditorDecorationType / registerCustomEditorProvider |
|
workspace |
rootPath / fs(受控 FS 网关)/ textDocuments / onDidOpen·Change·CloseTextDocument / getConfiguration(section) |
配置读 getConfiguration('<name>').get(key, default) |
languages |
14 类 provider 注册 + createDiagnosticCollection |
语言特性走 skill extend-language-plugin |
scm |
createSourceControl(id, label, rootUri?) |
|
ai |
getModels / sendRequest / computeTokenLength … |
仅 trusted(内置)插件可用;restricted(用户装的)拿不到此 proxy |
门面被 esbuild 内联进每个插件,运行时委托给 host 装在 globalThis 的 bridge 越进程 RPC。所以 @universe-editor/extension-api 只需列 devDependency。
contributes 贡献点写法(全量 schema 见 packages/extension-manifest/src/manifest-schema.ts)
- commands:
{ command, title, category?, icon? }(icon用 codicon 名如"bookmark") - keybindings:
{ command, key: "ctrl+shift+0", mac?, when?: "editorTextFocus" } - configuration:
{ title, properties: { "<name>.foo": { type, default, description, minimum?, enum?, items? } } }(type∈ string/number/boolean/array/object/null) - menus:
{ "<内置菜单位或自定义submenu id>": [ { command | submenu, group?: "navigation@1", when?, icon? } ] }(command/submenu二选一必填)。内置菜单位如scm/title、scm/inputBox、scm/resourceState/context、editor/title—— 完整样板见git - submenus:
{ id: "<name>.someMenu", label, icon? }(配合 menus 里{submenu: "<name>.someMenu"}) - jsonValidation / customEditors:见各自专门 skill
- 未知贡献点 / 未知字段被 zod
.passthrough()容忍(前向兼容);但activationEvents、engines、命令/菜单结构是强校验,不合法整插件被跳过
activationEvents 合法值(extension-manifest/src/activation.ts):"*"(eager,慎用)、"onStartupFinished"、"onCommand:<id>"、"onLanguage:<id>"、"onView:<id>"、"onCustomEditor:<viewType>"。懒激活优先(如命令型插件用 onCommand:),全局常驻才用 onStartupFinished。onCommand:<id> 可以省:宿主从 contributes.commands 自动派生它(对齐 VSCode;只有运行期注册、未进 contributes.commands 的命令必须显式声明)。
⚠️ handler 在 renderer Action2 的命令:只写 menus,绝不写 commands
内置扩展(git / perforce)贡献到 scm/title 菜单、但真正 handler 在 renderer Action2 的命令(如 git-graph.view / perforce-graph.view),绝不能再写进扩展 package.json 的 contributes.commands:那会在扩展宿主侧注册一个同名、无 handler 的命令,执行时它胜出并遮蔽 renderer Action2——executeCommand 静默返回 undefined、不抛错、编辑器不打开(命令「成功」却什么都没发生,极难排查)。
- 做法:只在
contributes.menus(scm / title 等)写菜单项,菜单项自带icon即可显示图标,title/tooltip 由 renderer Action2 提供。对照extensions/git:git-graph.view只出现在 menus。 - 排查:e2e 里用
getActiveEditorTypeId/getActiveGroupEditorCount探针对比同结构的git-graph.view(count=1 打开)与被遮蔽命令(count=0 no-op),秒判「命令被吞」而非「组件渲染崩」。 - 机制细节见
docs/development/commands-and-context-keys.md的「扩展命令与 renderer Action2 的同名遮蔽」节。
NLS 本地化 manifest 文案(可选,机制见 packages/extension-host/src/nls.ts)
- manifest 里所有用户可见字符串写
%key%占位(command title / submenu label / config description) - 插件根旁放
package.nls.json(英文默认,必须)+package.nls.<locale>.json(如package.nls.zh-cn.json) - NLS 文件必须列进
package.json的files数组,否则打包丢失(见git的files:["dist","package.nls.json","package.nls.zh-cn.json"]) - locale bundle 合并覆盖默认(缺 key 回退英文,再缺保留字面
%key%让缺翻译可见);locale 经env.UNIVERSE_DISPLAY_LOCALE传入 - 运行时代码内的消息本地化是另一套(不走 manifest NLS):仿
git/src/nls.ts内嵌 locale 表 +localize(key, defaultMsg, vars?),支持{0}插值。两者互不相关
加载链路(放进 extensions/ 就被自动发现,无需手动注册)
extensions/*在 pnpm workspace 内(pnpm-workspace.yamlglobs 含'extensions/*')。- 扫描目录
apps/editor/src/main/services/extensionHost/extensionHostMainService.ts:dev/e2e = repoextensions/(resolveFromRepo),packaged =resources/extensions/。目录经 envUNIVERSE_BUILTIN_EXTENSIONS_DIR传给 host。 - host bootstrap
packages/extension-host/src/bootstrap.ts:import { version as HOST_API_VERSION } from '@universe-editor/extension-api'→scanExtensions(dir, HOST_API_VERSION, locale)。 - 扫描器
packages/extension-host/src/extensionScanner.ts的scanOne顺序:读 package.json → 加载 NLS → 翻译%key%→ zod 校验 →satisfies(hostApiVersion, engines.universe)版本检查(:88)→ 解析 jsonValidation → 组装。任一步抛错则console.error跳过该插件、不阻塞其余。
engines 兼容红线(本 skill 的头号坑,务必看)
satisfies 是自研极简 semver(packages/extension-manifest/src/semver.ts),不是 npm 的 semver:
- 支持:exact /
*/x/ partial(1,1.2) /^/~/ 比较符 / 空格连接的 AND(>=0.1.0 <1.0.0)。 - 不支持:
||(OR)、hyphen range(1.0.0 - 2.0.0)——一律 fail-closed 直接拒绝加载。 - pre-1.0 的
^按 npm 语义锁 minor:^0.1.0=>=0.1.0 <0.2.0,0.12.0不满足 → 插件被跳过。
规则:内置插件统一写 ">=0.1.0 <1.0.0"(接受整个 0.x)。若插件依赖某次 minor 引入的新 API,把下界抬到那次 minor(如 Tree View 需要 ">=0.12.0 <1.0.0")。
当 packages/extension-api 的 version bump 时(走 COMPATIBILITY.md 的破坏性变更流程),必须同步检查全部内置插件的 engines.universe——这一步漏了会导致所有插件被静默拒载(历史事故:0.1.0→0.2.0 时内置插件仍写 ^0.1.0,全崩)。
加测试(node 环境,src/__tests__/*.test.ts)
mock extension-api 的标准套路(见 extensions/ai/src/__tests__/commitMessage.test.ts):
import { describe, it, expect, vi } from 'vitest'
const executeCommand = vi.fn()
vi.mock('@universe-editor/extension-api', () => ({
AiMessageRole: { System: 0, User: 1, Assistant: 2 }, // value 枚举要手动补
commands: { executeCommand: (...a) => executeCommand(...a) },
workspace: { getConfiguration: () => ({ get: (k, d) => d }) },
}))
const { generateCommitMessage } = await import('../commitMessage.js') // mock 提升,之后再动态 import 被测模块
只 stub 用到的 namespace 成员;枚举等 value 手动提供;逻辑抽到纯模块单测,入口 extension.ts 一般不测。
验证
# 改了 platform/extension-manifest/extensions-common/extension-host 后先重建 dist,apps/host 才看得到(pnpm dev 下 watcher 自动重建)
pnpm --filter @universe-editor/extension-manifest --filter @universe-editor/extensions-common --filter @universe-editor/extension-host build
pnpm ext:build # 重建 extensions/*(turbo,declaration-only 插件无 build 会被跳过,无妨)
pnpm check # lint + typecheck + test,仅看错误输出(被测错误路径的 stderr 噪音非失败)
- 手动端到端(
pnpm dev):触发插件的激活事件(命令面板跑命令 / 打开匹配文件),确认贡献点生效。 - 插件"完全不生效"排查:多半是 scanner 跳过了它——去应用的扩展输出通道找
[ext-host] skipping <name>: ...。最常见原因是engines.universe不满足(见红线)或 manifest zod 校验失败。诊断时可临时在 e2e/dev 把 host stderr echo 出来(host stderr 默认只进扩展输出通道,不进日志文件)。 - 涉及交互流程用
pnpm e2e(本地 Windows 跑 e2e 有 Playwright launch flake,交给 CI)。
关键参考路径
extensions/claude-helper/—— 纯声明型范例(仅 package.json)extensions/ai/—— 最简代码插件(单命令 + menus + configuration + 测试 mock 范式)extensions/numbered-bookmarks/—— 完整代码插件(commands/keybindings/configuration + workspaceState 持久化 + 装饰 + 多测试文件)extensions/git/—— 全贡献点样板(submenus / 嵌套 menus / when 上下文键 / manifest NLS + 运行时 NLS)packages/extension-api/src/index.ts—— 门面 namespace +version常量(= host API 版本)+ExtensionContext/Memento类型packages/extension-api/COMPATIBILITY.md—— API 版本承诺 + 破坏性变更流程(bump version 必同步内置插件 engines)packages/extension-manifest/src/manifest-schema.ts—— contributes 全量 zod schemapackages/extension-manifest/src/activation.ts—— 合法 activationEvents + 构造器 + 命令派生判据(matchesManifestActivationEvent)packages/extension-manifest/src/semver.ts—— 自研satisfies(engines 检查用;不支持||/hyphen)packages/extension-host/src/extensionScanner.ts—— scanOne 校验顺序 + engines satisfies 检查(:88)packages/extension-host/src/bootstrap.ts—— HOST_API_VERSION 来源 + scanExtensions 调用packages/extension-host/src/nls.ts—— manifest%key%本地化实现apps/editor/src/main/services/extensionHost/extensionHostMainService.ts—— 扫描目录解析(dev vs packaged)+ env 传参apps/editor/CLAUDE.md—— 套路 A(命令/键位 Action2)、套路 D(Contribution);docs/user/zh-CN/customization/extensions.md—— 用户视角说明- 知识容器:
packages/extension-host/CLAUDE.md(宿主运行时地图)、packages/extension-api/CLAUDE.md(API 版本契约)、extensions/typescript/CLAUDE.md(内置语言插件范例)
其它
- 涉及用户可见文案/命令名/键位改动时,检查
docs/user/zh-CN/customization/extensions.md是否需同步。 - 新建 skill 后跑
pnpm skills:policy生成agents/openai.yaml(codex 侧 manual-only 策略),CI 会用pnpm skills:policy:check校验。 - 后续用本 skill 发现新经验,同步更新本文件。