dsh-codex-port 0.2.0 → 0.2.1
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/README.en.md +8 -3
- package/README.md +5 -2
- package/lib/port.d.ts +2 -1
- package/lib/port.js +175 -36
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
[中文](README.md)
|
|
2
2
|
|
|
3
|
+
   
|
|
4
|
+
|
|
3
5
|
# dsh-codex-port
|
|
4
6
|
|
|
5
7
|
[](https://awesome-dsh-plugin.com)
|
|
@@ -10,7 +12,7 @@ Move the whole **official Codex plugin family** into DSH: scan `~/.codex` unpack
|
|
|
10
12
|
|
|
11
13
|
## Compatibility
|
|
12
14
|
|
|
13
|
-
Verified against `@deepseek-ai/dsh@0.1.
|
|
15
|
+
Verified against `@deepseek-ai/dsh@0.1.2-alpha.2` on 2026-08-31. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
|
|
14
16
|
|
|
15
17
|
## Installation
|
|
16
18
|
|
|
@@ -68,16 +70,19 @@ Ported skills are immediately usable from the DSH skills directory; the agent tr
|
|
|
68
70
|
- **Frontmatter conversion**: Codex `name/description/metadata` → DSH `name/description/compatibility/allowed-tools`, multi-line descriptions preserved
|
|
69
71
|
- **Codex-only files stripped**: `agents/*.yaml` and friends stay behind
|
|
70
72
|
- **Name sanitization**: invalid characters become underscores; `..` traversal rejected outright
|
|
73
|
+
- **Host loadability checks**: before replacement, the converted name must follow DSH's lowercase kebab-case grammar, exactly match its target directory, and have a nonempty description. Names with uppercase letters, underscores, dots, or a different sanitized directory name cannot replace an existing skill.
|
|
71
74
|
- **Idempotent**: same-name skills are skipped by default; `overwrite=true` to replace
|
|
75
|
+
- **Recoverable replacement**: copying, conversion, and read-back validation finish inside a unique `.dsh-port-<skill>-*` directory under the target root before the old skill moves to `previous/` and the new skill takes its place. Copy/conversion failures leave the old skill untouched. A failed switch attempts rollback; if rollback also fails, both copies remain and the error includes their recovery location. Content created by another writer is never deleted.
|
|
76
|
+
- **Backups and path checks**: successful replacements retain `previous/` and `recovery.json`, using additional disk space; archive them manually when recovery is no longer needed. No `SKILL.md` sits at the transaction directory's top level, so current DSH one-level skill discovery does not register backups. Overlapping source/target paths, target symlinks/junctions, paths outside the target root, and Windows device names are rejected. Multiple renames are not one atomic Windows transaction; after process or system interruption, inspect `recovery.json` to restore the appropriate directory.
|
|
72
77
|
- **Safe**: pure filesystem operations, zero runtime dependencies (yaml parsing only)
|
|
73
78
|
|
|
74
79
|
## Development
|
|
75
80
|
|
|
76
81
|
```bash
|
|
77
82
|
pnpm install
|
|
78
|
-
pnpm test # build +
|
|
83
|
+
pnpm test # build + offline tests; fixtures stay under workspace .harness-validation/codex-port-tests
|
|
79
84
|
```
|
|
80
85
|
|
|
81
86
|
## License
|
|
82
87
|
|
|
83
|
-
MIT
|
|
88
|
+
MIT
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
## 兼容性
|
|
17
17
|
|
|
18
|
-
在 `@deepseek-ai/dsh@0.1.
|
|
18
|
+
在 `@deepseek-ai/dsh@0.1.2-alpha.2` 上验证(2026-08-31)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
|
|
19
19
|
|
|
20
20
|
## 安装
|
|
21
21
|
|
|
@@ -89,14 +89,17 @@ codex_port { targetDir: ~/.claude/skills }
|
|
|
89
89
|
- **frontmatter 转换**:Codex 的 `name/description/metadata` → DSH 的 `name/description/compatibility/allowed-tools`,多行描述完整保留
|
|
90
90
|
- **剔除 codex 专属文件**:`agents/*.yaml` 等子代理描述不带走
|
|
91
91
|
- **名称清洗**:非法字符替换为下划线,`..` 路径穿越直接拒绝
|
|
92
|
+
- **宿主可加载性校验**:覆盖前要求转换后的技能名符合 DSH 的小写短横线命名规则、与目标目录同名,且描述为非空字符串;大写、下划线、点号或清洗后不同名的技能不会替换旧版。
|
|
92
93
|
- **幂等**:同名技能默认跳过,`overwrite=true` 才覆盖
|
|
94
|
+
- **覆盖失败可恢复**:先在目标根内的唯一 `.dsh-port-<技能名>-*` 目录中复制、转换并回读校验;成功后才把旧技能移到事务目录的 `previous/`,再切换新技能。复制或转换失败不会改动旧技能,切换失败会尝试恢复旧技能;恢复失败时保留两份资料,并在失败原因中给出恢复路径。不会删除已存在的其他写入。
|
|
95
|
+
- **备份与目录边界**:成功覆盖也保留 `previous/` 及 `recovery.json`,会额外占用磁盘空间;确认不需要恢复后可自行归档。事务目录顶层不含 `SKILL.md`,不会被当前 DSH 的单层技能发现重复注册。拒绝源/目标重叠、目标符号链接或目录联接、路径越界及 Windows 设备名称。多次目录重命名不构成 Windows 上全程原子的事务;进程或系统中断后可按 `recovery.json` 检查和恢复。
|
|
93
96
|
- **安全**:纯文件系统操作,零运行时依赖(仅 yaml 解析)
|
|
94
97
|
|
|
95
98
|
## 开发
|
|
96
99
|
|
|
97
100
|
```bash
|
|
98
101
|
pnpm install
|
|
99
|
-
pnpm test # 构建 +
|
|
102
|
+
pnpm test # 构建 + 离线测试;fixture 只写入工作区 .harness-validation/codex-port-tests
|
|
100
103
|
```
|
|
101
104
|
|
|
102
105
|
## License
|
package/lib/port.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface PortResult {
|
|
|
8
8
|
files: number;
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Port one skill without deleting its previous installation. Each rename is a
|
|
12
|
+
* filesystem operation; the complete replacement is not atomic on Windows.
|
|
12
13
|
*/
|
|
13
14
|
export declare function portSkill(skill: CodexSkillSource, plugin: CodexPluginInfo, targetDir: string, overwrite: boolean): PortResult;
|
package/lib/port.js
CHANGED
|
@@ -1,59 +1,198 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Stage and validate skill copies before replacing an installation; retain backups for recovery.
|
|
4
3
|
* @module dsh-codex-port/port
|
|
5
4
|
*/
|
|
6
|
-
import
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
import { convertSkillFile, sanitizeSkillName } from './frontmatter.js';
|
|
9
|
-
/** codex 专属目录,移植时剔除。 */
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { convertSkillFile, parseSkillFile, sanitizeSkillName } from './frontmatter.js';
|
|
10
8
|
const EXCLUDED_DIRS = new Set(['agents']);
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
// Matches Harness skill.isSkillName; keep the public filename sanitizer unchanged.
|
|
10
|
+
const DSH_SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
11
|
+
function isWithin(root, path) {
|
|
12
|
+
const child = relative(root, path);
|
|
13
|
+
return child !== '' && child !== '..' && !child.startsWith('..' + sep) && !isAbsolute(child);
|
|
14
|
+
}
|
|
15
|
+
function errorText(error) {
|
|
16
|
+
return error instanceof Error ? error.message : String(error);
|
|
17
|
+
}
|
|
18
|
+
/** Anchor every move to the resolved target root, rejecting symlinks and changed parents. */
|
|
19
|
+
class InstallRoot {
|
|
20
|
+
path;
|
|
21
|
+
constructor(targetDir) {
|
|
22
|
+
if (targetDir.trim() === '')
|
|
23
|
+
throw new Error('目标技能目录不能为空');
|
|
24
|
+
const requested = resolve(targetDir);
|
|
25
|
+
if (dirname(requested) === requested)
|
|
26
|
+
throw new Error('不能把文件系统根目录作为技能目录');
|
|
27
|
+
const existing = fs.lstatSync(requested, { throwIfNoEntry: false });
|
|
28
|
+
if (existing?.isSymbolicLink())
|
|
29
|
+
throw new Error('目标技能根目录不能是符号链接或目录联接');
|
|
30
|
+
// Resolve absent target roots through their existing ancestor without creating
|
|
31
|
+
// anything yet: overlap with the source must fail before filesystem writes.
|
|
32
|
+
let ancestor = requested;
|
|
33
|
+
while (fs.lstatSync(ancestor, { throwIfNoEntry: false }) === undefined)
|
|
34
|
+
ancestor = dirname(ancestor);
|
|
35
|
+
this.path = resolve(fs.realpathSync(ancestor), relative(ancestor, requested));
|
|
36
|
+
}
|
|
37
|
+
check(path) {
|
|
38
|
+
const absolute = resolve(path);
|
|
39
|
+
if (!isWithin(this.path, absolute))
|
|
40
|
+
throw new Error('安装路径越出目标技能根目录:' + absolute);
|
|
41
|
+
const root = fs.lstatSync(this.path);
|
|
42
|
+
if (!root.isDirectory() || root.isSymbolicLink() || fs.realpathSync(this.path) !== this.path) {
|
|
43
|
+
throw new Error('目标技能根目录在安装期间发生变化');
|
|
44
|
+
}
|
|
45
|
+
const parent = fs.realpathSync(dirname(absolute));
|
|
46
|
+
if (parent !== this.path && !isWithin(this.path, parent))
|
|
47
|
+
throw new Error('安装路径的父目录越界:' + absolute);
|
|
48
|
+
if (parent !== dirname(absolute))
|
|
49
|
+
throw new Error('安装路径的父目录包含符号链接或目录联接:' + absolute);
|
|
50
|
+
const entry = fs.lstatSync(absolute, { throwIfNoEntry: false });
|
|
51
|
+
if (entry?.isSymbolicLink())
|
|
52
|
+
throw new Error('安装路径不能是符号链接或目录联接:' + absolute);
|
|
53
|
+
if (entry !== undefined && !isWithin(this.path, fs.realpathSync(absolute))) {
|
|
54
|
+
throw new Error('安装路径解析后越界:' + absolute);
|
|
55
|
+
}
|
|
56
|
+
return absolute;
|
|
57
|
+
}
|
|
58
|
+
move(source, target) {
|
|
59
|
+
const from = this.check(source);
|
|
60
|
+
const to = this.check(target);
|
|
61
|
+
if (!fs.lstatSync(from).isDirectory())
|
|
62
|
+
throw new Error('只能切换技能目录:' + from);
|
|
63
|
+
if (fs.lstatSync(to, { throwIfNoEntry: false }) !== undefined)
|
|
64
|
+
throw new Error('切换目标已存在,保留现场:' + to);
|
|
65
|
+
fs.renameSync(from, to);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Copy only regular files/directories; the destination is a new private staging tree. */
|
|
69
|
+
function copyTree(source, target, sourceRoot, root) {
|
|
70
|
+
if (source !== sourceRoot && !isWithin(sourceRoot, fs.realpathSync(source)))
|
|
71
|
+
throw new Error('技能源目录越界');
|
|
72
|
+
if (fs.lstatSync(source).isSymbolicLink())
|
|
73
|
+
throw new Error('技能源目录不能是符号链接');
|
|
74
|
+
fs.mkdirSync(root.check(target));
|
|
14
75
|
let count = 0;
|
|
15
|
-
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
16
|
-
|
|
76
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
77
|
+
const from = join(source, entry.name);
|
|
78
|
+
const to = root.check(join(target, entry.name));
|
|
79
|
+
const stat = fs.lstatSync(from);
|
|
80
|
+
if (stat.isSymbolicLink())
|
|
17
81
|
continue;
|
|
18
|
-
if (
|
|
82
|
+
if (stat.isDirectory() && EXCLUDED_DIRS.has(entry.name))
|
|
19
83
|
continue;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
count += copyTree(from, to);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
writeFileSync(to, readFileSync(from));
|
|
84
|
+
if (!isWithin(sourceRoot, fs.realpathSync(from)))
|
|
85
|
+
throw new Error('技能源文件解析后越界:' + from);
|
|
86
|
+
if (stat.isDirectory())
|
|
87
|
+
count += copyTree(from, to, sourceRoot, root);
|
|
88
|
+
else if (stat.isFile()) {
|
|
89
|
+
fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
|
|
27
90
|
count += 1;
|
|
28
91
|
}
|
|
92
|
+
else
|
|
93
|
+
throw new Error('技能目录含非普通文件:' + from);
|
|
29
94
|
}
|
|
30
95
|
return count;
|
|
31
96
|
}
|
|
32
97
|
/**
|
|
33
|
-
*
|
|
98
|
+
* Port one skill without deleting its previous installation. Each rename is a
|
|
99
|
+
* filesystem operation; the complete replacement is not atomic on Windows.
|
|
34
100
|
*/
|
|
35
101
|
export function portSkill(skill, plugin, targetDir, overwrite) {
|
|
36
102
|
const safeName = sanitizeSkillName(skill.skillName !== '' ? skill.skillName : skill.skillDirName);
|
|
37
|
-
if (safeName === null) {
|
|
103
|
+
if (safeName === null || safeName.endsWith('.') || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(safeName)) {
|
|
38
104
|
return { skill: skill.skillDirName, plugin: plugin.name, status: 'failed', reason: '技能名清洗后为空或不合法,已跳过(可能是纯中文或含危险字符的名称)', files: 0 };
|
|
39
105
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
106
|
+
let root;
|
|
107
|
+
let target = '';
|
|
108
|
+
let transaction = '';
|
|
109
|
+
let incoming = '';
|
|
110
|
+
let previous = '';
|
|
111
|
+
let backedUp = false;
|
|
112
|
+
let installed = false;
|
|
113
|
+
const record = (phase, error) => {
|
|
114
|
+
if (root === undefined || transaction === '')
|
|
115
|
+
return;
|
|
116
|
+
const file = root.check(join(transaction, 'recovery.json'));
|
|
117
|
+
fs.writeFileSync(file, JSON.stringify({ version: 1, skill: safeName, target, incoming, previous, phase, ...(error === undefined ? {} : { error }) }, null, 2) + '\n', 'utf8');
|
|
118
|
+
};
|
|
44
119
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
120
|
+
root = new InstallRoot(targetDir);
|
|
121
|
+
target = join(root.path, safeName);
|
|
122
|
+
if (fs.lstatSync(root.path, { throwIfNoEntry: false }) !== undefined)
|
|
123
|
+
root.check(target);
|
|
124
|
+
const existing = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
125
|
+
if (existing !== undefined && !overwrite) {
|
|
126
|
+
return { skill: safeName, plugin: plugin.name, status: 'skipped', reason: '目标技能已存在(overwrite=false),跳过以免覆盖', files: 0 };
|
|
127
|
+
}
|
|
128
|
+
if (existing !== undefined && !existing.isDirectory())
|
|
129
|
+
throw new Error('目标技能不是目录,拒绝覆盖:' + target);
|
|
130
|
+
const sourceEntry = fs.lstatSync(skill.sourceDir);
|
|
131
|
+
if (!sourceEntry.isDirectory() || sourceEntry.isSymbolicLink())
|
|
132
|
+
throw new Error('技能源必须是普通目录');
|
|
133
|
+
const source = fs.realpathSync(skill.sourceDir);
|
|
134
|
+
if (source === root.path || isWithin(source, root.path) || source === target || isWithin(target, source)) {
|
|
135
|
+
throw new Error('技能源与安装目标重叠,拒绝递归复制或移动源目录');
|
|
136
|
+
}
|
|
137
|
+
fs.mkdirSync(root.path, { recursive: true });
|
|
138
|
+
root.check(target);
|
|
139
|
+
transaction = fs.mkdtempSync(root.check(join(root.path, '.dsh-port-' + safeName + '-')));
|
|
140
|
+
incoming = root.check(join(transaction, 'incoming'));
|
|
141
|
+
previous = root.check(join(transaction, 'previous'));
|
|
142
|
+
record('preparing');
|
|
143
|
+
const files = copyTree(source, incoming, source, root);
|
|
144
|
+
const skillFile = root.check(join(incoming, 'SKILL.md'));
|
|
145
|
+
const converted = convertSkillFile(fs.readFileSync(skillFile, 'utf8'), {
|
|
146
|
+
pluginName: plugin.name, homepage: plugin.homepage, license: plugin.license,
|
|
147
|
+
}, safeName);
|
|
148
|
+
fs.writeFileSync(skillFile, converted, 'utf8');
|
|
149
|
+
// Validate the bytes that were actually written, before moving the old skill.
|
|
150
|
+
const parsed = parseSkillFile(fs.readFileSync(skillFile, 'utf8'));
|
|
151
|
+
if (!parsed.hasFrontmatter || typeof parsed.frontmatter.name !== 'string'
|
|
152
|
+
|| !DSH_SKILL_NAME.test(parsed.frontmatter.name) || parsed.frontmatter.name !== safeName
|
|
153
|
+
|| typeof parsed.frontmatter.description !== 'string' || parsed.frontmatter.description.length === 0) {
|
|
154
|
+
throw new Error('转换后的 SKILL.md 未通过 DSH 技能名称、目录一致性或非空描述校验');
|
|
155
|
+
}
|
|
156
|
+
record('prepared');
|
|
157
|
+
if (existing !== undefined) {
|
|
158
|
+
root.move(target, previous);
|
|
159
|
+
backedUp = true;
|
|
160
|
+
record('previous-backed-up');
|
|
161
|
+
}
|
|
162
|
+
root.move(incoming, target);
|
|
163
|
+
installed = true;
|
|
164
|
+
let reason = '';
|
|
165
|
+
try {
|
|
166
|
+
record('installed');
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
reason = '技能已安装,但恢复记录更新失败:' + errorText(error) + ';恢复目录:' + transaction;
|
|
170
|
+
}
|
|
171
|
+
return { skill: safeName, plugin: plugin.name, status: 'ported', reason, files: Math.max(files, 1) };
|
|
55
172
|
}
|
|
56
173
|
catch (error) {
|
|
57
|
-
|
|
174
|
+
let reason = errorText(error);
|
|
175
|
+
let phase = 'failed-before-switch';
|
|
176
|
+
if (backedUp && !installed) {
|
|
177
|
+
try {
|
|
178
|
+
root.move(previous, target);
|
|
179
|
+
phase = 'rolled-back';
|
|
180
|
+
reason += ';已恢复原技能';
|
|
181
|
+
}
|
|
182
|
+
catch (rollbackError) {
|
|
183
|
+
phase = 'rollback-failed';
|
|
184
|
+
reason += ';自动恢复失败:' + errorText(rollbackError) + ';原技能备份:' + previous;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (transaction !== '') {
|
|
188
|
+
try {
|
|
189
|
+
record(phase, reason);
|
|
190
|
+
}
|
|
191
|
+
catch (recordError) {
|
|
192
|
+
reason += ';恢复记录写入失败:' + errorText(recordError);
|
|
193
|
+
}
|
|
194
|
+
reason += ';恢复资料保留在:' + transaction;
|
|
195
|
+
}
|
|
196
|
+
return { skill: safeName, plugin: plugin.name, status: 'failed', reason, files: 0 };
|
|
58
197
|
}
|
|
59
198
|
}
|
package/package.json
CHANGED