dsh-skill-picker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 a735624258
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # dsh-skill-picker
2
+
3
+ > **技能记不住名字?官方 `/` 补全靠前缀记忆,装了几十个技能谁记得住?** 本插件让技能**看得见、翻得到、选得快**——点一下 ⚡,全部技能带描述排在你面前,搜索、点选、插入,随消息发出自动加载。
4
+
5
+ DSH Web GUI 的技能选择器:在输入框(composer)工具行右侧加一个按钮,点开可以**搜索并点选已安装的技能**,选中后把官方 `/技能名` 手势插入发送框——随消息一起发出,DSH 原生机制就会自动加载该技能并执行。WorkBuddy 式"把技能写进发送框"的交互,DeepSeek Harness 复刻版。
6
+
7
+ English: A skill picker for the DSH Web GUI — a button in the composer's right tool row opens a searchable list of installed skills; picking one inserts the official `/skill-name` gesture into the draft, so DSH's native user-invocation path loads the skill with your message.
8
+
9
+ ## 为什么用它(vs 官方 `/` 补全)
10
+
11
+ 官方内置了 `/` 技能补全,但它是**记忆驱动**的——你得先记得技能名,打 `/` + 前缀才能过滤出来。技能一多就抓瞎:
12
+
13
+ | | 官方 `/` 补全 | dsh-skill-picker |
14
+ |---|---|---|
15
+ | 触发 | 输入框打 `/` | 输入框旁 ⚡ 按钮 |
16
+ | 查找方式 | 前缀记忆驱动,**忘了名字就找不到** | 全列表浏览 + 关键字搜索,**忘了名字也能翻到** |
17
+ | 排序 | 固定 | **最近使用置顶、常用靠前** |
18
+ | 描述可见 | 精简 | 完整描述一眼看全 |
19
+
20
+ **记得名字用官方,忘了名字用本插件——两者互补,可同时使用。**
21
+
22
+ ## 特性
23
+
24
+ - ⚡ 一键弹出全部技能(闪电图标,人人看得懂)
25
+ - 🔍 实时搜索(技能名 / 描述都搜)
26
+ - 🧠 **最近使用置顶、常用靠前**的智能排序(WorkBuddy 同款)
27
+ - 📋 直接扫描 `~/.dsh/skills`(官方技能 provider 同源目录,标准安装即用)
28
+ - 🧩 插入官方 `/技能名` 手势,加载/执行走 DSH 原生机制,**零 agent 侧改动**
29
+ - 🎨 跟随 Web UI 主题(CSS 变量),浅色/深色自适应
30
+ - 📦 纯 client + host 双半插件,无第三方运行时依赖
31
+
32
+ ## 安装
33
+
34
+ ```sh
35
+ # 方式一:GitHub 克隆 + link(推荐,无需发布 npm)
36
+ git clone https://github.com/a735624258/dsh-skill-picker.git
37
+ dsh plugin --profile web add link:/path/to/dsh-skill-picker
38
+
39
+ # 方式二:Git 依赖直装
40
+ dsh plugin --profile web add "github:a735624258/dsh-skill-picker"
41
+
42
+ # 方式三:发布到 npm 后(预构建安装,体验最佳)
43
+ dsh plugin --profile web add dsh-skill-picker
44
+ ```
45
+
46
+ > 注:目前**尚未发布 npm**(`npm view dsh-skill-picker` 会 404),请用方式一或方式二。
47
+ > 若 `dsh` 命令因 PowerShell 执行策略被拒(`File ... cannot be loaded`),用:
48
+ > `powershell -ExecutionPolicy Bypass -Command "dsh plugin --profile web add link:C:\path\to\dsh-skill-picker"`
49
+
50
+ 重启 `dsh web`(或刷新页面加载新 bundle)后生效。
51
+
52
+ ## 用法
53
+
54
+ 1. 打开任一会话,在输入框工具行右侧找到**⚡ 按钮**
55
+ 2. 点击弹出技能列表(可输入关键字过滤)
56
+ 3. 点选技能 → 发送框自动出现 `/技能名 `
57
+ 4. 继续输入你的话并发送——DSH 会识别 `/技能名` 手势,自动加载该技能并按其指令执行
58
+
59
+ 示例:点选 `duo-xuan-pi-gai` 后发送框变为 `/duo-xuan-pi-gai 帮我批改多选`,发送后技能自动加载。
60
+
61
+ ## 原理
62
+
63
+ DSH 的 [dsh-tool-skill](https://github.com/deepseek-ai/deepseek-harness) 在 `agent/pre-step` 阶段扫描用户消息中的 `/kebab-case-name` 手势(`SKILL_GESTURE` 正则),命中后把对应技能内容作为 `skill-invocation` 注入对话——即"用户消息里写 `/技能名` 就会自动加载技能"是官方既有能力,只是没有 UI。
64
+
65
+ 本插件只补 UI 一层:
66
+
67
+ ```
68
+ [client] ⚡ 按钮 → fetch('/dsh-skill-picker/skills')
69
+
70
+ [host] 扫描用户级 $DSH_HOME/skills + 项目级 <cwd>/.dsh/skills 等 → 技能目录(name + description)
71
+
72
+ [client] 点选 → inputActions.setDraft(draft + '/技能名 ')
73
+
74
+ [DSH] agent/pre-step 识别手势 → 自动加载技能 → 执行
75
+ ```
76
+
77
+ - client 半:注册到官方 `conversation.input.right` 插槽(composer 工具行、发送按钮左侧的控件位),**技能列表优先走官方宿主 skills API**(`connection.api.skills.list`——与 DSH 内置 `/` 补全同源,会话作用域,自动含用户级/项目级技能),失败时回退到 host 扫描路由;插入文本走框架输入机的 `inputActions.setDraft`(单一路径,撤销/草稿持久化自动处理);最近/常用排序存 localStorage
78
+
79
+ ## 与官方 `/` 补全的关系
80
+
81
+ DSH 官方已内置技能补全:在输入框输入 `/` 会弹出技能菜单,按前缀过滤(如输入 `/sk` 列出 skill 开头的技能)。本插件不替代它,而是补上官方方案的盲区:
82
+
83
+ | | 官方 `/` 补全 | dsh-skill-picker |
84
+ |---|---|---|
85
+ | 触发 | 输入框打 `/` | 输入框旁 ⚡ 按钮 |
86
+ | 查找方式 | **前缀记忆驱动**——需要先记得技能名才能打出来 | **全列表浏览 + 关键字搜索**——忘了名字也能翻到 |
87
+ | 排序 | 固定顺序 | 最近使用置顶、常用靠前(localStorage) |
88
+ | 适合场景 | 记得名字的老手 | 技能多、记不全名字、想翻着选的人 |
89
+
90
+ 一句话:**记得名字用官方,忘了名字用本插件**。两者互补,可同时使用。
91
+
92
+ ## 兼容性与注意事项
93
+
94
+ - **技能来源**:**优先走官方宿主 skills API**(`connection.api.skills.list`——与 DSH 内置 `/` 补全**完全同一个数据源**,会话作用域,自动覆盖用户级 `~/.dsh/skills`、项目级 `<workspace>/.dsh/skills`、`<workspace>/.agents/skills` 等全部官方目录);官方 API 不可用时**自动回退**到内置扫描(用户级 + 项目级目录)。两条路都支持 `DSH_HOME` 环境变量。
95
+ - **暂不扫描**:`~/.agents/skills` 与自定义技能目录(`customSkillDirs` 配置)——需要的话欢迎 PR。
96
+ - **失败保护**:client 端用 `ctx.slots.inject`(等 `conversation.input.right` 插槽声明存在才注册,插槽缺失时静默跳过,不会拖垮启动);host 端路由 try/catch,扫描目录不存在时返回空列表而非报错。
97
+ - **依赖版本**:按 DSH `0.1.0-rc.6` API 编写(cordis 4 / web profile 标准装配)。如遇 DSH 大版本更新导致 API 变化,插件会以启动日志的插件错误提示为准,卸载 `dsh plugin --profile web remove dsh-skill-picker` 即可回退。
98
+
99
+ ## 开发
100
+
101
+ ```sh
102
+ # 安装依赖(提供 esbuild)
103
+ npm install
104
+
105
+ # 构建(源码 src/ → 产物 lib/;client 半自动包 __ModuleLoader__ 握手)
106
+ npm run build
107
+
108
+ # 安装到 web profile(link 模式,改源码即生效)
109
+ dsh plugin --profile web add link:$PWD
110
+
111
+ # 语法自检(产物)
112
+ node --check lib/index.js
113
+ node --check lib/client.js
114
+ ```
115
+
116
+ > ⚠️ 改完源码**必须 `npm run build`**:`lib/client.js` 是构建产物,ESM 源码不能直接作
117
+ > 为 client bundle 加载——DSH web shell 要求 client bundle 以
118
+ > `window.__ModuleLoader__.load({ id, factory })` 的 CJS 握手格式注册,否则启动报
119
+ > `loaded without registering "dsh-skill-picker" via __ModuleLoader__.load`。
120
+ > 构建脚本(`build.mjs`)会通过 esbuild 的 banner/footer 自动注入这段握手。
121
+
122
+ 目录结构:
123
+
124
+ ```
125
+ dsh-skill-picker/
126
+ ├── package.json # dsh.bundle.patch + dsh.client 声明 + build script
127
+ ├── cordis.patch.yml # bundle patch:把插件行插入 web profile
128
+ ├── build.mjs # esbuild 构建:host ESM + client CJS(__ModuleLoader__握手)
129
+ ├── src/
130
+ │ ├── index.js # host 半源码:/dsh-skill-picker/skills 路由 + prompt section
131
+ │ └── client/
132
+ │ └── index.jsx # client 半源码:conversation.input.right 插槽组件
133
+ ├── lib/ # 构建产物(勿手改,`npm run build` 生成)
134
+ │ ├── index.js
135
+ │ └── client.js
136
+ └── README.md
137
+ ```
138
+
139
+ ## 依赖
140
+
141
+ - host:`@deepseek-ai/cordis`、`@deepseek-ai/dsh-host-webserver`、`@deepseek-ai/dsh-skill`、`@deepseek-ai/dsh-system-prompt`
142
+ - client:`@deepseek-ai/dsh-client-runtime`、`@deepseek-ai/dsh-client-ui-slots`、`react`
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,12 @@
1
+ # dsh-skill-picker bundle patch: inserts the skill-picker plugin row into the
2
+ # web profile roster. Applied as a profile bundle layer (the `dsh.bundle.patch`
3
+ # manifest field); install with
4
+ # `dsh plugin --profile web add <npm-or-link>`.
5
+ #
6
+ # The row is a bare plugin by package name: the node half (exports ".") runs
7
+ # in the host process (skills catalog route + agent guidance section), and the
8
+ # `dsh.client` declaration in package.json makes the browser half
9
+ # (exports "./client") load in the web GUI.
10
+ - insert:
11
+ - id: dsh-skill-picker
12
+ name: 'dsh-skill-picker'
package/lib/client.js ADDED
@@ -0,0 +1,331 @@
1
+ window.__ModuleLoader__.load({ id: 'dsh-skill-picker', factory: (require) => { var module = { exports: {} }; var exports = module.exports;
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/client/index.jsx
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ apply: () => apply,
34
+ inject: () => inject
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+ var import_react = __toESM(require("react"), 1);
38
+ var import_jsx_runtime = require("react/jsx-runtime");
39
+ var inject = ["slots", "connection", "sessions"];
40
+ var USAGE_KEY = "dsh-skill-picker:usage";
41
+ function loadUsage() {
42
+ try {
43
+ const raw = localStorage.getItem(USAGE_KEY);
44
+ if (raw === null) return {};
45
+ const parsed = JSON.parse(raw);
46
+ return parsed !== null && typeof parsed === "object" ? parsed : {};
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+ function saveUsage(usage) {
52
+ try {
53
+ localStorage.setItem(USAGE_KEY, JSON.stringify(usage));
54
+ } catch {
55
+ }
56
+ }
57
+ var buttonStyle = {
58
+ display: "inline-flex",
59
+ alignItems: "center",
60
+ justifyContent: "center",
61
+ width: "28px",
62
+ height: "28px",
63
+ margin: "0 2px",
64
+ padding: "0",
65
+ border: "1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))",
66
+ borderRadius: "8px",
67
+ background: "transparent",
68
+ color: "var(--dsw-alias-label-secondary, #c9d2e0)",
69
+ cursor: "pointer",
70
+ fontSize: "15px",
71
+ lineHeight: "1",
72
+ flex: "none"
73
+ };
74
+ var popoverStyle = {
75
+ position: "absolute",
76
+ bottom: "calc(100% + 8px)",
77
+ right: "0",
78
+ width: "340px",
79
+ maxHeight: "320px",
80
+ display: "flex",
81
+ flexDirection: "column",
82
+ background: "var(--dsw-specific-tip, #1e2533)",
83
+ border: "1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35))",
84
+ borderRadius: "12px",
85
+ boxShadow: "0 8px 28px rgba(0,0,0,0.35)",
86
+ overflow: "hidden",
87
+ zIndex: 1e3
88
+ };
89
+ var searchStyle = {
90
+ boxSizing: "border-box",
91
+ width: "calc(100% - 16px)",
92
+ margin: "8px",
93
+ padding: "6px 10px",
94
+ border: "1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3))",
95
+ borderRadius: "8px",
96
+ background: "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1))",
97
+ color: "var(--dsw-alias-label-primary, #e6ebf2)",
98
+ fontSize: "13px",
99
+ outline: "none"
100
+ };
101
+ var listStyle = {
102
+ overflowY: "auto",
103
+ flex: "auto",
104
+ padding: "0 6px 8px"
105
+ };
106
+ var itemStyle = {
107
+ display: "flex",
108
+ flexDirection: "column",
109
+ alignItems: "flex-start",
110
+ gap: "2px",
111
+ width: "100%",
112
+ padding: "7px 10px",
113
+ border: "none",
114
+ borderRadius: "8px",
115
+ background: "transparent",
116
+ color: "var(--dsw-alias-label-primary, #e6ebf2)",
117
+ cursor: "pointer",
118
+ textAlign: "left"
119
+ };
120
+ var nameStyle = {
121
+ fontFamily: "var(--ds-font-family-code, ui-monospace, monospace)",
122
+ fontSize: "13px",
123
+ fontWeight: 500
124
+ };
125
+ var descStyle = {
126
+ color: "var(--dsw-alias-label-tertiary, #8a94a6)",
127
+ fontSize: "12px",
128
+ lineHeight: "16px",
129
+ overflow: "hidden",
130
+ textOverflow: "ellipsis",
131
+ whiteSpace: "nowrap",
132
+ maxWidth: "100%"
133
+ };
134
+ var statusStyle = {
135
+ padding: "12px",
136
+ color: "var(--dsw-alias-label-tertiary, #8a94a6)",
137
+ fontSize: "13px"
138
+ };
139
+ function BoltIcon() {
140
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { viewBox: "0 0 24 24", width: "20", height: "20", "aria-hidden": "true", style: { display: "block" }, children: [
141
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("defs", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("linearGradient", { id: "dsh-sp-bolt-grad", x1: "0", y1: "0", x2: "0", y2: "1", children: [
142
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "0%", stopColor: "var(--dsw-static-deepseek-400, rgb(103, 158, 254))" }),
143
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("stop", { offset: "100%", stopColor: "var(--dsw-static-deepseek-600, rgb(72, 104, 178))" })
144
+ ] }) }),
145
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
146
+ "path",
147
+ {
148
+ d: "M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 10.42 7.54 13 3h1l-1 7h3.5c.49 0 .56.33.47.51l-.07.15C12.96 17.55 11 21 11 21z",
149
+ fill: "url(#dsh-sp-bolt-grad)",
150
+ stroke: "var(--dsw-static-deepseek-600, rgb(72, 104, 178))",
151
+ strokeWidth: "0.8",
152
+ strokeLinejoin: "round"
153
+ }
154
+ )
155
+ ] });
156
+ }
157
+ function SkillPickerButton(props) {
158
+ const [open, setOpen] = (0, import_react.useState)(false);
159
+ const [skills, setSkills] = (0, import_react.useState)(void 0);
160
+ const [error, setError] = (0, import_react.useState)(void 0);
161
+ const [query, setQuery] = (0, import_react.useState)("");
162
+ const [usage, setUsage] = (0, import_react.useState)(() => loadUsage());
163
+ const boxRef = (0, import_react.useRef)(null);
164
+ const load = (0, import_react.useCallback)(async () => {
165
+ if (skills !== void 0 || error !== void 0) return;
166
+ try {
167
+ if (typeof props.listSkills === "function" && props.session?.sessionId !== void 0) {
168
+ const listed = await props.listSkills(props.session.sessionId);
169
+ setSkills(Array.isArray(listed) ? listed : []);
170
+ return;
171
+ }
172
+ } catch (cause) {
173
+ console.warn("[dsh-skill-picker] official skills API failed, falling back to host route:", cause);
174
+ }
175
+ try {
176
+ const cwd = typeof props.cwd === "string" && props.cwd !== "" ? `?cwd=${encodeURIComponent(props.cwd)}` : "";
177
+ const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: "application/json" } });
178
+ const json = await res.json();
179
+ if (!json.ok) throw new Error(json.error || "bad response");
180
+ setSkills(Array.isArray(json.skills) ? json.skills : []);
181
+ } catch (cause) {
182
+ setError(String(cause?.message ?? cause));
183
+ }
184
+ }, [skills, error, props.listSkills, props.session, props.cwd]);
185
+ const toggle = () => {
186
+ if (!open) void load();
187
+ setOpen(!open);
188
+ };
189
+ const pick = (name) => {
190
+ let draft = "";
191
+ try {
192
+ if (props.input !== void 0 && typeof props.input.draft === "string") {
193
+ draft = props.input.draft;
194
+ } else if (typeof props.useInput === "function") {
195
+ const state = props.useInput((s) => s);
196
+ if (state !== void 0 && typeof state.draft === "string") draft = state.draft;
197
+ }
198
+ } catch (cause) {
199
+ console.error("[dsh-skill-picker] reading draft failed:", cause);
200
+ }
201
+ const separator = draft === "" || draft.endsWith(" ") || draft.endsWith("\n") ? "" : " ";
202
+ const next = `${draft}${separator}/${name} `;
203
+ try {
204
+ if (typeof props.inputActions?.setDraft === "function") {
205
+ props.inputActions.setDraft(next);
206
+ } else {
207
+ console.error("[dsh-skill-picker] inputActions.setDraft unavailable; draft not written:", next);
208
+ }
209
+ } catch (cause) {
210
+ console.error("[dsh-skill-picker] setDraft failed:", cause);
211
+ }
212
+ const nextUsage = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } };
213
+ setUsage(nextUsage);
214
+ saveUsage(nextUsage);
215
+ setOpen(false);
216
+ setQuery("");
217
+ };
218
+ (0, import_react.useEffect)(() => {
219
+ if (!open) return;
220
+ const onDown = (event) => {
221
+ if (boxRef.current !== null && !boxRef.current.contains(event.target)) setOpen(false);
222
+ };
223
+ document.addEventListener("mousedown", onDown);
224
+ return () => document.removeEventListener("mousedown", onDown);
225
+ }, [open]);
226
+ const ordered = (skills ?? []).slice().sort((a, b) => {
227
+ const ua = usage[a.name];
228
+ const ub = usage[b.name];
229
+ const la = ua?.lastUsed ?? 0;
230
+ const lb = ub?.lastUsed ?? 0;
231
+ if (la !== lb) return lb - la;
232
+ const ca = ua?.count ?? 0;
233
+ const cb = ub?.count ?? 0;
234
+ if (ca !== cb) return cb - ca;
235
+ return a.name.localeCompare(b.name);
236
+ });
237
+ const filtered = ordered.filter((skill) => {
238
+ const q = query.trim().toLowerCase();
239
+ if (q === "") return true;
240
+ return skill.name.toLowerCase().includes(q) || String(skill.description ?? "").toLowerCase().includes(q);
241
+ }).slice(0, 60);
242
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { ref: boxRef, style: { position: "relative", display: "inline-flex", flex: "none" }, children: [
243
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
244
+ "button",
245
+ {
246
+ type: "button",
247
+ onClick: toggle,
248
+ title: "\u9009\u62E9\u6280\u80FD\uFF08\u63D2\u5165 /\u6280\u80FD\u540D \u5230\u53D1\u9001\u6846\uFF09",
249
+ "aria-label": "\u9009\u62E9\u6280\u80FD",
250
+ style: {
251
+ ...buttonStyle,
252
+ ...open ? { color: "var(--dsw-alias-label-primary-bluish, #4cc9f0)" } : {}
253
+ },
254
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(BoltIcon, {})
255
+ }
256
+ ),
257
+ open && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: popoverStyle, children: [
258
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
259
+ "input",
260
+ {
261
+ value: query,
262
+ onChange: (event) => setQuery(event.target.value),
263
+ placeholder: "\u641C\u7D22\u6280\u80FD\u2026",
264
+ style: searchStyle,
265
+ autoFocus: true
266
+ }
267
+ ),
268
+ error !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: statusStyle, children: `\u52A0\u8F7D\u5931\u8D25\uFF1A${error}` }) : skills === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: statusStyle, children: "\u52A0\u8F7D\u4E2D\u2026" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: listStyle, children: filtered.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: statusStyle, children: "\u6CA1\u6709\u5339\u914D\u7684\u6280\u80FD" }) : filtered.map((skill) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
269
+ "button",
270
+ {
271
+ type: "button",
272
+ onClick: () => pick(skill.name),
273
+ onMouseEnter: (event) => {
274
+ event.currentTarget.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))";
275
+ },
276
+ onMouseLeave: (event) => {
277
+ event.currentTarget.style.background = "transparent";
278
+ },
279
+ style: itemStyle,
280
+ children: [
281
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: nameStyle, children: `/${skill.name}` }),
282
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: descStyle, children: skill.description ?? "" })
283
+ ]
284
+ },
285
+ skill.name
286
+ )) })
287
+ ] })
288
+ ] });
289
+ }
290
+ function apply(ctx) {
291
+ const listSkills = async (sessionId) => {
292
+ const skills = ctx.connection?.api?.skills;
293
+ if (skills === void 0 || typeof skills.list !== "function") {
294
+ throw new Error("connection.api.skills unavailable");
295
+ }
296
+ const controller = new AbortController();
297
+ const { result } = await skills.list({ sessionId }, controller.signal);
298
+ if (!result.ok) throw new Error(`skill.list failed: ${result.error?.code}: ${result.error?.message}`);
299
+ const raw = result.value?.skills ?? [];
300
+ return raw.map((skill) => ({ name: skill.name, description: skill.description ?? "" }));
301
+ };
302
+ let currentCwd = "";
303
+ const syncCwd = () => {
304
+ try {
305
+ const snapshot = ctx.sessions.list.getSnapshot();
306
+ const sessionId = snapshot.current;
307
+ const cwd = sessionId === void 0 ? void 0 : snapshot.byId[sessionId]?.cwd;
308
+ currentCwd = typeof cwd === "string" ? cwd : "";
309
+ } catch {
310
+ currentCwd = "";
311
+ }
312
+ };
313
+ syncCwd();
314
+ const unsubscribe = ctx.sessions.list.subscribe(syncCwd);
315
+ ctx.effect(() => {
316
+ const PickerWithCwd = (props) => import_react.default.createElement(SkillPickerButton, { ...props, cwd: currentCwd, listSkills });
317
+ const dispose = ctx.slots.inject(
318
+ "conversation.input.right",
319
+ () => ctx.slots.register(
320
+ { name: "conversation.input.right", id: "skill-picker", order: 100, label: "Skill picker" },
321
+ PickerWithCwd
322
+ )
323
+ );
324
+ return () => {
325
+ dispose();
326
+ unsubscribe();
327
+ };
328
+ }, "dsh-skill-picker: composer input slot");
329
+ }
330
+ return module.exports; } });
331
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/client/index.jsx"],
4
+ "sourcesContent": ["/**\n * dsh-skill-picker \u2014 browser half: mounts a skill-picker control into the\n * composer's right tool row (`conversation.input.right` list slot, the seat\n * just before the send button). Clicking it opens a searchable list of\n * installed skills (fetched from the host route `/dsh-skill-picker/skills`),\n * ordered by usage: recently picked first, then frequently used, then the\n * untouched rest (by name). Picking one inserts the official `/skill-name`\n * gesture into the draft via the framework input machine\n * (`inputActions.setDraft`), so DSH's native user-invocation path loads the\n * skill with the message.\n *\n * All DOM/runtime wiring failures are logged, never thrown \u2014 the web shell\n * fails the whole boot when a plugin apply throws.\n *\n * @module dsh-skill-picker/client\n */\n\nimport React, { useCallback, useEffect, useRef, useState } from 'react'\n\n/** Required services: slot registry, host connection (official skills API), sessions (workspace cwd fallback). */\nexport const inject = ['slots', 'connection', 'sessions']\n\n/** localStorage key for the picker's per-browser usage history. */\nconst USAGE_KEY = 'dsh-skill-picker:usage'\n\n/** Read the usage history {name: {count, lastUsed}}; never throws. */\nfunction loadUsage() {\n try {\n const raw = localStorage.getItem(USAGE_KEY)\n if (raw === null) return {}\n const parsed = JSON.parse(raw)\n return parsed !== null && typeof parsed === 'object' ? parsed : {}\n } catch {\n return {}\n }\n}\n\n/** Persist the usage history; never throws. */\nfunction saveUsage(usage) {\n try {\n localStorage.setItem(USAGE_KEY, JSON.stringify(usage))\n } catch {\n /* storage unavailable \u2014 ordering just won't persist */\n }\n}\n\n/** Row height matches the resident chrome (access mode, plan, attach, model). */\nconst buttonStyle = {\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n width: '28px',\n height: '28px',\n margin: '0 2px',\n padding: '0',\n border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))',\n borderRadius: '8px',\n background: 'transparent',\n color: 'var(--dsw-alias-label-secondary, #c9d2e0)',\n cursor: 'pointer',\n fontSize: '15px',\n lineHeight: '1',\n flex: 'none',\n}\n\nconst popoverStyle = {\n position: 'absolute',\n bottom: 'calc(100% + 8px)',\n right: '0',\n width: '340px',\n maxHeight: '320px',\n display: 'flex',\n flexDirection: 'column',\n background: 'var(--dsw-specific-tip, #1e2533)',\n border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35))',\n borderRadius: '12px',\n boxShadow: '0 8px 28px rgba(0,0,0,0.35)',\n overflow: 'hidden',\n zIndex: 1000,\n}\n\nconst searchStyle = {\n boxSizing: 'border-box',\n width: 'calc(100% - 16px)',\n margin: '8px',\n padding: '6px 10px',\n border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3))',\n borderRadius: '8px',\n background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1))',\n color: 'var(--dsw-alias-label-primary, #e6ebf2)',\n fontSize: '13px',\n outline: 'none',\n}\n\nconst listStyle = {\n overflowY: 'auto',\n flex: 'auto',\n padding: '0 6px 8px',\n}\n\nconst itemStyle = {\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'flex-start',\n gap: '2px',\n width: '100%',\n padding: '7px 10px',\n border: 'none',\n borderRadius: '8px',\n background: 'transparent',\n color: 'var(--dsw-alias-label-primary, #e6ebf2)',\n cursor: 'pointer',\n textAlign: 'left',\n}\n\nconst nameStyle = {\n fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',\n fontSize: '13px',\n fontWeight: 500,\n}\n\nconst descStyle = {\n color: 'var(--dsw-alias-label-tertiary, #8a94a6)',\n fontSize: '12px',\n lineHeight: '16px',\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n whiteSpace: 'nowrap',\n maxWidth: '100%',\n}\n\nconst statusStyle = {\n padding: '12px',\n color: 'var(--dsw-alias-label-tertiary, #8a94a6)',\n fontSize: '13px',\n}\n\n/** The picker's bolt glyph: DeepSeek palette gradient + slim stroke. */\nfunction BoltIcon() {\n return (\n <svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" aria-hidden=\"true\" style={{ display: 'block' }}>\n <defs>\n <linearGradient id=\"dsh-sp-bolt-grad\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"0%\" stopColor=\"var(--dsw-static-deepseek-400, rgb(103, 158, 254))\" />\n <stop offset=\"100%\" stopColor=\"var(--dsw-static-deepseek-600, rgb(72, 104, 178))\" />\n </linearGradient>\n </defs>\n <path\n d=\"M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 10.42 7.54 13 3h1l-1 7h3.5c.49 0 .56.33.47.51l-.07.15C12.96 17.55 11 21 11 21z\"\n fill=\"url(#dsh-sp-bolt-grad)\"\n stroke=\"var(--dsw-static-deepseek-600, rgb(72, 104, 178))\"\n strokeWidth=\"0.8\"\n strokeLinejoin=\"round\"\n />\n </svg>\n )\n}\n\n/**\n * The picker control for the composer's right tool row. Rendered by the slot\n * renderer with the composed props: owner InputZone (`session`, `input`) plus\n * the framework session kit (`useInput`, `inputActions`).\n */\nfunction SkillPickerButton(props) {\n const [open, setOpen] = useState(false)\n const [skills, setSkills] = useState(undefined)\n const [error, setError] = useState(undefined)\n const [query, setQuery] = useState('')\n const [usage, setUsage] = useState(() => loadUsage())\n const boxRef = useRef(null)\n\n const load = useCallback(async () => {\n if (skills !== undefined || error !== undefined) return\n try {\n // Primary path: the official host skills API (same source as DSH's own\n // `/` completion \u2014 session-scoped, covers user + project level).\n if (typeof props.listSkills === 'function' && props.session?.sessionId !== undefined) {\n const listed = await props.listSkills(props.session.sessionId)\n setSkills(Array.isArray(listed) ? listed : [])\n return\n }\n } catch (cause) {\n console.warn('[dsh-skill-picker] official skills API failed, falling back to host route:', cause)\n }\n // Fallback path: the host's own scan route (user + project level dirs).\n try {\n const cwd = typeof props.cwd === 'string' && props.cwd !== '' ? `?cwd=${encodeURIComponent(props.cwd)}` : ''\n const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })\n const json = await res.json()\n if (!json.ok) throw new Error(json.error || 'bad response')\n setSkills(Array.isArray(json.skills) ? json.skills : [])\n } catch (cause) {\n setError(String(cause?.message ?? cause))\n }\n }, [skills, error, props.listSkills, props.session, props.cwd])\n\n const toggle = () => {\n if (!open) void load()\n setOpen(!open)\n }\n\n const pick = (name) => {\n // Draft source: owner InputZone snapshot first, framework hook second.\n let draft = ''\n try {\n if (props.input !== undefined && typeof props.input.draft === 'string') {\n draft = props.input.draft\n } else if (typeof props.useInput === 'function') {\n const state = props.useInput((s) => s)\n if (state !== undefined && typeof state.draft === 'string') draft = state.draft\n }\n } catch (cause) {\n console.error('[dsh-skill-picker] reading draft failed:', cause)\n }\n const separator = draft === '' || draft.endsWith(' ') || draft.endsWith('\\n') ? '' : ' '\n const next = `${draft}${separator}/${name} `\n try {\n if (typeof props.inputActions?.setDraft === 'function') {\n props.inputActions.setDraft(next)\n } else {\n console.error('[dsh-skill-picker] inputActions.setDraft unavailable; draft not written:', next)\n }\n } catch (cause) {\n console.error('[dsh-skill-picker] setDraft failed:', cause)\n }\n\n // Record usage for ordering (recent first, then frequent).\n const nextUsage = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } }\n setUsage(nextUsage)\n saveUsage(nextUsage)\n\n setOpen(false)\n setQuery('')\n }\n\n // Close on outside pointer-down (the shell's menu convention).\n useEffect(() => {\n if (!open) return\n const onDown = (event) => {\n if (boxRef.current !== null && !boxRef.current.contains(event.target)) setOpen(false)\n }\n document.addEventListener('mousedown', onDown)\n return () => document.removeEventListener('mousedown', onDown)\n }, [open])\n\n // Usage ordering: last picked first, then most frequent, then by name.\n const ordered = (skills ?? []).slice().sort((a, b) => {\n const ua = usage[a.name]\n const ub = usage[b.name]\n const la = ua?.lastUsed ?? 0\n const lb = ub?.lastUsed ?? 0\n if (la !== lb) return lb - la\n const ca = ua?.count ?? 0\n const cb = ub?.count ?? 0\n if (ca !== cb) return cb - ca\n return a.name.localeCompare(b.name)\n })\n\n const filtered = ordered\n .filter((skill) => {\n const q = query.trim().toLowerCase()\n if (q === '') return true\n return skill.name.toLowerCase().includes(q) || String(skill.description ?? '').toLowerCase().includes(q)\n })\n .slice(0, 60)\n\n return (\n <div ref={boxRef} style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>\n <button\n type=\"button\"\n onClick={toggle}\n title=\"\u9009\u62E9\u6280\u80FD\uFF08\u63D2\u5165 /\u6280\u80FD\u540D \u5230\u53D1\u9001\u6846\uFF09\"\n aria-label=\"\u9009\u62E9\u6280\u80FD\"\n style={{\n ...buttonStyle,\n ...(open ? { color: 'var(--dsw-alias-label-primary-bluish, #4cc9f0)' } : {}),\n }}\n >\n <BoltIcon />\n </button>\n {open && (\n <div style={popoverStyle}>\n <input\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder=\"\u641C\u7D22\u6280\u80FD\u2026\"\n style={searchStyle}\n autoFocus\n />\n {error !== undefined ? (\n <div style={statusStyle}>{`\u52A0\u8F7D\u5931\u8D25\uFF1A${error}`}</div>\n ) : skills === undefined ? (\n <div style={statusStyle}>\u52A0\u8F7D\u4E2D\u2026</div>\n ) : (\n <div style={listStyle}>\n {filtered.length === 0 ? (\n <div style={statusStyle}>\u6CA1\u6709\u5339\u914D\u7684\u6280\u80FD</div>\n ) : (\n filtered.map((skill) => (\n <button\n key={skill.name}\n type=\"button\"\n onClick={() => pick(skill.name)}\n onMouseEnter={(event) => {\n event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))'\n }}\n onMouseLeave={(event) => {\n event.currentTarget.style.background = 'transparent'\n }}\n style={itemStyle}\n >\n <span style={nameStyle}>{`/${skill.name}`}</span>\n <span style={descStyle}>{skill.description ?? ''}</span>\n </button>\n ))\n )}\n </div>\n )}\n </div>\n )}\n </div>\n )\n}\n\n/** Apply the browser half: register the picker into the composer tool row. */\nexport function apply(ctx) {\n // Primary skill source: the official host skills API (the exact RPC ui-skill\n // feeds DSH's own `/` completion with \u2014 session-scoped, all skill layers).\n const listSkills = async (sessionId) => {\n const skills = ctx.connection?.api?.skills\n if (skills === undefined || typeof skills.list !== 'function') {\n throw new Error('connection.api.skills unavailable')\n }\n const controller = new AbortController()\n const { result } = await skills.list({ sessionId }, controller.signal)\n if (!result.ok) throw new Error(`skill.list failed: ${result.error?.code}: ${result.error?.message}`)\n const raw = result.value?.skills ?? []\n return raw.map((skill) => ({ name: skill.name, description: skill.description ?? '' }))\n }\n\n // Track the active session's workspace cwd for the host-route fallback.\n let currentCwd = ''\n const syncCwd = () => {\n try {\n const snapshot = ctx.sessions.list.getSnapshot()\n const sessionId = snapshot.current\n const cwd = sessionId === undefined ? undefined : snapshot.byId[sessionId]?.cwd\n currentCwd = typeof cwd === 'string' ? cwd : ''\n } catch {\n currentCwd = ''\n }\n }\n syncCwd()\n const unsubscribe = ctx.sessions.list.subscribe(syncCwd)\n ctx.effect(() => {\n // Wrap the component so framework props pass through untouched and the\n // live workspace cwd + official skills fetcher are attached \u2014 never\n // swallow the composed props.\n const PickerWithCwd = (props) =>\n React.createElement(SkillPickerButton, { ...props, cwd: currentCwd, listSkills })\n const dispose = ctx.slots.inject('conversation.input.right', () =>\n ctx.slots.register(\n { name: 'conversation.input.right', id: 'skill-picker', order: 100, label: 'Skill picker' },\n PickerWithCwd,\n ),\n )\n return () => {\n dispose()\n unsubscribe()\n }\n }, 'dsh-skill-picker: composer input slot')\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["React"]
7
+ }
package/lib/index.js ADDED
@@ -0,0 +1,83 @@
1
+ // src/index.js
2
+ import { readFile, readdir } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ var inject = ["webServer", "systemPrompt"];
6
+ var SECTION_ORDER = 215;
7
+ var SKILL_PICKER_GUIDANCE = "\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";
8
+ function userSkillsDir() {
9
+ const home = process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh");
10
+ return path.join(home, "skills");
11
+ }
12
+ function parseFrontmatter(content) {
13
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
14
+ if (!match) return {};
15
+ const out = {};
16
+ for (const line of match[1].split(/\r?\n/)) {
17
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
18
+ if (!kv) continue;
19
+ const value = kv[2].trim().replace(/^["']|["']$/g, "");
20
+ if (value !== "") out[kv[1]] = value;
21
+ }
22
+ return out;
23
+ }
24
+ async function scanSkillsDirInto(map, dir) {
25
+ let entries;
26
+ try {
27
+ entries = await readdir(dir, { withFileTypes: true });
28
+ } catch {
29
+ return;
30
+ }
31
+ for (const entry of entries) {
32
+ if (!entry.isDirectory()) continue;
33
+ const skillDir = path.join(dir, entry.name);
34
+ let content;
35
+ try {
36
+ content = await readFile(path.join(skillDir, "SKILL.md"), "utf8");
37
+ } catch {
38
+ continue;
39
+ }
40
+ const meta = parseFrontmatter(content);
41
+ map.set(meta.name ?? entry.name, {
42
+ name: meta.name ?? entry.name,
43
+ description: meta.description ?? "",
44
+ path: skillDir
45
+ });
46
+ }
47
+ }
48
+ async function scanSkills(cwd) {
49
+ const map = /* @__PURE__ */ new Map();
50
+ await scanSkillsDirInto(map, userSkillsDir());
51
+ if (typeof cwd === "string" && cwd !== "") {
52
+ await scanSkillsDirInto(map, path.join(cwd, ".dsh", "skills"));
53
+ await scanSkillsDirInto(map, path.join(cwd, ".agents", "skills"));
54
+ }
55
+ return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
56
+ }
57
+ function apply(ctx) {
58
+ ctx.effect(() => {
59
+ const handler = async (req, res) => {
60
+ try {
61
+ const cwd = req.url !== void 0 ? new URL(req.url, "http://dsh").searchParams.get("cwd") ?? void 0 : void 0;
62
+ const skills = await scanSkills(cwd);
63
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
64
+ res.end(JSON.stringify({ ok: true, complete: true, skills }));
65
+ } catch (error) {
66
+ res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
67
+ res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }));
68
+ }
69
+ };
70
+ return ctx.webServer.register({ kind: "prefix", path: "/dsh-skill-picker", handler });
71
+ }, "dsh-skill-picker: routes");
72
+ ctx.effect(() => ctx.systemPrompt.section({
73
+ name: "plugin:skill-picker",
74
+ order: SECTION_ORDER,
75
+ text: SKILL_PICKER_GUIDANCE
76
+ }), "dsh-skill-picker: prompt section");
77
+ }
78
+ export {
79
+ SKILL_PICKER_GUIDANCE,
80
+ apply,
81
+ inject
82
+ };
83
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.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\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/** 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 if (!entry.isDirectory()) 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 user-level plus (when a workspace cwd is known) project-level skill\n * directories, mirroring the official provider's roots. Never throws (a\n * missing user dir yields []).\n * @param cwd - the active session's workspace root (undefined = user level only).\n */\nasync function scanSkills(cwd) {\n const map = new Map()\n await scanSkillsDirInto(map, userSkillsDir())\n if (typeof cwd === 'string' && cwd !== '') {\n await scanSkillsDirInto(map, path.join(cwd, '.dsh', 'skills'))\n await scanSkillsDirInto(map, path.join(cwd, '.agents', '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"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "dsh-skill-picker",
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.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "scripts": {
13
+ "build": "node build.mjs"
14
+ },
15
+ "devDependencies": {
16
+ "esbuild": "^0.25.0"
17
+ },
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ },
22
+ "client": {
23
+ "inject": [
24
+ "@deepseek-ai/dsh-client-runtime",
25
+ "@deepseek-ai/dsh-client-locale"
26
+ ],
27
+ "platform": "web"
28
+ }
29
+ },
30
+ "peerDependencies": {
31
+ "@deepseek-ai/cordis": "^4.0.1",
32
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
33
+ "@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
34
+ "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
35
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
36
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
37
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
38
+ "react": "^18.2.0"
39
+ },
40
+ "files": [
41
+ "lib",
42
+ "src",
43
+ "cordis.patch.yml",
44
+ "README.md"
45
+ ],
46
+ "license": "MIT",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/a735624258/dsh-skill-picker.git"
50
+ }
51
+ }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * dsh-skill-picker — browser half: mounts a skill-picker control into the
3
+ * composer's right tool row (`conversation.input.right` list slot, the seat
4
+ * just before the send button). Clicking it opens a searchable list of
5
+ * installed skills (fetched from the host route `/dsh-skill-picker/skills`),
6
+ * ordered by usage: recently picked first, then frequently used, then the
7
+ * untouched rest (by name). Picking one inserts the official `/skill-name`
8
+ * gesture into the draft via the framework input machine
9
+ * (`inputActions.setDraft`), so DSH's native user-invocation path loads the
10
+ * skill with the message.
11
+ *
12
+ * All DOM/runtime wiring failures are logged, never thrown — the web shell
13
+ * fails the whole boot when a plugin apply throws.
14
+ *
15
+ * @module dsh-skill-picker/client
16
+ */
17
+
18
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
19
+
20
+ /** Required services: slot registry, host connection (official skills API), sessions (workspace cwd fallback). */
21
+ export const inject = ['slots', 'connection', 'sessions']
22
+
23
+ /** localStorage key for the picker's per-browser usage history. */
24
+ const USAGE_KEY = 'dsh-skill-picker:usage'
25
+
26
+ /** Read the usage history {name: {count, lastUsed}}; never throws. */
27
+ function loadUsage() {
28
+ try {
29
+ const raw = localStorage.getItem(USAGE_KEY)
30
+ if (raw === null) return {}
31
+ const parsed = JSON.parse(raw)
32
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
33
+ } catch {
34
+ return {}
35
+ }
36
+ }
37
+
38
+ /** Persist the usage history; never throws. */
39
+ function saveUsage(usage) {
40
+ try {
41
+ localStorage.setItem(USAGE_KEY, JSON.stringify(usage))
42
+ } catch {
43
+ /* storage unavailable — ordering just won't persist */
44
+ }
45
+ }
46
+
47
+ /** Row height matches the resident chrome (access mode, plan, attach, model). */
48
+ const buttonStyle = {
49
+ display: 'inline-flex',
50
+ alignItems: 'center',
51
+ justifyContent: 'center',
52
+ width: '28px',
53
+ height: '28px',
54
+ margin: '0 2px',
55
+ padding: '0',
56
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))',
57
+ borderRadius: '8px',
58
+ background: 'transparent',
59
+ color: 'var(--dsw-alias-label-secondary, #c9d2e0)',
60
+ cursor: 'pointer',
61
+ fontSize: '15px',
62
+ lineHeight: '1',
63
+ flex: 'none',
64
+ }
65
+
66
+ const popoverStyle = {
67
+ position: 'absolute',
68
+ bottom: 'calc(100% + 8px)',
69
+ right: '0',
70
+ width: '340px',
71
+ maxHeight: '320px',
72
+ display: 'flex',
73
+ flexDirection: 'column',
74
+ background: 'var(--dsw-specific-tip, #1e2533)',
75
+ border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35))',
76
+ borderRadius: '12px',
77
+ boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
78
+ overflow: 'hidden',
79
+ zIndex: 1000,
80
+ }
81
+
82
+ const searchStyle = {
83
+ boxSizing: 'border-box',
84
+ width: 'calc(100% - 16px)',
85
+ margin: '8px',
86
+ padding: '6px 10px',
87
+ border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3))',
88
+ borderRadius: '8px',
89
+ background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1))',
90
+ color: 'var(--dsw-alias-label-primary, #e6ebf2)',
91
+ fontSize: '13px',
92
+ outline: 'none',
93
+ }
94
+
95
+ const listStyle = {
96
+ overflowY: 'auto',
97
+ flex: 'auto',
98
+ padding: '0 6px 8px',
99
+ }
100
+
101
+ const itemStyle = {
102
+ display: 'flex',
103
+ flexDirection: 'column',
104
+ alignItems: 'flex-start',
105
+ gap: '2px',
106
+ width: '100%',
107
+ padding: '7px 10px',
108
+ border: 'none',
109
+ borderRadius: '8px',
110
+ background: 'transparent',
111
+ color: 'var(--dsw-alias-label-primary, #e6ebf2)',
112
+ cursor: 'pointer',
113
+ textAlign: 'left',
114
+ }
115
+
116
+ const nameStyle = {
117
+ fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
118
+ fontSize: '13px',
119
+ fontWeight: 500,
120
+ }
121
+
122
+ const descStyle = {
123
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
124
+ fontSize: '12px',
125
+ lineHeight: '16px',
126
+ overflow: 'hidden',
127
+ textOverflow: 'ellipsis',
128
+ whiteSpace: 'nowrap',
129
+ maxWidth: '100%',
130
+ }
131
+
132
+ const statusStyle = {
133
+ padding: '12px',
134
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
135
+ fontSize: '13px',
136
+ }
137
+
138
+ /** The picker's bolt glyph: DeepSeek palette gradient + slim stroke. */
139
+ function BoltIcon() {
140
+ return (
141
+ <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" style={{ display: 'block' }}>
142
+ <defs>
143
+ <linearGradient id="dsh-sp-bolt-grad" x1="0" y1="0" x2="0" y2="1">
144
+ <stop offset="0%" stopColor="var(--dsw-static-deepseek-400, rgb(103, 158, 254))" />
145
+ <stop offset="100%" stopColor="var(--dsw-static-deepseek-600, rgb(72, 104, 178))" />
146
+ </linearGradient>
147
+ </defs>
148
+ <path
149
+ d="M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 10.42 7.54 13 3h1l-1 7h3.5c.49 0 .56.33.47.51l-.07.15C12.96 17.55 11 21 11 21z"
150
+ fill="url(#dsh-sp-bolt-grad)"
151
+ stroke="var(--dsw-static-deepseek-600, rgb(72, 104, 178))"
152
+ strokeWidth="0.8"
153
+ strokeLinejoin="round"
154
+ />
155
+ </svg>
156
+ )
157
+ }
158
+
159
+ /**
160
+ * The picker control for the composer's right tool row. Rendered by the slot
161
+ * renderer with the composed props: owner InputZone (`session`, `input`) plus
162
+ * the framework session kit (`useInput`, `inputActions`).
163
+ */
164
+ function SkillPickerButton(props) {
165
+ const [open, setOpen] = useState(false)
166
+ const [skills, setSkills] = useState(undefined)
167
+ const [error, setError] = useState(undefined)
168
+ const [query, setQuery] = useState('')
169
+ const [usage, setUsage] = useState(() => loadUsage())
170
+ const boxRef = useRef(null)
171
+
172
+ const load = useCallback(async () => {
173
+ if (skills !== undefined || error !== undefined) return
174
+ try {
175
+ // Primary path: the official host skills API (same source as DSH's own
176
+ // `/` completion — session-scoped, covers user + project level).
177
+ if (typeof props.listSkills === 'function' && props.session?.sessionId !== undefined) {
178
+ const listed = await props.listSkills(props.session.sessionId)
179
+ setSkills(Array.isArray(listed) ? listed : [])
180
+ return
181
+ }
182
+ } catch (cause) {
183
+ console.warn('[dsh-skill-picker] official skills API failed, falling back to host route:', cause)
184
+ }
185
+ // Fallback path: the host's own scan route (user + project level dirs).
186
+ try {
187
+ const cwd = typeof props.cwd === 'string' && props.cwd !== '' ? `?cwd=${encodeURIComponent(props.cwd)}` : ''
188
+ const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
189
+ const json = await res.json()
190
+ if (!json.ok) throw new Error(json.error || 'bad response')
191
+ setSkills(Array.isArray(json.skills) ? json.skills : [])
192
+ } catch (cause) {
193
+ setError(String(cause?.message ?? cause))
194
+ }
195
+ }, [skills, error, props.listSkills, props.session, props.cwd])
196
+
197
+ const toggle = () => {
198
+ if (!open) void load()
199
+ setOpen(!open)
200
+ }
201
+
202
+ const pick = (name) => {
203
+ // Draft source: owner InputZone snapshot first, framework hook second.
204
+ let draft = ''
205
+ try {
206
+ if (props.input !== undefined && typeof props.input.draft === 'string') {
207
+ draft = props.input.draft
208
+ } else if (typeof props.useInput === 'function') {
209
+ const state = props.useInput((s) => s)
210
+ if (state !== undefined && typeof state.draft === 'string') draft = state.draft
211
+ }
212
+ } catch (cause) {
213
+ console.error('[dsh-skill-picker] reading draft failed:', cause)
214
+ }
215
+ const separator = draft === '' || draft.endsWith(' ') || draft.endsWith('\n') ? '' : ' '
216
+ const next = `${draft}${separator}/${name} `
217
+ try {
218
+ if (typeof props.inputActions?.setDraft === 'function') {
219
+ props.inputActions.setDraft(next)
220
+ } else {
221
+ console.error('[dsh-skill-picker] inputActions.setDraft unavailable; draft not written:', next)
222
+ }
223
+ } catch (cause) {
224
+ console.error('[dsh-skill-picker] setDraft failed:', cause)
225
+ }
226
+
227
+ // Record usage for ordering (recent first, then frequent).
228
+ const nextUsage = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } }
229
+ setUsage(nextUsage)
230
+ saveUsage(nextUsage)
231
+
232
+ setOpen(false)
233
+ setQuery('')
234
+ }
235
+
236
+ // Close on outside pointer-down (the shell's menu convention).
237
+ useEffect(() => {
238
+ if (!open) return
239
+ const onDown = (event) => {
240
+ if (boxRef.current !== null && !boxRef.current.contains(event.target)) setOpen(false)
241
+ }
242
+ document.addEventListener('mousedown', onDown)
243
+ return () => document.removeEventListener('mousedown', onDown)
244
+ }, [open])
245
+
246
+ // Usage ordering: last picked first, then most frequent, then by name.
247
+ const ordered = (skills ?? []).slice().sort((a, b) => {
248
+ const ua = usage[a.name]
249
+ const ub = usage[b.name]
250
+ const la = ua?.lastUsed ?? 0
251
+ const lb = ub?.lastUsed ?? 0
252
+ if (la !== lb) return lb - la
253
+ const ca = ua?.count ?? 0
254
+ const cb = ub?.count ?? 0
255
+ if (ca !== cb) return cb - ca
256
+ return a.name.localeCompare(b.name)
257
+ })
258
+
259
+ const filtered = ordered
260
+ .filter((skill) => {
261
+ const q = query.trim().toLowerCase()
262
+ if (q === '') return true
263
+ return skill.name.toLowerCase().includes(q) || String(skill.description ?? '').toLowerCase().includes(q)
264
+ })
265
+ .slice(0, 60)
266
+
267
+ return (
268
+ <div ref={boxRef} style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>
269
+ <button
270
+ type="button"
271
+ onClick={toggle}
272
+ title="选择技能(插入 /技能名 到发送框)"
273
+ aria-label="选择技能"
274
+ style={{
275
+ ...buttonStyle,
276
+ ...(open ? { color: 'var(--dsw-alias-label-primary-bluish, #4cc9f0)' } : {}),
277
+ }}
278
+ >
279
+ <BoltIcon />
280
+ </button>
281
+ {open && (
282
+ <div style={popoverStyle}>
283
+ <input
284
+ value={query}
285
+ onChange={(event) => setQuery(event.target.value)}
286
+ placeholder="搜索技能…"
287
+ style={searchStyle}
288
+ autoFocus
289
+ />
290
+ {error !== undefined ? (
291
+ <div style={statusStyle}>{`加载失败:${error}`}</div>
292
+ ) : skills === undefined ? (
293
+ <div style={statusStyle}>加载中…</div>
294
+ ) : (
295
+ <div style={listStyle}>
296
+ {filtered.length === 0 ? (
297
+ <div style={statusStyle}>没有匹配的技能</div>
298
+ ) : (
299
+ filtered.map((skill) => (
300
+ <button
301
+ key={skill.name}
302
+ type="button"
303
+ onClick={() => pick(skill.name)}
304
+ onMouseEnter={(event) => {
305
+ event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))'
306
+ }}
307
+ onMouseLeave={(event) => {
308
+ event.currentTarget.style.background = 'transparent'
309
+ }}
310
+ style={itemStyle}
311
+ >
312
+ <span style={nameStyle}>{`/${skill.name}`}</span>
313
+ <span style={descStyle}>{skill.description ?? ''}</span>
314
+ </button>
315
+ ))
316
+ )}
317
+ </div>
318
+ )}
319
+ </div>
320
+ )}
321
+ </div>
322
+ )
323
+ }
324
+
325
+ /** Apply the browser half: register the picker into the composer tool row. */
326
+ export function apply(ctx) {
327
+ // Primary skill source: the official host skills API (the exact RPC ui-skill
328
+ // feeds DSH's own `/` completion with — session-scoped, all skill layers).
329
+ const listSkills = async (sessionId) => {
330
+ const skills = ctx.connection?.api?.skills
331
+ if (skills === undefined || typeof skills.list !== 'function') {
332
+ throw new Error('connection.api.skills unavailable')
333
+ }
334
+ const controller = new AbortController()
335
+ const { result } = await skills.list({ sessionId }, controller.signal)
336
+ if (!result.ok) throw new Error(`skill.list failed: ${result.error?.code}: ${result.error?.message}`)
337
+ const raw = result.value?.skills ?? []
338
+ return raw.map((skill) => ({ name: skill.name, description: skill.description ?? '' }))
339
+ }
340
+
341
+ // Track the active session's workspace cwd for the host-route fallback.
342
+ let currentCwd = ''
343
+ const syncCwd = () => {
344
+ try {
345
+ const snapshot = ctx.sessions.list.getSnapshot()
346
+ const sessionId = snapshot.current
347
+ const cwd = sessionId === undefined ? undefined : snapshot.byId[sessionId]?.cwd
348
+ currentCwd = typeof cwd === 'string' ? cwd : ''
349
+ } catch {
350
+ currentCwd = ''
351
+ }
352
+ }
353
+ syncCwd()
354
+ const unsubscribe = ctx.sessions.list.subscribe(syncCwd)
355
+ ctx.effect(() => {
356
+ // Wrap the component so framework props pass through untouched and the
357
+ // live workspace cwd + official skills fetcher are attached — never
358
+ // swallow the composed props.
359
+ const PickerWithCwd = (props) =>
360
+ React.createElement(SkillPickerButton, { ...props, cwd: currentCwd, listSkills })
361
+ const dispose = ctx.slots.inject('conversation.input.right', () =>
362
+ ctx.slots.register(
363
+ { name: 'conversation.input.right', id: 'skill-picker', order: 100, label: 'Skill picker' },
364
+ PickerWithCwd,
365
+ ),
366
+ )
367
+ return () => {
368
+ dispose()
369
+ unsubscribe()
370
+ }
371
+ }, 'dsh-skill-picker: composer input slot')
372
+ }
package/src/index.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * dsh-skill-picker — host half: exposes the installed-skill catalog to the
3
+ * browser half through a small JSON route (`/dsh-skill-picker/skills`) and
4
+ * announces the picker to every agent through the system-prompt section
5
+ * mechanism.
6
+ *
7
+ * The catalog is scanned directly from the DSH user skills directory
8
+ * (`$DSH_HOME/skills`, default `~/.dsh/skills`) by reading each skill's
9
+ * `SKILL.md` frontmatter. Why not `ctx.skills`? The filesystem skill provider
10
+ * is mounted in agent scope (the standard preset's standing mount), so a
11
+ * host-context `ctx.skills.snapshot({})` sees only the global layer — which
12
+ * is empty for user skills. Scanning the same roots the provider uses keeps
13
+ * the picker's list in sync with what agents actually load.
14
+ *
15
+ * The browser half (exports "./client") is served by client-modules from the
16
+ * same package's dsh.client declaration.
17
+ *
18
+ * @module dsh-skill-picker
19
+ */
20
+
21
+ import { readFile, readdir } from 'node:fs/promises'
22
+ import os from 'node:os'
23
+ import path from 'node:path'
24
+
25
+ /** Required services: the route registry and the prompt band. */
26
+ export const inject = ['webServer', 'systemPrompt']
27
+
28
+ /** Order of the announcement section within the tool-guidance band. */
29
+ const SECTION_ORDER = 215
30
+
31
+ /** Model-facing announcement: picker presence and the user-visible gesture. */
32
+ export const SKILL_PICKER_GUIDANCE =
33
+ '本机已安装 dsh-skill-picker 插件(Web GUI 的技能选择器):输入框旁有技能按钮,用户点选技能后会把 `/技能名`(如 /duo-xuan-pi-gai)插入发送框并随消息发出。DSH 官方机制会把用户消息里的 `/技能名` 手势当作技能直接调用并自动加载技能内容——你照常按加载后的技能指令执行即可,无需额外操作。用户说「技能选择器 / 选个技能 / 技能列表」时即指本插件。'
34
+
35
+ /** Resolve the user skills directory, mirroring the official provider's default. */
36
+ function userSkillsDir() {
37
+ const home = process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh')
38
+ return path.join(home, 'skills')
39
+ }
40
+
41
+ /** Parse a SKILL.md frontmatter block into a key/value map (flat YAML subset). */
42
+ function parseFrontmatter(content) {
43
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
44
+ if (!match) return {}
45
+ const out = {}
46
+ for (const line of match[1].split(/\r?\n/)) {
47
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
48
+ if (!kv) continue
49
+ const value = kv[2].trim().replace(/^["']|["']$/g, '')
50
+ if (value !== '') out[kv[1]] = value
51
+ }
52
+ return out
53
+ }
54
+
55
+ /** Scan one skill directory into the map; never throws (missing dir is a no-op). */
56
+ async function scanSkillsDirInto(map, dir) {
57
+ let entries
58
+ try {
59
+ entries = await readdir(dir, { withFileTypes: true })
60
+ } catch {
61
+ return
62
+ }
63
+ for (const entry of entries) {
64
+ if (!entry.isDirectory()) continue
65
+ const skillDir = path.join(dir, entry.name)
66
+ let content
67
+ try {
68
+ content = await readFile(path.join(skillDir, 'SKILL.md'), 'utf8')
69
+ } catch {
70
+ continue
71
+ }
72
+ const meta = parseFrontmatter(content)
73
+ // Later writes win, so project-level skills override same-named user skills.
74
+ map.set(meta.name ?? entry.name, {
75
+ name: meta.name ?? entry.name,
76
+ description: meta.description ?? '',
77
+ path: skillDir,
78
+ })
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Scan user-level plus (when a workspace cwd is known) project-level skill
84
+ * directories, mirroring the official provider's roots. Never throws (a
85
+ * missing user dir yields []).
86
+ * @param cwd - the active session's workspace root (undefined = user level only).
87
+ */
88
+ async function scanSkills(cwd) {
89
+ const map = new Map()
90
+ await scanSkillsDirInto(map, userSkillsDir())
91
+ if (typeof cwd === 'string' && cwd !== '') {
92
+ await scanSkillsDirInto(map, path.join(cwd, '.dsh', 'skills'))
93
+ await scanSkillsDirInto(map, path.join(cwd, '.agents', 'skills'))
94
+ }
95
+ return [...map.values()].sort((a, b) => a.name.localeCompare(b.name))
96
+ }
97
+
98
+ /**
99
+ * Mount the skills route and the prompt section.
100
+ * @param ctx - context carrying webServer and systemPrompt.
101
+ */
102
+ export function apply(ctx) {
103
+ ctx.effect(() => {
104
+ const handler = async (req, res) => {
105
+ try {
106
+ // cwd query carries the active session's workspace root from the client.
107
+ const cwd = req.url !== undefined ? new URL(req.url, 'http://dsh').searchParams.get('cwd') ?? undefined : undefined
108
+ const skills = await scanSkills(cwd)
109
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
110
+ res.end(JSON.stringify({ ok: true, complete: true, skills }))
111
+ } catch (error) {
112
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
113
+ res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }))
114
+ }
115
+ }
116
+ return ctx.webServer.register({ kind: 'prefix', path: '/dsh-skill-picker', handler })
117
+ }, 'dsh-skill-picker: routes')
118
+
119
+ ctx.effect(() => ctx.systemPrompt.section({
120
+ name: 'plugin:skill-picker',
121
+ order: SECTION_ORDER,
122
+ text: SKILL_PICKER_GUIDANCE,
123
+ }), 'dsh-skill-picker: prompt section')
124
+ }