dsh-skill-picker 0.5.10 → 0.5.11
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 +1 -0
- package/lib/index.js +19 -3
- package/lib/index.js.map +2 -2
- package/package.json +1 -1
- package/src/index.js +51 -3
- package/src/patch-ui-skill.js +251 -251
package/README.md
CHANGED
|
@@ -124,6 +124,7 @@ DSH 的 [dsh-tool-skill](https://github.com/deepseek-ai/deepseek-harness) 在 `a
|
|
|
124
124
|
|
|
125
125
|
## 更新日志
|
|
126
126
|
|
|
127
|
+
- **v0.5.11**:**修复「补丁已就位也每次启动都打印 `ui-skill patch report`」(对应 issue #8)**——`healUiSkillPatches()` 返回的 `report.files` 是**逐目标文件的报告数组**:只要扫描到 ≥1 个目标文件就有一项,与这一轮**是否真的改动过无关**;而打印守卫用的正是 `report.files.length > 0`,等于把「找到目标」当成了「发生了变更」,于是**补丁早已 up-to-date 也每次启动都打印一行** `[dsh-skill-picker] ui-skill patch report: {…}`。这行虽然只是 `console.log`,但形态上落在启动日志第一行、长得像告警,很容易被误判成插件出问题(#8 就是这么来的),还会淹没真正需要关注的 `noop` / `errors`。现在改为按「这一轮到底发生了什么」判定:① **已是最新且无错 → 完全安静**;② 仅在**确实改过文件**(`patched` 非空)时打印报告,且只报这一轮真正动过的文件 + 全部错误;③ **锚点未命中(`noop` 非空)单独 `console.warn`**——它的语义是「官方实现又换了形态、增强**没打上**」,与 issue #7 的静默失效同类,不能再被淹没;④ 新增 `DSH_SKILL_PICKER_LOG=debug` 显式开关,需要完整报告(含 `skipped`)时按需打开。新增 7 个 `npm test` 回归用例:已就位时静默、无目标不重复报、真改动打一行、`noop` 转 warn、仅错误转 warn、改动+错误合并一行、debug 开关
|
|
127
128
|
- **v0.5.10**:**修复全局安装下 `/` 补全增强静默失效(对应 issue #7)**——`uiSkillClientPaths()` 原先只枚举两个位置:`profiles/<profile>/local/dsh-client-ui-skill` 与 `profiles/<profile>/node_modules/@deepseek-ai/dsh-client-ui-skill`。但用**全局 `npm i -g @deepseek-ai/dsh`** 安装时,官方包位于**共享根** `profiles/node_modules/@deepseek-ai/dsh-client-ui-skill`(`readdir(profiles)` 只会给出 `node_modules` 和 `web` 两个条目,两个候选**全部落空**),于是 `found = []`、**两个补丁一次都没跑**——而且**完全无声**:`{"files":[],"errors":[]}` 与「补丁都已应用、全部 skipped」在输出上一模一样,用户和排查者都看不出补丁根本没生效,表现成「插件一切正常、技能列表能用,**就是拼音/模糊搜索是坏的**」。修复四件事:① 候选新增**共享根**(不属于任何单个 profile,放在循环外采集);② 每个 profile 额外走一次 Node 自身解析 `createRequire().resolve()` 兜底,未枚举到的布局也能命中(按 realpath 去重,不会重复打补丁);③ 跳过 `profiles/node_modules` 这个假 profile 条目;④ **`found.length === 0` 时 `console.warn` 大声报出**——这个静默正是 issue #7 里最坑人的地方。另修写入方式:由原地 `writeFile` 改为**临时文件 + `rename`**——pnpm 安装的包是**硬链接**到共享内容寻址 store 的,原地写会连带改动 store 里的同一份(影响其他使用同版本的项目),`rename` 只替换目录项、不动共享 inode,顺带获得写入原子性(中断的启动不会留下半截文件)。新增 6 个 `npm test` 回归用例:共享根、profile local、profile node_modules、共享根+profile 去重、无任何安装、profiles 目录缺失
|
|
128
129
|
- **v0.5.9**:**修复符号链接 / Junction 型技能查不到(对应 issue #6)**——扫描技能目录时 `readdir` 的 `Dirent` 走的是 lstat 语义:Windows 下符号链接**和 Junction** 都报告 `isDirectory() === false` / `isSymbolicLink() === true`,于是链接型技能(如 `~/.agents/skills/neat` → `D:\repos\icraft-toolkit\skills\neat`)在第 79 行的目录过滤里被静默 `continue` 掉。现在链接条目改用 `stat`(跟随链接)判定真实类型:链接型技能与普通目录**完全一视同仁**,断链或指向普通文件的链接安全跳过(不再抛错、也不占用列表)。四个扫描根(`~/.agents/skills`、`~/.dsh/skills`、项目级 `.agents/skills` / `.dsh/skills`)与 profile 枚举路径全部受益;新增 `npm test`(`node --test`)回归用例:普通目录、链接目录、链接+普通混排、断链、无 `SKILL.md` 的链接、项目级链接技能
|
|
129
130
|
- **v0.5.7**:**搜索结果按匹配相关度排序**——⚡ 面板与 `/` 补全统一:名字开头匹配 > 名字包含 > 描述 > 拼音,置顶/最近使用只做同级次序;同时过滤掉纯粹"字母分散"的子序列噪音(如搜 `svg` 不再混入 deepseek/openviking 等恰好含 s-v-g 分散字母的技能)。`svg` → svg-diagram 稳居第一
|
package/lib/index.js
CHANGED
|
@@ -222,6 +222,23 @@ async function scanSkills(cwd) {
|
|
|
222
222
|
}
|
|
223
223
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
224
224
|
}
|
|
225
|
+
function reportUiSkillPatches(report, io = console) {
|
|
226
|
+
const files = report?.files ?? [];
|
|
227
|
+
const errors = report?.errors ?? [];
|
|
228
|
+
const stale = files.filter((file) => file.noop.length > 0);
|
|
229
|
+
if (stale.length > 0) {
|
|
230
|
+
io.warn("[dsh-skill-picker] ui-skill patch: anchors not found, enhancement NOT applied (the official implementation likely changed):", JSON.stringify(stale));
|
|
231
|
+
}
|
|
232
|
+
const changed = files.filter((file) => file.patched.length > 0);
|
|
233
|
+
const debug = process.env.DSH_SKILL_PICKER_LOG === "debug";
|
|
234
|
+
if (debug) {
|
|
235
|
+
io.log("[dsh-skill-picker] ui-skill patch report (debug):", JSON.stringify({ files, errors }));
|
|
236
|
+
} else if (changed.length > 0) {
|
|
237
|
+
io.log("[dsh-skill-picker] ui-skill patch report:", JSON.stringify({ files: changed, errors }));
|
|
238
|
+
} else if (errors.length > 0) {
|
|
239
|
+
io.warn("[dsh-skill-picker] ui-skill patch errors:", JSON.stringify(errors));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
225
242
|
function apply(ctx) {
|
|
226
243
|
ctx.effect(() => {
|
|
227
244
|
const handler = async (req, res) => {
|
|
@@ -244,9 +261,7 @@ function apply(ctx) {
|
|
|
244
261
|
}), "dsh-skill-picker: prompt section");
|
|
245
262
|
ctx.effect(() => {
|
|
246
263
|
healUiSkillPatches().then((report) => {
|
|
247
|
-
|
|
248
|
-
console.log("[dsh-skill-picker] ui-skill patch report:", JSON.stringify(report));
|
|
249
|
-
}
|
|
264
|
+
reportUiSkillPatches(report);
|
|
250
265
|
}).catch((error) => {
|
|
251
266
|
console.warn("[dsh-skill-picker] ui-skill patch failed:", error);
|
|
252
267
|
});
|
|
@@ -258,6 +273,7 @@ export {
|
|
|
258
273
|
SKILL_PICKER_GUIDANCE,
|
|
259
274
|
apply,
|
|
260
275
|
inject,
|
|
276
|
+
reportUiSkillPatches,
|
|
261
277
|
scanSkills
|
|
262
278
|
};
|
|
263
279
|
//# sourceMappingURL=index.js.map
|
package/lib/index.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/index.js", "../src/dir-entry.js", "../src/patch-ui-skill.js"],
|
|
4
|
-
"sourcesContent": ["/**\n * dsh-skill-picker \u2014 host half: exposes the installed-skill catalog to the\n * browser half through a small JSON route (`/dsh-skill-picker/skills`) and\n * announces the picker to every agent through the system-prompt section\n * mechanism.\n *\n * The catalog is scanned directly from the DSH user skills directory\n * (`$DSH_HOME/skills`, default `~/.dsh/skills`) by reading each skill's\n * `SKILL.md` frontmatter. Why not `ctx.skills`? The filesystem skill provider\n * is mounted in agent scope (the standard preset's standing mount), so a\n * host-context `ctx.skills.snapshot({})` sees only the global layer \u2014 which\n * is empty for user skills. Scanning the same roots the provider uses keeps\n * the picker's list in sync with what agents actually load.\n *\n * The browser half (exports \"./client\") is served by client-modules from the\n * same package's dsh.client declaration.\n *\n * @module dsh-skill-picker\n */\n\nimport { readFile, readdir } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\n\nimport { isDirectoryEntry } from './dir-entry.js'\nimport { healUiSkillPatches } from './patch-ui-skill.js'\n\n/** Required services: the route registry and the prompt band. */\nexport const inject = ['webServer', 'systemPrompt']\n\n/** Order of the announcement section within the tool-guidance band. */\nconst SECTION_ORDER = 215\n\n/** Model-facing announcement: picker presence and the user-visible gesture. */\nexport const SKILL_PICKER_GUIDANCE =\n '\u672C\u673A\u5DF2\u5B89\u88C5 dsh-skill-picker \u63D2\u4EF6\uFF08Web GUI \u7684\u6280\u80FD\u9009\u62E9\u5668\uFF09\uFF1A\u8F93\u5165\u6846\u65C1\u6709\u6280\u80FD\u6309\u94AE\uFF0C\u7528\u6237\u70B9\u9009\u6280\u80FD\u540E\u4F1A\u628A `/\u6280\u80FD\u540D`\uFF08\u5982 /duo-xuan-pi-gai\uFF09\u63D2\u5165\u53D1\u9001\u6846\u5E76\u968F\u6D88\u606F\u53D1\u51FA\u3002DSH \u5B98\u65B9\u673A\u5236\u4F1A\u628A\u7528\u6237\u6D88\u606F\u91CC\u7684 `/\u6280\u80FD\u540D` \u624B\u52BF\u5F53\u4F5C\u6280\u80FD\u76F4\u63A5\u8C03\u7528\u5E76\u81EA\u52A8\u52A0\u8F7D\u6280\u80FD\u5185\u5BB9\u2014\u2014\u4F60\u7167\u5E38\u6309\u52A0\u8F7D\u540E\u7684\u6280\u80FD\u6307\u4EE4\u6267\u884C\u5373\u53EF\uFF0C\u65E0\u9700\u989D\u5916\u64CD\u4F5C\u3002\u7528\u6237\u8BF4\u300C\u6280\u80FD\u9009\u62E9\u5668 / \u9009\u4E2A\u6280\u80FD / \u6280\u80FD\u5217\u8868\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\u3002'\n\n/** Resolve the user skills directory, mirroring the official provider's default. */\nfunction userSkillsDir() {\n const home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')\n return path.join(home, 'skills')\n}\n\n/**\n * Resolve the user agents-home skills directory, mirroring the official\n * provider's default (`$DSH_AGENTS_HOME` > `~/.agents`). This is the\n * cross-tool `.agents` convention; the official `dsh-skill-filesystem` scans\n * it as its `user-agents` root (rank 500), so the fallback must too \u2014 else\n * the picker's list silently misses skills that DSH's own `/` completion\n * shows (issue #5).\n */\nfunction userAgentsSkillsDir() {\n const agentsHome = process.env.DSH_AGENTS_HOME ?? path.join(os.homedir(), '.agents')\n return path.join(agentsHome, 'skills')\n}\n\n/** Parse a SKILL.md frontmatter block into a key/value map (flat YAML subset). */\nfunction parseFrontmatter(content) {\n const match = content.match(/^---\\r?\\n([\\s\\S]*?)\\r?\\n---/)\n if (!match) return {}\n const out = {}\n for (const line of match[1].split(/\\r?\\n/)) {\n const kv = line.match(/^([A-Za-z0-9_-]+):\\s*(.*)$/)\n if (!kv) continue\n const value = kv[2].trim().replace(/^[\"']|[\"']$/g, '')\n if (value !== '') out[kv[1]] = value\n }\n return out\n}\n\n/** Scan one skill directory into the map; never throws (missing dir is a no-op). */\nasync function scanSkillsDirInto(map, dir) {\n let entries\n try {\n entries = await readdir(dir, { withFileTypes: true })\n } catch {\n return\n }\n for (const entry of entries) {\n // Links are followed (`isDirectoryEntry`), so a skill may live behind a\n // symlink/junction \u2014 e.g. `~/.agents/skills/neat` \u2192 another repo (#6).\n if (!(await isDirectoryEntry(dir, entry))) continue\n const skillDir = path.join(dir, entry.name)\n let content\n try {\n content = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8')\n } catch {\n continue\n }\n const meta = parseFrontmatter(content)\n // Later writes win, so project-level skills override same-named user skills.\n map.set(meta.name ?? entry.name, {\n name: meta.name ?? entry.name,\n description: meta.description ?? '',\n path: skillDir,\n })\n }\n}\n\n/**\n * Scan the same roots the official `dsh-skill-filesystem` provider uses, so\n * the fallback route stays in sync with what agents actually load:\n * project `.dsh/skills` (rank 100) > project `.agents/skills` (200) >\n * user `~/.dsh/skills` (400) > user `~/.agents/skills` (500). Scanned\n * low-priority first, so later writes (higher priority) win in the map.\n * Never throws (a missing dir yields []).\n * @param cwd - the active session's workspace root (undefined = user level only).\n * @returns the deduplicated, name-sorted skill list.\n */\nexport async function scanSkills(cwd) {\n const map = new Map()\n await scanSkillsDirInto(map, userAgentsSkillsDir())\n await scanSkillsDirInto(map, userSkillsDir())\n if (typeof cwd === 'string' && cwd !== '') {\n await scanSkillsDirInto(map, path.join(cwd, '.agents', 'skills'))\n await scanSkillsDirInto(map, path.join(cwd, '.dsh', 'skills'))\n }\n return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))\n}\n\n/**\n * Mount the skills route and the prompt section.\n * @param ctx - context carrying webServer and systemPrompt.\n */\nexport function apply(ctx) {\n ctx.effect(() => {\n const handler = async (req, res) => {\n try {\n // cwd query carries the active session's workspace root from the client.\n const cwd = req.url !== undefined ? new URL(req.url, 'http://dsh').searchParams.get('cwd') ?? undefined : undefined\n const skills = await scanSkills(cwd)\n res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })\n res.end(JSON.stringify({ ok: true, complete: true, skills }))\n } catch (error) {\n res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })\n res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }))\n }\n }\n return ctx.webServer.register({ kind: 'prefix', path: '/dsh-skill-picker', handler })\n }, 'dsh-skill-picker: routes')\n\n ctx.effect(() => ctx.systemPrompt.section({\n name: 'plugin:skill-picker',\n order: SECTION_ORDER,\n text: SKILL_PICKER_GUIDANCE,\n }), 'dsh-skill-picker: prompt section')\n\n // Self-healing patch for the official ui-skill package: keeps the `/`\n // completion's skill group ordered above commands and its matching fuzzy\n // across DSH upgrades. Runs once per boot; idempotent, backed up, and\n // never allowed to take the host down.\n ctx.effect(() => {\n healUiSkillPatches().then((report) => {\n if (report.files.length > 0) {\n console.log('[dsh-skill-picker] ui-skill patch report:', JSON.stringify(report))\n }\n }).catch((error) => {\n console.warn('[dsh-skill-picker] ui-skill patch failed:', error)\n })\n return () => {}\n }, 'dsh-skill-picker: ui-skill self-heal patch')\n}\n", "/**\n * Directory-entry helpers shared by the skill scan and the ui-skill patch scan.\n *\n * `readdir(dir, { withFileTypes: true })` describes the entry itself, not what\n * it points at: on Windows a symbolic link **and a junction** both come back as\n * `isDirectory() === false` / `isSymbolicLink() === true` (Node reports junction\n * through the same lstat semantics). A directory filter that only tests\n * `isDirectory()` therefore drops every linked skill silently \u2014 that is exactly\n * the `~/.agents/skills/neat` \u2192 repo case in issue #6.\n *\n * @module dsh-skill-picker/dir-entry\n */\n\nimport { stat } from 'node:fs/promises'\nimport path from 'node:path'\n\n/**\n * Decide whether a dirent points at a directory we may descend into.\n *\n * Real directories answer straight from the dirent (no extra syscall). Link\n * entries are resolved with `stat`, which follows the link, so junctions and\n * symlinks are treated exactly like the directories they point at. A broken\n * link (or a link into a permission wall) makes `stat` throw and is reported as\n * \"not a directory\" rather than taking the caller down.\n *\n * @param dir - the directory the entry was read from.\n * @param entry - the `Dirent` returned by `readdir`.\n * @returns whether the entry is, or points at, a directory.\n */\nexport async function isDirectoryEntry(dir, entry) {\n if (entry.isDirectory()) return true\n if (!entry.isSymbolicLink()) return false\n try {\n return (await stat(path.join(dir, entry.name))).isDirectory()\n } catch {\n return false\n }\n}\n", "/**\n * dsh-skill-picker \u2014 host-side self-healing patch for the official\n * `@deepseek-ai/dsh-client-ui-skill` package.\n *\n * The picker upgrades the official `/` completion in two ways that the\n * official package does not provide out of the box:\n *\n * 1. `order: 2 \u2192 -1` \u2014 the skill group sorts ABOVE the command group\n * (commands register with the default order 0; lower = higher in the\n * official menu).\n * 2. fuzzy+pinyin candidates \u2014 the official prefix-only matcher\n * (`skill.name.startsWith(query)`) is replaced by the picker's\n * `window.__dshSkillPickerFuzzy` matcher when the picker is mounted\n * (single source group, same list, upgraded matching).\n *\n * Every DSH boot this module scans every profile under `$DSH_HOME/profiles`\n * (default `~/.dsh/profiles`) for an installed ui-skill `lib/client.js` \u2014 the\n * shared core root (`profiles/node_modules/@deepseek-ai/\u2026`, used by global\n * installs), the user's local patched copy (`local/dsh-client-ui-skill`), or\n * the plain npm install (`node_modules/@deepseek-ai/dsh-client-ui-skill`) \u2014 and\n * re-applies both patches when DSH upgrades overwrote them. The original file\n * is backed up once as `<file>.dsh-skill-picker.bak` before the first write.\n * All operations are idempotent and never throw: a missing profile, package\n * or read failure is reported and skipped so a broken patch can never take\n * the host down.\n *\n * @module dsh-skill-picker/patch-ui-skill\n */\n\nimport { readFile, writeFile, copyFile, readdir, access, realpath, rename } from 'node:fs/promises'\nimport { createRequire } from 'node:module'\nimport os from 'node:os'\nimport path from 'node:path'\n\nimport { isDirectoryEntry } from './dir-entry.js'\n\n/** Marker that the candidates patch is already in place. */\nconst FUZZY_MARKER = '__dshSkillPickerFuzzy'\n\n/** Marker that the pick-tracking patch is already in place. */\nconst TRACK_MARKER = '__dshSkillPickerTrack'\n\n/** The self-healing patches, in application order. */\nexport const PATCHES = [\n {\n id: 'order',\n title: 'skill group order 2 \u2192 -1 (above commands)',\n isApplied(text) {\n return /name: \"skill\",[\\s\\S]*?order:\\s*-1,/.test(text)\n },\n apply(text) {\n return text.replace(/(name: \"skill\",\\s*order: )2,/, '$1-1,')\n },\n },\n {\n id: 'fuzzy-candidates',\n title: 'prefix/rank matcher \u2192 fuzzy+pinyin matcher',\n isApplied(text) {\n return text.includes(FUZZY_MARKER)\n },\n apply(text) {\n // \u5B98\u65B9\u5B9E\u73B0\u968F\u7248\u672C\u53D8\u8FC7\u4E24\u6B21\uFF0C\u4E24\u4E2A\u5F62\u6001\u90FD\u8981\u8BA4\uFF082026-09-11 \u5B9E\u6D4B\uFF09\uFF1A\n // 0.1.2-alpha.x : return skills.filter((skill) => skill.name.startsWith(query)).map(...)\n // 0.1.5-rc.x : return (0, _xxx.rankByName)(skills, query).map(...) \u2190 \u6539\u6210 async candidates\n // \u6BCF\u7EC4\u6355\u83B7\uFF1A$1 = \u7F29\u8FDB\uFF0C$2 = \u5B98\u65B9\u90A3\u4E00\u6BB5\u8868\u8FBE\u5F0F\uFF08\u539F\u6837\u4FDD\u7559\u505A fallback\uFF09\u3002\n const ANCHORS = [\n /(\\t*)return (\\(0, [\\w.$]+\\.rankByName\\)\\(skills, query\\)|rankByName\\(skills, query\\))\\.map\\(\\(skill\\) => \\(\\{/,\n /(\\t*)return (skills\\.filter\\(\\(skill\\) => skill\\.name\\.startsWith\\(query\\)\\))\\.map\\(\\(skill\\) => \\(\\{/,\n ]\n for (const re of ANCHORS) {\n const m = text.match(re)\n if (!m) continue\n const [, indent, official] = m\n return text.replace(\n re,\n `${indent}// dsh-skill-picker patch: fuzzy+pinyin matcher (self-healed)\\n` +\n `${indent}const officialMatcher = ${official};\\n` +\n `${indent}const matcher = typeof window.${FUZZY_MARKER} === \"function\" ? window.${FUZZY_MARKER}(skills, query) : officialMatcher;\\n` +\n `${indent}return matcher.map((skill) => ({`,\n )\n }\n return text\n },\n },\n {\n id: 'pick-tracking',\n title: 'record usage when picked from the official / menu',\n isApplied(text) {\n return text.includes(TRACK_MARKER)\n },\n apply(text) {\n return text.replace(\n /(\\t*)onPick\\(\\{ candidate \\}\\) \\{\\n(\\t*)return \\{ text: `\\/\\$\\{candidate\\.name\\} ` \\};\\n(\\t*)\\}/,\n (match, i1, i2, i3) =>\n `${i1}onPick({ candidate }) {\\n` +\n `${i2} // dsh-skill-picker patch: usage tracking (self-healed)\\n` +\n `${i2} try { window.${TRACK_MARKER}?.(candidate.name) } catch { /* best-effort */ }\\n` +\n `${i2} return { text: \\`/\\${candidate.name} \\` };\\n` +\n `${i3}}`,\n )\n },\n },\n]\n\n/** Resolve the DSH home directory, mirroring the official convention. */\nexport function dshHome() {\n return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')\n}\n\n/**\n * Enumerate every installed ui-skill `lib/client.js` across all profiles.\n *\n * Three layouts are covered (deduplicated by real path):\n * 1. the **shared core root** `profiles/node_modules/@deepseek-ai/\u2026` \u2014 used by\n * a global `npm i -g @deepseek-ai/dsh`, where the official package is NOT\n * below any single profile (issue #7 \u2014 missing this made the whole patch\n * silently no-op for those installs);\n * 2. the user's local patched copy (`profiles/<p>/local/dsh-client-ui-skill`)\n * \u2014 that is what a linked profile actually loads;\n * 3. the plain npm install inside a profile\n * (`profiles/<p>/node_modules/@deepseek-ai/\u2026`).\n *\n * As a defensive extra, each profile is also asked through Node's own resolver\n * (`createRequire().resolve()`), so a layout we did not enumerate still works.\n * Never throws \u2014 a missing profiles dir yields [].\n * @returns {Promise<string[]>} candidate file paths.\n */\nexport async function uiSkillClientPaths() {\n const profilesDir = path.join(dshHome(), 'profiles')\n let profiles\n try {\n profiles = await readdir(profilesDir, { withFileTypes: true })\n } catch {\n return []\n }\n const seen = new Set()\n const found = []\n\n /** Add one candidate if it exists; deduplicate by real path. */\n const collect = async (candidate) => {\n try {\n await access(candidate)\n const real = await realpath(candidate)\n if (seen.has(real)) return\n seen.add(real)\n found.push(candidate)\n } catch {\n /* not present at this location */\n }\n }\n\n /** Ask Node's resolver where this profile would load the package from. */\n const collectByResolve = async (profileDir) => {\n try {\n const require = createRequire(path.join(profileDir, 'package.json'))\n await collect(require.resolve('@deepseek-ai/dsh-client-ui-skill/lib/client.js'))\n } catch {\n /* not resolvable from this profile */\n }\n }\n\n // 1. shared core root (global installs) \u2014 see the doc comment above.\n await collect(path.join(profilesDir, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))\n\n for (const entry of profiles) {\n // `node_modules` is a sibling of the profiles, not a profile itself.\n if (entry.name === 'node_modules') continue\n // A profile directory may itself be reached through a link; follow it\n // instead of relying on the lstat-based dirent (see dir-entry.js).\n if (!(await isDirectoryEntry(profilesDir, entry))) continue\n // 2. the user's local patched copy (what a linked profile actually loads).\n await collect(path.join(profilesDir, entry.name, 'local', 'dsh-client-ui-skill', 'lib', 'client.js'))\n // 3. the plain npm install inside the profile.\n await collect(path.join(profilesDir, entry.name, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))\n // Defensive: whatever Node itself would resolve (dupes removed above).\n await collectByResolve(path.join(profilesDir, entry.name))\n }\n return found\n}\n\n/**\n * Apply both patches to one ui-skill client.js. Idempotent: already-applied\n * patches are reported as skipped; the original file is backed up once before\n * the first modification. Never throws for a patch that does not match (it is\n * reported as `noop`), only for actual I/O failures.\n * @param {string} file - absolute path to the target client.js.\n * @returns {Promise<{file: string, patched: string[], skipped: string[], noop: string[]}>}\n */\nexport async function patchUiSkillFile(file) {\n const text = await readFile(file, 'utf8')\n const result = { file, patched: [], skipped: [], noop: [] }\n let next = text\n for (const patch of PATCHES) {\n if (patch.isApplied(next)) {\n result.skipped.push(patch.id)\n continue\n }\n const candidate = patch.apply(next)\n if (candidate === next) {\n result.noop.push(patch.id)\n continue\n }\n next = candidate\n result.patched.push(patch.id)\n }\n if (result.patched.length === 0) return result\n const backup = `${file}.dsh-skill-picker.bak`\n try {\n await access(backup)\n } catch {\n await copyFile(file, backup)\n }\n // Write through a temp file + rename instead of an in-place `writeFile`:\n // - pnpm installs are HARDLINKED to a shared content-addressable store, so\n // writing in place would mutate that shared inode and silently change\n // every other project using the same package version. rename() swaps the\n // directory entry instead, leaving the shared inode untouched.\n // - rename is also atomic, so an interrupted boot cannot leave a torn file.\n const tmp = `${file}.dsh-skill-picker.tmp`\n await writeFile(tmp, next, 'utf8')\n await rename(tmp, file)\n return result\n}\n\n/**\n * Self-heal entry point: scan all profiles, patch every ui-skill copy found,\n * and return one combined report. Never throws \u2014 each failure is collected\n * into `errors` so the host boot is never taken down by a broken patch.\n * @returns {Promise<{files: Array, errors: string[]}>}\n */\nexport async function healUiSkillPatches() {\n const files = await uiSkillClientPaths()\n const filesReport = []\n const errors = []\n for (const file of files) {\n try {\n filesReport.push(await patchUiSkillFile(file))\n } catch (error) {\n errors.push(`${file}: ${String(error?.message ?? error)}`)\n }\n }\n // Loud on the empty case. Silence here was the most confusing part of\n // issue #7: \"found 0 targets\" and \"everything already applied\" used to print\n // identically, so nobody could tell the patch had never run at all.\n if (files.length === 0) {\n console.warn('[dsh-skill-picker] ui-skill patch: 0 target client.js found under '\n + `${path.join(dshHome(), 'profiles')} \u2014 fuzzy+pinyin matching will NOT be applied. `\n + 'See https://github.com/a735624258/dsh-skill-picker/issues/7')\n }\n return { files: filesReport, errors }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["/**\n * dsh-skill-picker \u2014 host half: exposes the installed-skill catalog to the\n * browser half through a small JSON route (`/dsh-skill-picker/skills`) and\n * announces the picker to every agent through the system-prompt section\n * mechanism.\n *\n * The catalog is scanned directly from the DSH user skills directory\n * (`$DSH_HOME/skills`, default `~/.dsh/skills`) by reading each skill's\n * `SKILL.md` frontmatter. Why not `ctx.skills`? The filesystem skill provider\n * is mounted in agent scope (the standard preset's standing mount), so a\n * host-context `ctx.skills.snapshot({})` sees only the global layer \u2014 which\n * is empty for user skills. Scanning the same roots the provider uses keeps\n * the picker's list in sync with what agents actually load.\n *\n * The browser half (exports \"./client\") is served by client-modules from the\n * same package's dsh.client declaration.\n *\n * @module dsh-skill-picker\n */\n\nimport { readFile, readdir } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\n\nimport { isDirectoryEntry } from './dir-entry.js'\nimport { healUiSkillPatches } from './patch-ui-skill.js'\n\n/** Required services: the route registry and the prompt band. */\nexport const inject = ['webServer', 'systemPrompt']\n\n/** Order of the announcement section within the tool-guidance band. */\nconst SECTION_ORDER = 215\n\n/** Model-facing announcement: picker presence and the user-visible gesture. */\nexport const SKILL_PICKER_GUIDANCE =\n '\u672C\u673A\u5DF2\u5B89\u88C5 dsh-skill-picker \u63D2\u4EF6\uFF08Web GUI \u7684\u6280\u80FD\u9009\u62E9\u5668\uFF09\uFF1A\u8F93\u5165\u6846\u65C1\u6709\u6280\u80FD\u6309\u94AE\uFF0C\u7528\u6237\u70B9\u9009\u6280\u80FD\u540E\u4F1A\u628A `/\u6280\u80FD\u540D`\uFF08\u5982 /duo-xuan-pi-gai\uFF09\u63D2\u5165\u53D1\u9001\u6846\u5E76\u968F\u6D88\u606F\u53D1\u51FA\u3002DSH \u5B98\u65B9\u673A\u5236\u4F1A\u628A\u7528\u6237\u6D88\u606F\u91CC\u7684 `/\u6280\u80FD\u540D` \u624B\u52BF\u5F53\u4F5C\u6280\u80FD\u76F4\u63A5\u8C03\u7528\u5E76\u81EA\u52A8\u52A0\u8F7D\u6280\u80FD\u5185\u5BB9\u2014\u2014\u4F60\u7167\u5E38\u6309\u52A0\u8F7D\u540E\u7684\u6280\u80FD\u6307\u4EE4\u6267\u884C\u5373\u53EF\uFF0C\u65E0\u9700\u989D\u5916\u64CD\u4F5C\u3002\u7528\u6237\u8BF4\u300C\u6280\u80FD\u9009\u62E9\u5668 / \u9009\u4E2A\u6280\u80FD / \u6280\u80FD\u5217\u8868\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\u3002'\n\n/** Resolve the user skills directory, mirroring the official provider's default. */\nfunction userSkillsDir() {\n const home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')\n return path.join(home, 'skills')\n}\n\n/**\n * Resolve the user agents-home skills directory, mirroring the official\n * provider's default (`$DSH_AGENTS_HOME` > `~/.agents`). This is the\n * cross-tool `.agents` convention; the official `dsh-skill-filesystem` scans\n * it as its `user-agents` root (rank 500), so the fallback must too \u2014 else\n * the picker's list silently misses skills that DSH's own `/` completion\n * shows (issue #5).\n */\nfunction userAgentsSkillsDir() {\n const agentsHome = process.env.DSH_AGENTS_HOME ?? path.join(os.homedir(), '.agents')\n return path.join(agentsHome, 'skills')\n}\n\n/** Parse a SKILL.md frontmatter block into a key/value map (flat YAML subset). */\nfunction parseFrontmatter(content) {\n const match = content.match(/^---\\r?\\n([\\s\\S]*?)\\r?\\n---/)\n if (!match) return {}\n const out = {}\n for (const line of match[1].split(/\\r?\\n/)) {\n const kv = line.match(/^([A-Za-z0-9_-]+):\\s*(.*)$/)\n if (!kv) continue\n const value = kv[2].trim().replace(/^[\"']|[\"']$/g, '')\n if (value !== '') out[kv[1]] = value\n }\n return out\n}\n\n/** Scan one skill directory into the map; never throws (missing dir is a no-op). */\nasync function scanSkillsDirInto(map, dir) {\n let entries\n try {\n entries = await readdir(dir, { withFileTypes: true })\n } catch {\n return\n }\n for (const entry of entries) {\n // Links are followed (`isDirectoryEntry`), so a skill may live behind a\n // symlink/junction \u2014 e.g. `~/.agents/skills/neat` \u2192 another repo (#6).\n if (!(await isDirectoryEntry(dir, entry))) continue\n const skillDir = path.join(dir, entry.name)\n let content\n try {\n content = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8')\n } catch {\n continue\n }\n const meta = parseFrontmatter(content)\n // Later writes win, so project-level skills override same-named user skills.\n map.set(meta.name ?? entry.name, {\n name: meta.name ?? entry.name,\n description: meta.description ?? '',\n path: skillDir,\n })\n }\n}\n\n/**\n * Scan the same roots the official `dsh-skill-filesystem` provider uses, so\n * the fallback route stays in sync with what agents actually load:\n * project `.dsh/skills` (rank 100) > project `.agents/skills` (200) >\n * user `~/.dsh/skills` (400) > user `~/.agents/skills` (500). Scanned\n * low-priority first, so later writes (higher priority) win in the map.\n * Never throws (a missing dir yields []).\n * @param cwd - the active session's workspace root (undefined = user level only).\n * @returns the deduplicated, name-sorted skill list.\n */\nexport async function scanSkills(cwd) {\n const map = new Map()\n await scanSkillsDirInto(map, userAgentsSkillsDir())\n await scanSkillsDirInto(map, userSkillsDir())\n if (typeof cwd === 'string' && cwd !== '') {\n await scanSkillsDirInto(map, path.join(cwd, '.agents', 'skills'))\n await scanSkillsDirInto(map, path.join(cwd, '.dsh', 'skills'))\n }\n return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))\n}\n\n/**\n * Decide what (if anything) the ui-skill self-heal patch should say on boot\n * (issue #8).\n *\n * `healUiSkillPatches()` reports **every target file it found**, whether or not\n * this boot changed it. Keying the log off `report.files.length` therefore\n * printed a report-shaped JSON line on *every* start \u2014 even when the patches\n * were already up to date \u2014 which reads like a warning and buries the lines\n * that really matter. Speak up only when there is something to say:\n *\n * - `noop` \u2192 an anchor did not match: the official implementation moved and\n * the enhancement is NOT applied. Always a warning (this is the\n * same class of silent failure as issue #7).\n * - `patched` \u2192 this boot really rewrote a file: one report line.\n * - `errors` \u2192 I/O failures: one warning (folded into the report line when a\n * patch also succeeded, so a single line carries both).\n * - none \u2192 silent: the install is already up to date.\n *\n * Set `DSH_SKILL_PICKER_LOG=debug` for the full report including `skipped`.\n *\n * Exported for tests; `io` is injectable so they can capture the output.\n *\n * @param {{files?: Array, errors?: string[]}} report - result of healUiSkillPatches().\n * @param {Pick<Console, 'log' | 'warn'>} [io] - sinks (defaults to the console).\n */\nexport function reportUiSkillPatches(report, io = console) {\n const files = report?.files ?? []\n const errors = report?.errors ?? []\n // An anchor miss means the enhancement silently did not apply \u2014 never bury it.\n const stale = files.filter((file) => file.noop.length > 0)\n if (stale.length > 0) {\n io.warn('[dsh-skill-picker] ui-skill patch: anchors not found, enhancement NOT applied '\n + '(the official implementation likely changed):', JSON.stringify(stale))\n }\n const changed = files.filter((file) => file.patched.length > 0)\n // Read per call so the switch also works when set by tests or a wrapper.\n const debug = process.env.DSH_SKILL_PICKER_LOG === 'debug'\n if (debug) {\n io.log('[dsh-skill-picker] ui-skill patch report (debug):', JSON.stringify({ files, errors }))\n } else if (changed.length > 0) {\n // Only the files this boot actually touched, plus every error.\n io.log('[dsh-skill-picker] ui-skill patch report:', JSON.stringify({ files: changed, errors }))\n } else if (errors.length > 0) {\n io.warn('[dsh-skill-picker] ui-skill patch errors:', JSON.stringify(errors))\n }\n}\n\n/**\n * Mount the skills route and the prompt section.\n * @param ctx - context carrying webServer and systemPrompt.\n */\nexport function apply(ctx) {\n ctx.effect(() => {\n const handler = async (req, res) => {\n try {\n // cwd query carries the active session's workspace root from the client.\n const cwd = req.url !== undefined ? new URL(req.url, 'http://dsh').searchParams.get('cwd') ?? undefined : undefined\n const skills = await scanSkills(cwd)\n res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })\n res.end(JSON.stringify({ ok: true, complete: true, skills }))\n } catch (error) {\n res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })\n res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }))\n }\n }\n return ctx.webServer.register({ kind: 'prefix', path: '/dsh-skill-picker', handler })\n }, 'dsh-skill-picker: routes')\n\n ctx.effect(() => ctx.systemPrompt.section({\n name: 'plugin:skill-picker',\n order: SECTION_ORDER,\n text: SKILL_PICKER_GUIDANCE,\n }), 'dsh-skill-picker: prompt section')\n\n // Self-healing patch for the official ui-skill package: keeps the `/`\n // completion's skill group ordered above commands and its matching fuzzy\n // across DSH upgrades. Runs once per boot; idempotent, backed up, and\n // never allowed to take the host down.\n //\n // Logging is gated on \"did this boot change anything / fail\" rather than\n // \"was a target found\" \u2014 see `reportUiSkillPatches` (issue #8).\n ctx.effect(() => {\n healUiSkillPatches().then((report) => {\n reportUiSkillPatches(report)\n }).catch((error) => {\n console.warn('[dsh-skill-picker] ui-skill patch failed:', error)\n })\n return () => {}\n }, 'dsh-skill-picker: ui-skill self-heal patch')\n}\n", "/**\n * Directory-entry helpers shared by the skill scan and the ui-skill patch scan.\n *\n * `readdir(dir, { withFileTypes: true })` describes the entry itself, not what\n * it points at: on Windows a symbolic link **and a junction** both come back as\n * `isDirectory() === false` / `isSymbolicLink() === true` (Node reports junction\n * through the same lstat semantics). A directory filter that only tests\n * `isDirectory()` therefore drops every linked skill silently \u2014 that is exactly\n * the `~/.agents/skills/neat` \u2192 repo case in issue #6.\n *\n * @module dsh-skill-picker/dir-entry\n */\n\nimport { stat } from 'node:fs/promises'\nimport path from 'node:path'\n\n/**\n * Decide whether a dirent points at a directory we may descend into.\n *\n * Real directories answer straight from the dirent (no extra syscall). Link\n * entries are resolved with `stat`, which follows the link, so junctions and\n * symlinks are treated exactly like the directories they point at. A broken\n * link (or a link into a permission wall) makes `stat` throw and is reported as\n * \"not a directory\" rather than taking the caller down.\n *\n * @param dir - the directory the entry was read from.\n * @param entry - the `Dirent` returned by `readdir`.\n * @returns whether the entry is, or points at, a directory.\n */\nexport async function isDirectoryEntry(dir, entry) {\n if (entry.isDirectory()) return true\n if (!entry.isSymbolicLink()) return false\n try {\n return (await stat(path.join(dir, entry.name))).isDirectory()\n } catch {\n return false\n }\n}\n", "/**\r\n * dsh-skill-picker \u2014 host-side self-healing patch for the official\r\n * `@deepseek-ai/dsh-client-ui-skill` package.\r\n *\r\n * The picker upgrades the official `/` completion in two ways that the\r\n * official package does not provide out of the box:\r\n *\r\n * 1. `order: 2 \u2192 -1` \u2014 the skill group sorts ABOVE the command group\r\n * (commands register with the default order 0; lower = higher in the\r\n * official menu).\r\n * 2. fuzzy+pinyin candidates \u2014 the official prefix-only matcher\r\n * (`skill.name.startsWith(query)`) is replaced by the picker's\r\n * `window.__dshSkillPickerFuzzy` matcher when the picker is mounted\r\n * (single source group, same list, upgraded matching).\r\n *\r\n * Every DSH boot this module scans every profile under `$DSH_HOME/profiles`\r\n * (default `~/.dsh/profiles`) for an installed ui-skill `lib/client.js` \u2014 the\r\n * shared core root (`profiles/node_modules/@deepseek-ai/\u2026`, used by global\r\n * installs), the user's local patched copy (`local/dsh-client-ui-skill`), or\r\n * the plain npm install (`node_modules/@deepseek-ai/dsh-client-ui-skill`) \u2014 and\r\n * re-applies both patches when DSH upgrades overwrote them. The original file\r\n * is backed up once as `<file>.dsh-skill-picker.bak` before the first write.\r\n * All operations are idempotent and never throw: a missing profile, package\r\n * or read failure is reported and skipped so a broken patch can never take\r\n * the host down.\r\n *\r\n * @module dsh-skill-picker/patch-ui-skill\r\n */\r\n\r\nimport { readFile, writeFile, copyFile, readdir, access, realpath, rename } from 'node:fs/promises'\r\nimport { createRequire } from 'node:module'\r\nimport os from 'node:os'\r\nimport path from 'node:path'\r\n\r\nimport { isDirectoryEntry } from './dir-entry.js'\r\n\r\n/** Marker that the candidates patch is already in place. */\r\nconst FUZZY_MARKER = '__dshSkillPickerFuzzy'\r\n\r\n/** Marker that the pick-tracking patch is already in place. */\r\nconst TRACK_MARKER = '__dshSkillPickerTrack'\r\n\r\n/** The self-healing patches, in application order. */\r\nexport const PATCHES = [\r\n {\r\n id: 'order',\r\n title: 'skill group order 2 \u2192 -1 (above commands)',\r\n isApplied(text) {\r\n return /name: \"skill\",[\\s\\S]*?order:\\s*-1,/.test(text)\r\n },\r\n apply(text) {\r\n return text.replace(/(name: \"skill\",\\s*order: )2,/, '$1-1,')\r\n },\r\n },\r\n {\r\n id: 'fuzzy-candidates',\r\n title: 'prefix/rank matcher \u2192 fuzzy+pinyin matcher',\r\n isApplied(text) {\r\n return text.includes(FUZZY_MARKER)\r\n },\r\n apply(text) {\r\n // \u5B98\u65B9\u5B9E\u73B0\u968F\u7248\u672C\u53D8\u8FC7\u4E24\u6B21\uFF0C\u4E24\u4E2A\u5F62\u6001\u90FD\u8981\u8BA4\uFF082026-09-11 \u5B9E\u6D4B\uFF09\uFF1A\r\n // 0.1.2-alpha.x : return skills.filter((skill) => skill.name.startsWith(query)).map(...)\r\n // 0.1.5-rc.x : return (0, _xxx.rankByName)(skills, query).map(...) \u2190 \u6539\u6210 async candidates\r\n // \u6BCF\u7EC4\u6355\u83B7\uFF1A$1 = \u7F29\u8FDB\uFF0C$2 = \u5B98\u65B9\u90A3\u4E00\u6BB5\u8868\u8FBE\u5F0F\uFF08\u539F\u6837\u4FDD\u7559\u505A fallback\uFF09\u3002\r\n const ANCHORS = [\r\n /(\\t*)return (\\(0, [\\w.$]+\\.rankByName\\)\\(skills, query\\)|rankByName\\(skills, query\\))\\.map\\(\\(skill\\) => \\(\\{/,\r\n /(\\t*)return (skills\\.filter\\(\\(skill\\) => skill\\.name\\.startsWith\\(query\\)\\))\\.map\\(\\(skill\\) => \\(\\{/,\r\n ]\r\n for (const re of ANCHORS) {\r\n const m = text.match(re)\r\n if (!m) continue\r\n const [, indent, official] = m\r\n return text.replace(\r\n re,\r\n `${indent}// dsh-skill-picker patch: fuzzy+pinyin matcher (self-healed)\\n` +\r\n `${indent}const officialMatcher = ${official};\\n` +\r\n `${indent}const matcher = typeof window.${FUZZY_MARKER} === \"function\" ? window.${FUZZY_MARKER}(skills, query) : officialMatcher;\\n` +\r\n `${indent}return matcher.map((skill) => ({`,\r\n )\r\n }\r\n return text\r\n },\r\n },\r\n {\r\n id: 'pick-tracking',\r\n title: 'record usage when picked from the official / menu',\r\n isApplied(text) {\r\n return text.includes(TRACK_MARKER)\r\n },\r\n apply(text) {\r\n return text.replace(\r\n /(\\t*)onPick\\(\\{ candidate \\}\\) \\{\\n(\\t*)return \\{ text: `\\/\\$\\{candidate\\.name\\} ` \\};\\n(\\t*)\\}/,\r\n (match, i1, i2, i3) =>\r\n `${i1}onPick({ candidate }) {\\n` +\r\n `${i2} // dsh-skill-picker patch: usage tracking (self-healed)\\n` +\r\n `${i2} try { window.${TRACK_MARKER}?.(candidate.name) } catch { /* best-effort */ }\\n` +\r\n `${i2} return { text: \\`/\\${candidate.name} \\` };\\n` +\r\n `${i3}}`,\r\n )\r\n },\r\n },\r\n]\r\n\r\n/** Resolve the DSH home directory, mirroring the official convention. */\r\nexport function dshHome() {\r\n return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')\r\n}\r\n\r\n/**\r\n * Enumerate every installed ui-skill `lib/client.js` across all profiles.\r\n *\r\n * Three layouts are covered (deduplicated by real path):\r\n * 1. the **shared core root** `profiles/node_modules/@deepseek-ai/\u2026` \u2014 used by\r\n * a global `npm i -g @deepseek-ai/dsh`, where the official package is NOT\r\n * below any single profile (issue #7 \u2014 missing this made the whole patch\r\n * silently no-op for those installs);\r\n * 2. the user's local patched copy (`profiles/<p>/local/dsh-client-ui-skill`)\r\n * \u2014 that is what a linked profile actually loads;\r\n * 3. the plain npm install inside a profile\r\n * (`profiles/<p>/node_modules/@deepseek-ai/\u2026`).\r\n *\r\n * As a defensive extra, each profile is also asked through Node's own resolver\r\n * (`createRequire().resolve()`), so a layout we did not enumerate still works.\r\n * Never throws \u2014 a missing profiles dir yields [].\r\n * @returns {Promise<string[]>} candidate file paths.\r\n */\r\nexport async function uiSkillClientPaths() {\r\n const profilesDir = path.join(dshHome(), 'profiles')\r\n let profiles\r\n try {\r\n profiles = await readdir(profilesDir, { withFileTypes: true })\r\n } catch {\r\n return []\r\n }\r\n const seen = new Set()\r\n const found = []\r\n\r\n /** Add one candidate if it exists; deduplicate by real path. */\r\n const collect = async (candidate) => {\r\n try {\r\n await access(candidate)\r\n const real = await realpath(candidate)\r\n if (seen.has(real)) return\r\n seen.add(real)\r\n found.push(candidate)\r\n } catch {\r\n /* not present at this location */\r\n }\r\n }\r\n\r\n /** Ask Node's resolver where this profile would load the package from. */\r\n const collectByResolve = async (profileDir) => {\r\n try {\r\n const require = createRequire(path.join(profileDir, 'package.json'))\r\n await collect(require.resolve('@deepseek-ai/dsh-client-ui-skill/lib/client.js'))\r\n } catch {\r\n /* not resolvable from this profile */\r\n }\r\n }\r\n\r\n // 1. shared core root (global installs) \u2014 see the doc comment above.\r\n await collect(path.join(profilesDir, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))\r\n\r\n for (const entry of profiles) {\r\n // `node_modules` is a sibling of the profiles, not a profile itself.\r\n if (entry.name === 'node_modules') continue\r\n // A profile directory may itself be reached through a link; follow it\r\n // instead of relying on the lstat-based dirent (see dir-entry.js).\r\n if (!(await isDirectoryEntry(profilesDir, entry))) continue\r\n // 2. the user's local patched copy (what a linked profile actually loads).\r\n await collect(path.join(profilesDir, entry.name, 'local', 'dsh-client-ui-skill', 'lib', 'client.js'))\r\n // 3. the plain npm install inside the profile.\r\n await collect(path.join(profilesDir, entry.name, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))\r\n // Defensive: whatever Node itself would resolve (dupes removed above).\r\n await collectByResolve(path.join(profilesDir, entry.name))\r\n }\r\n return found\r\n}\r\n\r\n/**\r\n * Apply both patches to one ui-skill client.js. Idempotent: already-applied\r\n * patches are reported as skipped; the original file is backed up once before\r\n * the first modification. Never throws for a patch that does not match (it is\r\n * reported as `noop`), only for actual I/O failures.\r\n * @param {string} file - absolute path to the target client.js.\r\n * @returns {Promise<{file: string, patched: string[], skipped: string[], noop: string[]}>}\r\n */\r\nexport async function patchUiSkillFile(file) {\r\n const text = await readFile(file, 'utf8')\r\n const result = { file, patched: [], skipped: [], noop: [] }\r\n let next = text\r\n for (const patch of PATCHES) {\r\n if (patch.isApplied(next)) {\r\n result.skipped.push(patch.id)\r\n continue\r\n }\r\n const candidate = patch.apply(next)\r\n if (candidate === next) {\r\n result.noop.push(patch.id)\r\n continue\r\n }\r\n next = candidate\r\n result.patched.push(patch.id)\r\n }\r\n if (result.patched.length === 0) return result\r\n const backup = `${file}.dsh-skill-picker.bak`\r\n try {\r\n await access(backup)\r\n } catch {\r\n await copyFile(file, backup)\r\n }\r\n // Write through a temp file + rename instead of an in-place `writeFile`:\r\n // - pnpm installs are HARDLINKED to a shared content-addressable store, so\r\n // writing in place would mutate that shared inode and silently change\r\n // every other project using the same package version. rename() swaps the\r\n // directory entry instead, leaving the shared inode untouched.\r\n // - rename is also atomic, so an interrupted boot cannot leave a torn file.\r\n const tmp = `${file}.dsh-skill-picker.tmp`\r\n await writeFile(tmp, next, 'utf8')\r\n await rename(tmp, file)\r\n return result\r\n}\r\n\r\n/**\r\n * Self-heal entry point: scan all profiles, patch every ui-skill copy found,\r\n * and return one combined report. Never throws \u2014 each failure is collected\r\n * into `errors` so the host boot is never taken down by a broken patch.\r\n * @returns {Promise<{files: Array, errors: string[]}>}\r\n */\r\nexport async function healUiSkillPatches() {\r\n const files = await uiSkillClientPaths()\r\n const filesReport = []\r\n const errors = []\r\n for (const file of files) {\r\n try {\r\n filesReport.push(await patchUiSkillFile(file))\r\n } catch (error) {\r\n errors.push(`${file}: ${String(error?.message ?? error)}`)\r\n }\r\n }\r\n // Loud on the empty case. Silence here was the most confusing part of\r\n // issue #7: \"found 0 targets\" and \"everything already applied\" used to print\r\n // identically, so nobody could tell the patch had never run at all.\r\n if (files.length === 0) {\r\n console.warn('[dsh-skill-picker] ui-skill patch: 0 target client.js found under '\r\n + `${path.join(dshHome(), 'profiles')} \u2014 fuzzy+pinyin matching will NOT be applied. `\r\n + 'See https://github.com/a735624258/dsh-skill-picker/issues/7')\r\n }\r\n return { files: filesReport, errors }\r\n}\r\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["readFile", "readdir", "os", "path", "path", "path", "require", "path", "os", "readdir", "readFile"]
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-picker",
|
|
3
3
|
"description": "DSH Web GUI skill picker: a button beside the composer opens a searchable list of installed skills; picking one inserts the official `/skill-name` gesture into the input box, so the skill loads with your message (WorkBuddy-style skill invocation for DeepSeek Harness).",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.11",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|
package/src/index.js
CHANGED
|
@@ -118,6 +118,53 @@ export async function scanSkills(cwd) {
|
|
|
118
118
|
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Decide what (if anything) the ui-skill self-heal patch should say on boot
|
|
123
|
+
* (issue #8).
|
|
124
|
+
*
|
|
125
|
+
* `healUiSkillPatches()` reports **every target file it found**, whether or not
|
|
126
|
+
* this boot changed it. Keying the log off `report.files.length` therefore
|
|
127
|
+
* printed a report-shaped JSON line on *every* start — even when the patches
|
|
128
|
+
* were already up to date — which reads like a warning and buries the lines
|
|
129
|
+
* that really matter. Speak up only when there is something to say:
|
|
130
|
+
*
|
|
131
|
+
* - `noop` → an anchor did not match: the official implementation moved and
|
|
132
|
+
* the enhancement is NOT applied. Always a warning (this is the
|
|
133
|
+
* same class of silent failure as issue #7).
|
|
134
|
+
* - `patched` → this boot really rewrote a file: one report line.
|
|
135
|
+
* - `errors` → I/O failures: one warning (folded into the report line when a
|
|
136
|
+
* patch also succeeded, so a single line carries both).
|
|
137
|
+
* - none → silent: the install is already up to date.
|
|
138
|
+
*
|
|
139
|
+
* Set `DSH_SKILL_PICKER_LOG=debug` for the full report including `skipped`.
|
|
140
|
+
*
|
|
141
|
+
* Exported for tests; `io` is injectable so they can capture the output.
|
|
142
|
+
*
|
|
143
|
+
* @param {{files?: Array, errors?: string[]}} report - result of healUiSkillPatches().
|
|
144
|
+
* @param {Pick<Console, 'log' | 'warn'>} [io] - sinks (defaults to the console).
|
|
145
|
+
*/
|
|
146
|
+
export function reportUiSkillPatches(report, io = console) {
|
|
147
|
+
const files = report?.files ?? []
|
|
148
|
+
const errors = report?.errors ?? []
|
|
149
|
+
// An anchor miss means the enhancement silently did not apply — never bury it.
|
|
150
|
+
const stale = files.filter((file) => file.noop.length > 0)
|
|
151
|
+
if (stale.length > 0) {
|
|
152
|
+
io.warn('[dsh-skill-picker] ui-skill patch: anchors not found, enhancement NOT applied '
|
|
153
|
+
+ '(the official implementation likely changed):', JSON.stringify(stale))
|
|
154
|
+
}
|
|
155
|
+
const changed = files.filter((file) => file.patched.length > 0)
|
|
156
|
+
// Read per call so the switch also works when set by tests or a wrapper.
|
|
157
|
+
const debug = process.env.DSH_SKILL_PICKER_LOG === 'debug'
|
|
158
|
+
if (debug) {
|
|
159
|
+
io.log('[dsh-skill-picker] ui-skill patch report (debug):', JSON.stringify({ files, errors }))
|
|
160
|
+
} else if (changed.length > 0) {
|
|
161
|
+
// Only the files this boot actually touched, plus every error.
|
|
162
|
+
io.log('[dsh-skill-picker] ui-skill patch report:', JSON.stringify({ files: changed, errors }))
|
|
163
|
+
} else if (errors.length > 0) {
|
|
164
|
+
io.warn('[dsh-skill-picker] ui-skill patch errors:', JSON.stringify(errors))
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
121
168
|
/**
|
|
122
169
|
* Mount the skills route and the prompt section.
|
|
123
170
|
* @param ctx - context carrying webServer and systemPrompt.
|
|
@@ -149,11 +196,12 @@ export function apply(ctx) {
|
|
|
149
196
|
// completion's skill group ordered above commands and its matching fuzzy
|
|
150
197
|
// across DSH upgrades. Runs once per boot; idempotent, backed up, and
|
|
151
198
|
// never allowed to take the host down.
|
|
199
|
+
//
|
|
200
|
+
// Logging is gated on "did this boot change anything / fail" rather than
|
|
201
|
+
// "was a target found" — see `reportUiSkillPatches` (issue #8).
|
|
152
202
|
ctx.effect(() => {
|
|
153
203
|
healUiSkillPatches().then((report) => {
|
|
154
|
-
|
|
155
|
-
console.log('[dsh-skill-picker] ui-skill patch report:', JSON.stringify(report))
|
|
156
|
-
}
|
|
204
|
+
reportUiSkillPatches(report)
|
|
157
205
|
}).catch((error) => {
|
|
158
206
|
console.warn('[dsh-skill-picker] ui-skill patch failed:', error)
|
|
159
207
|
})
|
package/src/patch-ui-skill.js
CHANGED
|
@@ -1,251 +1,251 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-skill-picker — host-side self-healing patch for the official
|
|
3
|
-
* `@deepseek-ai/dsh-client-ui-skill` package.
|
|
4
|
-
*
|
|
5
|
-
* The picker upgrades the official `/` completion in two ways that the
|
|
6
|
-
* official package does not provide out of the box:
|
|
7
|
-
*
|
|
8
|
-
* 1. `order: 2 → -1` — the skill group sorts ABOVE the command group
|
|
9
|
-
* (commands register with the default order 0; lower = higher in the
|
|
10
|
-
* official menu).
|
|
11
|
-
* 2. fuzzy+pinyin candidates — the official prefix-only matcher
|
|
12
|
-
* (`skill.name.startsWith(query)`) is replaced by the picker's
|
|
13
|
-
* `window.__dshSkillPickerFuzzy` matcher when the picker is mounted
|
|
14
|
-
* (single source group, same list, upgraded matching).
|
|
15
|
-
*
|
|
16
|
-
* Every DSH boot this module scans every profile under `$DSH_HOME/profiles`
|
|
17
|
-
* (default `~/.dsh/profiles`) for an installed ui-skill `lib/client.js` — the
|
|
18
|
-
* shared core root (`profiles/node_modules/@deepseek-ai/…`, used by global
|
|
19
|
-
* installs), the user's local patched copy (`local/dsh-client-ui-skill`), or
|
|
20
|
-
* the plain npm install (`node_modules/@deepseek-ai/dsh-client-ui-skill`) — and
|
|
21
|
-
* re-applies both patches when DSH upgrades overwrote them. The original file
|
|
22
|
-
* is backed up once as `<file>.dsh-skill-picker.bak` before the first write.
|
|
23
|
-
* All operations are idempotent and never throw: a missing profile, package
|
|
24
|
-
* or read failure is reported and skipped so a broken patch can never take
|
|
25
|
-
* the host down.
|
|
26
|
-
*
|
|
27
|
-
* @module dsh-skill-picker/patch-ui-skill
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { readFile, writeFile, copyFile, readdir, access, realpath, rename } from 'node:fs/promises'
|
|
31
|
-
import { createRequire } from 'node:module'
|
|
32
|
-
import os from 'node:os'
|
|
33
|
-
import path from 'node:path'
|
|
34
|
-
|
|
35
|
-
import { isDirectoryEntry } from './dir-entry.js'
|
|
36
|
-
|
|
37
|
-
/** Marker that the candidates patch is already in place. */
|
|
38
|
-
const FUZZY_MARKER = '__dshSkillPickerFuzzy'
|
|
39
|
-
|
|
40
|
-
/** Marker that the pick-tracking patch is already in place. */
|
|
41
|
-
const TRACK_MARKER = '__dshSkillPickerTrack'
|
|
42
|
-
|
|
43
|
-
/** The self-healing patches, in application order. */
|
|
44
|
-
export const PATCHES = [
|
|
45
|
-
{
|
|
46
|
-
id: 'order',
|
|
47
|
-
title: 'skill group order 2 → -1 (above commands)',
|
|
48
|
-
isApplied(text) {
|
|
49
|
-
return /name: "skill",[\s\S]*?order:\s*-1,/.test(text)
|
|
50
|
-
},
|
|
51
|
-
apply(text) {
|
|
52
|
-
return text.replace(/(name: "skill",\s*order: )2,/, '$1-1,')
|
|
53
|
-
},
|
|
54
|
-
},
|
|
55
|
-
{
|
|
56
|
-
id: 'fuzzy-candidates',
|
|
57
|
-
title: 'prefix/rank matcher → fuzzy+pinyin matcher',
|
|
58
|
-
isApplied(text) {
|
|
59
|
-
return text.includes(FUZZY_MARKER)
|
|
60
|
-
},
|
|
61
|
-
apply(text) {
|
|
62
|
-
// 官方实现随版本变过两次,两个形态都要认(2026-09-11 实测):
|
|
63
|
-
// 0.1.2-alpha.x : return skills.filter((skill) => skill.name.startsWith(query)).map(...)
|
|
64
|
-
// 0.1.5-rc.x : return (0, _xxx.rankByName)(skills, query).map(...) ← 改成 async candidates
|
|
65
|
-
// 每组捕获:$1 = 缩进,$2 = 官方那一段表达式(原样保留做 fallback)。
|
|
66
|
-
const ANCHORS = [
|
|
67
|
-
/(\t*)return (\(0, [\w.$]+\.rankByName\)\(skills, query\)|rankByName\(skills, query\))\.map\(\(skill\) => \(\{/,
|
|
68
|
-
/(\t*)return (skills\.filter\(\(skill\) => skill\.name\.startsWith\(query\)\))\.map\(\(skill\) => \(\{/,
|
|
69
|
-
]
|
|
70
|
-
for (const re of ANCHORS) {
|
|
71
|
-
const m = text.match(re)
|
|
72
|
-
if (!m) continue
|
|
73
|
-
const [, indent, official] = m
|
|
74
|
-
return text.replace(
|
|
75
|
-
re,
|
|
76
|
-
`${indent}// dsh-skill-picker patch: fuzzy+pinyin matcher (self-healed)\n` +
|
|
77
|
-
`${indent}const officialMatcher = ${official};\n` +
|
|
78
|
-
`${indent}const matcher = typeof window.${FUZZY_MARKER} === "function" ? window.${FUZZY_MARKER}(skills, query) : officialMatcher;\n` +
|
|
79
|
-
`${indent}return matcher.map((skill) => ({`,
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
return text
|
|
83
|
-
},
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
id: 'pick-tracking',
|
|
87
|
-
title: 'record usage when picked from the official / menu',
|
|
88
|
-
isApplied(text) {
|
|
89
|
-
return text.includes(TRACK_MARKER)
|
|
90
|
-
},
|
|
91
|
-
apply(text) {
|
|
92
|
-
return text.replace(
|
|
93
|
-
/(\t*)onPick\(\{ candidate \}\) \{\n(\t*)return \{ text: `\/\$\{candidate\.name\} ` \};\n(\t*)\}/,
|
|
94
|
-
(match, i1, i2, i3) =>
|
|
95
|
-
`${i1}onPick({ candidate }) {\n` +
|
|
96
|
-
`${i2} // dsh-skill-picker patch: usage tracking (self-healed)\n` +
|
|
97
|
-
`${i2} try { window.${TRACK_MARKER}?.(candidate.name) } catch { /* best-effort */ }\n` +
|
|
98
|
-
`${i2} return { text: \`/\${candidate.name} \` };\n` +
|
|
99
|
-
`${i3}}`,
|
|
100
|
-
)
|
|
101
|
-
},
|
|
102
|
-
},
|
|
103
|
-
]
|
|
104
|
-
|
|
105
|
-
/** Resolve the DSH home directory, mirroring the official convention. */
|
|
106
|
-
export function dshHome() {
|
|
107
|
-
return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Enumerate every installed ui-skill `lib/client.js` across all profiles.
|
|
112
|
-
*
|
|
113
|
-
* Three layouts are covered (deduplicated by real path):
|
|
114
|
-
* 1. the **shared core root** `profiles/node_modules/@deepseek-ai/…` — used by
|
|
115
|
-
* a global `npm i -g @deepseek-ai/dsh`, where the official package is NOT
|
|
116
|
-
* below any single profile (issue #7 — missing this made the whole patch
|
|
117
|
-
* silently no-op for those installs);
|
|
118
|
-
* 2. the user's local patched copy (`profiles/<p>/local/dsh-client-ui-skill`)
|
|
119
|
-
* — that is what a linked profile actually loads;
|
|
120
|
-
* 3. the plain npm install inside a profile
|
|
121
|
-
* (`profiles/<p>/node_modules/@deepseek-ai/…`).
|
|
122
|
-
*
|
|
123
|
-
* As a defensive extra, each profile is also asked through Node's own resolver
|
|
124
|
-
* (`createRequire().resolve()`), so a layout we did not enumerate still works.
|
|
125
|
-
* Never throws — a missing profiles dir yields [].
|
|
126
|
-
* @returns {Promise<string[]>} candidate file paths.
|
|
127
|
-
*/
|
|
128
|
-
export async function uiSkillClientPaths() {
|
|
129
|
-
const profilesDir = path.join(dshHome(), 'profiles')
|
|
130
|
-
let profiles
|
|
131
|
-
try {
|
|
132
|
-
profiles = await readdir(profilesDir, { withFileTypes: true })
|
|
133
|
-
} catch {
|
|
134
|
-
return []
|
|
135
|
-
}
|
|
136
|
-
const seen = new Set()
|
|
137
|
-
const found = []
|
|
138
|
-
|
|
139
|
-
/** Add one candidate if it exists; deduplicate by real path. */
|
|
140
|
-
const collect = async (candidate) => {
|
|
141
|
-
try {
|
|
142
|
-
await access(candidate)
|
|
143
|
-
const real = await realpath(candidate)
|
|
144
|
-
if (seen.has(real)) return
|
|
145
|
-
seen.add(real)
|
|
146
|
-
found.push(candidate)
|
|
147
|
-
} catch {
|
|
148
|
-
/* not present at this location */
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Ask Node's resolver where this profile would load the package from. */
|
|
153
|
-
const collectByResolve = async (profileDir) => {
|
|
154
|
-
try {
|
|
155
|
-
const require = createRequire(path.join(profileDir, 'package.json'))
|
|
156
|
-
await collect(require.resolve('@deepseek-ai/dsh-client-ui-skill/lib/client.js'))
|
|
157
|
-
} catch {
|
|
158
|
-
/* not resolvable from this profile */
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// 1. shared core root (global installs) — see the doc comment above.
|
|
163
|
-
await collect(path.join(profilesDir, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
164
|
-
|
|
165
|
-
for (const entry of profiles) {
|
|
166
|
-
// `node_modules` is a sibling of the profiles, not a profile itself.
|
|
167
|
-
if (entry.name === 'node_modules') continue
|
|
168
|
-
// A profile directory may itself be reached through a link; follow it
|
|
169
|
-
// instead of relying on the lstat-based dirent (see dir-entry.js).
|
|
170
|
-
if (!(await isDirectoryEntry(profilesDir, entry))) continue
|
|
171
|
-
// 2. the user's local patched copy (what a linked profile actually loads).
|
|
172
|
-
await collect(path.join(profilesDir, entry.name, 'local', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
173
|
-
// 3. the plain npm install inside the profile.
|
|
174
|
-
await collect(path.join(profilesDir, entry.name, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
175
|
-
// Defensive: whatever Node itself would resolve (dupes removed above).
|
|
176
|
-
await collectByResolve(path.join(profilesDir, entry.name))
|
|
177
|
-
}
|
|
178
|
-
return found
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Apply both patches to one ui-skill client.js. Idempotent: already-applied
|
|
183
|
-
* patches are reported as skipped; the original file is backed up once before
|
|
184
|
-
* the first modification. Never throws for a patch that does not match (it is
|
|
185
|
-
* reported as `noop`), only for actual I/O failures.
|
|
186
|
-
* @param {string} file - absolute path to the target client.js.
|
|
187
|
-
* @returns {Promise<{file: string, patched: string[], skipped: string[], noop: string[]}>}
|
|
188
|
-
*/
|
|
189
|
-
export async function patchUiSkillFile(file) {
|
|
190
|
-
const text = await readFile(file, 'utf8')
|
|
191
|
-
const result = { file, patched: [], skipped: [], noop: [] }
|
|
192
|
-
let next = text
|
|
193
|
-
for (const patch of PATCHES) {
|
|
194
|
-
if (patch.isApplied(next)) {
|
|
195
|
-
result.skipped.push(patch.id)
|
|
196
|
-
continue
|
|
197
|
-
}
|
|
198
|
-
const candidate = patch.apply(next)
|
|
199
|
-
if (candidate === next) {
|
|
200
|
-
result.noop.push(patch.id)
|
|
201
|
-
continue
|
|
202
|
-
}
|
|
203
|
-
next = candidate
|
|
204
|
-
result.patched.push(patch.id)
|
|
205
|
-
}
|
|
206
|
-
if (result.patched.length === 0) return result
|
|
207
|
-
const backup = `${file}.dsh-skill-picker.bak`
|
|
208
|
-
try {
|
|
209
|
-
await access(backup)
|
|
210
|
-
} catch {
|
|
211
|
-
await copyFile(file, backup)
|
|
212
|
-
}
|
|
213
|
-
// Write through a temp file + rename instead of an in-place `writeFile`:
|
|
214
|
-
// - pnpm installs are HARDLINKED to a shared content-addressable store, so
|
|
215
|
-
// writing in place would mutate that shared inode and silently change
|
|
216
|
-
// every other project using the same package version. rename() swaps the
|
|
217
|
-
// directory entry instead, leaving the shared inode untouched.
|
|
218
|
-
// - rename is also atomic, so an interrupted boot cannot leave a torn file.
|
|
219
|
-
const tmp = `${file}.dsh-skill-picker.tmp`
|
|
220
|
-
await writeFile(tmp, next, 'utf8')
|
|
221
|
-
await rename(tmp, file)
|
|
222
|
-
return result
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Self-heal entry point: scan all profiles, patch every ui-skill copy found,
|
|
227
|
-
* and return one combined report. Never throws — each failure is collected
|
|
228
|
-
* into `errors` so the host boot is never taken down by a broken patch.
|
|
229
|
-
* @returns {Promise<{files: Array, errors: string[]}>}
|
|
230
|
-
*/
|
|
231
|
-
export async function healUiSkillPatches() {
|
|
232
|
-
const files = await uiSkillClientPaths()
|
|
233
|
-
const filesReport = []
|
|
234
|
-
const errors = []
|
|
235
|
-
for (const file of files) {
|
|
236
|
-
try {
|
|
237
|
-
filesReport.push(await patchUiSkillFile(file))
|
|
238
|
-
} catch (error) {
|
|
239
|
-
errors.push(`${file}: ${String(error?.message ?? error)}`)
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
// Loud on the empty case. Silence here was the most confusing part of
|
|
243
|
-
// issue #7: "found 0 targets" and "everything already applied" used to print
|
|
244
|
-
// identically, so nobody could tell the patch had never run at all.
|
|
245
|
-
if (files.length === 0) {
|
|
246
|
-
console.warn('[dsh-skill-picker] ui-skill patch: 0 target client.js found under '
|
|
247
|
-
+ `${path.join(dshHome(), 'profiles')} — fuzzy+pinyin matching will NOT be applied. `
|
|
248
|
-
+ 'See https://github.com/a735624258/dsh-skill-picker/issues/7')
|
|
249
|
-
}
|
|
250
|
-
return { files: filesReport, errors }
|
|
251
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-skill-picker — host-side self-healing patch for the official
|
|
3
|
+
* `@deepseek-ai/dsh-client-ui-skill` package.
|
|
4
|
+
*
|
|
5
|
+
* The picker upgrades the official `/` completion in two ways that the
|
|
6
|
+
* official package does not provide out of the box:
|
|
7
|
+
*
|
|
8
|
+
* 1. `order: 2 → -1` — the skill group sorts ABOVE the command group
|
|
9
|
+
* (commands register with the default order 0; lower = higher in the
|
|
10
|
+
* official menu).
|
|
11
|
+
* 2. fuzzy+pinyin candidates — the official prefix-only matcher
|
|
12
|
+
* (`skill.name.startsWith(query)`) is replaced by the picker's
|
|
13
|
+
* `window.__dshSkillPickerFuzzy` matcher when the picker is mounted
|
|
14
|
+
* (single source group, same list, upgraded matching).
|
|
15
|
+
*
|
|
16
|
+
* Every DSH boot this module scans every profile under `$DSH_HOME/profiles`
|
|
17
|
+
* (default `~/.dsh/profiles`) for an installed ui-skill `lib/client.js` — the
|
|
18
|
+
* shared core root (`profiles/node_modules/@deepseek-ai/…`, used by global
|
|
19
|
+
* installs), the user's local patched copy (`local/dsh-client-ui-skill`), or
|
|
20
|
+
* the plain npm install (`node_modules/@deepseek-ai/dsh-client-ui-skill`) — and
|
|
21
|
+
* re-applies both patches when DSH upgrades overwrote them. The original file
|
|
22
|
+
* is backed up once as `<file>.dsh-skill-picker.bak` before the first write.
|
|
23
|
+
* All operations are idempotent and never throw: a missing profile, package
|
|
24
|
+
* or read failure is reported and skipped so a broken patch can never take
|
|
25
|
+
* the host down.
|
|
26
|
+
*
|
|
27
|
+
* @module dsh-skill-picker/patch-ui-skill
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFile, writeFile, copyFile, readdir, access, realpath, rename } from 'node:fs/promises'
|
|
31
|
+
import { createRequire } from 'node:module'
|
|
32
|
+
import os from 'node:os'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
|
|
35
|
+
import { isDirectoryEntry } from './dir-entry.js'
|
|
36
|
+
|
|
37
|
+
/** Marker that the candidates patch is already in place. */
|
|
38
|
+
const FUZZY_MARKER = '__dshSkillPickerFuzzy'
|
|
39
|
+
|
|
40
|
+
/** Marker that the pick-tracking patch is already in place. */
|
|
41
|
+
const TRACK_MARKER = '__dshSkillPickerTrack'
|
|
42
|
+
|
|
43
|
+
/** The self-healing patches, in application order. */
|
|
44
|
+
export const PATCHES = [
|
|
45
|
+
{
|
|
46
|
+
id: 'order',
|
|
47
|
+
title: 'skill group order 2 → -1 (above commands)',
|
|
48
|
+
isApplied(text) {
|
|
49
|
+
return /name: "skill",[\s\S]*?order:\s*-1,/.test(text)
|
|
50
|
+
},
|
|
51
|
+
apply(text) {
|
|
52
|
+
return text.replace(/(name: "skill",\s*order: )2,/, '$1-1,')
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'fuzzy-candidates',
|
|
57
|
+
title: 'prefix/rank matcher → fuzzy+pinyin matcher',
|
|
58
|
+
isApplied(text) {
|
|
59
|
+
return text.includes(FUZZY_MARKER)
|
|
60
|
+
},
|
|
61
|
+
apply(text) {
|
|
62
|
+
// 官方实现随版本变过两次,两个形态都要认(2026-09-11 实测):
|
|
63
|
+
// 0.1.2-alpha.x : return skills.filter((skill) => skill.name.startsWith(query)).map(...)
|
|
64
|
+
// 0.1.5-rc.x : return (0, _xxx.rankByName)(skills, query).map(...) ← 改成 async candidates
|
|
65
|
+
// 每组捕获:$1 = 缩进,$2 = 官方那一段表达式(原样保留做 fallback)。
|
|
66
|
+
const ANCHORS = [
|
|
67
|
+
/(\t*)return (\(0, [\w.$]+\.rankByName\)\(skills, query\)|rankByName\(skills, query\))\.map\(\(skill\) => \(\{/,
|
|
68
|
+
/(\t*)return (skills\.filter\(\(skill\) => skill\.name\.startsWith\(query\)\))\.map\(\(skill\) => \(\{/,
|
|
69
|
+
]
|
|
70
|
+
for (const re of ANCHORS) {
|
|
71
|
+
const m = text.match(re)
|
|
72
|
+
if (!m) continue
|
|
73
|
+
const [, indent, official] = m
|
|
74
|
+
return text.replace(
|
|
75
|
+
re,
|
|
76
|
+
`${indent}// dsh-skill-picker patch: fuzzy+pinyin matcher (self-healed)\n` +
|
|
77
|
+
`${indent}const officialMatcher = ${official};\n` +
|
|
78
|
+
`${indent}const matcher = typeof window.${FUZZY_MARKER} === "function" ? window.${FUZZY_MARKER}(skills, query) : officialMatcher;\n` +
|
|
79
|
+
`${indent}return matcher.map((skill) => ({`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
return text
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'pick-tracking',
|
|
87
|
+
title: 'record usage when picked from the official / menu',
|
|
88
|
+
isApplied(text) {
|
|
89
|
+
return text.includes(TRACK_MARKER)
|
|
90
|
+
},
|
|
91
|
+
apply(text) {
|
|
92
|
+
return text.replace(
|
|
93
|
+
/(\t*)onPick\(\{ candidate \}\) \{\n(\t*)return \{ text: `\/\$\{candidate\.name\} ` \};\n(\t*)\}/,
|
|
94
|
+
(match, i1, i2, i3) =>
|
|
95
|
+
`${i1}onPick({ candidate }) {\n` +
|
|
96
|
+
`${i2} // dsh-skill-picker patch: usage tracking (self-healed)\n` +
|
|
97
|
+
`${i2} try { window.${TRACK_MARKER}?.(candidate.name) } catch { /* best-effort */ }\n` +
|
|
98
|
+
`${i2} return { text: \`/\${candidate.name} \` };\n` +
|
|
99
|
+
`${i3}}`,
|
|
100
|
+
)
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/** Resolve the DSH home directory, mirroring the official convention. */
|
|
106
|
+
export function dshHome() {
|
|
107
|
+
return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Enumerate every installed ui-skill `lib/client.js` across all profiles.
|
|
112
|
+
*
|
|
113
|
+
* Three layouts are covered (deduplicated by real path):
|
|
114
|
+
* 1. the **shared core root** `profiles/node_modules/@deepseek-ai/…` — used by
|
|
115
|
+
* a global `npm i -g @deepseek-ai/dsh`, where the official package is NOT
|
|
116
|
+
* below any single profile (issue #7 — missing this made the whole patch
|
|
117
|
+
* silently no-op for those installs);
|
|
118
|
+
* 2. the user's local patched copy (`profiles/<p>/local/dsh-client-ui-skill`)
|
|
119
|
+
* — that is what a linked profile actually loads;
|
|
120
|
+
* 3. the plain npm install inside a profile
|
|
121
|
+
* (`profiles/<p>/node_modules/@deepseek-ai/…`).
|
|
122
|
+
*
|
|
123
|
+
* As a defensive extra, each profile is also asked through Node's own resolver
|
|
124
|
+
* (`createRequire().resolve()`), so a layout we did not enumerate still works.
|
|
125
|
+
* Never throws — a missing profiles dir yields [].
|
|
126
|
+
* @returns {Promise<string[]>} candidate file paths.
|
|
127
|
+
*/
|
|
128
|
+
export async function uiSkillClientPaths() {
|
|
129
|
+
const profilesDir = path.join(dshHome(), 'profiles')
|
|
130
|
+
let profiles
|
|
131
|
+
try {
|
|
132
|
+
profiles = await readdir(profilesDir, { withFileTypes: true })
|
|
133
|
+
} catch {
|
|
134
|
+
return []
|
|
135
|
+
}
|
|
136
|
+
const seen = new Set()
|
|
137
|
+
const found = []
|
|
138
|
+
|
|
139
|
+
/** Add one candidate if it exists; deduplicate by real path. */
|
|
140
|
+
const collect = async (candidate) => {
|
|
141
|
+
try {
|
|
142
|
+
await access(candidate)
|
|
143
|
+
const real = await realpath(candidate)
|
|
144
|
+
if (seen.has(real)) return
|
|
145
|
+
seen.add(real)
|
|
146
|
+
found.push(candidate)
|
|
147
|
+
} catch {
|
|
148
|
+
/* not present at this location */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Ask Node's resolver where this profile would load the package from. */
|
|
153
|
+
const collectByResolve = async (profileDir) => {
|
|
154
|
+
try {
|
|
155
|
+
const require = createRequire(path.join(profileDir, 'package.json'))
|
|
156
|
+
await collect(require.resolve('@deepseek-ai/dsh-client-ui-skill/lib/client.js'))
|
|
157
|
+
} catch {
|
|
158
|
+
/* not resolvable from this profile */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 1. shared core root (global installs) — see the doc comment above.
|
|
163
|
+
await collect(path.join(profilesDir, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
164
|
+
|
|
165
|
+
for (const entry of profiles) {
|
|
166
|
+
// `node_modules` is a sibling of the profiles, not a profile itself.
|
|
167
|
+
if (entry.name === 'node_modules') continue
|
|
168
|
+
// A profile directory may itself be reached through a link; follow it
|
|
169
|
+
// instead of relying on the lstat-based dirent (see dir-entry.js).
|
|
170
|
+
if (!(await isDirectoryEntry(profilesDir, entry))) continue
|
|
171
|
+
// 2. the user's local patched copy (what a linked profile actually loads).
|
|
172
|
+
await collect(path.join(profilesDir, entry.name, 'local', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
173
|
+
// 3. the plain npm install inside the profile.
|
|
174
|
+
await collect(path.join(profilesDir, entry.name, 'node_modules', '@deepseek-ai', 'dsh-client-ui-skill', 'lib', 'client.js'))
|
|
175
|
+
// Defensive: whatever Node itself would resolve (dupes removed above).
|
|
176
|
+
await collectByResolve(path.join(profilesDir, entry.name))
|
|
177
|
+
}
|
|
178
|
+
return found
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Apply both patches to one ui-skill client.js. Idempotent: already-applied
|
|
183
|
+
* patches are reported as skipped; the original file is backed up once before
|
|
184
|
+
* the first modification. Never throws for a patch that does not match (it is
|
|
185
|
+
* reported as `noop`), only for actual I/O failures.
|
|
186
|
+
* @param {string} file - absolute path to the target client.js.
|
|
187
|
+
* @returns {Promise<{file: string, patched: string[], skipped: string[], noop: string[]}>}
|
|
188
|
+
*/
|
|
189
|
+
export async function patchUiSkillFile(file) {
|
|
190
|
+
const text = await readFile(file, 'utf8')
|
|
191
|
+
const result = { file, patched: [], skipped: [], noop: [] }
|
|
192
|
+
let next = text
|
|
193
|
+
for (const patch of PATCHES) {
|
|
194
|
+
if (patch.isApplied(next)) {
|
|
195
|
+
result.skipped.push(patch.id)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
const candidate = patch.apply(next)
|
|
199
|
+
if (candidate === next) {
|
|
200
|
+
result.noop.push(patch.id)
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
next = candidate
|
|
204
|
+
result.patched.push(patch.id)
|
|
205
|
+
}
|
|
206
|
+
if (result.patched.length === 0) return result
|
|
207
|
+
const backup = `${file}.dsh-skill-picker.bak`
|
|
208
|
+
try {
|
|
209
|
+
await access(backup)
|
|
210
|
+
} catch {
|
|
211
|
+
await copyFile(file, backup)
|
|
212
|
+
}
|
|
213
|
+
// Write through a temp file + rename instead of an in-place `writeFile`:
|
|
214
|
+
// - pnpm installs are HARDLINKED to a shared content-addressable store, so
|
|
215
|
+
// writing in place would mutate that shared inode and silently change
|
|
216
|
+
// every other project using the same package version. rename() swaps the
|
|
217
|
+
// directory entry instead, leaving the shared inode untouched.
|
|
218
|
+
// - rename is also atomic, so an interrupted boot cannot leave a torn file.
|
|
219
|
+
const tmp = `${file}.dsh-skill-picker.tmp`
|
|
220
|
+
await writeFile(tmp, next, 'utf8')
|
|
221
|
+
await rename(tmp, file)
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Self-heal entry point: scan all profiles, patch every ui-skill copy found,
|
|
227
|
+
* and return one combined report. Never throws — each failure is collected
|
|
228
|
+
* into `errors` so the host boot is never taken down by a broken patch.
|
|
229
|
+
* @returns {Promise<{files: Array, errors: string[]}>}
|
|
230
|
+
*/
|
|
231
|
+
export async function healUiSkillPatches() {
|
|
232
|
+
const files = await uiSkillClientPaths()
|
|
233
|
+
const filesReport = []
|
|
234
|
+
const errors = []
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
try {
|
|
237
|
+
filesReport.push(await patchUiSkillFile(file))
|
|
238
|
+
} catch (error) {
|
|
239
|
+
errors.push(`${file}: ${String(error?.message ?? error)}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Loud on the empty case. Silence here was the most confusing part of
|
|
243
|
+
// issue #7: "found 0 targets" and "everything already applied" used to print
|
|
244
|
+
// identically, so nobody could tell the patch had never run at all.
|
|
245
|
+
if (files.length === 0) {
|
|
246
|
+
console.warn('[dsh-skill-picker] ui-skill patch: 0 target client.js found under '
|
|
247
|
+
+ `${path.join(dshHome(), 'profiles')} — fuzzy+pinyin matching will NOT be applied. `
|
|
248
|
+
+ 'See https://github.com/a735624258/dsh-skill-picker/issues/7')
|
|
249
|
+
}
|
|
250
|
+
return { files: filesReport, errors }
|
|
251
|
+
}
|