dsh-skill-compat-plus 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shuxidemosheng
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # dsh-skill-compat-plus
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/dsh-skill-compat-plus.svg)](https://www.npmjs.com/package/dsh-skill-compat-plus)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ [![Node](https://img.shields.io/badge/node-%E2%89%A524-brightgreen.svg)](package.json)
6
+
7
+ **把 Claude Code / Codex / ZCode 的技能目录挂进 DeepSeek Harness(dsh)。** 一个插件,让 dsh 直接看到你已有生态里的 agent skills——无需复制、无需迁移、改动即时生效。
8
+
9
+ ## 它是什么
10
+
11
+ dsh 官方的技能发现只扫描 `.dsh/skills`、`.agents/skills`、`~/.dsh/skills` 等自有目录。而 Claude Code(`~/.claude/skills`)、Codex CLI(`~/.codex/skills`)、ZCode(`~/.zcode/skills`)各自维护着大量高质量技能。本插件以 **SkillProvider** 形式(`ctx.skills.registerProvider`)把这些目录作为只读来源挂进 dsh 的技能注册表:
12
+
13
+ - **模型可用**:导入技能进入 `skill` 工具的目录(catalog),模型按需加载;
14
+ - **用户可斜杠调用**:`user-invocable` 技能自动获得 dsh 原生的 `/name` 手势入口;
15
+ - **热更新**:目录里新增/修改/删除技能文件,无需重启即可生效(chokidar + 存在性轮询);
16
+ - **零侵入**:只读挂载,不复制、不改写你的任何文件;卸载即恢复原状。
17
+
18
+ ## 功能
19
+
20
+ - 三家来源默认全开,目录不存在时静默跳过:
21
+ - `claude-code`:`~/.claude/skills`(用户级)+ `<项目根>/.claude/skills`(项目级,按最近 `.git` 祖先探测)
22
+ - `codex`:`~/.codex/skills`(旧约定目录;Codex 新官方 `~/.agents/skills` dsh 原生已扫,不重复挂载)
23
+ - `zcode`:`~/.zcode/skills`(用户级)+ `<项目根>/.zcode/skills`(项目级)
24
+ - **frontmatter 字段映射**(详见下表):`when_to_use` → `whenToUse`、宽松布尔、Codex `agents/openai.yaml` 的 `policy.allow_implicit_invocation` 等
25
+ - **名称规范化**:外来名不符合 dsh kebab-case 时自动 slug 化(如 `_references` → `references`、`code_review` → `code-review`),可关闭
26
+ - **description 合成**:Claude 允许缺省 description,而 dsh 必填——自动取正文首段合成(可关闭)
27
+ - **优先级体系**:dsh 原生技能永远压过同名导入技能;项目级导入压过用户级导入
28
+ - **诊断聚合**:无效文件、命名冲突等按问题集合变化输出 warn,不刷屏
29
+ - YAML 用官方同款 `yaml` 包解析——块标量(`>-`、`|`)、嵌套 metadata 都能正确处理
30
+
31
+ ## 字段映射表
32
+
33
+ | 外来字段 | Claude Code | Codex | ZCode | 映射到 dsh |
34
+ |---|---|---|---|---|
35
+ | `name` | 可选(缺省取目录名) | 必填 | 可选(缺省取目录名) | 必填;缺省用目录名,不合规则 slug 化 |
36
+ | `description` | 可选 | 必填 | 可选 | 必填;缺失时从正文首段合成(可关) |
37
+ | `when_to_use` | 有 | 无 | 有 | `whenToUse`(snake → camel) |
38
+ | `disable-model-invocation` | 有 | 无 | 无 | `invocation.modelInvocable` |
39
+ | `user-invocable` | 有 | 无 | 无 | `invocation.userInvocable` |
40
+ | `agents/openai.yaml` 的 `policy.allow_implicit_invocation: false` | 无 | 有 | 无 | `invocation.modelInvocable: false` |
41
+ | `allowed-tools` / `disallowed-tools` | 有 | 有(空格分隔) | 无 | 保留在 `metadata`(dsh 无对应物) |
42
+ | `model` / `effort` / `hooks` / `paths` / `shell` / `argument-hint` 等 | 有 | 无 | 无 | 保留在 `metadata` |
43
+ | `license` / `compatibility` | 有 | 有 | 容忍 | 保留在 `metadata` |
44
+ | `metadata`(map) | 有 | 有 | 有 | 原样保留 |
45
+
46
+ 整个原始 frontmatter 会全量保真进 `metadata`,不做静默丢弃。
47
+
48
+ ## 优先级(rank,越小越优先)
49
+
50
+ | 来源 | 目录 | rank | 语义 |
51
+ |---|---|---|---|
52
+ | dsh 原生 project-dsh | `<项目根>/.dsh/skills` | 100 | 永远压过导入 |
53
+ | dsh 原生 project-agents | `<项目根>/.agents/skills` | 200 | 永远压过导入 |
54
+ | **claude-code 项目级** | `<项目根>/.claude/skills` | **210** | 输给 dsh 原生项目级 |
55
+ | **zcode 项目级** | `<项目根>/.zcode/skills` | **220** | 同上 |
56
+ | dsh 原生 custom | `Config.customSkillDirs` | 300 | |
57
+ | **extraDirs** | 任意目录 | **300**(可按目录覆盖) | 与官方 custom 同档 |
58
+ | dsh 原生 user-dsh | `~/.dsh/skills` | 400 | |
59
+ | **claude-code 用户级** | `~/.claude/skills` | **450** | 输给 dsh 原生用户级 |
60
+ | **codex 用户级** | `~/.codex/skills` | **460** | |
61
+ | **zcode 用户级** | `~/.zcode/skills` | **470** | |
62
+ | dsh 原生 user-agents / bundled | `~/.agents/skills` / 内置 | 500 / 600 | |
63
+
64
+ 同名技能裁决由 dsh 官方注册表完成(rank → provider 注册序 → list 内顺序),本插件只负责给出确定的 rank。
65
+
66
+ ## 安装
67
+
68
+ | 方式 | 命令 / 操作 |
69
+ |---|---|
70
+ | CLI(npm 包) | `dsh plugin --profile <你的profile> add dsh-skill-compat-plus` |
71
+ | 桌面端 | 插件页安装 `dsh-skill-compat-plus`,或:`ELECTRON_RUN_AS_NODE=1 "/d/DeepSeek Harness/DeepSeek Harness.exe" --expose-internals "D:/DeepSeek Harness/resources/app.asar/dsh/node_modules/@deepseek-ai/dsh-desktop-host/lib/cli.js" plugin --profile desktop add dsh-skill-compat-plus`(Git Bash,先完全退出应用含托盘) |
72
+ | git 直装(始终最新 main) | `dsh plugin --profile <你的profile> add git+https://github.com/shuxidemosheng/dsh-skill-compat-plus.git` |
73
+ | 本地 tgz | `npm pack` 后 `dsh plugin --profile <你的profile> add file:D:/AIcoding/dsh-skill-compat-plus/dsh-skill-compat-plus-0.1.0.tgz` |
74
+
75
+ ## 配置
76
+
77
+ 配置写在 `cordis.patch.yml` 的 entry `config` 下(profile 层可覆盖,整体替换语义):
78
+
79
+ | 键 | 默认 | 说明 |
80
+ |---|---|---|
81
+ | `providerName` | `skill-compat-plus` | provider 注册名(`runtime` 为保留字,不可用) |
82
+ | `sources.claude` | `true` | `boolean` 或 `{ enabled?, project?, root?, rank? }` |
83
+ | `sources.codex` | `true` | 同上 |
84
+ | `sources.zcode` | `true` | 同上 |
85
+ | `extraDirs` | `[]` | `{ path, label?, rank? }[]`,任意额外技能目录 |
86
+ | `watch` | `true` | 文件变化热更新 |
87
+ | `watchUsePolling` | `false` | 网络盘/特殊文件系统上开启轮询监视 |
88
+ | `watchStabilityThresholdMs` | `200` | awaitWriteFinish 稳定阈值 |
89
+ | `watchPollIntervalMs` | `100` | awaitWriteFinish 轮询间隔 |
90
+ | `watchMaxProjects` | `128` | 项目根监视 LRU 上限 |
91
+ | `slugifyNames` | `true` | 关闭后不合规名直接 skip |
92
+ | `synthesizeDescription` | `true` | 关闭后缺 description 的技能直接 skip |
93
+
94
+ 未知配置键会直接报错(fail-fast,不做静默忽略)。
95
+
96
+ ## 本地开发
97
+
98
+ ```bash
99
+ # Git Bash
100
+ npm install
101
+ npm test # vitest,32 个用例(含 Windows 路径、真实时序 watch 测试、与上游文法的对拍)
102
+ npm run build # tsc d.ts + esbuild bundle(yaml/chokidar 为外部运行时依赖)
103
+ node scripts/smoke.mjs # 用构建产物直接扫描本机真实技能目录并打印清单
104
+ ```
105
+
106
+ ## 安全
107
+
108
+ - 本插件**只读**外部技能目录:唯一的写操作是 chokidar/fs.watchFile 的监视句柄。
109
+ - 技能内容由 dsh 官方 `skill` 工具管线加载,本插件不执行任何技能内代码。
110
+ - **产物最小化**:`yaml`/`chokidar` 作为运行时依赖(由 plugin-manager 安装进 profile),构建产物只含第一方代码——安全扫描面与攻击面都最小。
111
+ - **Mimosa 深度扫描封印**(2026-10-11,v0.1.0,最终产物):
112
+ - 发布前复核:scanId `scan-2026-10-11T10-22-46.363Z-046924c5dc3e`,seal `sha256:8b6abe1217fbf677d8f9577792cf63a89ba5c97b04f85c6ae67027340a0f93a7`,findings 0,依赖风险 0
113
+ - seal: `sha256:f35ddf0890c53725a25589f8515b20f3b13025d213f8901af52a28d2dc77ccf1`
114
+ - findings 0,依赖风险 0。
115
+ - 历史记录:最初的 472KB 自包含产物曾报 6 条静态 advisory(scanId `scan-2026-10-11T09-25-20.462Z-3a00d7a1835b`,全部位于当时打包进去的上游闭包——`yaml` scalar 解析器、`@deepseek-ai/schemastery`、`@deepseek-ai/dsh-skill` 注册表内部,属静态污点分析对官方包内部实现的误报模式)。据此改为外部依赖方案后复扫归零。
116
+
117
+ ## 已知限制
118
+
119
+ - Claude/ZCode **插件包内部**的技能(`~/.claude/plugins`、`~/.zcode/cli/plugins/cache` 等)不在默认发现范围,可用 `extraDirs` 手动挂载;自动识别(`.claude-plugin/plugin.json` 等)留待后续版本。
120
+ - 项目级只扫描最近 `.git` 祖先**一级**(对齐 dsh 官方);ZCode 式的 CWD→worktree 多级扫描不做。
121
+ - Claude Code 的嵌套 `<subdir>/.claude/skills`、`--add-dir`、企业托管目录不支持。
122
+ - `allowed-tools` / `model` / `hooks` 等执行语义字段在 dsh 没有对应物,仅保留在 `metadata` 供追溯。
123
+ - 大量导入技能会增大模型端 catalog(每个会话一条 `<available_skills>` 提醒);建议用 `sources.*.enabled` / `extraDirs` 精选。
124
+ - dsh 处于 developer preview:peerDependencies 锁 `@deepseek-ai/dsh-skill >=0.2.0-rc.2`,上游 breaking change 需跟进。
125
+
126
+ ## FAQ
127
+
128
+ **Q: 会不会和 dsh 原生技能冲突?**
129
+ 同名时 dsh 原生优先(见 rank 表),不会出现不可预测的覆盖;本插件发现的重名会在日志里给出 `duplicate skill name` 诊断。
130
+
131
+ **Q: `~/.agents/skills` 里的技能为什么没被本插件列出?**
132
+ 那本来就是 dsh 原生扫描的目录(同时也是 Codex 新官方用户根),无需桥接。
133
+
134
+ **Q: 技能文件改了但没生效?**
135
+ 确认 `watch: true`;网络盘请开 `watchUsePolling: true`。watch 的存在性轮询约 2.5s,内容事件走 chokidar(亚秒级)。
136
+
137
+ ## 上游致谢
138
+
139
+ - [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) —— SkillProvider 接缝、`dsh-skill-filesystem` 参考实现、插件 bundle 机制
140
+ - 格式参考:[Claude Code skills 文档](https://code.claude.com/docs/en/skills)、[Codex skills 文档](https://developers.openai.com/codex/skills)、[Agent Skills 开放规范](https://agentskills.io)
141
+ - 同类项目:[YTyangtao666/dsh-skills-bridge](https://github.com/YTyangtao666/dsh-skills-bridge)(首发思路),本插件在目录覆盖面、YAML 解析、Windows 路径正确性、诊断聚合与测试覆盖上做了全面增强
142
+
143
+ ## 许可证
144
+
145
+ [MIT](LICENSE)
@@ -0,0 +1,16 @@
1
+ - insert:
2
+ - id: skill-compat-plus
3
+ name: dsh-skill-compat-plus
4
+ config:
5
+ # 各外部技能来源:true/false 或 { enabled, project, root, rank } 精细控制。
6
+ # 三家默认全开;目录不存在时静默跳过。
7
+ sources:
8
+ claude: true # ~/.claude/skills + <项目根>/.claude/skills
9
+ codex: true # ~/.codex/skills(Codex 旧约定目录;新官方 ~/.agents/skills 由 dsh 原生扫描)
10
+ zcode: true # ~/.zcode/skills + <项目根>/.zcode/skills
11
+ # 额外目录:{ path, label?, rank? },rank 默认 300(与官方 custom 同档)
12
+ extraDirs: []
13
+ watch: true
14
+ watchUsePolling: false # 网络盘/特殊文件系统上手动开启
15
+ slugifyNames: true # 外来技能名不符合 dsh kebab-case 时自动转换
16
+ synthesizeDescription: true # Claude 允许缺省 description;开启时从正文首段合成
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 配置校验:手写 resolveConfig,未知键直接抛错(对齐官方 plan-mode 的纪律,
3
+ * 避免拼写错误的配置键被静默吞掉)。cordis.patch.yml 的 patch config 是整体
4
+ * 替换语义,这里拿到的是经过 YAML 反序列化后的普通对象。
5
+ */
6
+ import type { ConfigInput, ResolvedConfig } from './types.js';
7
+ /**
8
+ * 校验并规范化配置。任何未知键或非法值都会抛错——让错误在插件激活期
9
+ * 暴露,而不是在扫描期被静默吞掉。
10
+ */
11
+ export declare function resolveConfig(input: ConfigInput | undefined): ResolvedConfig;
package/lib/config.js ADDED
@@ -0,0 +1,152 @@
1
+ const CONFIG_KEYS = [
2
+ 'providerName',
3
+ 'sources',
4
+ 'extraDirs',
5
+ 'watch',
6
+ 'watchUsePolling',
7
+ 'watchStabilityThresholdMs',
8
+ 'watchPollIntervalMs',
9
+ 'watchMaxProjects',
10
+ 'slugifyNames',
11
+ 'synthesizeDescription',
12
+ ];
13
+ const SOURCE_KEYS = ['enabled', 'project', 'root', 'rank'];
14
+ const SOURCE_IDS = ['claude', 'codex', 'zcode'];
15
+ function isPlainObject(value) {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ }
18
+ function booleanField(source, key) {
19
+ const value = source[key];
20
+ if (value === undefined)
21
+ return undefined;
22
+ if (typeof value !== 'boolean')
23
+ throw new Error(`config field "${key}" must be a boolean, got ${typeof value}`);
24
+ return value;
25
+ }
26
+ function positiveNumberField(source, key) {
27
+ const value = source[key];
28
+ if (value === undefined)
29
+ return undefined;
30
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
31
+ throw new Error(`config field "${key}" must be a positive finite number`);
32
+ }
33
+ return value;
34
+ }
35
+ function stringField(source, key) {
36
+ const value = source[key];
37
+ if (value === undefined)
38
+ return undefined;
39
+ if (typeof value !== 'string' || value.trim().length === 0) {
40
+ throw new Error(`config field "${key}" must be a non-empty string`);
41
+ }
42
+ return value;
43
+ }
44
+ function resolveSourceOption(id, option) {
45
+ const settings = { enabled: true, project: true };
46
+ if (option === undefined || typeof option === 'boolean') {
47
+ settings.enabled = option !== false;
48
+ return settings;
49
+ }
50
+ if (!isPlainObject(option)) {
51
+ throw new Error(`config sources.${id} must be a boolean or an object`);
52
+ }
53
+ for (const key of Object.keys(option)) {
54
+ if (!SOURCE_KEYS.includes(key)) {
55
+ throw new Error(`unknown config sources.${id} key "${key}" (expected ${SOURCE_KEYS.join(', ')})`);
56
+ }
57
+ }
58
+ const enabled = booleanField(option, 'enabled');
59
+ const project = booleanField(option, 'project');
60
+ const root = stringField(option, 'root');
61
+ const rank = option.rank;
62
+ if (rank !== undefined && (typeof rank !== 'number' || !Number.isFinite(rank))) {
63
+ throw new Error('config sources rank must be a finite number');
64
+ }
65
+ return {
66
+ enabled: enabled ?? true,
67
+ project: project ?? true,
68
+ root,
69
+ rank,
70
+ };
71
+ }
72
+ function resolveExtraDirs(value) {
73
+ if (value === undefined)
74
+ return [];
75
+ if (!Array.isArray(value))
76
+ throw new Error('config extraDirs must be an array');
77
+ const dirs = [];
78
+ for (const raw of value) {
79
+ if (!isPlainObject(raw))
80
+ throw new Error('config extraDirs entries must be objects like { path, label?, rank? }');
81
+ for (const key of Object.keys(raw)) {
82
+ if (!['path', 'label', 'rank'].includes(key)) {
83
+ throw new Error(`unknown config extraDirs entry key "${key}" (expected path, label, rank)`);
84
+ }
85
+ }
86
+ const path = stringField(raw, 'path');
87
+ if (path === undefined)
88
+ throw new Error('config extraDirs entry requires a non-empty "path"');
89
+ const label = stringField(raw, 'label') ?? 'custom';
90
+ const rank = raw.rank;
91
+ if (rank !== undefined && (typeof rank !== 'number' || !Number.isFinite(rank))) {
92
+ throw new Error('config extraDirs entry rank must be a finite number');
93
+ }
94
+ dirs.push({ path, label, rank: rank ?? 300 });
95
+ }
96
+ return dirs;
97
+ }
98
+ /**
99
+ * 校验并规范化配置。任何未知键或非法值都会抛错——让错误在插件激活期
100
+ * 暴露,而不是在扫描期被静默吞掉。
101
+ */
102
+ export function resolveConfig(input) {
103
+ if (input !== undefined && !isPlainObject(input)) {
104
+ throw new Error('plugin config must be an object');
105
+ }
106
+ const source = input ?? {};
107
+ for (const key of Object.keys(source)) {
108
+ if (!CONFIG_KEYS.includes(key)) {
109
+ throw new Error(`unknown plugin config key "${key}" (expected ${CONFIG_KEYS.join(', ')})`);
110
+ }
111
+ }
112
+ const providerName = stringField(source, 'providerName') ?? 'skill-compat-plus';
113
+ if (providerName === 'runtime') {
114
+ throw new Error('config providerName "runtime" is reserved by the dsh skill registry');
115
+ }
116
+ const rawSources = source.sources;
117
+ if (rawSources !== undefined && !isPlainObject(rawSources)) {
118
+ throw new Error('config sources must be an object with keys claude, codex, zcode');
119
+ }
120
+ for (const key of Object.keys(rawSources ?? {})) {
121
+ if (!SOURCE_IDS.includes(key)) {
122
+ throw new Error(`unknown config sources key "${key}" (expected ${SOURCE_IDS.join(', ')})`);
123
+ }
124
+ }
125
+ const sources = {
126
+ claude: resolveSourceOption('claude', rawSources?.claude),
127
+ codex: resolveSourceOption('codex', rawSources?.codex),
128
+ zcode: resolveSourceOption('zcode', rawSources?.zcode),
129
+ };
130
+ const watch = booleanField(source, 'watch') ?? true;
131
+ const watchUsePolling = booleanField(source, 'watchUsePolling') ?? false;
132
+ const watchStabilityThresholdMs = positiveNumberField(source, 'watchStabilityThresholdMs') ?? 200;
133
+ const watchPollIntervalMs = positiveNumberField(source, 'watchPollIntervalMs') ?? 100;
134
+ const watchMaxProjects = positiveNumberField(source, 'watchMaxProjects') ?? 128;
135
+ const slugifyNames = booleanField(source, 'slugifyNames') ?? true;
136
+ const synthesizeDescription = booleanField(source, 'synthesizeDescription') ?? true;
137
+ const extraDirs = resolveExtraDirs(source.extraDirs);
138
+ const anySourceEnabled = sources.claude.enabled || sources.codex.enabled || sources.zcode.enabled || extraDirs.length > 0;
139
+ return Object.freeze({
140
+ providerName,
141
+ sources,
142
+ extraDirs: Object.freeze(extraDirs),
143
+ watch,
144
+ watchUsePolling,
145
+ watchStabilityThresholdMs,
146
+ watchPollIntervalMs,
147
+ watchMaxProjects,
148
+ slugifyNames,
149
+ synthesizeDescription,
150
+ anySourceEnabled,
151
+ });
152
+ }
@@ -0,0 +1,55 @@
1
+ import type { MappedSkill } from './types.js';
2
+ export declare function isSkillName(name: string): boolean;
3
+ export interface ParsedFrontmatter {
4
+ data: Record<string, unknown>;
5
+ body: string;
6
+ }
7
+ /**
8
+ * 切分并解析 frontmatter。边界判定逐行扫描(首行必须是独立的 ---,
9
+ * 兼容 \r),与官方 skill-filesystem 同构;YAML 本体交给官方同款 yaml 包,
10
+ * 因此块标量(>- / |)与嵌套 map 都能正确解析。
11
+ */
12
+ export declare function splitFrontmatter(raw: string): ParsedFrontmatter | undefined;
13
+ /**
14
+ * 宽松布尔解析(对齐官方 parseInvocationPolicy:1/0、"true/false/yes/no/on/off")。
15
+ * 返回 undefined 表示不是合法布尔。
16
+ */
17
+ export declare function lenientBoolean(value: unknown): boolean | undefined;
18
+ /**
19
+ * 把外来名转换成 dsh kebab-case:小写、非 [a-z0-9] 的连续字符折叠成单 '-'、
20
+ * 去首尾 '-'。数字开头合法(dsh 正则允许 "4drawio")。
21
+ */
22
+ export declare function slugifySkillName(input: string): string;
23
+ /** 从正文合成 description:取首个非空行,压缩空白后截到 ~200 字符。 */
24
+ export declare function synthesizeDescription(body: string): string;
25
+ export interface MapForeignSkillInput {
26
+ /** SKILL.md 或扁平 .md 的绝对路径。 */
27
+ filePath: string;
28
+ /** resource base(目录 bundle 的目录或扁平文件所在根)。 */
29
+ directory: string;
30
+ /** 目录名(目录 bundle)或去扩展名的文件名(扁平)。 */
31
+ entryName: string;
32
+ parsed: ParsedFrontmatter;
33
+ slugify: boolean;
34
+ synthesize: boolean;
35
+ /** Codex agents/openai.yaml 解析结果(可选文件,缺省视为允许隐式调用)。 */
36
+ allowImplicitInvocation?: boolean;
37
+ }
38
+ export type MapForeignSkillResult = {
39
+ readonly ok: true;
40
+ readonly skill: MappedSkill;
41
+ } | {
42
+ readonly ok: false;
43
+ readonly reason: string;
44
+ };
45
+ /**
46
+ * 把一份已切分的外来 SKILL.md 映射成 dsh 技能字段。
47
+ * 失败返回人读 reason(由上层聚合成 warn),绝不 throw。
48
+ */
49
+ export declare function mapForeignSkill(input: MapForeignSkillInput): MapForeignSkillResult;
50
+ /**
51
+ * 读取 Codex 的 agents/openai.yaml(可选文件),提取
52
+ * policy.allow_implicit_invocation。文件不存在或格式异常时返回 undefined
53
+ * (可选文件解析失败不该拖垮整个技能)。
54
+ */
55
+ export declare function readCodexOpenAiPolicy(directory: string): Promise<boolean | undefined>;
@@ -0,0 +1,215 @@
1
+ /**
2
+ * 外来 frontmatter → dsh 技能字段映射。
3
+ *
4
+ * 三家生态的格式差异(调研结论,字段细节见 README 映射表):
5
+ * - Claude Code:全部字段可选,name 缺省取目录名;`when_to_use`(snake_case);
6
+ * 可能出现 `allowed-tools`/`model`/`hooks` 等 dsh 无对应物的字段。
7
+ * - Codex / Agent Skills 规范:name+description 必填;可选 agents/openai.yaml
8
+ * 的 policy.allow_implicit_invocation=false 表示"只能显式调用"。
9
+ * - ZCode:name 缺省取目录名;`when_to_use`(snake_case)。
10
+ * - dsh:name/description 必填且 name 必须是 kebab-case
11
+ * (/^[a-z0-9]+(?:-[a-z0-9]+)*$/),invocation 双布尔必给。
12
+ */
13
+ import { readFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { parse as parseYaml } from 'yaml';
16
+ /**
17
+ * dsh 技能名的公共文法。刻意本地复制官方 @deepseek-ai/dsh-skill 内部常量
18
+ * SKILL_NAME 而不运行时引入该包:避免把注册表闭包(schemastery 等)打进
19
+ * 构建产物。一致性由 tests/frontmatter.spec.ts 的对拍用例保证——上游若
20
+ * 修改文法,该测试会失败提醒同步。
21
+ */
22
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
23
+ export function isSkillName(name) {
24
+ return SKILL_NAME.test(name);
25
+ }
26
+ /**
27
+ * 切分并解析 frontmatter。边界判定逐行扫描(首行必须是独立的 ---,
28
+ * 兼容 \r),与官方 skill-filesystem 同构;YAML 本体交给官方同款 yaml 包,
29
+ * 因此块标量(>- / |)与嵌套 map 都能正确解析。
30
+ */
31
+ export function splitFrontmatter(raw) {
32
+ const firstLineEnd = raw.indexOf('\n');
33
+ if (firstLineEnd < 0)
34
+ return undefined;
35
+ const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '');
36
+ if (firstLine !== '---')
37
+ return undefined;
38
+ const start = firstLineEnd + 1;
39
+ let lineStart = start;
40
+ while (lineStart <= raw.length) {
41
+ const nextNewline = raw.indexOf('\n', lineStart);
42
+ const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
43
+ const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '');
44
+ if (line === '---') {
45
+ const yamlText = raw.slice(start, lineStart);
46
+ let parsed;
47
+ try {
48
+ parsed = parseYaml(yamlText);
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
54
+ return undefined;
55
+ return { data: parsed, body: raw.slice(nextNewline < 0 ? raw.length : nextNewline + 1) };
56
+ }
57
+ if (nextNewline < 0)
58
+ return undefined;
59
+ lineStart = nextNewline + 1;
60
+ }
61
+ return undefined;
62
+ }
63
+ /** dsh 禁止的 legacy camelCase 调用字段(官方解析器会直接 throw,这里同样拒绝)。 */
64
+ const LEGACY_INVOCATION_KEYS = ['disableModelInvocation', 'modelInvocable', 'userInvocable'];
65
+ /**
66
+ * 宽松布尔解析(对齐官方 parseInvocationPolicy:1/0、"true/false/yes/no/on/off")。
67
+ * 返回 undefined 表示不是合法布尔。
68
+ */
69
+ export function lenientBoolean(value) {
70
+ if (typeof value === 'boolean')
71
+ return value;
72
+ if (typeof value === 'number') {
73
+ if (value === 1)
74
+ return true;
75
+ if (value === 0)
76
+ return false;
77
+ return undefined;
78
+ }
79
+ if (typeof value === 'string') {
80
+ const normalized = value.trim().toLowerCase();
81
+ if (['true', 'yes', 'on', '1'].includes(normalized))
82
+ return true;
83
+ if (['false', 'no', 'off', '0'].includes(normalized))
84
+ return false;
85
+ }
86
+ return undefined;
87
+ }
88
+ /**
89
+ * 把外来名转换成 dsh kebab-case:小写、非 [a-z0-9] 的连续字符折叠成单 '-'、
90
+ * 去首尾 '-'。数字开头合法(dsh 正则允许 "4drawio")。
91
+ */
92
+ export function slugifySkillName(input) {
93
+ return input
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9]+/g, '-')
96
+ .replace(/^-+|-+$/g, '');
97
+ }
98
+ function firstMeaningfulText(text) {
99
+ for (const line of text.split(/\r?\n/)) {
100
+ const stripped = line.replace(/^\s*#+\s*/, '').trim();
101
+ if (stripped.length > 0)
102
+ return stripped;
103
+ }
104
+ return '';
105
+ }
106
+ /** 从正文合成 description:取首个非空行,压缩空白后截到 ~200 字符。 */
107
+ export function synthesizeDescription(body) {
108
+ const text = firstMeaningfulText(body);
109
+ if (text.length <= 200)
110
+ return text;
111
+ const slice = text.slice(0, 200);
112
+ const lastSpace = slice.lastIndexOf(' ');
113
+ return (lastSpace >= 160 ? slice.slice(0, lastSpace) : slice) + '…';
114
+ }
115
+ function stringField(data, key) {
116
+ const value = data[key];
117
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
118
+ }
119
+ /**
120
+ * 把一份已切分的外来 SKILL.md 映射成 dsh 技能字段。
121
+ * 失败返回人读 reason(由上层聚合成 warn),绝不 throw。
122
+ */
123
+ export function mapForeignSkill(input) {
124
+ const { parsed, filePath } = input;
125
+ const data = parsed.data;
126
+ for (const key of LEGACY_INVOCATION_KEYS) {
127
+ if (key in data) {
128
+ return {
129
+ ok: false,
130
+ reason: `frontmatter field "${key}" is unsupported; use "disable-model-invocation"`,
131
+ };
132
+ }
133
+ }
134
+ // name:frontmatter 优先,缺省回退目录名(Claude/ZCode 语义)。
135
+ const rawName = stringField(data, 'name') ?? input.entryName;
136
+ let name = rawName;
137
+ if (!isSkillName(name)) {
138
+ if (!input.slugify) {
139
+ return { ok: false, reason: `invalid skill name "${rawName}" and slugifyNames is disabled` };
140
+ }
141
+ const slug = slugifySkillName(rawName);
142
+ if (!isSkillName(slug)) {
143
+ return { ok: false, reason: `invalid skill name "${rawName}" cannot be converted to a dsh kebab-case name` };
144
+ }
145
+ name = slug;
146
+ }
147
+ // description:dsh 硬性必填;Claude 允许缺省 → 从正文合成。
148
+ let description = stringField(data, 'description');
149
+ if (description === undefined) {
150
+ if (!input.synthesize) {
151
+ return { ok: false, reason: 'frontmatter requires a description and synthesizeDescription is disabled' };
152
+ }
153
+ description = synthesizeDescription(parsed.body);
154
+ if (description.length === 0) {
155
+ return { ok: false, reason: 'frontmatter requires a description and the body is empty' };
156
+ }
157
+ }
158
+ const whenToUse = stringField(data, 'whenToUse') ?? stringField(data, 'when_to_use');
159
+ const disableModelInvocation = lenientBoolean(data['disable-model-invocation']);
160
+ if (data['disable-model-invocation'] !== undefined && disableModelInvocation === undefined) {
161
+ return { ok: false, reason: 'frontmatter field "disable-model-invocation" must be a boolean' };
162
+ }
163
+ const userInvocableRaw = data['user-invocable'];
164
+ let userInvocable = true;
165
+ if (userInvocableRaw !== undefined) {
166
+ const parsed = lenientBoolean(userInvocableRaw);
167
+ if (parsed === undefined) {
168
+ return { ok: false, reason: 'frontmatter field "user-invocable" must be a boolean' };
169
+ }
170
+ userInvocable = parsed;
171
+ }
172
+ const modelInvocable = disableModelInvocation !== true && input.allowImplicitInvocation !== false;
173
+ return {
174
+ ok: true,
175
+ skill: {
176
+ name,
177
+ description,
178
+ whenToUse,
179
+ invocation: { modelInvocable, userInvocable },
180
+ // 原始 frontmatter 全量保真:allowed-tools/model/hooks 等字段在 dsh
181
+ // 没有对应物,保留在 metadata 里可追溯,不做静默丢弃。
182
+ metadata: Object.freeze({ ...data }),
183
+ path: filePath,
184
+ directory: input.directory,
185
+ content: parsed.body.trim(),
186
+ },
187
+ };
188
+ }
189
+ /**
190
+ * 读取 Codex 的 agents/openai.yaml(可选文件),提取
191
+ * policy.allow_implicit_invocation。文件不存在或格式异常时返回 undefined
192
+ * (可选文件解析失败不该拖垮整个技能)。
193
+ */
194
+ export async function readCodexOpenAiPolicy(directory) {
195
+ let raw;
196
+ try {
197
+ raw = await readFile(join(directory, 'agents', 'openai.yaml'), { encoding: 'utf8' });
198
+ }
199
+ catch {
200
+ return undefined;
201
+ }
202
+ let parsed;
203
+ try {
204
+ parsed = parseYaml(raw);
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ if (typeof parsed !== 'object' || parsed === null)
210
+ return undefined;
211
+ const policy = parsed['policy'];
212
+ if (typeof policy !== 'object' || policy === null)
213
+ return undefined;
214
+ return lenientBoolean(policy['allow_implicit_invocation']);
215
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * dsh-skill-compat-plus —— 把 Claude Code / Codex / ZCode 的技能目录作为
3
+ * 只读 SkillProvider 挂载进 DeepSeek Harness。
4
+ *
5
+ * 纪律(与官方 postmortem 0001 / 既有插件一致):
6
+ * - 绝不 export default(Loader 的 unwrapExports 会折叠模块并丢掉 inject);
7
+ * - apply() 内同步调用 registerProvider;
8
+ * - 任何激活期失败都降级为 warn + return(entry 激活失败会让会话创建失败,
9
+ * 输家必须优雅退出)。
10
+ */
11
+ import type { Context } from '@deepseek-ai/cordis';
12
+ import type { ConfigInput } from './types.js';
13
+ export declare const name = "skill-compat-plus";
14
+ /** 宿主服务依赖:技能注册表(ctx.skills)。 */
15
+ export declare const inject: readonly ["skills"];
16
+ export declare function apply(ctx: Context, config: ConfigInput | undefined): void;