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 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`, `.agents/skills`, `.dsh/skills`, or one skill directory directly below those roots. 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.
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
- - 批量导入仅接受 `.claude/skills`、`.codex/skills`、`.agents/skills`、`.dsh/skills` 或其中的单个技能目录;每次最多扫描 200 个技能,单个技能最多包含 2,000 个文件和 10 MB 资源,不接受符号链接。
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 (skill) => {
306
- if (!window.confirm(t("deleteConfirm", { name: skill.name }))) return;
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
- })) setMessage(t("deleted"));
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 commitBatchDirectory = async () => {
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
- if (!window.confirm(t("batchConfirm", {
418
+ setBatchConfirmation({
413
419
  importing,
414
420
  replacing,
415
421
  skipped
416
- }))) return;
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 commitBatchDirectory();
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(`/${name} ${input}`);
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
- input.setDraft(`/${option.id} ${rest}`);
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
- const grandparent = basename(dirname(dirname(sourcePath)));
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
- const session = scanBatch({
765
- sourcePath: body.sourcePath,
766
- target: body.target,
767
- workspacePath
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.1",
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 grandparent = basename(dirname(dirname(sourcePath)))
290
- const isSkillsRoot = leaf === 'skills' && AGENT_SKILL_PARENTS.has(parent)
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('批量导入仅支持 .claude、.codex、.agents 或 .dsh 下的 skills 目录')
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)