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 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
- ### 1. Add the plugin to the Web profile
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
- ### 2. Enable it in the profile
62
+ The plugin registers itself with the Web profile through `dsh.bundle`; do not edit `cordis.patch.yml` manually.
63
63
 
64
- Add the following entry to `$DSH_HOME/profiles/web/cordis.patch.yml`:
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
- - id: skill-importer
68
- name: dsh-skill-importer
105
+ minimumReleaseAgeExclude:
106
+ - dsh-skill-importer@X.Y.Z
69
107
  ```
70
108
 
71
- ### 3. Restart dsh Web
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` via `npx @deepseek-ai/dsh web`.
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
- Add the profile entry shown above, then restart dsh Web.
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 per scan; each skill may contain up to 2,000 files and 10 MB of resources. Symbolic links are refused.
133
- - URL import preserves `.md` sources; HTML pages use lightweight text extraction, so direct Markdown URLs work best.
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
- ### 1. 安装到 Web profile
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
- ### 2. 在 profile 中启用
62
+ 插件通过 `dsh.bundle` 自动注册到 Web profile,无需手动修改 `cordis.patch.yml`。
63
63
 
64
- 向 `$DSH_HOME/profiles/web/cordis.patch.yml` 添加:
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
- - id: skill-importer
68
- name: dsh-skill-importer
105
+ minimumReleaseAgeExclude:
106
+ - dsh-skill-importer@X.Y.Z
69
107
  ```
70
108
 
71
- ### 3. 重启 dsh Web
109
+ 将两处 `X.Y.Z` 替换为同一个目标版本。这会绕过该版本的供应链等待期,不建议把包名永久加入例外列表。版本成熟后可以删除这条例外。
72
110
 
73
111
  打开 **设置 → 技能**,选择 Markdown 文件、粘贴 URL,或通过「批量导入」迁移其他 Agent 的完整技能目录,再选择目标范围。文件系统 watcher 发现后,技能会立即出现。
74
112
 
75
- > 需要 DeepSeek Harness `>= 0.1.0-rc.6`,使用 `npx @deepseek-ai/dsh web` 运行。
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
- 随后添加上文的 profile 配置并重启 dsh Web。
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
- - 批量导入每次最多扫描 200 个技能;单个技能最多包含 2,000 个文件和 10 MB 资源,不接受符号链接。
133
- - URL 导入会原样保留 `.md`;HTML 页面仅做轻量正文提取,因此推荐使用 Markdown 直链。
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
- if (!text.startsWith("---\n")) return {
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 = text.indexOf("\n---", 4);
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 = text.slice(4, end);
57
- const body = text.slice(end + 4).replace(/^\n/, "");
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
- if (!text.startsWith("---\n")) return {
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 = text.indexOf("\n---", 4);
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 = text.slice(4, end);
52
- const body = text.slice(end + 4).replace(/^\n/, "");
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(request.sourcePath);
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(request.sourcePath, source.path) || ".";
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: request.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
- const response = await fetch(url, {
490
- redirect: "follow",
491
- signal: AbortSignal.timeout(15e3)
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 (curl, same-origin GET) pass.
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 true;
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";
@@ -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
- * Fetch a URL's content for import. Markdown/plain responses pass through
86
- * verbatim; HTML is roughly extracted to text (the import UI recommends
87
- * `.md` sources).
88
- * @param url - the source URL.
89
- * @returns the text to write as the skill body.
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 (curl, same-origin GET) pass.
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.0",
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
+ }