dsh-skill-editor 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) 2025 bosinHU
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,80 @@
1
+ # dsh-skill-editor
2
+
3
+ <p align="center">
4
+ <img alt="license" src="https://img.shields.io/badge/license-MIT-green.svg">
5
+ <img alt="dsh" src="https://img.shields.io/badge/dsh-plugin-web-blue.svg">
6
+ <img alt="node" src="https://img.shields.io/badge/node-%3E%3D18-339933.svg">
7
+ <img alt="platform" src="https://img.shields.io/badge/platform-web-brightgreen.svg">
8
+ </p>
9
+
10
+ 在 dsh Web 设置页直接编辑 skill 的 `SKILL.md`(简介 + 正文),保存即写回磁盘、无需重启。一个独立、轻量、纯 `node:fs` 实现的 dsh 插件。
11
+ 本项目所有代码由dsh完成,本人完全不会编程
12
+ ## ✨ 特性
13
+
14
+ - **设置页即编即改**:`设置 → 技能编辑器`,列出所有技能,点开即可编辑。
15
+ - **安全分级(防手滑)**:
16
+ - **本地技能**(`~/.dsh/skills`、`~/.agents/skills`、工作区 `.dsh/skills`):可编辑保存。
17
+ - **系统/内置技能**(bundled / 插件内嵌):只读,显示风险提示。
18
+ - **保存即写盘**:写回 `SKILL.md`,技能热加载,刷新即生效。
19
+ - **只动正文 + name/description**:不碰其它 frontmatter 字段,原样保留。
20
+ - **零第三方依赖的读写逻辑**:用 `node:fs` 直接操作明文文件,不依赖任何 skill 插件内部实现,升级不失效。
21
+ - **可独立回滚**:删除本插件即可完全移除,不影响其它功能。
22
+
23
+ ## 📦 安装
24
+
25
+ ```bash
26
+ # 直接从 GitHub 仓库安装
27
+ dsh plugin --profile web add https://github.com/bosinHU/dsh-skill-editor
28
+ ```
29
+
30
+ > 本插件在 `dsh.client.platform: web` 下自动注入,无需手动改 `cordis.patch.yml`。
31
+
32
+ 安装后重启 web 服务(`dsh --profile web`),刷新页面,设置页即出现「技能编辑器」。
33
+
34
+ ## 🚀 使用
35
+
36
+ 1. 进入 dsh Web 设置 →「技能编辑器」。
37
+ 2. 展开某个技能,编辑 `description` 或正文(`SKILL.md` 中 `---` 分隔线之后的部分)。
38
+ 3. 点击「保存」,内容立即写回磁盘。
39
+
40
+ ## 🖼️ 界面预览
41
+
42
+ > 图为 dsh Web 设置页「技能编辑器」的真实界面:左侧为设置导航,右侧列出全部技能(`全局`/`内置`),展开后可编辑 `name`、`description` 与正文(`SKILL.md`)。
43
+
44
+ ![技能编辑器真实界面](./interface-preview.png)
45
+
46
+ ## 🗂️ 项目结构
47
+
48
+ ```
49
+ dsh-skill-editor/
50
+ ├── package.json # 包声明(dsh.client / dsh.bundle.patch)
51
+ ├── cordis.patch.yml # bundle patch(注册 host 插件)
52
+ ├── lib/
53
+ │ ├── index.js # host:skillEditor 远程服务(list/content/saveContent)
54
+ │ └── client.js # client:设置页「技能编辑器」UI
55
+ ├── README.md
56
+ └── LICENSE
57
+ ```
58
+
59
+ ## 🧠 技术细节(typert 协议)
60
+
61
+ - host 端 `lib/index.js` 的 `MANIFEST` 中,每个参数都带 `codec` 字段(新版 typert 强制要求):
62
+ - 标量参数(`name` / `sessionId`)用 `codec: { mode: "src-json" }`;
63
+ - 对象参数(`payload`)用 `codec: { mode: "strict", typeSymbol: "...", schema }`。
64
+ - `apply(ctx)` 里显式调用 `ctx.typert.register(MANIFEST)`,否则前端 `remote.skillEditor` 连不上后端。
65
+
66
+ ## ❓ 常见问题
67
+
68
+ **Q: 保存后要重启 dsh 吗?**
69
+ 不需要,技能是热加载的,刷新页面即可生效。
70
+
71
+ **Q: 系统/内置技能为什么不能改?**
72
+ 防止误改内置技能导致 dsh 异常。如需修改,请在本地 `~/.dsh/skills` 下新建同名技能,或改用本地副本。
73
+
74
+ ## 📄 License
75
+
76
+ [MIT](./LICENSE) © 2025 [bosinHU](https://github.com/bosinHU)
77
+
78
+ ---
79
+
80
+ 所有代码内容由 dsh 自主完成。
@@ -0,0 +1,12 @@
1
+ # dsh-skill-editor bundle patch.
2
+ #
3
+ # Referenced by package.json `dsh.bundle.patch`. When the package is installed
4
+ # with `dsh plugin --profile web add <package>`, the CLI recognizes the
5
+ # `dsh.bundle` declaration, runs pnpm, and appends this package to the web
6
+ # profile layer stack; the row below then loads the host half (lib/index.js)
7
+ # as an ordinary Cordis plugin. The browser half rides the `dsh.client` scan
8
+ # (lib/client.js) via the web client loader.
9
+
10
+ - insert:
11
+ - id: dsh-skill-editor
12
+ name: 'dsh-skill-editor'
package/lib/client.js ADDED
@@ -0,0 +1,199 @@
1
+ // dsh-skill-editor — client 半区(动态 bundle)。
2
+ // 在 dsh Web 设置页注册一个独立的「技能编辑器」栏目:列出技能,点击后在
3
+ // 文本域里编辑 SKILL.md 的正文与 name/description,保存后写回磁盘。
4
+ // 本地技能无提示;系统/内置技能(bundled)只读并显示风险提示。
5
+
6
+ window.__ModuleLoader__.load({
7
+ id: "dsh-skill-editor",
8
+ factory: (require) => {
9
+ var module = { exports: {} };
10
+ var exports = module.exports;
11
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
12
+ const react = require("react");
13
+ const react_jsx_runtime = require("react/jsx-runtime");
14
+
15
+ // ── 样式 ───────────────────────────────────────────────────────────
16
+ const css = ".SKE_section{position:relative;width:100%;max-width:760px;color:var(--dsw-alias-label-primary);flex-direction:column;gap:14px;display:flex}.SKE_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:10px;min-width:0;overflow:hidden}.SKE_cardHeader{width:100%;align-items:center;gap:8px;font:inherit;color:var(--dsw-alias-label-primary);cursor:pointer;background:0 0;border:none;padding:10px 12px;display:flex;text-align:left}.SKE_cardTitle{min-width:0;flex:1;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;font-size:13px;font-weight:500;line-height:20px}.SKE_tag{background:var(--dsw-alias-bg-layer-1);min-height:20px;color:var(--dsw-alias-label-secondary);border-radius:5px;padding:1px 6px;font-size:11px;line-height:16px;display:inline-flex;flex:none}.SKE_tag[data-bundled=true]{color:var(--dsw-alias-state-warning-primary)}.SKE_cardBody{border-top:1px solid var(--dsw-alias-border-l2);flex-direction:column;gap:10px;padding:10px 12px;display:flex}.SKE_field{display:flex;flex-direction:column;gap:5px}.SKE_label{font-size:12px;color:var(--dsw-alias-label-secondary);margin:0}.SKE_input{box-sizing:border-box;width:100%;height:32px;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;padding:0 10px}.SKE_textarea{box-sizing:border-box;width:100%;min-height:240px;font:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;resize:vertical;font-family:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;white-space:pre-wrap}.SKE_actions{display:flex;align-items:center;gap:8px}.SKE_btn{font:inherit;cursor:pointer;border-radius:6px;padding:5px 14px;font-size:13px;line-height:20px}.SKE_primary{background:var(--dsw-alias-state-business-primary);border:1px solid transparent;color:var(--dsw-alias-state-business-on-primary,#fff)}.SKE_primary:disabled{opacity:.6;cursor:default}.SKE_ghost{background:0 0;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}.SKE_msg{font-size:12px;line-height:18px;margin:0}.SKE_msg[data-kind=ok]{color:var(--dsw-alias-state-success-primary)}.SKE_msg[data-kind=err]{color:var(--dsw-alias-state-error-primary)}.SKE_warn{border:1px solid color-mix(in srgb,var(--dsw-alias-state-warning-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 10%,transparent);border-radius:8px;padding:8px 12px;color:var(--dsw-alias-state-warning-primary);font-size:12px;line-height:18px}.SKE_empty{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;margin:0}";
17
+ const tagId = "dsh-skill-editor/Editor.module.css";
18
+ if (typeof document !== "undefined") {
19
+ let tag = document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']');
20
+ if (!tag) { tag = document.createElement("style"); tag.setAttribute("data-plugin-css", tagId); tag.textContent = css; document.head.appendChild(tag); }
21
+ }
22
+ const c = { section: "SKE_section", card: "SKE_card", cardHeader: "SKE_cardHeader", cardTitle: "SKE_cardTitle", tag: "SKE_tag", cardBody: "SKE_cardBody", field: "SKE_field", label: "SKE_label", input: "SKE_input", textarea: "SKE_textarea", actions: "SKE_actions", primary: "SKE_primary", ghost: "SKE_ghost", msg: "SKE_msg", warn: "SKE_warn", empty: "SKE_empty" };
23
+
24
+ const NS = "settings.skill-editor";
25
+ const zh = {
26
+ nav: "技能编辑器",
27
+ loading: "正在读取技能…",
28
+ error: "暂时无法读取技能。",
29
+ retry: "重试",
30
+ empty: "暂无技能。",
31
+ descLabel: "description",
32
+ bodyLabel: "正文(SKILL.md 正文部分)",
33
+ save: "保存",
34
+ saving: "保存中…",
35
+ cancel: "取消",
36
+ saved: "已保存 ✓",
37
+ bundledWarn: "这是一个系统/内置技能(不可编辑)。如需修改,请在本地 ~/.dsh/skills 下新建同名技能,或改用本地副本。",
38
+ loadFailed: "技能内容加载失败。",
39
+ opFailed: "操作失败:",
40
+ reopen: "重新载入",
41
+ nameLabel: "name",
42
+ };
43
+ const en = {
44
+ nav: "Skill Editor",
45
+ loading: "Reading skills…",
46
+ error: "Skills are temporarily unavailable.",
47
+ retry: "Retry",
48
+ empty: "No skills.",
49
+ descLabel: "description",
50
+ bodyLabel: "Body (SKILL.md content)",
51
+ save: "Save",
52
+ saving: "Saving…",
53
+ cancel: "Cancel",
54
+ saved: "Saved ✓",
55
+ bundledWarn: "This is a bundled / built-in skill (read-only). To edit it, create a same-named local skill under ~/.dsh/skills instead.",
56
+ loadFailed: "Failed to load skill content.",
57
+ opFailed: "Operation failed: ",
58
+ reopen: "Reload",
59
+ nameLabel: "name",
60
+ };
61
+
62
+ const identity = (value) => value;
63
+ const codec = (symbol) => ({ mode: "strict", typeSymbol: symbol, schema: { parse: identity } });
64
+ const CONTRIBUTION = {
65
+ package: "dsh-skill-editor",
66
+ descriptors: [
67
+ { id: "dsh-skill-editor#skillEditor/list", service: "skillEditor", namespace: "skillEditor", method: "list", invocation: { kind: "direct" }, parameters: [{ name: "sessionId", wire: "sessionId", source: "json", acceptsUndefined: true, codec: codec("dsh-skill-editor#sessionId") }], result: codec("dsh-skill-editor#ListResult") },
68
+ { id: "dsh-skill-editor#skillEditor/content", service: "skillEditor", namespace: "skillEditor", method: "content", invocation: { kind: "direct" }, parameters: [{ name: "name", wire: "name", source: "json", codec: codec("dsh-skill-editor#SkillName") }, { name: "sessionId", wire: "sessionId", source: "json", acceptsUndefined: true, codec: codec("dsh-skill-editor#sessionId") }], result: codec("dsh-skill-editor#SkillContent") },
69
+ { id: "dsh-skill-editor#skillEditor/saveContent", service: "skillEditor", namespace: "skillEditor", method: "saveContent", invocation: { kind: "direct" }, parameters: [{ name: "name", wire: "name", source: "json", codec: codec("dsh-skill-editor#SkillName") }, { name: "payload", wire: "payload", source: "json", codec: codec("dsh-skill-editor#SavePayload") }, { name: "sessionId", wire: "sessionId", source: "json", acceptsUndefined: true, codec: codec("dsh-skill-editor#sessionId") }], result: codec("dsh-skill-editor#SaveResult") },
70
+ ],
71
+ };
72
+
73
+ function EditorCard({ skill, t, callRemote }) {
74
+ const [open, setOpen] = react.useState(false);
75
+ const [state, setState] = react.useState({ status: "idle" });
76
+ const [desc, setDesc] = react.useState("");
77
+ const [body, setBody] = react.useState("");
78
+ const [busy, setBusy] = react.useState(false);
79
+ const [msg, setMsg] = react.useState(null);
80
+
81
+ const load = react.useCallback(() => {
82
+ setState({ status: "loading" });
83
+ Promise.resolve()
84
+ .then(() => callRemote("content", skill.name))
85
+ .then((res) => {
86
+ if (res === null || res === undefined) { setState({ status: "missing" }); return; }
87
+ setDesc(res.description || "");
88
+ setBody(res.body || "");
89
+ setState({ status: "ready", res });
90
+ })
91
+ .catch((e) => setState({ status: "err", error: String(e && e.message || e) }));
92
+ }, [skill.name, callRemote]);
93
+
94
+ const onToggle = () => {
95
+ const next = !open;
96
+ setOpen(next);
97
+ if (next && state.status === "idle") load();
98
+ };
99
+
100
+ const onSave = () => {
101
+ setBusy(true);
102
+ setMsg(null);
103
+ Promise.resolve()
104
+ .then(() => callRemote("saveContent", skill.name, { name: skill.name, description: desc, body }))
105
+ .then(() => setMsg({ kind: "ok", text: t("saved") }))
106
+ .catch((e) => setMsg({ kind: "err", text: t("opFailed") + (e && e.message || e) }))
107
+ .finally(() => setBusy(false));
108
+ };
109
+
110
+ const bundled = skill.bundled === true;
111
+ return react_jsx_runtime.jsxs("li", { className: c.card, children: [
112
+ react_jsx_runtime.jsx("button", { type: "button", className: c.cardHeader, onClick: onToggle, children: [
113
+ react_jsx_runtime.jsx("span", { className: c.cardTitle, children: skill.name }),
114
+ react_jsx_runtime.jsx("span", { className: c.tag, "data-bundled": bundled, children: bundled ? "内置" : (skill.scope && skill.scope.kind === "workspace" ? "工作区" : "全局") }),
115
+ ]}),
116
+ open ? react_jsx_runtime.jsxs("div", { className: c.cardBody, children: [
117
+ bundled ? react_jsx_runtime.jsx("div", { className: c.warn, children: t("bundledWarn") }) : null,
118
+ state.status === "loading" ? react_jsx_runtime.jsx("p", { className: c.msg, children: t("loading") })
119
+ : state.status === "missing" ? react_jsx_runtime.jsx("p", { className: c.msg, children: t("loadFailed") })
120
+ : state.status === "err" ? react_jsx_runtime.jsxs("div", { className: c.actions, children: [
121
+ react_jsx_runtime.jsx("p", { className: c.msg, children: t("error") }), react_jsx_runtime.jsx("button", { type: "button", className: c.ghost, onClick: load, children: t("reopen") }),
122
+ ]})
123
+ : react_jsx_runtime.jsxs(react.Fragment, { children: [
124
+ react_jsx_runtime.jsxs("div", { className: c.field, children: [
125
+ react_jsx_runtime.jsx("label", { className: c.label, children: t("nameLabel") }),
126
+ react_jsx_runtime.jsx("input", { className: c.input, value: skill.name, readOnly: true, disabled: true }),
127
+ ]}),
128
+ react_jsx_runtime.jsxs("div", { className: c.field, children: [
129
+ react_jsx_runtime.jsx("label", { className: c.label, children: t("descLabel") }),
130
+ react_jsx_runtime.jsx("textarea", { className: c.textarea, style: { minHeight: "60px" }, value: desc, onChange: (e) => setDesc(e.target.value), disabled: bundled }),
131
+ ]}),
132
+ react_jsx_runtime.jsxs("div", { className: c.field, children: [
133
+ react_jsx_runtime.jsx("label", { className: c.label, children: t("bodyLabel") }),
134
+ react_jsx_runtime.jsx("textarea", { className: c.textarea, value: body, onChange: (e) => setBody(e.target.value), disabled: bundled }),
135
+ ]}),
136
+ react_jsx_runtime.jsxs("div", { className: c.actions, children: [
137
+ react_jsx_runtime.jsx("button", { type: "button", className: c.primary, disabled: bundled || busy, onClick: onSave, children: busy ? t("saving") : t("save") }),
138
+ react_jsx_runtime.jsx("button", { type: "button", className: c.ghost, disabled: busy, onClick: () => setOpen(false), children: t("cancel") }),
139
+ ]}),
140
+ msg ? react_jsx_runtime.jsx("p", { className: c.msg, "data-kind": msg.kind, children: msg.text }) : null,
141
+ ]}),
142
+ ]}) : null,
143
+ ]});
144
+ }
145
+
146
+ const inject = ["slots", "locale", "remote", "sessions"];
147
+
148
+ function apply(ctx) {
149
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "ui-skill-editor: dictionaries");
150
+ const t = ctx.locale.bind(NS);
151
+ const mount = ctx.remote.$mount(CONTRIBUTION);
152
+ const currentSessionId = () => { try { return ctx.get("sessions").currentProvideInfo.getSnapshot().sessionId; } catch { return undefined; } };
153
+ const callRemote = async (method, ...args) => {
154
+ await mount;
155
+ const remote = ctx.get("remote.skillEditor");
156
+ const result = await remote[method](...args);
157
+ if (!result.ok) throw new Error("skillEditor." + method + " failed: " + (result.error && (result.error.code || result.error.message)));
158
+ return result.value;
159
+ };
160
+
161
+ const EditorSection = (props) => {
162
+ const [query, setQuery] = react.useState("");
163
+ const [listState, setListState] = react.useState({ status: "loading" });
164
+ const [request, setRequest] = react.useState(0);
165
+ react.useEffect(() => {
166
+ // 不依赖注入的 listSkills;直接内部调用,最稳妥。
167
+ setListState({ status: "loading" });
168
+ Promise.resolve().then(() => callRemote("list", currentSessionId())).then((snapshot) => setListState({ status: "ready", skills: snapshot.skills || [] })).catch((e) => setListState({ status: "err", error: String(e && e.message || e) }));
169
+ }, [request]);
170
+ const q = (query || "").trim().toLowerCase();
171
+ const skills = (listState.status === "ready" ? listState.skills : []).filter((s) => !q || s.name.toLowerCase().includes(q));
172
+ return react_jsx_runtime.jsxs("div", { className: c.section, children: [
173
+ react_jsx_runtime.jsxs("div", { className: c.actions, children: [
174
+ react_jsx_runtime.jsx("input", { className: c.input, placeholder: t("nav"), value: query, onChange: (e) => setQuery(e.target.value), style: { maxWidth: "280px" } }),
175
+ listState.status === "err" ? react_jsx_runtime.jsx("button", { type: "button", className: c.ghost, onClick: () => setRequest((n) => n + 1), children: t("retry") }) : null,
176
+ ]}),
177
+ listState.status === "loading" ? react_jsx_runtime.jsx("p", { className: c.msg, children: t("loading") })
178
+ : listState.status === "err" ? react_jsx_runtime.jsx("p", { className: c.msg, children: t("error") })
179
+ : skills.length === 0 ? react_jsx_runtime.jsx("p", { className: c.empty, children: t("empty") })
180
+ : react_jsx_runtime.jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gridTemplateColumns: "repeat(2,minmax(0,1fr))", gap: "10px" }, children: skills.map((s) => react_jsx_runtime.jsx(EditorCard, { skill: s, t, callRemote }, s.name + (s.scope && s.scope.kind === "workspace" ? "@ws" : ""))) }),
181
+ ]});
182
+ };
183
+
184
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
185
+ name: "settings.section",
186
+ id: "skill-editor",
187
+ order: 16.8,
188
+ label: () => t("nav"),
189
+ locale: NS,
190
+ inject: () => ({}),
191
+ }, (props) => react_jsx_runtime.jsx(EditorSection, { ...props, t })));
192
+ }
193
+
194
+ exports.NS = NS;
195
+ exports.apply = apply;
196
+ exports.inject = inject;
197
+ return exports;
198
+ },
199
+ });
package/lib/index.js ADDED
@@ -0,0 +1,341 @@
1
+ // dsh-skill-editor — host 半区(完全独立,不依赖任何第三方技能插件)。
2
+ //
3
+ // 一个 Typert 远程服务("skillEditor"),对外暴露:
4
+ // - list() 列出所有可编辑技能(区分 local / bundled / workspace)
5
+ // - content() 读取某个技能的 SKILL.md(返回 frontmatter 与正文分离)
6
+ // - saveContent() 写回某个技能的 frontmatter 和/或正文(本地技能;bundled 只读)
7
+ //
8
+ // 技能即明文文件:<root>/<name>/SKILL.md 或 <root>/<name>.md。
9
+ // 本插件完全用 node:fs 读写,不走第三方包的内部逻辑,重启后热刷新。
10
+ //
11
+ // 安全分级(与用户约定):
12
+ // - 本地技能(~/.dsh/skills、~/.agents/skills、工作区 .dsh/skills):可改,无提示。
13
+ // - 系统自带技能(bundled / 插件内嵌,无对应可写文件):只读,前端会显示风险提示。
14
+
15
+ import { mkdir, readFile, writeFile, readdir, stat } from "node:fs/promises";
16
+ import { basename, dirname, join, resolve as resolvePath } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { z } from "zod";
19
+
20
+ export const name = "dsh-skill-editor";
21
+ export const inject = ["typert", "skills", "sessions"];
22
+
23
+ // ── root 解析 ───────────────────────────────────────────────────────────
24
+ function dshHome() {
25
+ return process.env.DSH_HOME || join(homedir(), ".dsh");
26
+ }
27
+ function agentsHome() {
28
+ return process.env.DSH_AGENTS_HOME || join(homedir(), ".agents");
29
+ }
30
+ /** 返回 [root, source] 列表;source ∈ {"user-dsh","user-agents"}。项目根动态扫描由 list() 补充。 */
31
+ function userRoots() {
32
+ return [
33
+ { path: join(dshHome(), "skills"), source: "user-dsh" },
34
+ { path: join(agentsHome(), "skills"), source: "user-agents" },
35
+ ];
36
+ }
37
+
38
+ // ── frontmatter 解析(轻量版,足够分离 name/description/正文)────────
39
+ // 只解析 "---" 之间的行,读取 name 与 description,正文是其后内容。
40
+ function parseFrontmatter(raw) {
41
+ const lines = raw.split("\n");
42
+ if (lines[0]?.trim() !== "---") return undefined;
43
+ let end = -1;
44
+ for (let i = 1; i < lines.length; i++) {
45
+ if (lines[i].trim() === "---") { end = i; break; }
46
+ }
47
+ if (end === -1) return undefined;
48
+ const meta = {};
49
+ for (let i = 1; i < end; i++) {
50
+ const m = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
51
+ if (m) meta[m[1]] = m[2].trim();
52
+ }
53
+ const body = lines.slice(end + 1).join("\n").trim();
54
+ return { name: meta.name, description: meta.description ?? "", body, meta };
55
+ }
56
+
57
+ /** 把 frontmatter 重新拼回(保留其它字段)。body 语义内容应已 trim。 */
58
+ function rebuildFrontmatter(meta, body) {
59
+ const lines = ["---"];
60
+ for (const [k, v] of Object.entries(meta)) {
61
+ lines.push(k + ": " + v);
62
+ }
63
+ lines.push("---", "");
64
+ if (body) lines.push(body.trim());
65
+ return lines.join("\n") + "\n";
66
+ }
67
+
68
+ // ── 目录扫描:找出所有 .md 技能文件 ──────────────────────────────────
69
+ async function scanDir(dir, projectRoot, source, depth, maxDepth, out) {
70
+ let items;
71
+ try {
72
+ items = await readdir(dir, { withFileTypes: true });
73
+ }
74
+ catch {
75
+ return; // 目录不存在
76
+ }
77
+ for (const item of items) {
78
+ if (item.name.startsWith(".") || item.name === "node_modules") continue;
79
+ const full = join(dir, item.name);
80
+ if (item.isDirectory()) {
81
+ if (item.name.endsWith(".disabled")) continue;
82
+ // 目录束技能:内含 SKILL.md
83
+ const skillFile = join(full, "SKILL.md");
84
+ try {
85
+ const info = await stat(skillFile);
86
+ if (info.isFile()) {
87
+ const raw = await readFile(skillFile, "utf8");
88
+ const parsed = parseFrontmatter(raw);
89
+ out.push({
90
+ name: parsed?.name || item.name,
91
+ description: parsed?.description || "",
92
+ path: skillFile,
93
+ kind: "dir",
94
+ projectRoot,
95
+ source,
96
+ bundled: false,
97
+ raw,
98
+ });
99
+ // 目录束不下钻
100
+ continue;
101
+ }
102
+ }
103
+ catch { /* 非技能目录 */ }
104
+ if (depth < maxDepth) await scanDir(full, projectRoot, source, depth + 1, maxDepth, out);
105
+ }
106
+ else if (item.isFile() && item.name.endsWith(".md")) {
107
+ const raw = await readFile(full, "utf8");
108
+ const parsed = parseFrontmatter(raw);
109
+ out.push({
110
+ name: parsed?.name || item.name.slice(0, -3),
111
+ description: parsed?.description || "",
112
+ path: full,
113
+ kind: "flat",
114
+ projectRoot,
115
+ source,
116
+ bundled: false,
117
+ raw,
118
+ });
119
+ }
120
+ // 其它文件忽略
121
+ }
122
+ }
123
+
124
+ /** 列出所有本地可写技能。 */
125
+ async function collectFiles() {
126
+ const out = [];
127
+ for (const root of userRoots()) {
128
+ await scanDir(root.path, undefined, root.source, 0, 8, out);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ // ── schema ──────────────────────────────────────────────────────────────
134
+ const scopeSchema = z.object({
135
+ kind: z.enum(["global", "workspace"]),
136
+ path: z.string().optional(),
137
+ label: z.string().optional(),
138
+ });
139
+ const skillSummarySchema = z.object({
140
+ name: z.string(),
141
+ description: z.string(),
142
+ provider: z.string(),
143
+ source: z.string(),
144
+ bundled: z.boolean(),
145
+ scope: scopeSchema.optional(),
146
+ path: z.string().optional(),
147
+ });
148
+ const listResultSchema = z.object({ skills: z.array(skillSummarySchema) });
149
+ const skillContentSchema = z
150
+ .object({
151
+ name: z.string(),
152
+ description: z.string(),
153
+ body: z.string(),
154
+ content: z.string(),
155
+ provider: z.string(),
156
+ bundled: z.boolean(),
157
+ path: z.string().optional(),
158
+ })
159
+ .nullable();
160
+ const savePayloadSchema = z.object({
161
+ name: z.string(),
162
+ description: z.string().optional(),
163
+ body: z.string().optional(),
164
+ content: z.string().optional(),
165
+ });
166
+ const saveResultSchema = z.object({ name: z.string(), ok: z.boolean() });
167
+
168
+ /** 注册到 API 网关的类型化 wire 描述符。 */
169
+ const MANIFEST = {
170
+ package: "dsh-skill-editor",
171
+ face: "host",
172
+ schemas: [],
173
+ invocations: [
174
+ {
175
+ id: "dsh-skill-editor#skillEditor/list",
176
+ service: "skillEditor",
177
+ namespace: "skillEditor",
178
+ method: "list",
179
+ invocation: { kind: "direct" },
180
+ parameters: [{ name: "sessionId", wire: "sessionId", source: "json", acceptsUndefined: true, codec: { mode: "src-json" } }],
181
+ result: { mode: "strict", typeSymbol: "dsh-skill-editor#ListResult", schema: listResultSchema },
182
+ },
183
+ {
184
+ id: "dsh-skill-editor#skillEditor/content",
185
+ service: "skillEditor",
186
+ namespace: "skillEditor",
187
+ method: "content",
188
+ invocation: { kind: "direct" },
189
+ parameters: [
190
+ { name: "name", wire: "name", source: "json", codec: { mode: "src-json" } },
191
+ { name: "sessionId", wire: "sessionId", source: "json", acceptsUndefined: true, codec: { mode: "src-json" } },
192
+ ],
193
+ result: { mode: "strict", typeSymbol: "dsh-skill-editor#SkillContent", schema: skillContentSchema },
194
+ },
195
+ {
196
+ id: "dsh-skill-editor#skillEditor/saveContent",
197
+ service: "skillEditor",
198
+ namespace: "skillEditor",
199
+ method: "saveContent",
200
+ invocation: { kind: "direct" },
201
+ parameters: [
202
+ { name: "name", wire: "name", source: "json", codec: { mode: "src-json" } },
203
+ { name: "payload", wire: "payload", source: "json", codec: { mode: "strict", typeSymbol: "dsh-skill-editor#SavePayload", schema: savePayloadSchema } },
204
+ { name: "sessionId", wire: "sessionId", source: "json", acceptsUndefined: true, codec: { mode: "src-json" } },
205
+ ],
206
+ result: { mode: "strict", typeSymbol: "dsh-skill-editor#SaveResult", schema: saveResultSchema },
207
+ },
208
+ ],
209
+ };
210
+
211
+ // ── 远程服务实例 ────────────────────────────────────────────────────────
212
+ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
213
+
214
+ class SkillEditorService extends TypertRemoteService {
215
+ constructor(ctx) {
216
+ super(ctx, "skillEditor");
217
+ // 列表缓存(简单起见每次实时扫)
218
+ }
219
+ /** 对动态 harness 上下文(sessions/agents/typert)的无类型访问桥。 */
220
+ get C() {
221
+ return this.ctx;
222
+ }
223
+
224
+ async list(_sessionId) {
225
+ try {
226
+ const files = await collectFiles.call(this);
227
+ const skills = files.map((f) => ({
228
+ name: f.name,
229
+ description: f.description,
230
+ provider: "filesystem",
231
+ source: f.source,
232
+ bundled: f.bundled,
233
+ path: f.path,
234
+ ...(f.projectRoot === undefined
235
+ ? {}
236
+ : { scope: { kind: "workspace", path: f.projectRoot } }),
237
+ }));
238
+ // 把注册表里可见的 bundled/plugin 技能也列出来(只读标识)。
239
+ const registry = this.C.get("skills");
240
+ if (registry !== undefined && typeof registry.list === "function") {
241
+ try {
242
+ const live = await registry.list({ cwd: undefined });
243
+ for (const s of live) {
244
+ if (skills.some((e) => e.name === s.name)) continue;
245
+ skills.push({
246
+ name: s.name,
247
+ description: s.description ?? "",
248
+ provider: s.provider ?? "bundled",
249
+ source: s.source ?? "bundled",
250
+ bundled: !(s.path || s.resourceBase),
251
+ path: s.path,
252
+ });
253
+ }
254
+ }
255
+ catch { /* 忽略注册表故障 */ }
256
+ }
257
+ console.error("[dsh-skill-editor] list() 返回", skills.length, "个技能:", skills.map((s) => s.name).join(", "));
258
+ return { skills };
259
+ }
260
+ catch (e) {
261
+ console.error("[dsh-skill-editor] list() 抛出异常:", e && e.message ? e.message : String(e));
262
+ throw e;
263
+ }
264
+ }
265
+
266
+ async content(name, _sessionId) {
267
+ const files = await collectFiles.call(this);
268
+ const entry = files.find((f) => f.name === name);
269
+ if (entry !== undefined) {
270
+ const parsed = parseFrontmatter(entry.raw);
271
+ return {
272
+ name: entry.name,
273
+ description: parsed?.description ?? "",
274
+ body: parsed?.body ?? "",
275
+ content: entry.raw,
276
+ provider: "filesystem",
277
+ bundled: false,
278
+ path: entry.path,
279
+ };
280
+ }
281
+ // 注册表里的(bundled / 插件内嵌)技能:展示全文,但 bundled 不可保存。
282
+ const registry = this.C.get("skills");
283
+ if (registry !== undefined && typeof registry.get === "function") {
284
+ try {
285
+ const skill = await registry.get(name, { cwd: undefined });
286
+ if (skill !== undefined) {
287
+ return {
288
+ name: skill.name,
289
+ description: skill.description ?? "",
290
+ body: skill.content ?? "",
291
+ content: skill.content ?? "",
292
+ provider: skill.provider ?? "bundled",
293
+ bundled: !skill.path,
294
+ ...(skill.path === undefined ? {} : { path: skill.path }),
295
+ };
296
+ }
297
+ }
298
+ catch { /* 忽略 */ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ async saveContent(name, payload, _sessionId) {
304
+ const files = await collectFiles.call(this);
305
+ const entry = files.find((f) => f.name === name);
306
+ if (entry === undefined || entry.bundled) {
307
+ throw new Error('技能 "' + name + '" 不可编辑(不存在或为系统/内置技能)');
308
+ }
309
+ const parsed = parseFrontmatter(entry.raw) ?? { meta: { name, description: "" }, body: "" };
310
+ const meta = { ...parsed.meta };
311
+ // 正文:显式传入的 body/content 优先;否则保留当前正文,绝不因只改简介而清空。
312
+ const nextBody =
313
+ payload.body !== undefined && typeof payload.body === "string"
314
+ ? payload.body
315
+ : payload.content !== undefined && typeof payload.content === "string"
316
+ ? payload.content
317
+ : parsed.body;
318
+ if (typeof payload.description === "string") meta.description = payload.description;
319
+ await mkdir(dirname(entry.path), { recursive: true });
320
+ await writeFile(entry.path, rebuildFrontmatter(meta, nextBody), "utf8");
321
+ return { name, ok: true };
322
+ }
323
+ }
324
+
325
+ export function apply(ctx) {
326
+ try {
327
+ new SkillEditorService(ctx);
328
+ console.error("[dsh-skill-editor] SkillEditorService 已实例化, service=skillEditor");
329
+ // 必须把调用描述符注册进 typert,否则前端 remote.skillEditor 无法建连。
330
+ ctx.effect(() => {
331
+ const r = ctx.typert.register(MANIFEST);
332
+ console.error("[dsh-skill-editor] ctx.typert.register 已调用, 返回:", r === undefined ? "undefined" : typeof r);
333
+ return r;
334
+ }, "dsh-skill-editor: typert manifest");
335
+ console.error("[dsh-skill-editor] apply() 完成, 无异常");
336
+ }
337
+ catch (e) {
338
+ console.error("[dsh-skill-editor] apply() 抛出异常:", e && e.message ? e.message : String(e));
339
+ throw e;
340
+ }
341
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "dsh-skill-editor",
3
+ "version": "0.1.0",
4
+ "description": "dsh-skill-editor: edit skill SKILL.md content (body + name/description) right from the DSH web Settings page. Local skills edit freely; bundled / built-in skills are read-only with a warning.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib",
15
+ "cordis.patch.yml",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "dsh",
21
+ "deepseek-harness",
22
+ "plugin",
23
+ "skill",
24
+ "skill-editor",
25
+ "settings"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/bosinHU/dsh-skill-editor.git"
30
+ },
31
+ "homepage": "https://github.com/bosinHU/dsh-skill-editor",
32
+ "bugs": {
33
+ "url": "https://github.com/bosinHU/dsh-skill-editor/issues"
34
+ },
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "platform": "web",
41
+ "inject": [
42
+ "@deepseek-ai/dsh-client-runtime",
43
+ "@deepseek-ai/dsh-client-locale",
44
+ "@deepseek-ai/dsh-api-gateway"
45
+ ]
46
+ }
47
+ },
48
+ "license": "MIT",
49
+ "dependencies": {
50
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6",
51
+ "zod": "^4.4.3"
52
+ }
53
+ }