dsh-skill-importer 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.md +53 -11
- package/README.zh.md +53 -11
- package/lib/client.js +5 -4
- package/lib/index.js +83 -15
- package/lib/types/server.d.ts +9 -8
- package/package.json +6 -2
- package/src/frontmatter.ts +112 -0
- package/src/server.ts +605 -0
- package/src/types.ts +123 -0
- package/tests/batch-import.test.mjs +104 -0
package/README.md
CHANGED
|
@@ -53,26 +53,68 @@ All three fill the composer with the same highlighted `/name ` gesture; sending
|
|
|
53
53
|
|
|
54
54
|
## Installation
|
|
55
55
|
|
|
56
|
-
###
|
|
56
|
+
### First install
|
|
57
57
|
|
|
58
58
|
```sh
|
|
59
|
-
dsh plugin --profile web add dsh-skill-importer
|
|
59
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-skill-importer@latest
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
The plugin registers itself with the Web profile through `dsh.bundle`; do not edit `cordis.patch.yml` manually.
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
Restart dsh Web after installation:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npx @deepseek-ai/dsh web
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Update to the latest version
|
|
71
|
+
|
|
72
|
+
Existing users can run the same command to update:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-skill-importer@latest
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Updating replaces only the plugin package. It does not remove project skills in `.agents/skills` or global skills in `~/.dsh/skills`. Restart dsh Web after updating.
|
|
79
|
+
|
|
80
|
+
Check the latest published version:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
npm view dsh-skill-importer version
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
List the plugins installed in the Web profile:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
npx @deepseek-ai/dsh plugin --profile web list
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### Supply-chain waiting period for new releases
|
|
93
|
+
|
|
94
|
+
DSH profiles use pnpm to manage plugins. pnpm's `minimumReleaseAge` policy delays newly published versions; during that window, `@latest` may still resolve to the previous mature release. This is an intentional supply-chain safeguard, not a download failure. The recommended path is to wait for the profile's configured window, then rerun the `@latest` command above.
|
|
95
|
+
|
|
96
|
+
Pre-1.0 semver ranges also stop at the next minor: for example, `^0.1.2` does not include `0.2.0`. After the waiting period, specify the target version when crossing a minor:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-skill-importer@X.Y.Z
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
If you have verified the release and must install it immediately, add a temporary exception for that **exact version** to `$DSH_HOME/profiles/web/pnpm-workspace.yaml`, then run the exact-version command:
|
|
65
103
|
|
|
66
104
|
```yaml
|
|
67
|
-
|
|
68
|
-
|
|
105
|
+
minimumReleaseAgeExclude:
|
|
106
|
+
- dsh-skill-importer@X.Y.Z
|
|
69
107
|
```
|
|
70
108
|
|
|
71
|
-
|
|
109
|
+
Replace both `X.Y.Z` placeholders with the same target version. This opts that version out of the supply-chain waiting period. Do not permanently exempt the package name; remove the exact-version exception after the release matures.
|
|
72
110
|
|
|
73
111
|
Open **Settings → Skills**. Import a Markdown file or URL, or choose **Batch import** to migrate another agent's complete skills directory. Choose a target and the imported skills will appear as soon as the filesystem watcher discovers them.
|
|
74
112
|
|
|
75
|
-
> Requires DeepSeek Harness `>= 0.1.0-rc.6
|
|
113
|
+
> Requires DeepSeek Harness `>= 0.1.0-rc.6`.
|
|
114
|
+
|
|
115
|
+
### Duplicate plugin after upgrading
|
|
116
|
+
|
|
117
|
+
If startup fails with `duplicate loader entry id: skill-importer`, the profile still contains the old manual configuration. Remove the manually added `skill-importer` entry from `$DSH_HOME/profiles/web/cordis.patch.yml`, then restart dsh Web. Current releases register automatically and do not need that entry.
|
|
76
118
|
|
|
77
119
|
## How it works
|
|
78
120
|
|
|
@@ -124,13 +166,13 @@ npm run build
|
|
|
124
166
|
dsh plugin --profile web add /path/to/dsh-skill-importer
|
|
125
167
|
```
|
|
126
168
|
|
|
127
|
-
|
|
169
|
+
If `dsh` is not installed globally, replace the last line with `npx @deepseek-ai/dsh plugin --profile web add /path/to/dsh-skill-importer`. The plugin registers automatically; restart dsh Web afterward.
|
|
128
170
|
|
|
129
171
|
## Notes
|
|
130
172
|
|
|
131
173
|
- A single imported file may be up to 256 KB.
|
|
132
|
-
- Batch import accepts up to 200 skills
|
|
133
|
-
- URL import
|
|
174
|
+
- Batch import accepts `.claude/skills`, `.codex/skills`, `.agents/skills`, `.dsh/skills`, or one skill directory directly below those roots. A scan accepts up to 200 skills; each skill may contain up to 2,000 files and 10 MB of resources. Symbolic links are refused.
|
|
175
|
+
- URL import is HTTPS-only and refuses loopback, private, link-local, and reserved addresses; every redirect is validated again. `.md` sources are preserved, while HTML pages use lightweight text extraction, so direct Markdown URLs work best.
|
|
134
176
|
- The installed list polls briefly after import (every 2 seconds for up to 20 seconds). **Refresh** syncs external changes immediately.
|
|
135
177
|
|
|
136
178
|
## License
|
package/README.zh.md
CHANGED
|
@@ -53,26 +53,68 @@
|
|
|
53
53
|
|
|
54
54
|
## 安装
|
|
55
55
|
|
|
56
|
-
###
|
|
56
|
+
### 首次安装
|
|
57
57
|
|
|
58
58
|
```sh
|
|
59
|
-
dsh plugin --profile web add dsh-skill-importer
|
|
59
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-skill-importer@latest
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
插件通过 `dsh.bundle` 自动注册到 Web profile,无需手动修改 `cordis.patch.yml`。
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
安装完成后重启 dsh Web:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npx @deepseek-ai/dsh web
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 更新到最新版
|
|
71
|
+
|
|
72
|
+
已安装用户执行同一条命令即可更新:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-skill-importer@latest
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
更新只会替换插件包,不会删除 `.agents/skills` 中的项目技能或 `~/.dsh/skills` 中的全局技能。更新后请重启 dsh Web。
|
|
79
|
+
|
|
80
|
+
查看 npm 上的最新版本:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
npm view dsh-skill-importer version
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
查看 Web profile 中已安装的插件:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
npx @deepseek-ai/dsh plugin --profile web list
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### 新版本的安全等待期
|
|
93
|
+
|
|
94
|
+
DSH profile 使用 pnpm 管理插件。pnpm 会按 `minimumReleaseAge` 暂缓安装刚发布的版本;在等待期内,`@latest` 可能仍解析到上一个已成熟版本。这是供应链保护机制,不是下载失败。推荐等待 profile 配置的时间窗口结束后,再执行上面的 `@latest` 命令。
|
|
95
|
+
|
|
96
|
+
`0.x` 版本还遵循特殊的 semver 范围:例如 `^0.1.2` 不包含 `0.2.0`。跨 minor 更新时可在等待期结束后明确指定目标版本:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-skill-importer@X.Y.Z
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
如果已经核验该版本并且必须立即安装,可在 `$DSH_HOME/profiles/web/pnpm-workspace.yaml` 中为这个**精确版本**添加临时信任例外,再执行精确版本命令:
|
|
65
103
|
|
|
66
104
|
```yaml
|
|
67
|
-
|
|
68
|
-
|
|
105
|
+
minimumReleaseAgeExclude:
|
|
106
|
+
- dsh-skill-importer@X.Y.Z
|
|
69
107
|
```
|
|
70
108
|
|
|
71
|
-
|
|
109
|
+
将两处 `X.Y.Z` 替换为同一个目标版本。这会绕过该版本的供应链等待期,不建议把包名永久加入例外列表。版本成熟后可以删除这条例外。
|
|
72
110
|
|
|
73
111
|
打开 **设置 → 技能**,选择 Markdown 文件、粘贴 URL,或通过「批量导入」迁移其他 Agent 的完整技能目录,再选择目标范围。文件系统 watcher 发现后,技能会立即出现。
|
|
74
112
|
|
|
75
|
-
> 需要 DeepSeek Harness `>= 0.1.0-rc.6
|
|
113
|
+
> 需要 DeepSeek Harness `>= 0.1.0-rc.6`。
|
|
114
|
+
|
|
115
|
+
### 旧配置导致重复插件
|
|
116
|
+
|
|
117
|
+
如果启动时报错 `duplicate loader entry id: skill-importer`,说明 profile 中还保留了旧版手动配置。请从 `$DSH_HOME/profiles/web/cordis.patch.yml` 删除手动添加的 `skill-importer` 条目,再重启 dsh Web。当前版本会自动注册,不需要保留该条目。
|
|
76
118
|
|
|
77
119
|
## 工作原理
|
|
78
120
|
|
|
@@ -124,13 +166,13 @@ npm run build
|
|
|
124
166
|
dsh plugin --profile web add /path/to/dsh-skill-importer
|
|
125
167
|
```
|
|
126
168
|
|
|
127
|
-
|
|
169
|
+
如果没有全局 `dsh` 命令,可将最后一行替换为 `npx @deepseek-ai/dsh plugin --profile web add /path/to/dsh-skill-importer`。插件会自动注册,随后重启 dsh Web 即可。
|
|
128
170
|
|
|
129
171
|
## 注意事项
|
|
130
172
|
|
|
131
173
|
- 单个导入文件最大 256 KB。
|
|
132
|
-
-
|
|
133
|
-
- URL
|
|
174
|
+
- 批量导入仅接受 `.claude/skills`、`.codex/skills`、`.agents/skills`、`.dsh/skills` 或其中的单个技能目录;每次最多扫描 200 个技能,单个技能最多包含 2,000 个文件和 10 MB 资源,不接受符号链接。
|
|
175
|
+
- URL 导入仅支持 HTTPS,并拒绝本机、私有网络、链路本地和保留地址;每次重定向都会重新校验。`.md` 会原样保留,HTML 页面仅做轻量正文提取,因此推荐使用 Markdown 直链。
|
|
134
176
|
- 导入后列表会短暂轮询(每 2 秒一次,最多 20 秒);外部改动可点击 **刷新** 立即同步。
|
|
135
177
|
|
|
136
178
|
## License
|
package/lib/client.js
CHANGED
|
@@ -44,17 +44,18 @@ window.__ModuleLoader__.load({
|
|
|
44
44
|
*/
|
|
45
45
|
function parseSkillFile(text) {
|
|
46
46
|
const frontmatter = {};
|
|
47
|
-
|
|
47
|
+
const normalized = text.replace(/\r\n?/g, "\n");
|
|
48
|
+
if (!normalized.startsWith("---\n")) return {
|
|
48
49
|
frontmatter,
|
|
49
50
|
body: text
|
|
50
51
|
};
|
|
51
|
-
const end =
|
|
52
|
+
const end = normalized.indexOf("\n---", 4);
|
|
52
53
|
if (end < 0) return {
|
|
53
54
|
frontmatter,
|
|
54
55
|
body: text
|
|
55
56
|
};
|
|
56
|
-
const block =
|
|
57
|
-
const body =
|
|
57
|
+
const block = normalized.slice(4, end);
|
|
58
|
+
const body = normalized.slice(end + 4).replace(/^\n/, "");
|
|
58
59
|
for (const line of block.split("\n")) {
|
|
59
60
|
const colon = line.indexOf(":");
|
|
60
61
|
if (colon <= 0) continue;
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { lookup } from "node:dns/promises";
|
|
4
|
+
import { isIP } from "node:net";
|
|
3
5
|
import { homedir } from "node:os";
|
|
4
|
-
import { basename, extname, isAbsolute, join, relative } from "node:path";
|
|
6
|
+
import { basename, dirname, extname, isAbsolute, join, relative } from "node:path";
|
|
5
7
|
//#region src/frontmatter.ts
|
|
6
8
|
/** Kebab-case name rule shared with `dsh-skill` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). */
|
|
7
9
|
const KEBAB_CASE$1 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -39,17 +41,18 @@ function parseScalar(raw) {
|
|
|
39
41
|
*/
|
|
40
42
|
function parseSkillFile(text) {
|
|
41
43
|
const frontmatter = {};
|
|
42
|
-
|
|
44
|
+
const normalized = text.replace(/\r\n?/g, "\n");
|
|
45
|
+
if (!normalized.startsWith("---\n")) return {
|
|
43
46
|
frontmatter,
|
|
44
47
|
body: text
|
|
45
48
|
};
|
|
46
|
-
const end =
|
|
49
|
+
const end = normalized.indexOf("\n---", 4);
|
|
47
50
|
if (end < 0) return {
|
|
48
51
|
frontmatter,
|
|
49
52
|
body: text
|
|
50
53
|
};
|
|
51
|
-
const block =
|
|
52
|
-
const body =
|
|
54
|
+
const block = normalized.slice(4, end);
|
|
55
|
+
const body = normalized.slice(end + 4).replace(/^\n/, "");
|
|
53
56
|
for (const line of block.split("\n")) {
|
|
54
57
|
const colon = line.indexOf(":");
|
|
55
58
|
if (colon <= 0) continue;
|
|
@@ -96,6 +99,13 @@ const MAX_BATCH_FILES_PER_SKILL = 2e3;
|
|
|
96
99
|
const MAX_BATCH_SKILL_BYTES = 10485760;
|
|
97
100
|
const BATCH_SCAN_TTL_MS = 6e5;
|
|
98
101
|
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
102
|
+
const AGENT_SKILL_PARENTS = /* @__PURE__ */ new Set([
|
|
103
|
+
".agents",
|
|
104
|
+
".claude",
|
|
105
|
+
".codex",
|
|
106
|
+
".dsh"
|
|
107
|
+
]);
|
|
108
|
+
const MAX_URL_REDIRECTS = 5;
|
|
99
109
|
/** The harness home (`$DSH_HOME`, defaulting to `~/.dsh`). */
|
|
100
110
|
function dshHomeDir() {
|
|
101
111
|
return process.env.DSH_HOME ?? join(homedir(), ".dsh");
|
|
@@ -300,8 +310,15 @@ function batchSources(root) {
|
|
|
300
310
|
function scanBatch(request) {
|
|
301
311
|
if (!isAbsolute(request.sourcePath)) throw new Error("批量导入目录必须是绝对路径");
|
|
302
312
|
if (!existsSync(request.sourcePath) || !statSync(request.sourcePath).isDirectory()) throw new Error("批量导入目录不存在或不可读");
|
|
313
|
+
const sourcePath = realpathSync(request.sourcePath);
|
|
314
|
+
const leaf = basename(sourcePath);
|
|
315
|
+
const parent = basename(dirname(sourcePath));
|
|
316
|
+
const grandparent = basename(dirname(dirname(sourcePath)));
|
|
317
|
+
const isSkillsRoot = leaf === "skills" && AGENT_SKILL_PARENTS.has(parent);
|
|
318
|
+
const isSkillDirectory = parent === "skills" && AGENT_SKILL_PARENTS.has(grandparent);
|
|
319
|
+
if (!isSkillsRoot && !isSkillDirectory) throw new Error("批量导入仅支持 .claude、.codex、.agents 或 .dsh 下的 skills 目录");
|
|
303
320
|
if (request.target !== "user" && request.workspacePath === void 0) throw new Error("项目目标需要 workspacePath(当前工作区路径)");
|
|
304
|
-
const sources = batchSources(
|
|
321
|
+
const sources = batchSources(sourcePath);
|
|
305
322
|
if (sources.length === 0) throw new Error("所选目录中没有找到可导入的技能");
|
|
306
323
|
if (sources.length > 200) throw new Error(`一次最多扫描 200 个技能`);
|
|
307
324
|
const entries = [];
|
|
@@ -309,7 +326,7 @@ function scanBatch(request) {
|
|
|
309
326
|
const nameRows = /* @__PURE__ */ new Map();
|
|
310
327
|
for (const [index, source] of sources.entries()) {
|
|
311
328
|
const id = String(index + 1);
|
|
312
|
-
const relativePath = relative(
|
|
329
|
+
const relativePath = relative(sourcePath, source.path) || ".";
|
|
313
330
|
try {
|
|
314
331
|
const skillFile = source.kind === "directory" ? join(source.path, "SKILL.md") : source.path;
|
|
315
332
|
const text = readFileSync(skillFile, "utf8");
|
|
@@ -367,7 +384,7 @@ function scanBatch(request) {
|
|
|
367
384
|
}
|
|
368
385
|
return {
|
|
369
386
|
scanId: randomUUID(),
|
|
370
|
-
sourcePath
|
|
387
|
+
sourcePath,
|
|
371
388
|
target: request.target,
|
|
372
389
|
...request.workspacePath === void 0 ? {} : { workspacePath: request.workspacePath },
|
|
373
390
|
expiresAt: Date.now() + BATCH_SCAN_TTL_MS,
|
|
@@ -485,11 +502,61 @@ function extractHtmlText(html) {
|
|
|
485
502
|
* @param url - the source URL.
|
|
486
503
|
* @returns the text to write as the skill body.
|
|
487
504
|
*/
|
|
505
|
+
function privateIpv4(address) {
|
|
506
|
+
const octets = address.split(".").map(Number);
|
|
507
|
+
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return true;
|
|
508
|
+
const [a = 0, b = 0, c = 0] = octets;
|
|
509
|
+
return a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && (b === 0 || b === 168 || b === 0 && c === 2) || a === 198 && (b === 18 || b === 19 || b === 51 && c === 100) || a === 203 && b === 0 && c === 113;
|
|
510
|
+
}
|
|
511
|
+
/** Whether an address is unsafe for a host-side URL import. */
|
|
512
|
+
function isPrivateAddress(address) {
|
|
513
|
+
const normalized = address.toLowerCase().split("%")[0] ?? "";
|
|
514
|
+
if (isIP(normalized) === 4) return privateIpv4(normalized);
|
|
515
|
+
if (isIP(normalized) !== 6) return true;
|
|
516
|
+
if (normalized.startsWith("::ffff:")) return privateIpv4(normalized.slice(7));
|
|
517
|
+
return normalized === "::" || normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff") || normalized.startsWith("2001:db8:");
|
|
518
|
+
}
|
|
519
|
+
const resolveHost = async (hostname) => lookup(hostname, {
|
|
520
|
+
all: true,
|
|
521
|
+
verbatim: true
|
|
522
|
+
});
|
|
523
|
+
/** Parse and resolve one URL before the host is allowed to request it. */
|
|
524
|
+
async function assertSafeImportUrl(input, resolver = resolveHost) {
|
|
525
|
+
let url;
|
|
526
|
+
try {
|
|
527
|
+
url = new URL(input);
|
|
528
|
+
} catch {
|
|
529
|
+
throw new Error("URL 格式无效");
|
|
530
|
+
}
|
|
531
|
+
if (url.protocol !== "https:") throw new Error("URL 导入仅支持 HTTPS");
|
|
532
|
+
if (url.username.length > 0 || url.password.length > 0) throw new Error("URL 不能包含登录凭据");
|
|
533
|
+
const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
534
|
+
if (hostname === "localhost" || hostname.endsWith(".localhost")) throw new Error("URL 不能指向本机或私有网络");
|
|
535
|
+
const addresses = isIP(hostname) === 0 ? await resolver(hostname) : [{ address: hostname }];
|
|
536
|
+
if (addresses.length === 0 || addresses.some(({ address }) => isPrivateAddress(address))) throw new Error("URL 不能指向本机、私有网络或保留地址");
|
|
537
|
+
return url;
|
|
538
|
+
}
|
|
488
539
|
async function fetchUrlContent(url) {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
540
|
+
let current = await assertSafeImportUrl(url);
|
|
541
|
+
let response;
|
|
542
|
+
for (let redirects = 0; redirects <= MAX_URL_REDIRECTS; redirects += 1) {
|
|
543
|
+
response = await fetch(current, {
|
|
544
|
+
redirect: "manual",
|
|
545
|
+
signal: AbortSignal.timeout(15e3)
|
|
546
|
+
});
|
|
547
|
+
if (![
|
|
548
|
+
301,
|
|
549
|
+
302,
|
|
550
|
+
303,
|
|
551
|
+
307,
|
|
552
|
+
308
|
|
553
|
+
].includes(response.status)) break;
|
|
554
|
+
if (redirects === MAX_URL_REDIRECTS) throw new Error(`重定向次数超过 ${MAX_URL_REDIRECTS}`);
|
|
555
|
+
const location = response.headers.get("location");
|
|
556
|
+
if (location === null) throw new Error("重定向响应缺少 Location");
|
|
557
|
+
current = await assertSafeImportUrl(new URL(location, current).href);
|
|
558
|
+
}
|
|
559
|
+
if (response === void 0) throw new Error("抓取失败");
|
|
493
560
|
if (!response.ok) throw new Error(`抓取失败:HTTP ${response.status}`);
|
|
494
561
|
const type = response.headers.get("content-type") ?? "";
|
|
495
562
|
const text = await response.text();
|
|
@@ -533,11 +600,12 @@ function readJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
|
533
600
|
* Origin fence: the routes are served on the harness's loopback-only web
|
|
534
601
|
* server, so only the browser page itself (or a local curl) reaches them.
|
|
535
602
|
* A cross-origin page (any other website) is refused. Requests without an
|
|
536
|
-
* Origin header
|
|
603
|
+
* Origin header is rejected: all state-changing browser requests include it,
|
|
604
|
+
* while accepting an absent header would let non-browser clients bypass the fence.
|
|
537
605
|
*/
|
|
538
606
|
function originAllowed(req) {
|
|
539
607
|
const origin = req.headers.origin;
|
|
540
|
-
if (origin === void 0) return
|
|
608
|
+
if (origin === void 0) return false;
|
|
541
609
|
try {
|
|
542
610
|
const hostname = new URL(origin).hostname;
|
|
543
611
|
return hostname === "127.0.0.1" || hostname === "localhost";
|
package/lib/types/server.d.ts
CHANGED
|
@@ -81,13 +81,13 @@ export interface BatchScanSession {
|
|
|
81
81
|
export declare function scanBatch(request: BatchScanRequest): BatchScanSession;
|
|
82
82
|
/** Commit a valid, unexpired scan once; invalid rows are returned as errors and never written. */
|
|
83
83
|
export declare function commitBatch(session: BatchScanSession, replaceNames: ReadonlySet<string>): BatchCommitEntry[];
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
84
|
+
/** Whether an address is unsafe for a host-side URL import. */
|
|
85
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
86
|
+
type ResolveHost = (hostname: string) => Promise<readonly {
|
|
87
|
+
readonly address: string;
|
|
88
|
+
}[]>;
|
|
89
|
+
/** Parse and resolve one URL before the host is allowed to request it. */
|
|
90
|
+
export declare function assertSafeImportUrl(input: string, resolver?: ResolveHost): Promise<URL>;
|
|
91
91
|
export declare function fetchUrlContent(url: string): Promise<string>;
|
|
92
92
|
/**
|
|
93
93
|
* Resolve one import request into a written file path. Shared by the file
|
|
@@ -102,7 +102,8 @@ export declare function readJsonBody(req: IncomingMessage, limit?: number): Prom
|
|
|
102
102
|
* Origin fence: the routes are served on the harness's loopback-only web
|
|
103
103
|
* server, so only the browser page itself (or a local curl) reaches them.
|
|
104
104
|
* A cross-origin page (any other website) is refused. Requests without an
|
|
105
|
-
* Origin header
|
|
105
|
+
* Origin header is rejected: all state-changing browser requests include it,
|
|
106
|
+
* while accepting an absent header would let non-browser clients bypass the fence.
|
|
106
107
|
*/
|
|
107
108
|
export declare function originAllowed(req: IncomingMessage): boolean;
|
|
108
109
|
/** Send one JSON response. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-importer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Web UI plugin for DeepSeek Harness: import skills from local Markdown files or URLs into the skill roots the harness discovers automatically",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -36,7 +36,11 @@
|
|
|
36
36
|
"lib/index.js",
|
|
37
37
|
"lib/client.js",
|
|
38
38
|
"lib/types/**/*.d.ts",
|
|
39
|
-
"cordis.patch.yml"
|
|
39
|
+
"cordis.patch.yml",
|
|
40
|
+
"src/frontmatter.ts",
|
|
41
|
+
"src/server.ts",
|
|
42
|
+
"src/types.ts",
|
|
43
|
+
"tests/**/*.mjs"
|
|
40
44
|
],
|
|
41
45
|
"scripts": {
|
|
42
46
|
"build": "rm -rf lib && tsc -p tsconfig.json && tsdown",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Markdown frontmatter parsing and validation (pure, browser-safe).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the subset of `dsh-skill-filesystem`'s frontmatter contract the
|
|
5
|
+
* import UI needs for preview and pre-flight validation. The authoritative
|
|
6
|
+
* parse remains the host provider's; this parser only previews and catches
|
|
7
|
+
* obvious mistakes before the import instruction is sent.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Frontmatter fields the import UI understands. */
|
|
11
|
+
export interface SkillFrontmatter {
|
|
12
|
+
/** Kebab-case skill name (required for a valid skill). */
|
|
13
|
+
name?: string
|
|
14
|
+
/** One-line routing description (required for a valid skill). */
|
|
15
|
+
description?: string
|
|
16
|
+
/** Optional routing guidance. */
|
|
17
|
+
whenToUse?: string
|
|
18
|
+
/** `disable-model-invocation: true` keeps the skill out of model catalogs. */
|
|
19
|
+
disableModelInvocation?: boolean
|
|
20
|
+
/** `user-invocable: false` keeps the skill out of the `/` menu. */
|
|
21
|
+
userInvocable?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parsed Markdown file: frontmatter plus the body after it. */
|
|
25
|
+
export interface ParsedSkillFile {
|
|
26
|
+
/** Parsed frontmatter values (empty when the file has no frontmatter block). */
|
|
27
|
+
readonly frontmatter: SkillFrontmatter
|
|
28
|
+
/** Markdown body after the closing `---` (empty when absent). */
|
|
29
|
+
readonly body: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Kebab-case name rule shared with `dsh-skill` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). */
|
|
33
|
+
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
34
|
+
|
|
35
|
+
/** True when the name satisfies the harness skill-name rule. */
|
|
36
|
+
export function isValidSkillName(name: string): boolean {
|
|
37
|
+
return KEBAB_CASE.test(name)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BOOLEAN_TRUE = new Set(['true', 'yes', 'on', '1'])
|
|
41
|
+
const BOOLEAN_FALSE = new Set(['false', 'no', 'off', '0'])
|
|
42
|
+
|
|
43
|
+
/** Parse one frontmatter scalar: quoted strings, booleans, or plain text. */
|
|
44
|
+
function parseScalar(raw: string): string | boolean | undefined {
|
|
45
|
+
const value = raw.trim()
|
|
46
|
+
if (value.length === 0) return undefined
|
|
47
|
+
const lower = value.toLowerCase()
|
|
48
|
+
if (BOOLEAN_TRUE.has(lower)) return true
|
|
49
|
+
if (BOOLEAN_FALSE.has(lower)) return false
|
|
50
|
+
if (
|
|
51
|
+
(value.startsWith('"') && value.endsWith('"') && value.length >= 2)
|
|
52
|
+
|| (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
|
|
53
|
+
) {
|
|
54
|
+
return value.slice(1, -1)
|
|
55
|
+
}
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parse a skill Markdown file's frontmatter block.
|
|
61
|
+
* @param text - full file text.
|
|
62
|
+
* @returns parsed frontmatter and body; a file without a leading `---` block
|
|
63
|
+
* yields an empty frontmatter with the whole text as body.
|
|
64
|
+
*/
|
|
65
|
+
export function parseSkillFile(text: string): ParsedSkillFile {
|
|
66
|
+
const frontmatter: SkillFrontmatter = {}
|
|
67
|
+
// Normalize platform line endings before locating and parsing the YAML
|
|
68
|
+
// fence. This accepts Windows CRLF and legacy CR files while keeping the
|
|
69
|
+
// canonical output produced by normalizeSkillText consistently LF-based.
|
|
70
|
+
const normalized = text.replace(/\r\n?/g, '\n')
|
|
71
|
+
if (!normalized.startsWith('---\n')) return { frontmatter, body: text }
|
|
72
|
+
const end = normalized.indexOf('\n---', 4)
|
|
73
|
+
if (end < 0) return { frontmatter, body: text }
|
|
74
|
+
const block = normalized.slice(4, end)
|
|
75
|
+
const body = normalized.slice(end + 4).replace(/^\n/, '')
|
|
76
|
+
for (const line of block.split('\n')) {
|
|
77
|
+
const colon = line.indexOf(':')
|
|
78
|
+
if (colon <= 0) continue
|
|
79
|
+
const key = line.slice(0, colon).trim()
|
|
80
|
+
const value = parseScalar(line.slice(colon + 1))
|
|
81
|
+
if (value === undefined) continue
|
|
82
|
+
switch (key) {
|
|
83
|
+
case 'name':
|
|
84
|
+
if (typeof value === 'string') frontmatter.name = value
|
|
85
|
+
break
|
|
86
|
+
case 'description':
|
|
87
|
+
if (typeof value === 'string') frontmatter.description = value
|
|
88
|
+
break
|
|
89
|
+
case 'whenToUse':
|
|
90
|
+
if (typeof value === 'string') frontmatter.whenToUse = value
|
|
91
|
+
break
|
|
92
|
+
case 'disable-model-invocation':
|
|
93
|
+
if (typeof value === 'boolean') frontmatter.disableModelInvocation = value
|
|
94
|
+
break
|
|
95
|
+
case 'user-invocable':
|
|
96
|
+
if (typeof value === 'boolean') frontmatter.userInvocable = value
|
|
97
|
+
break
|
|
98
|
+
default:
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { frontmatter, body }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Why a parsed file cannot be imported as a skill (or undefined when it can). */
|
|
106
|
+
export function validateSkillFile(text: string): string | undefined {
|
|
107
|
+
const { frontmatter } = parseSkillFile(text)
|
|
108
|
+
if (frontmatter.name === undefined || frontmatter.name.length === 0) return 'frontmatter 缺少 name 字段'
|
|
109
|
+
if (!isValidSkillName(frontmatter.name)) return 'name 必须是 kebab-case(小写字母、数字、短横线)'
|
|
110
|
+
if (frontmatter.description === undefined || frontmatter.description.length === 0) return 'frontmatter 缺少 description 字段'
|
|
111
|
+
return undefined
|
|
112
|
+
}
|