dsh-skill-importer 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +213 -14
- package/lib/index.js +14 -17
- package/lib/types/client/SkillsPicker.d.ts +1 -1
- package/lib/types/client/atomicSkillDelete.d.ts +19 -0
- package/lib/types/client/composerFocus.d.ts +9 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/package.json +5 -1
- package/src/server.ts +5 -7
- package/tests/atomic-skill-delete.test.mjs +24 -0
- package/tests/batch-import.test.mjs +7 -1
package/README.md
CHANGED
|
@@ -171,7 +171,7 @@ If `dsh` is not installed globally, replace the last line with `npx @deepseek-ai
|
|
|
171
171
|
## Notes
|
|
172
172
|
|
|
173
173
|
- A single imported file may be up to 256 KB.
|
|
174
|
-
- Batch import accepts `.claude/skills`, `.codex/skills`,
|
|
174
|
+
- Batch import accepts any agent directory named `skills` (for example `.claude/skills`, `.codex/skills`, or a custom `agent/skills`) or one skill directory directly below it. A scan accepts up to 200 skills; each skill may contain up to 2,000 files and 10 MB of resources. Symbolic links are refused.
|
|
175
175
|
- URL import is HTTPS-only and refuses loopback, private, link-local, and reserved addresses; every redirect is validated again. `.md` sources are preserved, while HTML pages use lightweight text extraction, so direct Markdown URLs work best.
|
|
176
176
|
- The installed list polls briefly after import (every 2 seconds for up to 20 seconds). **Refresh** syncs external changes immediately.
|
|
177
177
|
|
package/README.zh.md
CHANGED
|
@@ -171,7 +171,7 @@ dsh plugin --profile web add /path/to/dsh-skill-importer
|
|
|
171
171
|
## 注意事项
|
|
172
172
|
|
|
173
173
|
- 单个导入文件最大 256 KB。
|
|
174
|
-
-
|
|
174
|
+
- 批量导入接受任意 Agent 中名为 `skills` 的目录(例如 `.claude/skills`、`.codex/skills`、项目自定义的 `agent/skills`)或其中的单个技能目录;每次最多扫描 200 个技能,单个技能最多包含 2,000 个文件和 10 MB 资源,不接受符号链接。
|
|
175
175
|
- URL 导入仅支持 HTTPS,并拒绝本机、私有网络、链路本地和保留地址;每次重定向都会重新校验。`.md` 会原样保留,HTML 页面仅做轻量正文提取,因此推荐使用 Markdown 直链。
|
|
176
176
|
- 导入后列表会短暂轮询(每 2 秒一次,最多 20 秒);外部改动可点击 **刷新** 立即同步。
|
|
177
177
|
|
package/lib/client.js
CHANGED
|
@@ -5,8 +5,8 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
7
|
let react = require("react");
|
|
8
|
-
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
8
|
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
9
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
10
|
//#region src/frontmatter.ts
|
|
11
11
|
/** Kebab-case name rule shared with `dsh-skill` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). */
|
|
12
12
|
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -261,6 +261,8 @@ window.__ModuleLoader__.load({
|
|
|
261
261
|
const [batchScan, setBatchScan] = (0, react.useState)();
|
|
262
262
|
const [batchResults, setBatchResults] = (0, react.useState)();
|
|
263
263
|
const [replaceNames, setReplaceNames] = (0, react.useState)(() => /* @__PURE__ */ new Set());
|
|
264
|
+
const [pendingDelete, setPendingDelete] = (0, react.useState)();
|
|
265
|
+
const [batchConfirmation, setBatchConfirmation] = (0, react.useState)();
|
|
264
266
|
const fileInput = (0, react.useRef)(null);
|
|
265
267
|
const onFilePicked = async (file) => {
|
|
266
268
|
setFileDraft(void 0);
|
|
@@ -302,8 +304,9 @@ window.__ModuleLoader__.load({
|
|
|
302
304
|
}, () => pollForSkill(name, attemptsLeft - 1));
|
|
303
305
|
}, 2e3);
|
|
304
306
|
};
|
|
305
|
-
const removeSkill = async (
|
|
306
|
-
if (
|
|
307
|
+
const removeSkill = async () => {
|
|
308
|
+
if (pendingDelete === void 0) return;
|
|
309
|
+
const skill = pendingDelete;
|
|
307
310
|
setBusy(true);
|
|
308
311
|
setMessage(void 0);
|
|
309
312
|
setMessageError(false);
|
|
@@ -312,7 +315,10 @@ window.__ModuleLoader__.load({
|
|
|
312
315
|
name: skill.name,
|
|
313
316
|
source: skill.source,
|
|
314
317
|
workspacePath: workspace?.path
|
|
315
|
-
}))
|
|
318
|
+
})) {
|
|
319
|
+
setMessage(t("deleted"));
|
|
320
|
+
setPendingDelete(void 0);
|
|
321
|
+
}
|
|
316
322
|
setListEpoch((epoch) => epoch + 1);
|
|
317
323
|
} catch (error) {
|
|
318
324
|
setMessageError(true);
|
|
@@ -403,17 +409,20 @@ window.__ModuleLoader__.load({
|
|
|
403
409
|
setBusy(false);
|
|
404
410
|
}
|
|
405
411
|
};
|
|
406
|
-
const
|
|
412
|
+
const requestBatchCommit = () => {
|
|
407
413
|
if (batchScan === void 0) return;
|
|
408
414
|
const ready = batchScan.entries.filter((entry) => entry.status === "ready");
|
|
409
415
|
const replacing = ready.filter((entry) => replaceNames.has(entry.name)).length;
|
|
410
416
|
const importing = ready.filter((entry) => !entry.conflict).length;
|
|
411
417
|
const skipped = ready.filter((entry) => entry.conflict && !replaceNames.has(entry.name)).length;
|
|
412
|
-
|
|
418
|
+
setBatchConfirmation({
|
|
413
419
|
importing,
|
|
414
420
|
replacing,
|
|
415
421
|
skipped
|
|
416
|
-
})
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
const commitBatchDirectory = async () => {
|
|
425
|
+
if (batchScan === void 0 || batchConfirmation === void 0) return;
|
|
417
426
|
setBusy(true);
|
|
418
427
|
setMessage(void 0);
|
|
419
428
|
setMessageError(false);
|
|
@@ -425,6 +434,7 @@ window.__ModuleLoader__.load({
|
|
|
425
434
|
setBatchResults(result.results);
|
|
426
435
|
setBatchScan(void 0);
|
|
427
436
|
setReplaceNames(/* @__PURE__ */ new Set());
|
|
437
|
+
setBatchConfirmation(void 0);
|
|
428
438
|
setListEpoch((epoch) => epoch + 1);
|
|
429
439
|
} catch (error) {
|
|
430
440
|
setMessageError(true);
|
|
@@ -451,7 +461,7 @@ window.__ModuleLoader__.load({
|
|
|
451
461
|
return;
|
|
452
462
|
}
|
|
453
463
|
if (batchScan === void 0) scanBatchDirectory();
|
|
454
|
-
else
|
|
464
|
+
else requestBatchCommit();
|
|
455
465
|
};
|
|
456
466
|
const primaryLabel = mode !== "batch" ? busy ? t("importing") : t("import") : busy ? t("batchWorking") : batchScan === void 0 ? t("batchScan") : t("batchCommit");
|
|
457
467
|
const flags = (skill) => {
|
|
@@ -592,9 +602,7 @@ window.__ModuleLoader__.load({
|
|
|
592
602
|
type: "button",
|
|
593
603
|
"aria-label": `${t("delete")} ${skill.name}`,
|
|
594
604
|
title: t("delete"),
|
|
595
|
-
onClick: () =>
|
|
596
|
-
removeSkill(skill);
|
|
597
|
-
},
|
|
605
|
+
onClick: () => setPendingDelete(skill),
|
|
598
606
|
style: deleteIconButtonStyle,
|
|
599
607
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashIcon, {})
|
|
600
608
|
})
|
|
@@ -1048,6 +1056,50 @@ window.__ModuleLoader__.load({
|
|
|
1048
1056
|
})
|
|
1049
1057
|
]
|
|
1050
1058
|
})
|
|
1059
|
+
}),
|
|
1060
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
|
|
1061
|
+
open: pendingDelete !== void 0,
|
|
1062
|
+
onClose: () => {
|
|
1063
|
+
if (!busy) setPendingDelete(void 0);
|
|
1064
|
+
},
|
|
1065
|
+
title: t("deleteTitle"),
|
|
1066
|
+
closeLabel: t("close"),
|
|
1067
|
+
description: pendingDelete === void 0 ? void 0 : t("deleteConfirm", { name: pendingDelete.name }),
|
|
1068
|
+
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1069
|
+
variant: "outline",
|
|
1070
|
+
disabled: busy,
|
|
1071
|
+
onClick: () => setPendingDelete(void 0),
|
|
1072
|
+
children: t("cancel")
|
|
1073
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1074
|
+
variant: "primary",
|
|
1075
|
+
disabled: busy,
|
|
1076
|
+
onClick: () => {
|
|
1077
|
+
removeSkill();
|
|
1078
|
+
},
|
|
1079
|
+
children: busy ? t("deleting") : t("delete")
|
|
1080
|
+
})] })
|
|
1081
|
+
}),
|
|
1082
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
|
|
1083
|
+
open: batchConfirmation !== void 0,
|
|
1084
|
+
onClose: () => {
|
|
1085
|
+
if (!busy) setBatchConfirmation(void 0);
|
|
1086
|
+
},
|
|
1087
|
+
title: t("batchConfirmTitle"),
|
|
1088
|
+
closeLabel: t("close"),
|
|
1089
|
+
description: batchConfirmation === void 0 ? void 0 : t("batchConfirm", batchConfirmation),
|
|
1090
|
+
footer: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1091
|
+
variant: "outline",
|
|
1092
|
+
disabled: busy,
|
|
1093
|
+
onClick: () => setBatchConfirmation(void 0),
|
|
1094
|
+
children: t("cancel")
|
|
1095
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1096
|
+
variant: "primary",
|
|
1097
|
+
disabled: busy,
|
|
1098
|
+
onClick: () => {
|
|
1099
|
+
commitBatchDirectory();
|
|
1100
|
+
},
|
|
1101
|
+
children: busy ? t("batchWorking") : t("batchCommit")
|
|
1102
|
+
})] })
|
|
1051
1103
|
})
|
|
1052
1104
|
]
|
|
1053
1105
|
});
|
|
@@ -1516,6 +1568,58 @@ window.__ModuleLoader__.load({
|
|
|
1516
1568
|
cursor: "default"
|
|
1517
1569
|
};
|
|
1518
1570
|
//#endregion
|
|
1571
|
+
//#region src/client/atomicSkillDelete.ts
|
|
1572
|
+
function leadingSkillToken(draft, names) {
|
|
1573
|
+
const ordered = [...new Set(names)].sort((a, b) => b.length - a.length);
|
|
1574
|
+
for (const name of ordered) {
|
|
1575
|
+
if (name.length === 0) continue;
|
|
1576
|
+
const token = `/${name}`;
|
|
1577
|
+
if (!draft.startsWith(token)) continue;
|
|
1578
|
+
const following = draft[token.length];
|
|
1579
|
+
if (following !== void 0 && !/\s/u.test(following)) continue;
|
|
1580
|
+
return {
|
|
1581
|
+
tokenEnd: token.length,
|
|
1582
|
+
boundaryEnd: token.length + (following === " " ? 1 : 0)
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Delete one exact, leading, installed `/name` token as a single edit.
|
|
1588
|
+
*
|
|
1589
|
+
* This deliberately handles only the common boundary gesture. Selections,
|
|
1590
|
+
* token-internal edits, unknown names, leading whitespace, and non-boundary
|
|
1591
|
+
* carets fall through to the textarea's normal behavior.
|
|
1592
|
+
*/
|
|
1593
|
+
function atomicSkillBackspace(draft, caret, names) {
|
|
1594
|
+
if (!Number.isInteger(caret) || caret < 0 || caret > draft.length) return void 0;
|
|
1595
|
+
const token = leadingSkillToken(draft, names);
|
|
1596
|
+
if (token === void 0) return void 0;
|
|
1597
|
+
if (caret !== token.tokenEnd && caret !== token.boundaryEnd) return void 0;
|
|
1598
|
+
return {
|
|
1599
|
+
draft: draft.slice(token.boundaryEnd),
|
|
1600
|
+
caret: 0
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* Resolve one whole-token horizontal caret jump.
|
|
1605
|
+
* Left from anywhere inside/just after the leading skill goes before it;
|
|
1606
|
+
* right from before/inside it goes after its single separator.
|
|
1607
|
+
*/
|
|
1608
|
+
function atomicSkillArrow(draft, caret, direction, names) {
|
|
1609
|
+
if (!Number.isInteger(caret) || caret < 0 || caret > draft.length) return void 0;
|
|
1610
|
+
const token = leadingSkillToken(draft, names);
|
|
1611
|
+
if (token === void 0) return void 0;
|
|
1612
|
+
if (direction === "left" && caret > 0 && caret <= token.boundaryEnd) return 0;
|
|
1613
|
+
if (direction === "right" && caret >= 0 && caret < token.boundaryEnd) return token.boundaryEnd;
|
|
1614
|
+
}
|
|
1615
|
+
//#endregion
|
|
1616
|
+
//#region src/client/composerFocus.ts
|
|
1617
|
+
const COMPOSER_FOCUS_EVENT = "dsh-skill-importer/composer-focus";
|
|
1618
|
+
/** Ask the mounted picker in one session to restore its composer focus. */
|
|
1619
|
+
function requestComposerFocus(request) {
|
|
1620
|
+
window.dispatchEvent(new CustomEvent(COMPOSER_FOCUS_EVENT, { detail: request }));
|
|
1621
|
+
}
|
|
1622
|
+
//#endregion
|
|
1519
1623
|
//#region src/client/SkillsPicker.tsx
|
|
1520
1624
|
/**
|
|
1521
1625
|
* Composer skill picker: a compact pill trigger in the composer's tool row
|
|
@@ -1526,13 +1630,25 @@ window.__ModuleLoader__.load({
|
|
|
1526
1630
|
* with `/name ` — the native text gesture — highlighted via the plugin's
|
|
1527
1631
|
* lexicon contribution.
|
|
1528
1632
|
*/
|
|
1633
|
+
/** Find the one composer textarea owned by the picker nearest in the slot tree. */
|
|
1634
|
+
function composerTextareaFor(root) {
|
|
1635
|
+
let node = root?.parentElement;
|
|
1636
|
+
while (node !== null && node !== void 0) {
|
|
1637
|
+
const textareas = node.querySelectorAll("textarea");
|
|
1638
|
+
if (textareas.length > 0) return textareas.length === 1 ? textareas[0] : void 0;
|
|
1639
|
+
node = node.parentElement;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1529
1642
|
/** One row of the composer skill picker. */
|
|
1530
|
-
function SkillsPicker({ t, useSkills, useInput, inputActions, actions }) {
|
|
1643
|
+
function SkillsPicker({ t, sessionId, useSkills, useInput, inputActions, actions }) {
|
|
1531
1644
|
const skills = useSkills((value) => value);
|
|
1532
1645
|
const input = useInput((value) => value.draft);
|
|
1533
1646
|
const [open, setOpen] = (0, react.useState)(false);
|
|
1534
1647
|
const [query, setQuery] = (0, react.useState)("");
|
|
1535
1648
|
const rootRef = (0, react.useRef)(null);
|
|
1649
|
+
const draftRef = (0, react.useRef)(input);
|
|
1650
|
+
const skillNamesRef = (0, react.useRef)([]);
|
|
1651
|
+
const caretFrameRef = (0, react.useRef)();
|
|
1536
1652
|
(0, react.useEffect)(() => {
|
|
1537
1653
|
actions.refreshSkills().catch(() => {});
|
|
1538
1654
|
}, [actions]);
|
|
@@ -1548,11 +1664,77 @@ window.__ModuleLoader__.load({
|
|
|
1548
1664
|
if (open) setQuery("");
|
|
1549
1665
|
}, [open]);
|
|
1550
1666
|
const usable = skills.filter((skill) => skill.userInvocable);
|
|
1667
|
+
draftRef.current = input;
|
|
1668
|
+
skillNamesRef.current = usable.map((skill) => skill.name);
|
|
1669
|
+
const scheduleComposerFocus = (expectedDraft, caret) => {
|
|
1670
|
+
const textarea = composerTextareaFor(rootRef.current);
|
|
1671
|
+
if (caretFrameRef.current !== void 0) window.cancelAnimationFrame(caretFrameRef.current);
|
|
1672
|
+
caretFrameRef.current = window.requestAnimationFrame(() => {
|
|
1673
|
+
caretFrameRef.current = void 0;
|
|
1674
|
+
if (textarea === void 0 || !textarea.isConnected || textarea.value !== expectedDraft) return;
|
|
1675
|
+
textarea.focus({ preventScroll: true });
|
|
1676
|
+
textarea.setSelectionRange(caret, caret);
|
|
1677
|
+
});
|
|
1678
|
+
};
|
|
1679
|
+
(0, react.useEffect)(() => {
|
|
1680
|
+
const onFocusRequest = (event) => {
|
|
1681
|
+
const request = event.detail;
|
|
1682
|
+
if (request.sessionId !== sessionId) return;
|
|
1683
|
+
scheduleComposerFocus(request.expectedDraft, request.caret);
|
|
1684
|
+
};
|
|
1685
|
+
window.addEventListener(COMPOSER_FOCUS_EVENT, onFocusRequest);
|
|
1686
|
+
return () => window.removeEventListener(COMPOSER_FOCUS_EVENT, onFocusRequest);
|
|
1687
|
+
}, [sessionId]);
|
|
1688
|
+
(0, react.useEffect)(() => {
|
|
1689
|
+
const onKeyDown = (event) => {
|
|
1690
|
+
if (![
|
|
1691
|
+
"Backspace",
|
|
1692
|
+
"ArrowLeft",
|
|
1693
|
+
"ArrowRight"
|
|
1694
|
+
].includes(event.key)) return;
|
|
1695
|
+
if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return;
|
|
1696
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
1697
|
+
if (event.shiftKey && event.key !== "Backspace") return;
|
|
1698
|
+
const textarea = event.target;
|
|
1699
|
+
if (!(textarea instanceof HTMLTextAreaElement) || document.activeElement !== textarea) return;
|
|
1700
|
+
if (composerTextareaFor(rootRef.current) !== textarea || textarea.value !== draftRef.current) return;
|
|
1701
|
+
const start = textarea.selectionStart;
|
|
1702
|
+
const end = textarea.selectionEnd;
|
|
1703
|
+
if (start === null || end === null || start !== end) return;
|
|
1704
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
1705
|
+
const caret = atomicSkillArrow(draftRef.current, start, event.key === "ArrowLeft" ? "left" : "right", skillNamesRef.current);
|
|
1706
|
+
if (caret === void 0) return;
|
|
1707
|
+
event.preventDefault();
|
|
1708
|
+
event.stopPropagation();
|
|
1709
|
+
textarea.setSelectionRange(caret, caret);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const edit = atomicSkillBackspace(draftRef.current, start, skillNamesRef.current);
|
|
1713
|
+
if (edit === void 0) return;
|
|
1714
|
+
event.preventDefault();
|
|
1715
|
+
event.stopPropagation();
|
|
1716
|
+
inputActions.setDraft(edit.draft);
|
|
1717
|
+
if (caretFrameRef.current !== void 0) window.cancelAnimationFrame(caretFrameRef.current);
|
|
1718
|
+
caretFrameRef.current = window.requestAnimationFrame(() => {
|
|
1719
|
+
caretFrameRef.current = void 0;
|
|
1720
|
+
if (!textarea.isConnected || document.activeElement !== textarea || textarea.value !== edit.draft) return;
|
|
1721
|
+
textarea.setSelectionRange(edit.caret, edit.caret);
|
|
1722
|
+
});
|
|
1723
|
+
};
|
|
1724
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
1725
|
+
return () => {
|
|
1726
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
1727
|
+
if (caretFrameRef.current !== void 0) window.cancelAnimationFrame(caretFrameRef.current);
|
|
1728
|
+
};
|
|
1729
|
+
}, [inputActions]);
|
|
1551
1730
|
if (usable.length === 0) return null;
|
|
1552
1731
|
const filtered = query.trim().length === 0 ? usable : usable.filter((skill) => skill.name.toLowerCase().includes(query.trim().toLowerCase()));
|
|
1553
1732
|
const onSelect = (name) => {
|
|
1733
|
+
const prefix = `/${name} `;
|
|
1734
|
+
const nextDraft = `${prefix}${input}`;
|
|
1554
1735
|
setOpen(false);
|
|
1555
|
-
inputActions.setDraft(
|
|
1736
|
+
inputActions.setDraft(nextDraft);
|
|
1737
|
+
scheduleComposerFocus(nextDraft, prefix.length);
|
|
1556
1738
|
};
|
|
1557
1739
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1558
1740
|
ref: rootRef,
|
|
@@ -1754,7 +1936,11 @@ window.__ModuleLoader__.load({
|
|
|
1754
1936
|
refresh: "刷新",
|
|
1755
1937
|
installedEmpty: "暂无技能。导入新技能或手动放入技能目录后会自动出现。",
|
|
1756
1938
|
delete: "删除",
|
|
1939
|
+
deleting: "删除中…",
|
|
1940
|
+
deleteTitle: "删除技能",
|
|
1757
1941
|
deleteConfirm: "确定删除技能 {name} 吗?将删除对应目录下的 SKILL.md 及资源文件。",
|
|
1942
|
+
cancel: "取消",
|
|
1943
|
+
close: "关闭",
|
|
1758
1944
|
deleteFailed: "删除失败",
|
|
1759
1945
|
deleted: "已删除。",
|
|
1760
1946
|
installedFailed: "无法读取技能列表",
|
|
@@ -1800,6 +1986,7 @@ window.__ModuleLoader__.load({
|
|
|
1800
1986
|
batchConflict: "名称重复",
|
|
1801
1987
|
batchReplace: "替换",
|
|
1802
1988
|
batchConfirm: "将新增 {importing} 个、替换 {replacing} 个、跳过 {skipped} 个技能。确认继续吗?",
|
|
1989
|
+
batchConfirmTitle: "确认批量导入",
|
|
1803
1990
|
batchResultTitle: "本次导入结果",
|
|
1804
1991
|
batchResultSummary: "新增 {imported} · 替换 {replaced} · 跳过 {skipped} · 失败 {failed}",
|
|
1805
1992
|
batchStatusImported: "已导入",
|
|
@@ -1831,7 +2018,11 @@ window.__ModuleLoader__.load({
|
|
|
1831
2018
|
refresh: "Refresh",
|
|
1832
2019
|
installedEmpty: "No skills yet. Import one or drop files into a skill directory and they will appear automatically.",
|
|
1833
2020
|
delete: "Delete",
|
|
2021
|
+
deleting: "Deleting…",
|
|
2022
|
+
deleteTitle: "Delete skill",
|
|
1834
2023
|
deleteConfirm: "Delete the skill {name}? This removes its SKILL.md and resources from that directory.",
|
|
2024
|
+
cancel: "Cancel",
|
|
2025
|
+
close: "Close",
|
|
1835
2026
|
deleteFailed: "Failed to delete",
|
|
1836
2027
|
deleted: "Deleted.",
|
|
1837
2028
|
installedFailed: "Unable to read the skill list",
|
|
@@ -1877,6 +2068,7 @@ window.__ModuleLoader__.load({
|
|
|
1877
2068
|
batchConflict: "Name conflict",
|
|
1878
2069
|
batchReplace: "Replace",
|
|
1879
2070
|
batchConfirm: "Add {importing}, replace {replacing}, and skip {skipped} skills. Continue?",
|
|
2071
|
+
batchConfirmTitle: "Confirm batch import",
|
|
1880
2072
|
batchResultTitle: "Import results",
|
|
1881
2073
|
batchResultSummary: "Added {imported} · Replaced {replaced} · Skipped {skipped} · Failed {failed}",
|
|
1882
2074
|
batchStatusImported: "Imported",
|
|
@@ -2000,7 +2192,14 @@ window.__ModuleLoader__.load({
|
|
|
2000
2192
|
if (actx === void 0) return;
|
|
2001
2193
|
const input = conversation.input.for(actx);
|
|
2002
2194
|
const rest = input.state.getSnapshot().draft.replace(/^\s*\/[a-zA-Z][a-zA-Z0-9-]*\s*/, "");
|
|
2003
|
-
|
|
2195
|
+
const prefix = `/${option.id} `;
|
|
2196
|
+
const nextDraft = `${prefix}${rest}`;
|
|
2197
|
+
input.setDraft(nextDraft);
|
|
2198
|
+
requestComposerFocus({
|
|
2199
|
+
sessionId: session.sessionId,
|
|
2200
|
+
expectedDraft: nextDraft,
|
|
2201
|
+
caret: prefix.length
|
|
2202
|
+
});
|
|
2004
2203
|
}
|
|
2005
2204
|
}
|
|
2006
2205
|
});
|
package/lib/index.js
CHANGED
|
@@ -99,12 +99,6 @@ const MAX_BATCH_FILES_PER_SKILL = 2e3;
|
|
|
99
99
|
const MAX_BATCH_SKILL_BYTES = 10485760;
|
|
100
100
|
const BATCH_SCAN_TTL_MS = 6e5;
|
|
101
101
|
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
102
|
-
const AGENT_SKILL_PARENTS = /* @__PURE__ */ new Set([
|
|
103
|
-
".agents",
|
|
104
|
-
".claude",
|
|
105
|
-
".codex",
|
|
106
|
-
".dsh"
|
|
107
|
-
]);
|
|
108
102
|
const MAX_URL_REDIRECTS = 5;
|
|
109
103
|
/** The harness home (`$DSH_HOME`, defaulting to `~/.dsh`). */
|
|
110
104
|
function dshHomeDir() {
|
|
@@ -311,12 +305,9 @@ function scanBatch(request) {
|
|
|
311
305
|
if (!isAbsolute(request.sourcePath)) throw new Error("批量导入目录必须是绝对路径");
|
|
312
306
|
if (!existsSync(request.sourcePath) || !statSync(request.sourcePath).isDirectory()) throw new Error("批量导入目录不存在或不可读");
|
|
313
307
|
const sourcePath = realpathSync(request.sourcePath);
|
|
314
|
-
const leaf = basename(sourcePath);
|
|
315
|
-
const parent = basename(dirname(sourcePath));
|
|
316
|
-
|
|
317
|
-
const isSkillsRoot = leaf === "skills" && AGENT_SKILL_PARENTS.has(parent);
|
|
318
|
-
const isSkillDirectory = parent === "skills" && AGENT_SKILL_PARENTS.has(grandparent);
|
|
319
|
-
if (!isSkillsRoot && !isSkillDirectory) throw new Error("批量导入仅支持 .claude、.codex、.agents 或 .dsh 下的 skills 目录");
|
|
308
|
+
const leaf = basename(sourcePath).toLowerCase();
|
|
309
|
+
const parent = basename(dirname(sourcePath)).toLowerCase();
|
|
310
|
+
if (!(leaf === "skills") && !(parent === "skills")) throw new Error("批量导入仅支持名为 skills 的目录或其中的单个技能目录");
|
|
320
311
|
if (request.target !== "user" && request.workspacePath === void 0) throw new Error("项目目标需要 workspacePath(当前工作区路径)");
|
|
321
312
|
const sources = batchSources(sourcePath);
|
|
322
313
|
if (sources.length === 0) throw new Error("所选目录中没有找到可导入的技能");
|
|
@@ -761,11 +752,17 @@ function apply(ctx) {
|
|
|
761
752
|
}
|
|
762
753
|
const workspacePath = requireWorkspace(body.target, body.workspacePath);
|
|
763
754
|
pruneBatchScans();
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
755
|
+
let session;
|
|
756
|
+
try {
|
|
757
|
+
session = scanBatch({
|
|
758
|
+
sourcePath: body.sourcePath,
|
|
759
|
+
target: body.target,
|
|
760
|
+
workspacePath
|
|
761
|
+
});
|
|
762
|
+
} catch (error) {
|
|
763
|
+
sendError(res, 400, error instanceof Error ? error.message : String(error));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
769
766
|
batchScans.set(session.scanId, session);
|
|
770
767
|
sendJson(res, 200, {
|
|
771
768
|
ok: true,
|
|
@@ -13,4 +13,4 @@ import type { SkillImporterInjected } from './index.ts';
|
|
|
13
13
|
/** Props the renderer binds for the picker. */
|
|
14
14
|
export type SkillsPickerProps = PropsRuntime<'conversation.input.left'> & PropsLocale<'skills.importer'> & InjectFace<SkillImporterInjected>;
|
|
15
15
|
/** One row of the composer skill picker. */
|
|
16
|
-
export declare function SkillsPicker({ t, useSkills, useInput, inputActions, actions }: SkillsPickerProps): ReactNode;
|
|
16
|
+
export declare function SkillsPicker({ t, sessionId, useSkills, useInput, inputActions, actions }: SkillsPickerProps): ReactNode;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** One safe, collapsed-caret Backspace rewrite over a leading skill token. */
|
|
2
|
+
export interface AtomicSkillDeleteEdit {
|
|
3
|
+
readonly draft: string;
|
|
4
|
+
readonly caret: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Delete one exact, leading, installed `/name` token as a single edit.
|
|
8
|
+
*
|
|
9
|
+
* This deliberately handles only the common boundary gesture. Selections,
|
|
10
|
+
* token-internal edits, unknown names, leading whitespace, and non-boundary
|
|
11
|
+
* carets fall through to the textarea's normal behavior.
|
|
12
|
+
*/
|
|
13
|
+
export declare function atomicSkillBackspace(draft: string, caret: number, names: readonly string[]): AtomicSkillDeleteEdit | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve one whole-token horizontal caret jump.
|
|
16
|
+
* Left from anywhere inside/just after the leading skill goes before it;
|
|
17
|
+
* right from before/inside it goes after its single separator.
|
|
18
|
+
*/
|
|
19
|
+
export declare function atomicSkillArrow(draft: string, caret: number, direction: 'left' | 'right', names: readonly string[]): number | undefined;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Session-routed request emitted after a non-React command popup edits the draft. */
|
|
2
|
+
export interface ComposerFocusRequest {
|
|
3
|
+
readonly sessionId: string;
|
|
4
|
+
readonly expectedDraft: string;
|
|
5
|
+
readonly caret: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const COMPOSER_FOCUS_EVENT = "dsh-skill-importer/composer-focus";
|
|
8
|
+
/** Ask the mounted picker in one session to restore its composer focus. */
|
|
9
|
+
export declare function requestComposerFocus(request: ComposerFocusRequest): void;
|
|
@@ -21,7 +21,11 @@ export declare const zh: {
|
|
|
21
21
|
refresh: string;
|
|
22
22
|
installedEmpty: string;
|
|
23
23
|
delete: string;
|
|
24
|
+
deleting: string;
|
|
25
|
+
deleteTitle: string;
|
|
24
26
|
deleteConfirm: string;
|
|
27
|
+
cancel: string;
|
|
28
|
+
close: string;
|
|
25
29
|
deleteFailed: string;
|
|
26
30
|
deleted: string;
|
|
27
31
|
installedFailed: string;
|
|
@@ -67,6 +71,7 @@ export declare const zh: {
|
|
|
67
71
|
batchConflict: string;
|
|
68
72
|
batchReplace: string;
|
|
69
73
|
batchConfirm: string;
|
|
74
|
+
batchConfirmTitle: string;
|
|
70
75
|
batchResultTitle: string;
|
|
71
76
|
batchResultSummary: string;
|
|
72
77
|
batchStatusImported: string;
|
|
@@ -100,7 +105,11 @@ export declare const en: {
|
|
|
100
105
|
refresh: string;
|
|
101
106
|
installedEmpty: string;
|
|
102
107
|
delete: string;
|
|
108
|
+
deleting: string;
|
|
109
|
+
deleteTitle: string;
|
|
103
110
|
deleteConfirm: string;
|
|
111
|
+
cancel: string;
|
|
112
|
+
close: string;
|
|
104
113
|
deleteFailed: string;
|
|
105
114
|
deleted: string;
|
|
106
115
|
installedFailed: string;
|
|
@@ -146,6 +155,7 @@ export declare const en: {
|
|
|
146
155
|
batchConflict: string;
|
|
147
156
|
batchReplace: string;
|
|
148
157
|
batchConfirm: string;
|
|
158
|
+
batchConfirmTitle: string;
|
|
149
159
|
batchResultTitle: string;
|
|
150
160
|
batchResultSummary: string;
|
|
151
161
|
batchStatusImported: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-skill-importer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Web UI plugin for DeepSeek Harness: import skills from local Markdown files or URLs into the skill roots the harness discovers automatically",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"@deepseek-ai/dsh-client-locale",
|
|
25
25
|
"@deepseek-ai/dsh-client-ui-slots",
|
|
26
26
|
"@deepseek-ai/dsh-client-ui-settings",
|
|
27
|
+
"@deepseek-ai/dsh-client-ui-primitives",
|
|
27
28
|
"@deepseek-ai/dsh-api-remotes"
|
|
28
29
|
],
|
|
29
30
|
"platform": "web"
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"scripts": {
|
|
46
47
|
"build": "rm -rf lib && tsc -p tsconfig.json && tsdown",
|
|
47
48
|
"test:batch": "node --experimental-strip-types tests/batch-import.test.mjs",
|
|
49
|
+
"test:atomic-delete": "node --experimental-strip-types tests/atomic-skill-delete.test.mjs",
|
|
48
50
|
"watch": "tsdown --watch"
|
|
49
51
|
},
|
|
50
52
|
"license": "MIT",
|
|
@@ -55,6 +57,7 @@
|
|
|
55
57
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
56
58
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
57
59
|
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
58
61
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
59
62
|
"react": "^18.2.0",
|
|
60
63
|
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
|
@@ -71,6 +74,7 @@
|
|
|
71
74
|
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
72
75
|
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
73
76
|
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
|
74
78
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
75
79
|
"@types/react": "^18.3.0",
|
|
76
80
|
"react": "^18.2.0",
|
package/src/server.ts
CHANGED
|
@@ -34,7 +34,6 @@ export const MAX_BATCH_SKILL_BYTES = 10 * 1024 * 1024
|
|
|
34
34
|
export const BATCH_SCAN_TTL_MS = 10 * 60 * 1000
|
|
35
35
|
|
|
36
36
|
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
37
|
-
const AGENT_SKILL_PARENTS = new Set(['.agents', '.claude', '.codex', '.dsh'])
|
|
38
37
|
const MAX_URL_REDIRECTS = 5
|
|
39
38
|
|
|
40
39
|
/** The harness home (`$DSH_HOME`, defaulting to `~/.dsh`). */
|
|
@@ -284,13 +283,12 @@ export function scanBatch(request: BatchScanRequest): BatchScanSession {
|
|
|
284
283
|
if (!isAbsolute(request.sourcePath)) throw new Error('批量导入目录必须是绝对路径')
|
|
285
284
|
if (!existsSync(request.sourcePath) || !statSync(request.sourcePath).isDirectory()) throw new Error('批量导入目录不存在或不可读')
|
|
286
285
|
const sourcePath = realpathSync(request.sourcePath)
|
|
287
|
-
const leaf = basename(sourcePath)
|
|
288
|
-
const parent = basename(dirname(sourcePath))
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
const isSkillDirectory = parent === 'skills' && AGENT_SKILL_PARENTS.has(grandparent)
|
|
286
|
+
const leaf = basename(sourcePath).toLowerCase()
|
|
287
|
+
const parent = basename(dirname(sourcePath)).toLowerCase()
|
|
288
|
+
const isSkillsRoot = leaf === 'skills'
|
|
289
|
+
const isSkillDirectory = parent === 'skills'
|
|
292
290
|
if (!isSkillsRoot && !isSkillDirectory) {
|
|
293
|
-
throw new Error('
|
|
291
|
+
throw new Error('批量导入仅支持名为 skills 的目录或其中的单个技能目录')
|
|
294
292
|
}
|
|
295
293
|
if (request.target !== 'user' && request.workspacePath === undefined) throw new Error('项目目标需要 workspacePath(当前工作区路径)')
|
|
296
294
|
const sources = batchSources(sourcePath)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { atomicSkillArrow, atomicSkillBackspace } from '../src/client/atomicSkillDelete.ts'
|
|
3
|
+
|
|
4
|
+
const names = ['foo', 'foo-bar', 'pangu-agent']
|
|
5
|
+
|
|
6
|
+
assert.deepEqual(atomicSkillBackspace('/pangu-agent ', 13, names), { draft: '', caret: 0 })
|
|
7
|
+
assert.deepEqual(atomicSkillBackspace('/pangu-agent ', 12, names), { draft: '', caret: 0 })
|
|
8
|
+
assert.deepEqual(atomicSkillBackspace('/foo-bar explain', 9, names), { draft: 'explain', caret: 0 })
|
|
9
|
+
assert.deepEqual(atomicSkillBackspace('/foo-bar', 8, names), { draft: '', caret: 0 })
|
|
10
|
+
|
|
11
|
+
assert.equal(atomicSkillBackspace('/pangu-agent ', 6, names), undefined)
|
|
12
|
+
assert.equal(atomicSkillBackspace('/unknown ', 9, names), undefined)
|
|
13
|
+
assert.equal(atomicSkillBackspace(' /pangu-agent ', 14, names), undefined)
|
|
14
|
+
assert.equal(atomicSkillBackspace('/foo-barium ', 12, names), undefined)
|
|
15
|
+
assert.equal(atomicSkillBackspace('/foo\nnext', 5, names), undefined)
|
|
16
|
+
|
|
17
|
+
assert.equal(atomicSkillArrow('/pangu-agent ', 13, 'left', names), 0)
|
|
18
|
+
assert.equal(atomicSkillArrow('/pangu-agent ', 6, 'left', names), 0)
|
|
19
|
+
assert.equal(atomicSkillArrow('/pangu-agent ', 0, 'right', names), 13)
|
|
20
|
+
assert.equal(atomicSkillArrow('/pangu-agent explain', 13, 'right', names), undefined)
|
|
21
|
+
assert.equal(atomicSkillArrow('/pangu-agent explain', 14, 'left', names), undefined)
|
|
22
|
+
assert.equal(atomicSkillArrow('/unknown ', 0, 'right', names), undefined)
|
|
23
|
+
|
|
24
|
+
console.log('atomic skill delete test: OK')
|
|
@@ -70,13 +70,19 @@ try {
|
|
|
70
70
|
assert.match(changedResults[0]?.message ?? '', /发生变化/)
|
|
71
71
|
assert.equal(existsSync(join(process.env.DSH_HOME, 'skills', 'beta')), false)
|
|
72
72
|
|
|
73
|
+
// Any agent may use its own parent directory; the selected root name is the boundary.
|
|
74
|
+
const customAgentSource = join(sandbox, 've_hcs_agent', 'skills')
|
|
75
|
+
mkdirSync(join(customAgentSource, 'gamma'), { recursive: true })
|
|
76
|
+
writeFileSync(join(customAgentSource, 'gamma', 'SKILL.md'), skill('gamma'))
|
|
77
|
+
assert.equal(scanBatch({ sourcePath: customAgentSource, target: 'user' }).entries[0]?.name, 'gamma')
|
|
78
|
+
|
|
73
79
|
const empty = join(sandbox, '.agents', 'skills')
|
|
74
80
|
mkdirSync(empty, { recursive: true })
|
|
75
81
|
assert.throws(() => scanBatch({ sourcePath: empty, target: 'user' }), /没有找到/)
|
|
76
82
|
|
|
77
83
|
const arbitrary = join(sandbox, 'arbitrary')
|
|
78
84
|
mkdirSync(arbitrary)
|
|
79
|
-
assert.throws(() => scanBatch({ sourcePath: arbitrary, target: 'user' }),
|
|
85
|
+
assert.throws(() => scanBatch({ sourcePath: arbitrary, target: 'user' }), /名为 skills/)
|
|
80
86
|
|
|
81
87
|
assert.equal(originAllowed({ headers: {} }), false)
|
|
82
88
|
assert.equal(originAllowed({ headers: { origin: 'https://evil.example' } }), false)
|