读懂 registry:四套声明式资源,怎么用一个注册表骨架管起来
这篇讲什么
前面我们讲过 skills(第 1 篇的 load_skill 按需加载)、agents(第 5 篇的子 agent)、commands(斜杠命令)。它们看起来是不同的功能------技能、子智能体、命令、输出风格。但如果你退一步看它们的底层结构,会发现惊人地一致:
都是"声明式资源"------在一个目录里放 .md 文件,文件头的 frontmatter 声明元数据(name/description/...),正文是指令文本;启动时扫描目录、解析、注册进一张表,运行时按 name 查找。skills 扫 <name>/SKILL.md,commands 扫 <name>.md,agents 扫 agent manifest------扫描的细节不同,但骨架完全一样。
这篇拆 common/registry.ts,一个不到 80 行的统一注册表骨架。它把四套资源(skills/agents/commands/outputStyles)的同构部分抽成两层公共基座,差异通过参数吸收。读完你会看到,"识别结构同构、抽公共基座"是比"每个子系统各写一套"高级得多的工程------改一处骨架,四个子系统全受益。
一、先看全貌:什么资源走这套骨架、怎么触发
读这篇的钥匙,是下面这张"子系统→扫描模式→来源优先级→产物"表。四套声明式资源,全走 common/registry 的骨架:
| 子系统 | 扫描模式 | 三来源优先级 | 注入产物 | 触发时机 |
|---|---|---|---|---|
| skills | 子目录 <name>/SKILL.md |
builtin < global < project | load_skill 工具 |
启动期 initSkills |
| agents | agent manifest | builtin < global < project | spawn_agent 工具 |
启动期 initAgents |
| commands | 扁平 <name>.md |
builtin < global < project | 斜杠命令(不注入工具) | 启动期 initCommands |
| outputStyles | 扁平 <name>.md |
builtin < global < project | persona 正文 | 启动期 initOutputStyles |
四个子系统,同一套骨架,差异只在"扫描模式"(子目录 vs 扁平)和"manifest 字段/注入产物"。来源优先级四个都一样------builtin(随包分发,最低)→ global(~/.deepseeker-code/)→ project(<cwd>/.deepseeker-code/,最高)。同名后者覆盖前者,所以 project 能覆盖 global,global 能覆盖 builtin。
拿几个场景盘活"什么时候触发这套骨架":
-
场景 A:用户在
~/.deepseeker-code/skills/放了个自定义 skilltdd。 启动时 initSkills 扫三个来源,builtin 没有 tdd、global 有 → 注册 global 的 tdd。用户用/skill能看到它,模型能 load_skill 加载它。 -
场景 B:用户 clone 了一个开源仓库,它带了
.deepseeker-code/skills/evil。 如果用户没信任这个项目 (includeProject=false),filterSources 把 project 来源整个裁掉,evil skill 根本不被扫描注册。防 clone 恶意仓库自动注入高危配置。 -
场景 C:某个 skill 的 SKILL.md frontmatter 格式错了(缺 description)。 parseSkillAt 返回 null,scanSources 跳过它(单项失败隔离),其他 skill 照常加载。一个坏文件不拖垮整个加载。
-
场景 D:内置有个 skill 叫
git,用户又在 project 放了个git。 project 后注册,覆盖内置的------用户的项目级gitskill 生效,内置的被覆盖。用户能定制/覆盖内置行为。 -
场景 E:一个项目没有任何 .deepseeker-code 目录。 scanSources 的 readdir 对不存在的目录 try/catch 静默跳过,什么也不发生。没配置零负担。
理解了这张表和这些场景,再去看 common/registry.ts,它就是两张牌:一个"覆盖语义注册表"(createRegistry),一个"容错扫描骨架"(scanSources)。下面拆这两层。
二、两层公共基座
第一层:createRegistry ------ 覆盖语义注册表
注册表的本质是 Map<name, manifest>,加几个访问方法(registry.ts :17):
ts
export const createRegistry = <T extends { name: string }>() => {
const map = new Map<string, T>();
return {
register: (item: T) => { map.set(item.name, item); }, // 同名后者覆盖前者
list: () => Array.from(map.values()), // Map 插入序
get: (name: string) => map.get(name),
clear: () => { map.clear(); }, // 测试/热重载
size: () => map.size,
};
};
三个要点:
覆盖语义 ------map.set(item.name, item),同名后者覆盖前者。这是"优先级"的落点:按 builtin→global→project 顺序 register,project 最后 set,所以它覆盖前两个。
Map 插入序 ------Array.from(map.values()) 保留插入顺序。这让 list 的顺序可预期(先 builtin 再 global 再 project),注入系统提示词的目录清单也是这个序。
泛型约束 <T extends { name: string }>------只要 manifest 有 name 字段就能用这个注册表。SkillManifest、AgentManifest、CommandManifest 都有 name,所以共用一个 createRegistry。这就是"结构同构"在类型层面的体现------用泛型约束提炼共性,让一个工厂服务多种 manifest。
每个子系统各持一个实例(skills/registry.ts :43):const skills = createRegistry<SkillManifest>(),然后只 export 各自需要的子集方法(registerSkill = skills.register、listSkills = skills.list)。同一套实现,不同子系统的对外 API 各自裁剪。
第二层:scanSources + filterSources ------ 三来源容错扫描骨架
扫描要做三件事:遍历多个来源目录、挑出目标文件、解析成 manifest。这三件事 skills/agents/commands 都要做,差异只在"怎么挑文件"。scanSources 把公共的遍历/容错逻辑抽出来,把"挑"和"解析"作为参数(:53):
ts
export const scanSources = async <S extends string, T>(
sources: LoadSource<S>[], // 来源目录列表(顺序=优先级)
pick: (entry: Dirent, dir: string) => string | undefined, // 挑目标文件(差异点)
parse: (file, dir, source) => Promise<T | null>, // 解析为 manifest(差异点)
) => {
const out = [];
for (const s of sources) {
let entries;
try { entries = await fs.readdir(s.dir, { withFileTypes: true }); }
catch { continue; } // ★ 目录不存在静默跳过
for (const entry of entries) {
const file = pick(entry, s.dir); // 谓词判定:要不要这个 entry
if (!file) continue;
const item = await parse(file, s.dir, s.source);
if (item) out.push({ item, file, source: s.source }); // ★ parse 返回 null 则跳过(单项隔离)
}
}
return out;
};
两个容错点都用 ★ 标了:目录不存在静默跳过 (readdir try/catch → continue),单项解析失败隔离(parse 返回 null → 不 push,不影响其它)。这意味着一个来源目录缺失、一个 SKILL.md 写错,都不会拖垮整个加载。
filterSources(:40)是信任闸门------按 includeProject 裁剪来源:
ts
export const filterSources = (sources, includeProject) =>
includeProject ? sources : sources.filter(s => s.source !== "project");
未信任时把 project 来源整个剔除。这个闸门在 loader 调 scanSources 之前执行------scanSources(filterSources(SOURCES, includeProject), ...)。项目级资源只有"被信任"才扫描。
三、差异怎么被吸收
骨架是公共的,差异在哪?全在 pick 和 parse 两个谓词参数里。对比 skills 和 commands 的调用,差异一目了然。
skills:子目录扫描模式
ts
// skills/loader.ts:87
const picked = await scanSources(
filterSources(SOURCES, includeProject),
(e, dir) => e.isDirectory() ? path.join(dir, e.name, "SKILL.md") : undefined, // ★ 子目录/SKILL.md
parseSkillAt,
);
pick 谓词:entry 是目录 → 拼成 <dir>/<name>/SKILL.md;不是目录 → undefined 跳过。skill 可能有附属资源(不只一个 .md),所以用目录组织。
commands:扁平扫描模式
ts
// commands/loader.ts:81
const picked = await scanSources(
filterSources(SOURCES, includeProject),
(e, dir) => (e.isFile() && e.name.endsWith(".md")) ? path.join(dir, e.name) : undefined, // ★ 扁平 *.md
parseCommandAt,
);
pick 谓词:entry 是 .md 文件 → 取它;不是 → 跳过。命令是单文件 prompt 模板,无附属资源,扁平更易写。
同一个 scanSources,两个 pick 谓词,吸收了"子目录 vs 扁平"的扫描差异。 parse 谓词同理------parseSkillAt 解析出 SkillManifest(带 allowedTools/triggers/context),parseCommandAt 解析出 CommandManifest(带 model/allowedTools),各自的字段差异封装在各自的 parse 里。骨架完全不感知这些差异。
注入产物的差异
还有一个差异在"加载完注入什么"。skills 有 skill 才注入 load_skill 工具(initSkills 的 into 参数),commands 不注入工具(initCommands 无 into 参数,命令是斜杠展开不是工具调用)。这个差异在各自的 init 函数里处理,骨架不管。骨架只管"扫描注册","注册完怎么用"交还给各子系统。
四、四个设计决策
决策一:识别结构同构、抽公共基座
skills/agents/commands/outputStyles 四个子系统,结构高度同构(Map 注册表 + 三来源扫描 + 覆盖语义)。与其每个各写一套 register/list/scan,不如抽出 createRegistry + scanSources 两层公共基座。收益是改一处骨架,四个子系统全受益------比如要加个"扫描时记日志"的能力,改 scanSources 一处就够。代价是引入一层泛型抽象(初读要多理解一层)。对四个同构子系统,这个取舍明显划算。这是"DRY"在子系统级别的体现。
决策二:三来源覆盖优先级(project 能覆盖内置)
builtin < global < project 的覆盖优先级,让用户和项目能定制/覆盖内置行为。内置 git skill 不合你意?在 project 放个同名 git skill,它就覆盖内置的。这让内置资源成为"可被覆盖的默认值",而不是"硬规定"。覆盖语义用 Map 的 set 自然实现------后 set 的覆盖先 set 的,配合"按优先级顺序 register"就成立了。
决策三:项目级信任闸门(filterSources)
clone 一个仓库,它可能带 .deepseeker-code/skills/evil------一个恶意 skill 可以在 load_skill 时注入恶意指令。所以 project 来源默认不信任 (includeProject=false),filterSources 把它裁掉。只有用户显式信任这个项目(比如在受信任目录列表里),project 资源才加载。这是"不可信输入"边界的又一个体现------和第 9 篇 MCP 的"第三方不可信"一脉相承:项目级资源是仓库作者写的,不是你写的,默认不自动执行。
决策四:单项失败隔离 + 加载失败不阻断启动
scanSources 的两层容错(目录不存在跳过、单项解析失败跳过)+ 各 init 函数外层 try/catch(加载失败只 warn 不抛),保证一个坏文件/一个缺失目录永远不会让服务起不来。声明式资源是"锦上添花",不是"必需品"------skills 挂了 agent 照样能跑(只是没 skill 用),不该因为一个 frontmatter 写错就整个崩。这和第 9 篇 MCP 的"单 server 失败不影响其他"是同一个鲁棒性原则。
五、五个技术难点
难点一:覆盖语义怎么靠 Map + 插入序实现
"优先级"听起来要排序,但这里用 Map 的两个特性就实现了:set 是覆盖 (同名后者替换前者)、values() 保插入序(先 builtin 再 global 再 project)。所以只要按优先级顺序调 register,覆盖和排序都自然成立------不需要额外的优先级字段、不需要排序算法。这是个"用对数据结构,逻辑就消失了"的例子。
难点二:pick 谓词怎么吸收扫描模式差异
skills 扫子目录、commands 扫扁平文件,如果硬编码在骨架里,骨架就得 if/else 区分子系统。解法是把"挑文件"做成谓词参数 (entry, dir) => string | undefined------返回路径就解析,返回 undefined 就跳过。差异从骨架的 if/else,变成了调用方传的不同函数。 这是"控制反转"------骨架控制遍历流程,调用方控制"挑什么"。skills 传子目录谓词、commands 传扁平谓词,骨架一行不用改。
难点三:项目级信任闸门的设计
为什么是 filterSources 在 scanSources 之前裁剪,而不是扫描后再过滤?因为扫描本身就有副作用 ------如果先扫描 project 的所有文件再过滤,恶意 SKILL.md 的正文可能已经被读取(甚至触发 parse 里的逻辑)。先 filterSources 把 project 来源整个去掉,scanSources 根本不碰那个目录,连 readdir 都不发生。这是"信任检查要前置"的安全原则------在可能有副作用的操作之前做闸门。
难点四:泛型设计(createRegistry<T>、scanSources<S, T>)
两层基座都用了泛型,但泛型参数不同,各有考量。createRegistry 是 <T extends { name: string }>------约束 manifest 必须有 name(注册表的 key 来源),但不约束其它字段(SkillManifest/AgentManifest 字段各异)。scanSources 是 <S extends string, T>------S 是来源标签类型('builtin'|'global'|'project'),T 是 manifest 类型,两者独立。泛型约束提炼共性(name)、放开个性(其它字段),让一套骨架服务多种 manifest 而不丢失类型安全。
难点五:frontmatter 复用(KISS 自实现 vs 引 YAML)
skills/commands/outputStyles 都要解析 frontmatter(文件头的 --- 块)。这里没有引 yaml 库,而是自实现了一个 KISS 的 parseFrontmatter (skills/frontmatter.ts,commands 复用)。因为 frontmatter 只用了 key: value + 正文这种最简结构,引一个完整的 YAML 解析器(处理锚点、引用、多文档)是过度。按实际需要的能力选型------只用 key:value 就不背 YAML 的复杂度。 这和第 9 篇 MCP 的"零依赖手搓 JSON-RPC"是同一种克制。
六、推荐的源码阅读顺序
- 先读 common/registry.ts 全文(不到 80 行)。先建立"两层基座"的全景:createRegistry(注册表)+ filterSources/scanSources(扫描骨架)。
- 重点读 scanSources 的两个容错点 (
:63continue、:70if(item)):理解单项失败隔离。 - 对比读 skills/loader.ts 和 commands/loader.ts 的 loadXxx:看同一个 scanSources,两个不同的 pick 谓词怎么吸收扫描模式差异。这是理解"骨架+谓词"设计的关键。
- 读 skills/registry.ts:看 createRegistry<SkillManifest>() 怎么实例化,子集方法怎么 export。理解"同一实现,各自裁剪 API"。
- 读 filterSources 的调用 (skills/loader.ts
:88):理解项目级信任闸门怎么前置。 - 扩展读 agents/loader.ts、outputStyles/loader.ts:验证它们也是同一套骨架,巩固"结构同构"的认知。
七、关联:registry 是扩展机制的公共底座
registry 这套骨架,是 DeepSeeker-Code 几个扩展机制的共同基础:
- skills(第 1 篇):走这套骨架扫描注册,注入 load_skill 工具。getSkillCatalog 把 listSkills() 拼成系统提示词的"技能目录"。
- agents(第 5 篇):子 agent 的 manifest 走这套骨架,注入 spawn_agent 工具。
- commands/outputStyles:斜杠命令、输出风格,同一套骨架,只是不注入工具。
- 和 MCP 的对照 :第 9 篇的 MCP loader(initMcpTools)虽然没直接用 common/registry(MCP 工具是动态从 server 来的,不是静态 .md 扫描),但容错骨架是同构的------skills/loader.ts 的注释明确写了"复刻 MCP loader 四件套的容错骨架:目录不存在跳过、单项失败隔离"。可以说 common/registry 是把 MCP loader 的容错模式提炼成了通用骨架。
- 信任边界 :filterSources 的项目级闸门,和第 9 篇 MCP 的"第三方不可信"、第 7 篇的"保护路径"一脉相承------项目级资源/第三方资源默认不信任,是整个系统一致的安全姿态。
你会看到,"扩展性统一抽象"(设局七条之一)不是一句空话------它具体落到 common/registry 这套骨架上。四个子系统因为结构同构,共用一个骨架;差异通过谓词吸收;信任边界统一前置。识别同构、抽公共基座,让扩展机制可复用、可维护、可一致演进。
最后
写四个相似的子系统,最省事的做法是各 copy 一套------快,但留四份要维护的代码,改一处要改四处。高级的做法是退一步识别它们的同构结构,抽出公共骨架,把差异参数化。common/registry 就是后者的范例:createRegistry 提炼了"覆盖语义注册表",scanSources 提炼了"容错扫描骨架",差异(扫描模式、manifest 字段、注入产物)全交给谓词和各 init 函数。
读这段源码,最值得带走的是**"识别结构同构"这个工程直觉**。当你发现自己在写第二、第三套相似的东西时,停下来问一句:"它们结构一样吗?一样的话,骨架能不能共用?" 这个习惯,会让你的代码从"能跑的重复"进化到"可演进的抽象"。这套 registry 骨架,就是这个问题被正确回答后的样子------不到 80 行,管起四套扩展资源。
下一篇,我们读 Hooks 系统------看六类生命周期事件,怎么用四种引擎(command/http/prompt/agent)被监听和响应。
项目源码开源在 github.com/xnk/deepSee... ,文章里提到的文件都在 src/core/src/common/ 和各子系统的 loader.ts/registry.ts,欢迎对着源码读。觉得这个导读系列有点意思,点个 star 是对我最大的鼓励。
总结
- 四套声明式资源结构同构:skills/agents/commands/outputStyles 都是"目录放 .md + frontmatter 元数据 + 正文指令 + 三来源扫描 + 按 name 注册",共用 common/registry 的骨架;
- 两层公共基座 :createRegistry(Map 覆盖语义注册表,泛型
<T extends {name}>提炼共性放开个性)+ filterSources/scanSources(三来源容错扫描骨架,目录不存在跳过、单项解析失败隔离); - 差异靠谓词吸收:pick 谓词吸收扫描模式差异(skills 子目录/SKILL.md vs commands 扁平 *.md),parse 谓词封装各自 manifest 字段,注入产物的差异留在各 init 函数;
- 四个设计决策:识别结构同构抽公共基座、三来源覆盖优先级(project 能覆盖内置 builtin)、项目级信任闸门(filterSources 前置裁剪防 clone 恶意仓库注入)、单项失败隔离 + 加载失败不阻断启动;
- 五个技术难点:覆盖语义靠 Map set + 插入序(无需排序)、pick 谓词吸收差异(控制反转)、信任闸门前置(扫描前裁剪避免副作用)、泛型约束提炼共性放开个性、frontmatter KISS 自实现(只用 key:value 不引 YAML)。