@morehao/dsh-skills-console 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -41
- package/client.js +195 -17
- package/lib/core/cli.js +3 -1
- package/lib/core/links.js +1 -1
- package/lib/core/service.js +338 -64
- package/lib/core/updates.js +12 -0
- package/lib/routes.js +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ DSH(DeepSeek Harness)的**技能控制台**:装、更新、删除、接入
|
|
|
29
29
|
| 接入 | 软链四态判定(`managed` / `managed-external` / `foreign-link` / `real`),**绝不覆盖**别人创建的东西;只列本机已安装目录,已删的 Agent 自动从列表消失 |
|
|
30
30
|
| 启停 | `mode: on/off` 只摘或重建 DSH 侧链接,不动 `SKILL.md`,其他 Agent 的链接也不受影响 |
|
|
31
31
|
| 作用范围 | 全局 `~/.dsh/skills`,或仅指定项目;项目可直接勾选 DSH 已登记的工作区,也可手动选目录 |
|
|
32
|
-
| 订阅 | 关注 `owner/repo`(可指定 ref /
|
|
32
|
+
| 订阅 | 关注 `owner/repo`(可指定 ref / 子目录;ref 缺省时跟随仓库默认分支,`master` 仓库也能用),只记快照、不装技能;上游读不到快照时直接报错,不会留下空订阅 |
|
|
33
33
|
|
|
34
34
|
## 界面
|
|
35
35
|
|
|
@@ -57,16 +57,14 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
57
57
|
|
|
58
58
|
客户端 half 只注册 `platform: web`,面板出现在 DSH Web 的设置页 / 侧栏。
|
|
59
59
|
|
|
60
|
-
> 本机可能还装着 `@michengai/dsh-skills-manager`(显示名「技能管理」)。两者互不干扰(路由前缀 `/api/morehao/skills-console/*`、状态目录 `$DSH_HOME/skills-console/` 都不同),但会各出一个设置分区;只想留一个时把另一个 `plugin_manager set_plugin enabled:false` 即可。
|
|
61
|
-
|
|
62
60
|
## 快速上手
|
|
63
61
|
|
|
64
|
-
1. **添加技能**:点右上角「添加技能」,填 `owner/repo
|
|
62
|
+
1. **添加技能**:点右上角「添加技能」,填 `owner/repo` 或 GitHub URL,点「检测技能」;仓库里有多个技能时勾选要装的(支持多选,默认全选,列表带每个技能的说明),只有一个会默认勾上。检测不到时可以直接手填技能名,留空则装仓库内全部。
|
|
65
63
|
2. **接到别的 Agent**:点行内「接入设置」,勾选目标 Agent;需要限定项目时改选「仅以下项目」并勾工作区。之后新装技能默认接哪些 Agent,用它旁边的「自动接入」设定。
|
|
66
64
|
3. **检查更新**:点列表头上的刷新图标;有更新的行会亮起「更新」,也可以一次「全部更新」。本地改过的技能会先要求确认。
|
|
67
65
|
4. **临时停用**:关掉行内开关。DSH 看不到它,但文件和其他 Agent 的接入都还在;再打开就恢复。
|
|
68
66
|
|
|
69
|
-
##
|
|
67
|
+
## 可靠性
|
|
70
68
|
|
|
71
69
|
- **canonical 与 CLI lock 只读**:`~/.agents/skills`、`~/.agents/.skill-lock.json` 的增删改只经 `skills` CLI,插件不直接写、不克隆仓库、不代理来源。
|
|
72
70
|
- **只动自己的东西**:只处理本插件创建的 `managed` 链接,以及已记账的 `managed-external`;`real` 目录和 `foreign-link` 永不改写,删除前体检直接中止。
|
|
@@ -88,43 +86,9 @@ plugin_manager install_bundle(target = <本仓库绝对路径>)
|
|
|
88
86
|
|
|
89
87
|
环境变量:`DSH_SKILLS_CONSOLE_HOME`、`DSH_SKILLS_CONSOLE_CLI`、`DSH_SKILLS_CONSOLE_NPX_SPEC`、`DSH_SKILLS_CONSOLE_TIMEOUT_MS`、`GITHUB_TOKEN` / `GH_TOKEN`。
|
|
90
88
|
|
|
91
|
-
##
|
|
92
|
-
|
|
93
|
-
```bash
|
|
94
|
-
npm install # 只装构建期依赖(esbuild);运行时依赖仍为零
|
|
95
|
-
npm run build:client # 从 client/ 生成根 client.js(生成物要提交,不要手改)
|
|
96
|
-
npm run dev:client # 监听 client/ 自动重建(配合 link: 接入,改完刷新即可)
|
|
97
|
-
npm test # node --test,80 个离线用例,临时 HOME / DSH_HOME,不碰真实目录
|
|
98
|
-
npm run check # 语法自检 + 生成物一致性 + 命名/导出接线 + 打包白名单 + Release 说明投影冒烟
|
|
99
|
-
npm run sync:agents -- <skills/dist/cli.mjs> # 重新导出 agent 目录表(默认写回 lib/agent-table.json)
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
Web Client 的源码在 `client/`(`host` / `copy` / `styles` / `api` / `icons` / `hooks` / `actions` / `views/*` / `section` / `surface` / `app`),仓根 `client.js` 是 `npm run build:client` 的产物;只改产物不改源码,`npm run check` 会直接红。
|
|
103
|
-
|
|
104
|
-
目录结构、测试策略与调试手段见 [docs/development.md](docs/development.md);在本仓工作的 AI agent 请先读 [AGENTS.md](AGENTS.md)。
|
|
105
|
-
|
|
106
|
-
## 发布
|
|
107
|
-
|
|
108
|
-
打注记 tag `vX.Y.Z` → GitHub Actions(OIDC / Trusted Publishing)自动发布到 npm → 用 tag 注记正文建 GitHub Release,不需要任何 npm token。
|
|
109
|
-
|
|
110
|
-
```bash
|
|
111
|
-
# 升版本走分支 + PR;合并后在 main 最新提交上打注记 tag(正文就是 Release body)
|
|
112
|
-
git tag -a v0.2.0 -m "release: v0.2.0
|
|
113
|
-
<变更说明>"
|
|
114
|
-
git push origin v0.2.0
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
完整流程、一次性建包配置与排障清单见 [docs/publishing.md](docs/publishing.md)。
|
|
118
|
-
|
|
119
|
-
## 从 `dsh-skills-manager` 迁移
|
|
120
|
-
|
|
121
|
-
`v0.2.0` 起包名、插件 id、路由前缀、状态目录与环境变量统一改为 `skills-console`,**不提供兼容读旧值**。保住旧账本(订阅、回收站、链接记录、版本缓存)只需搬一次目录:
|
|
122
|
-
|
|
123
|
-
```bash
|
|
124
|
-
mv ~/.dsh/skills-manager ~/.dsh/skills-console
|
|
125
|
-
```
|
|
89
|
+
## 开发与发布
|
|
126
90
|
|
|
127
|
-
|
|
91
|
+
源码结构、测试策略与调试手段见 [docs/development.md](docs/development.md)(含本地 `link:` 验证与回退步骤);发布流程与排障清单见 [docs/publishing.md](docs/publishing.md)。在本仓工作的 AI agent 请先读 [AGENTS.md](AGENTS.md)。
|
|
128
92
|
|
|
129
93
|
## 边界(明确不做)
|
|
130
94
|
|
package/client.js
CHANGED
|
@@ -148,11 +148,23 @@ var DICT = {
|
|
|
148
148
|
"banner.visible": "模型可见技能与本地账本不一致,差异已列在下方",
|
|
149
149
|
"form.source": "来源(owner/repo 或 GitHub URL)",
|
|
150
150
|
"form.sourceHint": "例如 morehao/ai-kit",
|
|
151
|
+
"form.probe": "检测技能",
|
|
152
|
+
"form.probing": "读取中…",
|
|
153
|
+
"form.probingHint": "正在读仓库的文件树,稍等一下…",
|
|
154
|
+
"form.detected": "检测到 {n} 个技能,勾选要安装的",
|
|
155
|
+
"form.skipped": "另有 {n} 个 SKILL.md 缺少 name/description,CLI 无法安装,已跳过",
|
|
156
|
+
"form.selectAll": "全选",
|
|
157
|
+
"form.selectNone": "全不选",
|
|
158
|
+
"form.pickHint": "先填来源并检测;仓库里有多个技能时可以多选,只有一个会默认勾上。",
|
|
159
|
+
"form.noSkills": "仓库里没找到 SKILL.md,可以直接填技能名安装",
|
|
160
|
+
"form.probeFailed": "检测失败,仍可手动填技能名",
|
|
151
161
|
"form.skills": "技能名(多个用逗号分隔)",
|
|
152
162
|
"form.ref": "ref(分支 / tag,可空)",
|
|
163
|
+
"form.refHint": "留空则自动跟随仓库默认分支",
|
|
153
164
|
"form.subpath": "子目录(可空)",
|
|
154
165
|
"form.hint": "会立即下载并写入 ~/.agents/skills,并按「自动接入」设置接到对应 Agent。",
|
|
155
166
|
"form.subHint": "只记录你关注这个仓库,不会安装任何技能。",
|
|
167
|
+
"subscription.defaultRef": "默认分支",
|
|
156
168
|
"confirm.deleteTitle": "删除技能",
|
|
157
169
|
"confirm.deleteBody": "会从本机移除,断开本插件建立的软链并调 skills CLI 卸载;目录先备份到回收站(保留 7 天)。不会删除你的源仓库。",
|
|
158
170
|
"confirm.updateTitle": "本地有改动",
|
|
@@ -266,11 +278,23 @@ var DICT = {
|
|
|
266
278
|
"banner.visible": "Model-visible skills differ from the local ledger",
|
|
267
279
|
"form.source": "Source (owner/repo or GitHub URL)",
|
|
268
280
|
"form.sourceHint": "e.g. morehao/ai-kit",
|
|
281
|
+
"form.probe": "Detect",
|
|
282
|
+
"form.probing": "Reading…",
|
|
283
|
+
"form.probingHint": "Reading the repository tree…",
|
|
284
|
+
"form.detected": "Found {n} skills; pick the ones to install",
|
|
285
|
+
"form.skipped": "{n} SKILL.md file(s) skipped: missing name/description",
|
|
286
|
+
"form.selectAll": "Select all",
|
|
287
|
+
"form.selectNone": "Clear",
|
|
288
|
+
"form.pickHint": "Enter a source and detect first. Pick one or more when the repo has several.",
|
|
289
|
+
"form.noSkills": "No SKILL.md found; type skill names instead",
|
|
290
|
+
"form.probeFailed": "Detection failed; you can still type names",
|
|
269
291
|
"form.skills": "Skill names (comma separated)",
|
|
270
292
|
"form.ref": "ref (branch / tag, optional)",
|
|
293
|
+
"form.refHint": "empty follows the repository default branch",
|
|
271
294
|
"form.subpath": "Subdirectory (optional)",
|
|
272
295
|
"form.hint": "Downloads into ~/.agents/skills and links agents per the auto-link setting.",
|
|
273
296
|
"form.subHint": "Only records the repository; installs nothing.",
|
|
297
|
+
"subscription.defaultRef": "default branch",
|
|
274
298
|
"confirm.deleteTitle": "Delete skill",
|
|
275
299
|
"confirm.deleteBody": "Removes it locally, disconnects this plugin’s links and calls skills remove. A snapshot stays in trash for 7 days. Your source repo is untouched.",
|
|
276
300
|
"confirm.updateTitle": "Local changes",
|
|
@@ -888,6 +912,10 @@ var CSS = `
|
|
|
888
912
|
border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); outline: none; }
|
|
889
913
|
.dshsc-field input:focus { border-color: var(--dsw-alias-brand-primary);
|
|
890
914
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent); }
|
|
915
|
+
/* 来源输入 + 检测按钮同一行;输入框吃掉剩余宽度,按钮不被挤压 */
|
|
916
|
+
.dshsc-inline { display: flex; gap: 6px; align-items: stretch; }
|
|
917
|
+
.dshsc-inline input { flex: 1; min-width: 0; }
|
|
918
|
+
.dshsc-inline .dshsc-btn { flex: none; }
|
|
891
919
|
|
|
892
920
|
.dshsc-check { display: flex; align-items: center; gap: 9px; font-size: 13px; padding: 5px 8px; border-radius: 8px; cursor: pointer; }
|
|
893
921
|
.dshsc-check:hover { background: var(--dsw-alias-bg-layer-1); }
|
|
@@ -905,6 +933,14 @@ var CSS = `
|
|
|
905
933
|
.dshsc-check input:disabled { opacity: .55; cursor: not-allowed; }
|
|
906
934
|
.dshsc-check small { color: var(--dsw-alias-label-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
907
935
|
.dshsc-check span { white-space: nowrap; }
|
|
936
|
+
/* 添加技能:每个技能一行,名字 + 两行描述 + 路径;描述太长时截断,不把弹窗撑高 */
|
|
937
|
+
.dshsc-skill { align-items: flex-start; }
|
|
938
|
+
.dshsc-skill input[type="checkbox"] { margin-top: 3px; }
|
|
939
|
+
.dshsc-skill-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
|
|
940
|
+
.dshsc-skill-name { font-weight: 600; }
|
|
941
|
+
.dshsc-check .dshsc-skill-desc { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
|
942
|
+
overflow: hidden; white-space: normal; line-height: 1.4; }
|
|
943
|
+
.dshsc-check .dshsc-skill-path { font-size: 11px; opacity: .75; }
|
|
908
944
|
|
|
909
945
|
.dshsc-panel { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1);
|
|
910
946
|
padding: 4px; max-height: 300px; overflow: auto; display: flex; flex-direction: column; }
|
|
@@ -1009,11 +1045,13 @@ function useSkillActions({ t, rows, reload, run, setBusy, setToast, setModal })
|
|
|
1009
1045
|
function requestAddSubscription() {
|
|
1010
1046
|
setModal({ type: "add-subscription" });
|
|
1011
1047
|
}
|
|
1012
|
-
async function
|
|
1013
|
-
|
|
1048
|
+
async function probeSkills(source) {
|
|
1049
|
+
return apiCall("POST", "/probe", { source: String(source || "").trim() });
|
|
1050
|
+
}
|
|
1051
|
+
async function installSkills({ source, skills }) {
|
|
1014
1052
|
setModal(null);
|
|
1015
1053
|
await run("install", async () => {
|
|
1016
|
-
const result = await apiCall("POST", "/install", { source:
|
|
1054
|
+
const result = await apiCall("POST", "/install", { source: String(source || "").trim(), skills });
|
|
1017
1055
|
const warnings = result.warnings?.length || 0;
|
|
1018
1056
|
setToast({
|
|
1019
1057
|
kind: warnings ? "err" : "ok",
|
|
@@ -1060,7 +1098,8 @@ function useSkillActions({ t, rows, reload, run, setBusy, setToast, setModal })
|
|
|
1060
1098
|
saveLinks,
|
|
1061
1099
|
requestAddSkill,
|
|
1062
1100
|
requestAddSubscription,
|
|
1063
|
-
|
|
1101
|
+
probeSkills,
|
|
1102
|
+
installSkills,
|
|
1064
1103
|
addSubscription,
|
|
1065
1104
|
refreshSubscription,
|
|
1066
1105
|
deleteSubscription,
|
|
@@ -1768,7 +1807,7 @@ function SubscriptionsTab({
|
|
|
1768
1807
|
onClick: () => onToggle(subscription.id)
|
|
1769
1808
|
}, h(CaretIcon, { open })),
|
|
1770
1809
|
h("span", { className: "dshsc-name" }, subscription.source),
|
|
1771
|
-
h("span", { className: "dshsc-tag" }, subscription.ref || "
|
|
1810
|
+
h("span", { className: "dshsc-tag" }, subscription.ref || t("subscription.defaultRef")),
|
|
1772
1811
|
h(
|
|
1773
1812
|
"span",
|
|
1774
1813
|
{ className: "dshsc-substat" },
|
|
@@ -1848,6 +1887,9 @@ function Overlay({ t, title, children, footer, onCancel }) {
|
|
|
1848
1887
|
}
|
|
1849
1888
|
function FormModal({ t, title, hint, fields, onCancel, onSubmit }) {
|
|
1850
1889
|
const [form, setForm] = import_react3.default.useState(() => Object.fromEntries(fields.map((field) => [field.key, ""])));
|
|
1890
|
+
const incomplete = fields.some(
|
|
1891
|
+
(field) => field.required !== false && !String(form[field.key] || "").trim()
|
|
1892
|
+
);
|
|
1851
1893
|
return h(
|
|
1852
1894
|
Overlay,
|
|
1853
1895
|
{
|
|
@@ -1859,7 +1901,7 @@ function FormModal({ t, title, hint, fields, onCancel, onSubmit }) {
|
|
|
1859
1901
|
h("button", {
|
|
1860
1902
|
key: "s",
|
|
1861
1903
|
className: "dshsc-btn primary",
|
|
1862
|
-
disabled:
|
|
1904
|
+
disabled: incomplete,
|
|
1863
1905
|
onClick: () => onSubmit(form)
|
|
1864
1906
|
}, t("action.save"))
|
|
1865
1907
|
]
|
|
@@ -2029,18 +2071,153 @@ function AutoLinkModal({ t, settings, agents, onCancel, onSave }) {
|
|
|
2029
2071
|
h("p", { className: "dshsc-sub" }, t("autoLink.hint"))
|
|
2030
2072
|
);
|
|
2031
2073
|
}
|
|
2074
|
+
function AddSkillModal({ t, onCancel, onProbe, onInstall }) {
|
|
2075
|
+
const [source, setSource] = import_react3.default.useState("");
|
|
2076
|
+
const [phase, setPhase] = import_react3.default.useState("idle");
|
|
2077
|
+
const [options, setOptions] = import_react3.default.useState([]);
|
|
2078
|
+
const [selected, setSelected] = import_react3.default.useState(() => /* @__PURE__ */ new Set());
|
|
2079
|
+
const [manual, setManual] = import_react3.default.useState("");
|
|
2080
|
+
const [skipped, setSkipped] = import_react3.default.useState(0);
|
|
2081
|
+
const [error, setError] = import_react3.default.useState(null);
|
|
2082
|
+
const reset = (value) => {
|
|
2083
|
+
setSource(value);
|
|
2084
|
+
setPhase("idle");
|
|
2085
|
+
setOptions([]);
|
|
2086
|
+
setSelected(/* @__PURE__ */ new Set());
|
|
2087
|
+
setSkipped(0);
|
|
2088
|
+
setError(null);
|
|
2089
|
+
};
|
|
2090
|
+
const probe = async () => {
|
|
2091
|
+
const value = source.trim();
|
|
2092
|
+
if (!value || phase === "probing") return;
|
|
2093
|
+
setPhase("probing");
|
|
2094
|
+
setError(null);
|
|
2095
|
+
try {
|
|
2096
|
+
const result = await onProbe(value);
|
|
2097
|
+
const found = Array.isArray(result?.skills) ? result.skills : [];
|
|
2098
|
+
setOptions(found);
|
|
2099
|
+
setSelected(new Set(found.map((item) => item.name)));
|
|
2100
|
+
setSkipped(Math.max(0, Number(result?.skipped) || 0));
|
|
2101
|
+
setPhase(found.length > 0 ? "picked" : "empty");
|
|
2102
|
+
} catch (err) {
|
|
2103
|
+
setError(err?.message || String(err));
|
|
2104
|
+
setPhase("failed");
|
|
2105
|
+
}
|
|
2106
|
+
};
|
|
2107
|
+
const toggle = (name) => setSelected((prev) => {
|
|
2108
|
+
const next = new Set(prev);
|
|
2109
|
+
if (next.has(name)) next.delete(name);
|
|
2110
|
+
else next.add(name);
|
|
2111
|
+
return next;
|
|
2112
|
+
});
|
|
2113
|
+
const names = manual.split(/[,,\s]+/).map((item) => item.trim()).filter(Boolean);
|
|
2114
|
+
const skippedText = skipped > 0 ? t("form.skipped").replace("{n}", String(skipped)) : null;
|
|
2115
|
+
const manualMode = phase === "empty" || phase === "failed";
|
|
2116
|
+
const skills = phase === "picked" ? [...selected] : names;
|
|
2117
|
+
const canSubmit = phase === "picked" ? selected.size > 0 : manualMode ? names.length > 0 : false;
|
|
2118
|
+
const submit = () => {
|
|
2119
|
+
if (!canSubmit) return;
|
|
2120
|
+
onInstall({ source: source.trim(), skills });
|
|
2121
|
+
};
|
|
2122
|
+
return h(
|
|
2123
|
+
Overlay,
|
|
2124
|
+
{
|
|
2125
|
+
t,
|
|
2126
|
+
title: t("action.addSkill"),
|
|
2127
|
+
onCancel,
|
|
2128
|
+
footer: [
|
|
2129
|
+
h("button", { key: "c", className: "dshsc-btn ghost", onClick: onCancel }, t("action.cancel")),
|
|
2130
|
+
h("button", {
|
|
2131
|
+
key: "s",
|
|
2132
|
+
className: "dshsc-btn primary",
|
|
2133
|
+
disabled: !canSubmit,
|
|
2134
|
+
onClick: submit
|
|
2135
|
+
}, t("action.save"))
|
|
2136
|
+
]
|
|
2137
|
+
},
|
|
2138
|
+
h("p", { className: "dshsc-sub" }, t("form.pickHint")),
|
|
2139
|
+
h(
|
|
2140
|
+
"div",
|
|
2141
|
+
{ className: "dshsc-field" },
|
|
2142
|
+
h("span", null, t("form.source")),
|
|
2143
|
+
h(
|
|
2144
|
+
"div",
|
|
2145
|
+
{ className: "dshsc-inline" },
|
|
2146
|
+
h("input", {
|
|
2147
|
+
value: source,
|
|
2148
|
+
placeholder: t("form.sourceHint"),
|
|
2149
|
+
onChange: (event) => reset(event.target.value),
|
|
2150
|
+
onKeyDown: (event) => {
|
|
2151
|
+
if (event.key === "Enter") void probe();
|
|
2152
|
+
}
|
|
2153
|
+
}),
|
|
2154
|
+
h("button", {
|
|
2155
|
+
className: "dshsc-btn",
|
|
2156
|
+
disabled: !source.trim() || phase === "probing",
|
|
2157
|
+
onClick: () => void probe()
|
|
2158
|
+
}, phase === "probing" ? t("form.probing") : t("form.probe"))
|
|
2159
|
+
)
|
|
2160
|
+
),
|
|
2161
|
+
phase === "picked" ? h(
|
|
2162
|
+
"div",
|
|
2163
|
+
{ key: "picked" },
|
|
2164
|
+
h("div", { className: "dshsc-group" }, t("form.detected").replace("{n}", String(options.length))),
|
|
2165
|
+
h(
|
|
2166
|
+
"div",
|
|
2167
|
+
{ style: { display: "flex", gap: 6, marginBottom: 6 } },
|
|
2168
|
+
h("button", {
|
|
2169
|
+
className: "dshsc-btn ghost",
|
|
2170
|
+
onClick: () => setSelected(new Set(options.map((item) => item.name)))
|
|
2171
|
+
}, t("form.selectAll")),
|
|
2172
|
+
h("button", {
|
|
2173
|
+
className: "dshsc-btn ghost",
|
|
2174
|
+
onClick: () => setSelected(/* @__PURE__ */ new Set())
|
|
2175
|
+
}, t("form.selectNone"))
|
|
2176
|
+
),
|
|
2177
|
+
h("div", { className: "dshsc-panel" }, options.map((option) => h(
|
|
2178
|
+
"label",
|
|
2179
|
+
{ className: "dshsc-check dshsc-skill", key: option.name },
|
|
2180
|
+
h("input", {
|
|
2181
|
+
type: "checkbox",
|
|
2182
|
+
checked: selected.has(option.name),
|
|
2183
|
+
onChange: () => toggle(option.name)
|
|
2184
|
+
}),
|
|
2185
|
+
h(
|
|
2186
|
+
"span",
|
|
2187
|
+
{ className: "dshsc-skill-main" },
|
|
2188
|
+
h("span", { className: "dshsc-skill-name" }, option.name),
|
|
2189
|
+
option.description ? h("small", { className: "dshsc-skill-desc" }, option.description) : null,
|
|
2190
|
+
h("small", { className: "dshsc-skill-path" }, option.skillPath)
|
|
2191
|
+
)
|
|
2192
|
+
))),
|
|
2193
|
+
skippedText ? h("p", { className: "dshsc-sub" }, skippedText) : null
|
|
2194
|
+
) : null,
|
|
2195
|
+
phase === "probing" ? h("p", { className: "dshsc-sub" }, t("form.probingHint")) : null,
|
|
2196
|
+
manualMode ? h(
|
|
2197
|
+
"div",
|
|
2198
|
+
{ key: "manual" },
|
|
2199
|
+
h("p", { className: "dshsc-sub" }, phase === "empty" ? skippedText || t("form.noSkills") : `${t("form.probeFailed")}${error ? ` · ${error}` : ""}`),
|
|
2200
|
+
h(
|
|
2201
|
+
"label",
|
|
2202
|
+
{ className: "dshsc-field" },
|
|
2203
|
+
h("span", null, t("form.skills")),
|
|
2204
|
+
h("input", {
|
|
2205
|
+
value: manual,
|
|
2206
|
+
placeholder: "git-kit, handoff",
|
|
2207
|
+
onChange: (event) => setManual(event.target.value)
|
|
2208
|
+
})
|
|
2209
|
+
)
|
|
2210
|
+
) : null,
|
|
2211
|
+
h("p", { className: "dshsc-sub" }, t("form.hint"))
|
|
2212
|
+
);
|
|
2213
|
+
}
|
|
2032
2214
|
function Modals({ t, modal, data, autoLinkAgents, pickDirectory, actions, onClose, onSaveAutoLink }) {
|
|
2033
2215
|
if (modal.type === "add-skill") {
|
|
2034
|
-
return h(
|
|
2216
|
+
return h(AddSkillModal, {
|
|
2035
2217
|
t,
|
|
2036
|
-
title: t("action.addSkill"),
|
|
2037
|
-
hint: t("form.hint"),
|
|
2038
|
-
fields: [
|
|
2039
|
-
{ key: "source", label: t("form.source"), placeholder: t("form.sourceHint") },
|
|
2040
|
-
{ key: "skills", label: t("form.skills"), placeholder: "git-kit, handoff" }
|
|
2041
|
-
],
|
|
2042
2218
|
onCancel: onClose,
|
|
2043
|
-
|
|
2219
|
+
onProbe: actions.probeSkills,
|
|
2220
|
+
onInstall: actions.installSkills
|
|
2044
2221
|
});
|
|
2045
2222
|
}
|
|
2046
2223
|
if (modal.type === "add-subscription") {
|
|
@@ -2050,8 +2227,8 @@ function Modals({ t, modal, data, autoLinkAgents, pickDirectory, actions, onClos
|
|
|
2050
2227
|
hint: t("form.subHint"),
|
|
2051
2228
|
fields: [
|
|
2052
2229
|
{ key: "source", label: t("form.source"), placeholder: t("form.sourceHint") },
|
|
2053
|
-
{ key: "ref", label: t("form.ref"), placeholder: "
|
|
2054
|
-
{ key: "subpath", label: t("form.subpath"), placeholder: "skills" }
|
|
2230
|
+
{ key: "ref", label: t("form.ref"), placeholder: t("form.refHint"), required: false },
|
|
2231
|
+
{ key: "subpath", label: t("form.subpath"), placeholder: "skills", required: false }
|
|
2055
2232
|
],
|
|
2056
2233
|
onCancel: onClose,
|
|
2057
2234
|
onSubmit: (form) => void actions.addSubscription(form)
|
|
@@ -2267,7 +2444,8 @@ function Section({ t, page = false, pickDirectory, onPlacementChange }) {
|
|
|
2267
2444
|
autoLinkAgents: autolinkTargets,
|
|
2268
2445
|
pickDirectory,
|
|
2269
2446
|
actions: {
|
|
2270
|
-
|
|
2447
|
+
probeSkills: actions.probeSkills,
|
|
2448
|
+
installSkills: actions.installSkills,
|
|
2271
2449
|
addSubscription: actions.addSubscription,
|
|
2272
2450
|
saveLinks: actions.saveLinks
|
|
2273
2451
|
},
|
package/lib/core/cli.js
CHANGED
|
@@ -213,10 +213,12 @@ export function createCli({ env = process.env, logger = () => {} } = {}) {
|
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
215
|
* 安装:`-a universal` 必带(F17),`--json` 配 `-y`(F11),`--full-depth` 必带(F24)。
|
|
216
|
+
* 技能名留空 = `--skill '*'`(整仓全部);不能留空值的 `--skill`,那会把后面的 `-a` 当成它的参数。
|
|
216
217
|
* 永不带 `--all` / `-a '*'`。
|
|
217
218
|
*/
|
|
218
219
|
async add({ source, skills = [], timeoutMs = env.DSH_SKILLS_CONSOLE_TIMEOUT_MS ? Number(env.DSH_SKILLS_CONSOLE_TIMEOUT_MS) : 120000 }) {
|
|
219
|
-
const
|
|
220
|
+
const names = skills.length > 0 ? skills : ['*'];
|
|
221
|
+
const args = ['add', source, '--skill', ...names, '-a', 'universal', '-g', '-y', '--json', '--full-depth'];
|
|
220
222
|
const result = await call(args, { timeoutMs });
|
|
221
223
|
return result;
|
|
222
224
|
},
|
package/lib/core/links.js
CHANGED
|
@@ -55,7 +55,7 @@ export function inspectTarget({ linkPath, canonicalPath }) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
58
|
+
* 建链:同层用相对路径(可跨机器复用),算不出相对就退回绝对路径并标记。
|
|
59
59
|
* Windows 用 junction + 绝对路径(对齐 CLI 的 createSymlink,F2)。
|
|
60
60
|
*/
|
|
61
61
|
export function createLink({ linkPath, targetPath, platform = process.platform, fsImpl = fs }) {
|
package/lib/core/service.js
CHANGED
|
@@ -7,6 +7,7 @@ import { buildLedger, readLock, scanCanonical } from './inventory.js';
|
|
|
7
7
|
import { createBackup, readBackup, restoreBackup, trashSkill } from './trash.js';
|
|
8
8
|
import {
|
|
9
9
|
checkSkill,
|
|
10
|
+
fetchDefaultBranch,
|
|
10
11
|
fetchTree,
|
|
11
12
|
listSkillsFromTree,
|
|
12
13
|
parseSource,
|
|
@@ -30,6 +31,83 @@ function nowIso() {
|
|
|
30
31
|
return new Date().toISOString();
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** `skills add --json` 的输出:数组或 `{ installed: [...] }`,只取 name 并去重。 */
|
|
35
|
+
function installedNamesFrom(stdout) {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(stdout);
|
|
38
|
+
const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.installed) ? parsed.installed : [];
|
|
39
|
+
const names = rows.map((row) => (typeof row === 'string' ? row : row?.name)).filter(Boolean);
|
|
40
|
+
return [...new Set(names)];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 极简 frontmatter 解析:只取 CLI 安装必需的两项(name / description)。 */
|
|
47
|
+
function parseSkillFrontmatter(text) {
|
|
48
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(String(text || ''));
|
|
49
|
+
if (!match) return {};
|
|
50
|
+
const lines = match[1].split(/\r?\n/);
|
|
51
|
+
const fields = {};
|
|
52
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
53
|
+
const line = lines[index];
|
|
54
|
+
if (!line || /^\s/.test(line)) continue;
|
|
55
|
+
const colon = line.indexOf(':');
|
|
56
|
+
if (colon <= 0) continue;
|
|
57
|
+
const key = line.slice(0, colon).trim();
|
|
58
|
+
if (key !== 'name' && key !== 'description') continue;
|
|
59
|
+
let value = line.slice(colon + 1).trim();
|
|
60
|
+
const block = /^([|>])[+-]?$/.exec(value);
|
|
61
|
+
if (block) {
|
|
62
|
+
const folded = block[1] === '>';
|
|
63
|
+
const rows = [];
|
|
64
|
+
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
|
|
65
|
+
rows.push(lines[index + 1].replace(/^\s+/, ''));
|
|
66
|
+
index += 1;
|
|
67
|
+
}
|
|
68
|
+
value = folded ? rows.join(' ') : rows.join('\n');
|
|
69
|
+
} else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
70
|
+
const quote = value[0];
|
|
71
|
+
value = value.slice(1, -1);
|
|
72
|
+
if (quote === '"') value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n');
|
|
73
|
+
}
|
|
74
|
+
fields[key] = value.trim();
|
|
75
|
+
}
|
|
76
|
+
return fields;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 用 raw 内容补 frontmatter 里的真实技能名/描述(CLI 按 name 安装);取不到就回落目录名。 */
|
|
80
|
+
async function enrichSkillEntries({ source, ref, entries, token, fetchImpl, concurrency = 8 }) {
|
|
81
|
+
const results = new Array(entries.length);
|
|
82
|
+
let cursor = 0;
|
|
83
|
+
const worker = async () => {
|
|
84
|
+
while (cursor < entries.length) {
|
|
85
|
+
const index = cursor;
|
|
86
|
+
cursor += 1;
|
|
87
|
+
results[index] = await inspectSkillEntry({ source, ref, entry: entries[index], token, fetchImpl });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, worker));
|
|
91
|
+
return results;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function inspectSkillEntry({ source, ref, entry, token, fetchImpl }) {
|
|
95
|
+
const fallback = { name: entry.name, description: '', skillPath: entry.skillPath, valid: true };
|
|
96
|
+
try {
|
|
97
|
+
const url = `https://raw.githubusercontent.com/${source}/${ref}/${entry.skillPath.split('/').map(encodeURIComponent).join('/')}`;
|
|
98
|
+
const response = await fetchImpl(url, {
|
|
99
|
+
headers: { 'user-agent': 'dsh-skills-console', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
100
|
+
});
|
|
101
|
+
if (!response?.ok) return fallback;
|
|
102
|
+
const fields = parseSkillFrontmatter(await response.text());
|
|
103
|
+
// CLI 要求 name + description 都在,缺一个就整个跳过:这里同步标成 invalid,别让用户选了才发现装不上
|
|
104
|
+
if (!fields.name || !fields.description) return { ...fallback, valid: false };
|
|
105
|
+
return { name: fields.name, description: fields.description, skillPath: entry.skillPath, valid: true };
|
|
106
|
+
} catch {
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
33
111
|
/** 写操作全局单写者队列:`add` 会同时改 canonical 与 lock,按技能分桶没有意义(设计 §14.3)。 */
|
|
34
112
|
function createQueue(logger) {
|
|
35
113
|
let chain = Promise.resolve();
|
|
@@ -63,6 +141,10 @@ function createQueue(logger) {
|
|
|
63
141
|
export function createService({ paths, store, registry, cli, logger, env = process.env, fetchImpl = fetch, visibleProvider = async () => ({ skills: [], complete: true }), workspacesProvider = async () => [] }) {
|
|
64
142
|
const enqueue = createQueue(logger);
|
|
65
143
|
const tokenOf = (env.GITHUB_TOKEN || env.GH_TOKEN) || null;
|
|
144
|
+
// 探测缓存:同一来源短时间内重复检测直接复用;并发的相同请求合并成一次(不重复烧 API 配额)
|
|
145
|
+
const probeCache = new Map();
|
|
146
|
+
const PROBE_TTL_MS = 60_000;
|
|
147
|
+
const PROBE_MAX = 50;
|
|
66
148
|
|
|
67
149
|
function state() {
|
|
68
150
|
return store.state;
|
|
@@ -121,6 +203,90 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
121
203
|
return { created, skipped, failed };
|
|
122
204
|
}
|
|
123
205
|
|
|
206
|
+
/** 订阅的分支:显式 ref > 记录里的 ref > 仓库默认分支。解析成功后顺手记住,省掉下次请求。 */
|
|
207
|
+
async function branchOf(subscription, { persist = false } = {}) {
|
|
208
|
+
if (subscription.ref) return subscription.ref;
|
|
209
|
+
let branch = null;
|
|
210
|
+
try {
|
|
211
|
+
branch = await fetchDefaultBranch({
|
|
212
|
+
source: subscription.source,
|
|
213
|
+
token: tokenOf,
|
|
214
|
+
fetchImpl,
|
|
215
|
+
});
|
|
216
|
+
} catch {
|
|
217
|
+
branch = null;
|
|
218
|
+
}
|
|
219
|
+
if (!branch) return null;
|
|
220
|
+
if (persist && !store.readOnly) {
|
|
221
|
+
store.update((draft) => {
|
|
222
|
+
const row = draft.subscriptions.find((item) => item.id === subscription.id);
|
|
223
|
+
if (row && !row.ref) row.ref = branch;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return branch;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 读一次订阅快照:只有网络与纯计算,**不写 store**。
|
|
231
|
+
* 上游失败一律抛 ServiceError —— 添加订阅必须先读到快照再记账,
|
|
232
|
+
* 否则会留下「订阅存在但一个 skill 都没有」的空壳(上游 404 时还会显示添加成功)。
|
|
233
|
+
*/
|
|
234
|
+
async function readSnapshot(subscription, { force = false, withCommit = true } = {}) {
|
|
235
|
+
const source = subscription.source;
|
|
236
|
+
try {
|
|
237
|
+
const branch = await branchOf(subscription);
|
|
238
|
+
if (!branch) {
|
|
239
|
+
throw new ServiceError(
|
|
240
|
+
'error.skills.upstream-failed',
|
|
241
|
+
`读不到 ${source} 的默认分支(仓库不存在、私有,或 GitHub 接口不可用/已限流)`,
|
|
242
|
+
{ source },
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const result = await fetchTree({
|
|
246
|
+
source,
|
|
247
|
+
ref: branch,
|
|
248
|
+
etag: force ? null : subscription.snapshot?.etag || null,
|
|
249
|
+
token: tokenOf,
|
|
250
|
+
fetchImpl,
|
|
251
|
+
});
|
|
252
|
+
if (result.error) {
|
|
253
|
+
throw new ServiceError('error.skills.upstream-failed', `读取 ${source}@${branch} 失败:${result.error}`, {
|
|
254
|
+
source,
|
|
255
|
+
ref: branch,
|
|
256
|
+
status: result.status ?? null,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (result.notModified) return { notModified: true, branch };
|
|
260
|
+
const entries = listSkillsFromTree({
|
|
261
|
+
tree: result.tree,
|
|
262
|
+
rootSha: result.rootSha,
|
|
263
|
+
subpath: subscription.subpath,
|
|
264
|
+
repoName: source.split('/').pop(),
|
|
265
|
+
});
|
|
266
|
+
const previousRoot = subscription.snapshot?.rootSha || null;
|
|
267
|
+
// 探测只需要文件树,不付 resolveCommit 那一次请求
|
|
268
|
+
const commit = !withCommit
|
|
269
|
+
? null
|
|
270
|
+
: previousRoot && previousRoot === result.rootSha && subscription.snapshot?.commit
|
|
271
|
+
? subscription.snapshot.commit
|
|
272
|
+
: await resolveCommit({ source, ref: branch, token: tokenOf, fetchImpl });
|
|
273
|
+
const changed =
|
|
274
|
+
!subscription.snapshot ||
|
|
275
|
+
previousRoot !== result.rootSha ||
|
|
276
|
+
JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
|
|
277
|
+
return {
|
|
278
|
+
branch,
|
|
279
|
+
changed,
|
|
280
|
+
snapshot: { commit, rootSha: result.rootSha, entries, fetchedAt: nowIso(), etag: result.etag },
|
|
281
|
+
};
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (error instanceof ServiceError) throw error;
|
|
284
|
+
throw new ServiceError('error.skills.upstream-failed', `读取 ${source} 失败:${error?.message || error}`, {
|
|
285
|
+
source,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
124
290
|
function recordLink(name, target, linkPath, mode, created) {
|
|
125
291
|
if (store.readOnly) return;
|
|
126
292
|
const existing = state().links.find((item) => item.skill === name && item.target === target.target);
|
|
@@ -455,8 +621,8 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
455
621
|
if (!capabilities.usable) {
|
|
456
622
|
throw new ServiceError('error.skills.cli-missing', 'skills CLI 不可用或不支持 --json/--full-depth,写操作已禁用', capabilities);
|
|
457
623
|
}
|
|
458
|
-
|
|
459
|
-
|
|
624
|
+
// 技能名可留空:有订阅快照就装快照里的条目,否则交给 CLI 的 `--skill '*'`(整仓全部)
|
|
625
|
+
const requested = (skills || []).filter(Boolean);
|
|
460
626
|
|
|
461
627
|
return enqueue('install', async () => {
|
|
462
628
|
const subscription = subscriptionId
|
|
@@ -467,10 +633,11 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
467
633
|
if (!parseSource(resolvedSource)) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
|
|
468
634
|
|
|
469
635
|
const snapshot = subscription?.snapshot || null;
|
|
636
|
+
const planned = requested.length > 0 ? requested : (snapshot?.entries || []).map((entry) => entry.name);
|
|
470
637
|
const pin = commit || snapshot?.commit || subscription?.ref || null;
|
|
471
638
|
const sourceArg = pin ? `${resolvedSource}#${pin}` : resolvedSource;
|
|
472
639
|
|
|
473
|
-
const result = await cli.add({ source: sourceArg, skills:
|
|
640
|
+
const result = await cli.add({ source: sourceArg, skills: planned });
|
|
474
641
|
if (result.timedOut) throw new ServiceError('error.skills.cli-timeout', 'skills add 超时');
|
|
475
642
|
if (result.code !== 0) {
|
|
476
643
|
throw new ServiceError('error.skills.cli-failed', 'skills add 失败', {
|
|
@@ -478,6 +645,13 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
478
645
|
stderr: result.stderr.slice(-2000),
|
|
479
646
|
});
|
|
480
647
|
}
|
|
648
|
+
// 留空时实际装了哪些技能以 CLI 的 JSON 输出为准(可能不止一个)
|
|
649
|
+
const names = planned.length > 0 ? planned : installedNamesFrom(result.stdout);
|
|
650
|
+
if (names.length === 0) {
|
|
651
|
+
throw new ServiceError('error.skills.cli-failed', 'skills add 成功,但输出里没有安装结果,无法记账', {
|
|
652
|
+
stdout: result.stdout.slice(-2000),
|
|
653
|
+
});
|
|
654
|
+
}
|
|
481
655
|
|
|
482
656
|
const lock = readLock(paths);
|
|
483
657
|
const installed = [];
|
|
@@ -605,26 +779,136 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
605
779
|
});
|
|
606
780
|
},
|
|
607
781
|
|
|
782
|
+
/** 只读探测来源里的 SKILL.md,供「添加技能」多选;不写 state、不建订阅。 */
|
|
783
|
+
async probeSource({ source, ref, subpath }) {
|
|
784
|
+
const parsed = parseSource(source);
|
|
785
|
+
if (!parsed) throw new ServiceError('error.skills.bad-source', '只接受 owner/repo 或 GitHub URL(公开源)');
|
|
786
|
+
const canonicalSource = `${parsed.owner}/${parsed.repo}`;
|
|
787
|
+
const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
|
|
788
|
+
const requestedSubpath = typeof subpath === 'string' && subpath.trim()
|
|
789
|
+
? subpath.trim().replace(/^\/+|\/+$/g, '') || null
|
|
790
|
+
: null;
|
|
791
|
+
const key = `${canonicalSource}@${requestedRef || ''}#${requestedSubpath || ''}`;
|
|
792
|
+
const cached = probeCache.get(key);
|
|
793
|
+
if (cached && (cached.pending || Date.now() - cached.at < PROBE_TTL_MS)) {
|
|
794
|
+
return cached.pending || cached.value;
|
|
795
|
+
}
|
|
796
|
+
const pending = readSnapshot(
|
|
797
|
+
{
|
|
798
|
+
id: null,
|
|
799
|
+
source: canonicalSource,
|
|
800
|
+
ref: requestedRef,
|
|
801
|
+
subpath: requestedSubpath,
|
|
802
|
+
snapshot: null,
|
|
803
|
+
},
|
|
804
|
+
{ force: true, withCommit: false },
|
|
805
|
+
).then(async (probe) => {
|
|
806
|
+
const enriched = await enrichSkillEntries({
|
|
807
|
+
source: canonicalSource,
|
|
808
|
+
ref: probe.branch,
|
|
809
|
+
entries: probe.snapshot.entries,
|
|
810
|
+
token: tokenOf,
|
|
811
|
+
fetchImpl,
|
|
812
|
+
});
|
|
813
|
+
const skills = enriched
|
|
814
|
+
.filter((skill) => skill.valid)
|
|
815
|
+
.map(({ name, description, skillPath }) => ({ name, description, skillPath }));
|
|
816
|
+
return {
|
|
817
|
+
ok: true,
|
|
818
|
+
source: canonicalSource,
|
|
819
|
+
ref: probe.branch,
|
|
820
|
+
skills,
|
|
821
|
+
skipped: enriched.length - skills.length,
|
|
822
|
+
};
|
|
823
|
+
});
|
|
824
|
+
probeCache.set(key, { pending, at: Date.now() });
|
|
825
|
+
try {
|
|
826
|
+
const value = await pending;
|
|
827
|
+
probeCache.set(key, { value, at: Date.now() });
|
|
828
|
+
for (const oldest of probeCache.keys()) {
|
|
829
|
+
if (probeCache.size <= PROBE_MAX) break;
|
|
830
|
+
probeCache.delete(oldest);
|
|
831
|
+
}
|
|
832
|
+
return value;
|
|
833
|
+
} catch (error) {
|
|
834
|
+
// 失败不留缓存,用户改一下来源或重试就能重新探测
|
|
835
|
+
probeCache.delete(key);
|
|
836
|
+
throw error;
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
|
|
608
840
|
async addSubscription({ source, ref, subpath, skills = '*' }) {
|
|
609
841
|
assertWritable();
|
|
610
842
|
const parsed = parseSource(source);
|
|
611
843
|
if (!parsed) throw new ServiceError('error.skills.bad-source', '订阅只接受 owner/repo 或 GitHub URL');
|
|
612
|
-
const
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
844
|
+
const canonicalSource = `${parsed.owner}/${parsed.repo}`;
|
|
845
|
+
const requestedRef = typeof ref === 'string' && ref.trim() ? ref.trim() : null;
|
|
846
|
+
const requestedSubpath = typeof subpath === 'string' && subpath.trim()
|
|
847
|
+
? subpath.trim().replace(/^\/+|\/+$/g, '') || null
|
|
848
|
+
: null;
|
|
849
|
+
const id = snapshotId({ source: canonicalSource, ref: requestedRef, subpath: requestedSubpath });
|
|
850
|
+
// 老记录可能用 URL / 原始字符串算过 id:按来源+ref+子目录再找一遍,避免同一仓库出现两条订阅
|
|
851
|
+
const existing =
|
|
852
|
+
state().subscriptions.find((item) => item.id === id) ||
|
|
853
|
+
state().subscriptions.find(
|
|
854
|
+
(item) =>
|
|
855
|
+
item.source === canonicalSource &&
|
|
856
|
+
(item.ref || null) === requestedRef &&
|
|
857
|
+
(item.subpath || null) === requestedSubpath,
|
|
858
|
+
);
|
|
859
|
+
// 已有快照的重复添加直接返回;空壳(旧版本失败留下的)要走一次真实拉取,成功后再补全
|
|
860
|
+
if (existing?.snapshot) return { ok: true, subscription: existing, existing: true };
|
|
861
|
+
|
|
862
|
+
const candidate = {
|
|
863
|
+
id: existing?.id || id,
|
|
864
|
+
source: canonicalSource,
|
|
865
|
+
ref: requestedRef || existing?.ref || null,
|
|
866
|
+
subpath: requestedSubpath,
|
|
620
867
|
skills,
|
|
621
868
|
lastCheck: null,
|
|
622
869
|
snapshot: null,
|
|
623
870
|
};
|
|
871
|
+
|
|
872
|
+
// 先读快照再落盘:上游失败时 state 里不能留下订阅,接口也必须如实报错
|
|
873
|
+
const probe = await readSnapshot(candidate, { force: true });
|
|
874
|
+
// 一条 skill 都没有 = 订阅了也没东西可装(多半是 subpath/ref 写错),同样不能算添加成功
|
|
875
|
+
if (probe.snapshot.entries.length === 0) {
|
|
876
|
+
throw new ServiceError(
|
|
877
|
+
'error.skills.no-skills',
|
|
878
|
+
`${canonicalSource}@${probe.branch}${requestedSubpath ? `/${requestedSubpath}` : ''} 里没有找到 SKILL.md`,
|
|
879
|
+
{ source: canonicalSource, ref: probe.branch, subpath: requestedSubpath },
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const record = {
|
|
884
|
+
...candidate,
|
|
885
|
+
...(existing?.adopted ? { adopted: true } : {}),
|
|
886
|
+
ref: probe.branch,
|
|
887
|
+
snapshot: probe.snapshot,
|
|
888
|
+
lastCheck: {
|
|
889
|
+
at: nowIso(),
|
|
890
|
+
commit: probe.snapshot.commit,
|
|
891
|
+
treeEtag: probe.snapshot.etag,
|
|
892
|
+
status: 'changed',
|
|
893
|
+
},
|
|
894
|
+
};
|
|
624
895
|
store.update((draft) => {
|
|
625
|
-
draft.subscriptions.
|
|
896
|
+
const index = draft.subscriptions.findIndex((item) => item.id === record.id);
|
|
897
|
+
if (index >= 0) draft.subscriptions[index] = { ...draft.subscriptions[index], ...record };
|
|
898
|
+
else draft.subscriptions.push(record);
|
|
626
899
|
});
|
|
627
|
-
|
|
900
|
+
logger('subscription.add', {
|
|
901
|
+
id: record.id,
|
|
902
|
+
source: record.source,
|
|
903
|
+
ref: record.ref,
|
|
904
|
+
entries: record.snapshot.entries.length,
|
|
905
|
+
});
|
|
906
|
+
return {
|
|
907
|
+
ok: true,
|
|
908
|
+
changed: true,
|
|
909
|
+
entries: record.snapshot.entries.length,
|
|
910
|
+
subscription: state().subscriptions.find((item) => item.id === record.id),
|
|
911
|
+
};
|
|
628
912
|
},
|
|
629
913
|
|
|
630
914
|
async removeSubscription({ id, keepInstalled = true }) {
|
|
@@ -646,55 +930,39 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
646
930
|
assertWritable();
|
|
647
931
|
const subscription = state().subscriptions.find((item) => item.id === id);
|
|
648
932
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '订阅不存在');
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: result.error };
|
|
662
|
-
});
|
|
663
|
-
return { ok: false, code: 'error.skills.upstream-failed', message: result.error };
|
|
664
|
-
}
|
|
665
|
-
if (result.notModified) {
|
|
666
|
-
store.update((draft) => {
|
|
667
|
-
const row = draft.subscriptions.find((item) => item.id === id);
|
|
668
|
-
// 304 只刷新时间,绝不把 entries 置空
|
|
669
|
-
if (row) row.lastCheck = { ...(row.lastCheck || {}), at: nowIso(), status: 'unchanged' };
|
|
670
|
-
});
|
|
671
|
-
return { ok: true, notModified: true, subscription: state().subscriptions.find((item) => item.id === id) };
|
|
933
|
+
let probe;
|
|
934
|
+
try {
|
|
935
|
+
probe = await readSnapshot(subscription, { force });
|
|
936
|
+
} catch (error) {
|
|
937
|
+
// 失败留痕(清单里能看到 lastCheck),但接口必须报错,不能把失败伪装成成功
|
|
938
|
+
if (!store.readOnly) {
|
|
939
|
+
store.update((draft) => {
|
|
940
|
+
const row = draft.subscriptions.find((item) => item.id === id);
|
|
941
|
+
if (row) row.lastCheck = { at: nowIso(), status: 'unknown', detail: error.message };
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
throw error;
|
|
672
945
|
}
|
|
673
|
-
|
|
674
|
-
const entries = listSkillsFromTree({
|
|
675
|
-
tree: result.tree,
|
|
676
|
-
rootSha: result.rootSha,
|
|
677
|
-
subpath: subscription.subpath,
|
|
678
|
-
repoName: subscription.source.split('/').pop(),
|
|
679
|
-
});
|
|
680
|
-
const previousRoot = subscription.snapshot?.rootSha || null;
|
|
681
|
-
const commit =
|
|
682
|
-
previousRoot && previousRoot === result.rootSha && subscription.snapshot?.commit
|
|
683
|
-
? subscription.snapshot.commit
|
|
684
|
-
: await resolveCommit({ source: subscription.source, ref, token: tokenOf, fetchImpl });
|
|
685
|
-
|
|
686
|
-
const changed =
|
|
687
|
-
!subscription.snapshot ||
|
|
688
|
-
previousRoot !== result.rootSha ||
|
|
689
|
-
JSON.stringify(entries) !== JSON.stringify(subscription.snapshot.entries);
|
|
946
|
+
const at = nowIso();
|
|
690
947
|
store.update((draft) => {
|
|
691
948
|
const row = draft.subscriptions.find((item) => item.id === id);
|
|
692
949
|
if (!row) return;
|
|
693
|
-
row.
|
|
694
|
-
|
|
950
|
+
if (!row.ref && probe.branch) row.ref = probe.branch;
|
|
951
|
+
if (probe.notModified) {
|
|
952
|
+
// 304 只刷新时间,绝不把 entries 置空
|
|
953
|
+
row.lastCheck = { ...(row.lastCheck || {}), at, status: 'unchanged' };
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
row.snapshot = probe.snapshot;
|
|
957
|
+
row.lastCheck = { at, commit: probe.snapshot.commit, treeEtag: probe.snapshot.etag, status: probe.changed ? 'changed' : 'unchanged' };
|
|
695
958
|
});
|
|
696
|
-
logger('subscription.refresh', { id, entries: entries.length, changed });
|
|
697
|
-
return {
|
|
959
|
+
if (!probe.notModified) logger('subscription.refresh', { id, entries: probe.snapshot.entries.length, changed: probe.changed });
|
|
960
|
+
return {
|
|
961
|
+
ok: true,
|
|
962
|
+
changed: !probe.notModified && probe.changed,
|
|
963
|
+
...(probe.notModified ? { notModified: true } : {}),
|
|
964
|
+
subscription: state().subscriptions.find((item) => item.id === id),
|
|
965
|
+
};
|
|
698
966
|
},
|
|
699
967
|
|
|
700
968
|
async checkUpdates({ names, subscriptionId } = {}) {
|
|
@@ -708,16 +976,18 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
708
976
|
for (const install of installs) {
|
|
709
977
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
710
978
|
if (!subscription) continue;
|
|
711
|
-
|
|
712
|
-
if (!groups.has(
|
|
713
|
-
groups.get(
|
|
979
|
+
// 按订阅(id 已含 source/ref/subpath)分组:不能用 `ref || 'main'` 当键,否则 null-ref 与 main 会撞车
|
|
980
|
+
if (!groups.has(subscription.id)) groups.set(subscription.id, { subscription, installs: [] });
|
|
981
|
+
groups.get(subscription.id).installs.push(install);
|
|
714
982
|
}
|
|
715
983
|
|
|
716
984
|
const results = [];
|
|
717
985
|
for (const { subscription, installs: groupInstalls } of groups.values()) {
|
|
986
|
+
// 没写 ref 的订阅(旧版本/接管建的)按仓库默认分支检查,别硬套 main
|
|
987
|
+
const ref = (await branchOf(subscription, { persist: true })) || 'main';
|
|
718
988
|
const tree = await fetchTree({
|
|
719
989
|
source: subscription.source,
|
|
720
|
-
ref
|
|
990
|
+
ref,
|
|
721
991
|
etag: subscription.snapshot?.etag || null,
|
|
722
992
|
token: tokenOf,
|
|
723
993
|
fetchImpl,
|
|
@@ -779,9 +1049,10 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
779
1049
|
if (!install) throw new ServiceError('error.skills.not-managed', `${name} 没有安装记录`);
|
|
780
1050
|
const subscription = state().subscriptions.find((item) => item.id === install.subscriptionId);
|
|
781
1051
|
if (!subscription) throw new ServiceError('error.skills.no-subscription', '该技能不属于任何订阅,无法生成更新计划');
|
|
1052
|
+
const ref = (await branchOf(subscription)) || 'main';
|
|
782
1053
|
const tree = await fetchTree({
|
|
783
1054
|
source: subscription.source,
|
|
784
|
-
ref
|
|
1055
|
+
ref,
|
|
785
1056
|
token: tokenOf,
|
|
786
1057
|
fetchImpl,
|
|
787
1058
|
});
|
|
@@ -791,7 +1062,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
791
1062
|
const entry = tree.tree.find((item) => item.type === 'blob' && item.path === skillPath);
|
|
792
1063
|
if (!entry) throw new ServiceError('error.skills.upstream-deleted', '上游已移除该技能');
|
|
793
1064
|
const targetTreeSha = skillTreeSha({ tree: tree.tree, skillPath, rootSha: tree.rootSha });
|
|
794
|
-
const targetCommit = await resolveCommit({ source: subscription.source, ref
|
|
1065
|
+
const targetCommit = await resolveCommit({ source: subscription.source, ref, token: tokenOf, fetchImpl });
|
|
795
1066
|
const token = planToken({
|
|
796
1067
|
subscriptionId: subscription.id,
|
|
797
1068
|
from: install.commit || null,
|
|
@@ -806,6 +1077,7 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
806
1077
|
token,
|
|
807
1078
|
fromCommit: install.commit || null,
|
|
808
1079
|
toCommit: targetCommit,
|
|
1080
|
+
ref,
|
|
809
1081
|
targetTreeSha,
|
|
810
1082
|
skillPath,
|
|
811
1083
|
localModified: Boolean(install.installedHash) && contentHash(path.join(paths.canonical, name)) !== install.installedHash,
|
|
@@ -833,7 +1105,9 @@ export function createService({ paths, store, registry, cli, logger, env = proce
|
|
|
833
1105
|
? createBackup({ paths, skill: name, fromDir: canonicalPath, hash: install.installedHash })
|
|
834
1106
|
: null;
|
|
835
1107
|
|
|
836
|
-
const sourceArg = plan.toCommit
|
|
1108
|
+
const sourceArg = plan.toCommit
|
|
1109
|
+
? `${subscription.source}#${plan.toCommit}`
|
|
1110
|
+
: `${subscription.source}#${plan.ref || subscription.ref || 'main'}`;
|
|
837
1111
|
const result = await cli.add({ source: sourceArg, skills: [name] });
|
|
838
1112
|
const lock = readLock(paths);
|
|
839
1113
|
const lockHash = lock.skills?.[name]?.skillFolderHash || null;
|
package/lib/core/updates.js
CHANGED
|
@@ -64,6 +64,18 @@ export async function fetchTree({ source, ref = 'main', etag, token, fetchImpl }
|
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 仓库默认分支。`ref` 缺省时以它为准:不少技能仓库默认是 `master`,
|
|
69
|
+
* 硬套 `main` 只会得到 404(例如 alchaincyf/huashu-design)。
|
|
70
|
+
*/
|
|
71
|
+
export async function fetchDefaultBranch({ source, token, fetchImpl } = {}) {
|
|
72
|
+
const parsed = parseSource(source);
|
|
73
|
+
if (!parsed) return null;
|
|
74
|
+
const result = await callJson(`${API}/repos/${parsed.owner}/${parsed.repo}`, { token, fetchImpl });
|
|
75
|
+
if (result.error || !result.body?.default_branch) return null;
|
|
76
|
+
return result.body.default_branch;
|
|
77
|
+
}
|
|
78
|
+
|
|
67
79
|
/** 解析 ref 对应的 commit(只在安装/计划时需要,检测阶段不付这笔成本)。 */
|
|
68
80
|
export async function resolveCommit({ source, ref = 'main', token, fetchImpl } = {}) {
|
|
69
81
|
const parsed = parseSource(source);
|
package/lib/routes.js
CHANGED
|
@@ -59,6 +59,7 @@ function matchRoute(method, pathname) {
|
|
|
59
59
|
if (segments.length === 1 && segments[0] === 'settings' && method === 'POST') return { name: 'settings' };
|
|
60
60
|
if (segments.length === 1 && segments[0] === 'links' && method === 'POST') return { name: 'links' };
|
|
61
61
|
if (segments.length === 1 && segments[0] === 'install' && method === 'POST') return { name: 'install' };
|
|
62
|
+
if (segments.length === 1 && segments[0] === 'probe' && method === 'POST') return { name: 'probe' };
|
|
62
63
|
if (segments.length === 1 && segments[0] === 'diagnostics' && method === 'GET') return { name: 'diagnostics' };
|
|
63
64
|
|
|
64
65
|
if (segments[0] === 'skills' && segments.length === 2 && method === 'GET') {
|
|
@@ -154,6 +155,8 @@ async function dispatch(route, { body, query, service }) {
|
|
|
154
155
|
return service.setLinks(body);
|
|
155
156
|
case 'install':
|
|
156
157
|
return service.install(body);
|
|
158
|
+
case 'probe':
|
|
159
|
+
return service.probeSource(body);
|
|
157
160
|
case 'diagnostics':
|
|
158
161
|
return service.diagnostics();
|
|
159
162
|
case 'skill.body':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morehao/dsh-skills-console",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "统一管理本地技能的安装、更新、删除与接入,让同一份技能在多个 Agent 间复用。A skill manager for DeepSeek Harness: install, update, remove, link and toggle agent skills.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "morehao <morehao@qq.com>",
|