@xiaoso/dsh-tool-plus 0.1.0-beta.7 → 0.1.1

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.en.md CHANGED
@@ -2,13 +2,17 @@
2
2
 
3
3
  [简体中文](./README.md) | English
4
4
 
5
- Essential tools enhancement for DeepSeek Harness: persistent bash, structured read, multi-mode edit, atomic write, full-text search, and direct image reading — one plugin covers it all. Ported from the [Oh My Pi](https://github.com/can1357/oh-my-pi) core; once installed it automatically takes over the official bash / pwsh / file / search tools, with optional `ast_grep` / `ast_edit` structural search and rewrite.
5
+ [![npm version](https://img.shields.io/npm/v/@xiaoso/dsh-tool-plus?logo=npm)](https://www.npmjs.com/package/@xiaoso/dsh-tool-plus)
6
+ [![License: MIT](https://img.shields.io/github/license/xiaoso456/dsh-tool-plus)](LICENSE)
7
+ [![Node](https://img.shields.io/badge/node-%3E%3D22.19%20%7C%20%3E%3D24-339933?logo=nodedotjs)](https://nodejs.org)
8
+ [![Platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux-lightgrey)]()
6
9
 
7
- > Currently in early beta — behavior and configuration may change at any time.
10
+ Essential tools enhancement for DeepSeek Harness: persistent bash, structured read, multi-mode edit, atomic write, full-text search, and direct image reading — one plugin covers it all. Ported from the [Oh My Pi](https://github.com/can1357/oh-my-pi) core; once installed it automatically takes over the official bash / pwsh / file / search tools, with optional `ast_grep` / `ast_edit` structural search and rewrite.
8
11
 
9
12
  ## Features
10
13
 
11
14
  - **bash**: persistent shell; `cd` and `export` keep state across calls; verbose logs (git/npm/cargo…) are condensed automatically; overlong output keeps only head and tail, with the full content written to disk for later retrieval; long-running commands are moved to the background automatically; optionally intercepts `cat`, `grep`, `find`, and `sed -i` to steer you toward the dedicated tools
15
+ - **Safe delete**: `rm` moves files to the system trash by default instead of deleting permanently, so accidental deletes are recoverable; use `command rm` for a real delete
12
16
  - **read**: precise line-range reading (supports `:N-M`, `:raw`, and multiple ranges); large code files return a structural summary by default, with details expanded on demand; zip/tar archives, SQLite, notebooks, and PDFs read directly; PNG / JPEG / WebP / GIF images read directly, oversized ones scaled down automatically; can fetch web content (including images embedded in the page); SPA sites (content rendered dynamically by JS, e.g. excalidraw.com) are automatically fetched via local browser rendering
13
17
  - **write**: atomic writes that return a diff of the changes; supports patch-style writing, and can write directly into zip/tar archive members and SQLite data
14
18
  - **edit**: `replace` by default, with patch / hashline / apply-patch formats also supported; multi-hunk edits, uniqueness validation, fuzzy matching tolerant of whitespace differences
@@ -30,12 +34,12 @@ Install **both** the plugin and the presets:
30
34
  ### Install from npm (recommended)
31
35
 
32
36
  ```sh
33
- dsh plugin --profile web add @xiaoso/dsh-tool-plus@beta
37
+ dsh plugin --profile web add @xiaoso/dsh-tool-plus
34
38
  ```
35
39
 
36
40
  ### Install from GitHub
37
41
 
38
- Track the latest development build (the project has no formal release yet — every install is a test build):
42
+ Track the latest development build:
39
43
 
40
44
  ```sh
41
45
  dsh plugin --profile web add github:xiaoso456/dsh-tool-plus
@@ -52,7 +56,7 @@ dsh plugin --profile web add link:<path to this repo>
52
56
  **Option 1 · Install with the npx script**
53
57
 
54
58
  ```sh
55
- npx @xiaoso/dsh-tool-plus-presets@beta
59
+ npx @xiaoso/dsh-tool-plus-presets
56
60
  ```
57
61
 
58
62
  **Option 2 · Have an AI session configure the presets** (no dependency on the package above) — paste the following into an AI session that can edit files on your machine:
package/README.md CHANGED
@@ -2,13 +2,17 @@
2
2
 
3
3
  简体中文 | [English](./README.en.md)
4
4
 
5
- DeepSeek Harness 基础工具增强:持久 bash、结构化 read、多模式 edit、原子 write、全文搜索、图像直读,一个插件全覆盖。基于 [Oh My Pi](https://github.com/can1357/oh-my-pi) 内核移植,安装后自动接管官方 bash / pwsh / 文件 / 搜索工具,另有可选的 `ast_grep` / `ast_edit` 结构化搜索与重写。
5
+ [![npm version](https://img.shields.io/npm/v/@xiaoso/dsh-tool-plus?logo=npm)](https://www.npmjs.com/package/@xiaoso/dsh-tool-plus)
6
+ [![License: MIT](https://img.shields.io/github/license/xiaoso456/dsh-tool-plus)](LICENSE)
7
+ [![Node](https://img.shields.io/badge/node-%3E%3D22.19%20%7C%20%3E%3D24-339933?logo=nodedotjs)](https://nodejs.org)
8
+ [![Platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux-lightgrey)]()
6
9
 
7
- > 当前为早期测试版(beta),行为与配置可能随时调整。
10
+ DeepSeek Harness 基础工具增强:持久 bash、结构化 read、多模式 edit、原子 write、全文搜索、图像直读,一个插件全覆盖。基于 [Oh My Pi](https://github.com/can1357/oh-my-pi) 内核移植,安装后自动接管官方 bash / pwsh / 文件 / 搜索工具,另有可选的 `ast_grep` / `ast_edit` 结构化搜索与重写。
8
11
 
9
12
  ## 功能特性
10
13
 
11
14
  - **bash**:持久 shell,`cd`、`export` 跨调用保持状态;冗长日志(git/npm/cargo…)自动压缩;超长输出只保留头尾,完整内容落盘可回读;长命令自动转后台;可选拦截 `cat`、`grep`、`find`、`sed -i`,引导改用专用工具
15
+ - **安全删除**:`rm` 默认把文件移入系统回收站而非永久删除,误删可恢复;需要真删时用 `command rm`
12
16
  - **read**:按行区间精准读取(支持 `:N-M`、`:raw`、多区间);大代码文件默认返回结构摘要,细节按需展开;zip/tar 归档、SQLite、notebook、PDF 直接读;PNG / JPEG / WebP / GIF 图片直读,超大图自动缩放;可抓取网页内容(含网页内图片);SPA 站点(内容由 JS 动态渲染,如 excalidraw.com)自动改用本机浏览器渲染后抓取
13
17
  - **write**:原子写入,返回修改 diff;支持补丁式写入,可直接写 zip/tar 归档成员与 SQLite 数据
14
18
  - **edit**:replace 默认,另支持 patch / hashline / apply-patch 三种补丁格式;多段编辑、唯一性校验、空白差异模糊匹配
@@ -30,12 +34,12 @@ DeepSeek Harness 基础工具增强:持久 bash、结构化 read、多模式 e
30
34
  ### 从 npm 安装(推荐)
31
35
 
32
36
  ```sh
33
- dsh plugin --profile web add @xiaoso/dsh-tool-plus@beta
37
+ dsh plugin --profile web add @xiaoso/dsh-tool-plus
34
38
  ```
35
39
 
36
40
  ### 从 GitHub 安装
37
41
 
38
- 跟踪最新开发版(本项目现阶段没有正式发布,一切安装均为测试构建):
42
+ 跟踪最新开发版:
39
43
 
40
44
  ```sh
41
45
  dsh plugin --profile web add github:xiaoso456/dsh-tool-plus
@@ -52,7 +56,7 @@ dsh plugin --profile web add link:<本仓库路径>
52
56
  **方法一 · 使用 npx 脚本安装**
53
57
 
54
58
  ```sh
55
- npx @xiaoso/dsh-tool-plus-presets@beta
59
+ npx @xiaoso/dsh-tool-plus-presets
56
60
  ```
57
61
 
58
62
  **方法二 · 使用大模型配置预设**(不依赖上面的包)——把下面这段发给一个能改你电脑上文件的 AI 会话:
package/lib/client.js CHANGED
@@ -75,6 +75,15 @@ window.__ModuleLoader__.load({
75
75
  group: "behavior",
76
76
  tool: "bash"
77
77
  },
78
+ {
79
+ name: "rmSafe",
80
+ kind: "boolean",
81
+ default: true,
82
+ labelKey: "rmSafe",
83
+ hintKey: "rmSafeHint",
84
+ group: "behavior",
85
+ tool: "bash"
86
+ },
78
87
  {
79
88
  name: "useShellCommandWrapper",
80
89
  kind: "boolean",
@@ -745,7 +754,6 @@ window.__ModuleLoader__.load({
745
754
  const SETTINGS_ROWS_CSS = `
746
755
  .tp-row{display:flex;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--dsw-alias-border-l1);transition:background .16s ease}
747
756
  .tp-row:last-child{border-bottom:none}
748
- .tp-rowClickable{cursor:pointer}
749
757
  .tp-rowText{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;padding-right:24px}
750
758
  .tp-rowTitle{font-size:14px;font-weight:400;line-height:22px;color:var(--dsw-alias-label-primary)}
751
759
  .tp-rowDesc{font-size:12px;font-weight:400;line-height:18px;color:var(--dsw-alias-label-tertiary)}
@@ -824,18 +832,16 @@ window.__ModuleLoader__.load({
824
832
  }
825
833
  /**
826
834
  * Number row: same left-text / right-control anatomy as toggle and select
827
- * rows, with the input right-aligned at a fixed width. The label is wired to
828
- * the input (htmlFor) and clicking anywhere on the row focuses the input.
835
+ * rows, with the input right-aligned at a fixed width. The title is wired to
836
+ * the input (htmlFor), so clicking the title focuses the input; the
837
+ * description and the rest of the row are inert — each side manages its own
838
+ * interaction.
829
839
  */
830
840
  function NumberRow(props) {
831
841
  const { t, control, disabled, onEdit, onReset } = props;
832
842
  const inputId = (0, react.useId)();
833
843
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
834
- className: "tp-row" + (disabled ? "" : " tp-rowClickable"),
835
- onClick: (event) => {
836
- if (event.target.closest("input,button,select,textarea,a")) return;
837
- event.currentTarget.querySelector(".tp-input")?.focus();
838
- },
844
+ className: "tp-row",
839
845
  children: [
840
846
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
841
847
  className: "tp-rowText",
@@ -883,11 +889,7 @@ window.__ModuleLoader__.load({
883
889
  }));
884
890
  const close = () => onOpenChange(null);
885
891
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
886
- className: "tp-row" + (disabled ? "" : " tp-rowClickable"),
887
- onClick: (event) => {
888
- if (event.target.closest("input,button,select,textarea,a")) return;
889
- event.currentTarget.querySelector(".tp-selectTrigger")?.click();
890
- },
892
+ className: "tp-row",
891
893
  children: [
892
894
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
893
895
  className: "tp-rowText",
@@ -941,11 +943,7 @@ window.__ModuleLoader__.load({
941
943
  function ToggleRow(props) {
942
944
  const { t, control, disabled, onToggle, onReset } = props;
943
945
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
944
- className: "tp-row" + (disabled ? "" : " tp-rowClickable"),
945
- onClick: (event) => {
946
- if (event.target.closest("input,button,select,textarea,a")) return;
947
- event.currentTarget.querySelector(".tp-switch input")?.click();
948
- },
946
+ className: "tp-row",
949
947
  children: [
950
948
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
951
949
  className: "tp-rowText",
@@ -1641,6 +1639,8 @@ window.__ModuleLoader__.load({
1641
1639
  */
1642
1640
  /** Logical RPC channel registered by the host half of this plugin. */
1643
1641
  const TOOL_PLUS_RPC_CHANNEL = "/tool-plus";
1642
+ /** Endpoint that reports the rmSafe injection status (query = ensure + verify). */
1643
+ const RM_SAFE_STATUS_ENDPOINT = "rmSafe/status";
1644
1644
  //#endregion
1645
1645
  //#region src/client/web-connection-rpc.ts
1646
1646
  /**
@@ -2029,6 +2029,27 @@ window.__ModuleLoader__.load({
2029
2029
  };
2030
2030
  }
2031
2031
  //#endregion
2032
+ //#region src/client/rm-safe-status-text.ts
2033
+ /** Locale key of one rmSafe failure reason. */
2034
+ function rmSafeReasonKey(reason) {
2035
+ switch (reason) {
2036
+ case "snapshot-unavailable": return "rmSafeReasonSnapshotUnavailable";
2037
+ case "cli-missing": return "rmSafeReasonCliMissing";
2038
+ case "script-write-failed": return "rmSafeReasonScriptWriteFailed";
2039
+ case "snapshot-write-failed": return "rmSafeReasonSnapshotWriteFailed";
2040
+ case "runtime-not-effective": return "rmSafeReasonRuntimeNotEffective";
2041
+ }
2042
+ }
2043
+ /**
2044
+ * Localized text of one rmSafe/status result. `disabled` maps to the empty
2045
+ * string — the caller decides not to surface it.
2046
+ */
2047
+ function rmSafeStatusText(t, value) {
2048
+ if (value.status === "failed") return t("rmSafeStatusFailed").replace("{reason}", t(rmSafeReasonKey(value.reason)));
2049
+ if (value.status === "disabled") return "";
2050
+ return value.runtime === "function" ? t("rmSafeStatusInjected") : t("rmSafeStatusInjectedUnverified");
2051
+ }
2052
+ //#endregion
2032
2053
  //#region src/client/ToolPlusSection.tsx
2033
2054
  /**
2034
2055
  * Tool Plus settings section — the plugin's own page in the Settings panel,
@@ -2272,6 +2293,27 @@ window.__ModuleLoader__.load({
2272
2293
  const [openSelect, setOpenSelect] = (0, react.useState)(null);
2273
2294
  const [actionState, setActionState] = (0, react.useState)({});
2274
2295
  const [actionResults, setActionResults] = (0, react.useState)({});
2296
+ const [rmSafeToast, setRmSafeToast] = (0, react.useState)(null);
2297
+ const handleSave = (0, react.useCallback)(async () => {
2298
+ const before = Boolean(scope.getSnapshot().value?.rmSafe ?? true);
2299
+ await form.actions.save();
2300
+ const after = Boolean(scope.getSnapshot().value?.rmSafe ?? true);
2301
+ if (before === after || !after) return;
2302
+ try {
2303
+ const result = await createWebConnectionRpc().call(TOOL_PLUS_RPC_CHANNEL, RM_SAFE_STATUS_ENDPOINT, {});
2304
+ if (!result.ok) return;
2305
+ const value = result.value;
2306
+ if (value.status === "disabled") return;
2307
+ setRmSafeToast({
2308
+ text: rmSafeStatusText(t, value),
2309
+ ok: value.status === "injected"
2310
+ });
2311
+ } catch {}
2312
+ }, [
2313
+ scope,
2314
+ form,
2315
+ t
2316
+ ]);
2275
2317
  const runAction = (0, react.useCallback)((field) => {
2276
2318
  const actionKey = field.actionKey;
2277
2319
  if (!actionKey) return;
@@ -2520,10 +2562,17 @@ window.__ModuleLoader__.load({
2520
2562
  type: "button",
2521
2563
  className: "tps-save",
2522
2564
  disabled: blocked,
2523
- onClick: () => void form.actions.save(),
2565
+ onClick: () => void handleSave(),
2524
2566
  children: form.shell.saving ? t("saving") : t("save")
2525
2567
  })
2526
2568
  ]
2569
+ }),
2570
+ rmSafeToast !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Toast, {
2571
+ text: rmSafeToast.text,
2572
+ icon: rmSafeToast.ok ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline16, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconWarningOutline16, {}),
2573
+ onDone: () => {
2574
+ setRmSafeToast(null);
2575
+ }
2527
2576
  })
2528
2577
  ]
2529
2578
  });
@@ -2663,6 +2712,16 @@ window.__ModuleLoader__.load({
2663
2712
  nonInteractiveEnvHint: "对非交互使用加固环境:关闭分页器与彩色输出,不弹交互提示。",
2664
2713
  snapshotEnabled: "Shell 快照",
2665
2714
  snapshotEnabledHint: "把用户的 rc 文件(别名、函数、选项)快照进会话 shell。",
2715
+ rmSafe: "安全 rm",
2716
+ rmSafeHint: "在会话 shell 里重定义 rm:删除的文件移入系统回收站而非永久删除;command rm 仍走真删除。",
2717
+ rmSafeStatusInjected: "安全 rm 已注入并验证:rm 进入回收站",
2718
+ rmSafeStatusInjectedUnverified: "安全 rm 已注入(运行时验证不可用)",
2719
+ rmSafeStatusFailed: "安全 rm 注入失败:{reason},rm 将永久删除",
2720
+ rmSafeReasonSnapshotUnavailable: "快照不可用",
2721
+ rmSafeReasonCliMissing: "trash-cli 产物缺失",
2722
+ rmSafeReasonScriptWriteFailed: "脚本写入失败",
2723
+ rmSafeReasonSnapshotWriteFailed: "快照不可写",
2724
+ rmSafeReasonRuntimeNotEffective: "运行时未生效",
2666
2725
  useShellCommandWrapper: "用 bash -c 包裹命令",
2667
2726
  useShellCommandWrapperHint: "把每条命令用 bash -c 包裹,以获得完整的 bash 环境。",
2668
2727
  outputTruncateStrategy: "截断按",
@@ -2834,6 +2893,16 @@ window.__ModuleLoader__.load({
2834
2893
  nonInteractiveEnvHint: "Harden the environment for non-interactive use: no pagers, no prompts, no color.",
2835
2894
  snapshotEnabled: "Shell snapshot",
2836
2895
  snapshotEnabledHint: "Capture the user's rc file (aliases, functions, options) into the session shell.",
2896
+ rmSafe: "Safe rm",
2897
+ rmSafeHint: "Redefine rm in the session shell: deleted files move to the system trash instead of being permanently removed; `command rm` still deletes for real.",
2898
+ rmSafeStatusInjected: "Safe rm injected and verified: rm moves to trash",
2899
+ rmSafeStatusInjectedUnverified: "Safe rm injected (runtime verification unavailable)",
2900
+ rmSafeStatusFailed: "Safe rm injection failed: {reason}, rm deletes permanently",
2901
+ rmSafeReasonSnapshotUnavailable: "snapshot unavailable",
2902
+ rmSafeReasonCliMissing: "trash-cli binary missing",
2903
+ rmSafeReasonScriptWriteFailed: "script write failed",
2904
+ rmSafeReasonSnapshotWriteFailed: "snapshot not writable",
2905
+ rmSafeReasonRuntimeNotEffective: "not effective at runtime",
2837
2906
  useShellCommandWrapper: "Wrap commands in bash -c",
2838
2907
  useShellCommandWrapperHint: "Wrap every command in bash -c for a full bash environment.",
2839
2908
  outputTruncateStrategy: "Truncate by",
package/lib/index.mjs CHANGED
@@ -3,7 +3,7 @@ __installBunShim();
3
3
  import { n as fs_promises_shim_exports, t as exists } from "./fs-promises-shim-Bx0fpxuk.mjs";
4
4
  import { i as setRuntimeLogger, n as systemChromiumCandidatesForTest, r as runtimeLogger } from "./launch-DwpE70Pv.mjs";
5
5
  import { $ as peekFile, A as formatAge, C as error, F as $envpos, I as $flag, M as formatCount, P as $env, Q as isProbablyBinary, S as debug, T as warn, U as getAutoQaDbPath, V as VERSION, X as getRemoteDir, Y as getPuppeteerDir, b as isEnotdir, c as combineSignals, d as $which, j as formatBytes$1, k as installBunShim, l as exec, p as readImageMetadata, q as getInstallId, tt as untilAborted, u as render, v as hasFsCode, y as isEnoent } from "./src-D_e1qMwi.mjs";
6
- import { C as truncateToWidth$1, S as replaceTabs, _ as formatCodeFrameLine, a as fetchBinary, b as LRUCache, c as getParallelExtractContent, d as convertFileWithMarkit, f as Settings, g as capParseErrors, h as OMP_DEFAULTS, i as convertWithMarkit, l as ensureTool, m as settings, o as extractWithParallel, p as isSettingsInitialized, s as findParallelApiKey, u as CONVERTIBLE_EXTENSIONS, v as formatParseErrors, x as padding, y as shortenPath } from "./utils-DSEEKy3G.mjs";
6
+ import { C as truncateToWidth$1, S as replaceTabs, _ as formatCodeFrameLine, a as fetchBinary, b as LRUCache, c as getParallelExtractContent, d as convertFileWithMarkit, f as Settings, g as capParseErrors, h as OMP_DEFAULTS, i as convertWithMarkit, l as ensureTool, m as settings, o as extractWithParallel, p as isSettingsInitialized, s as findParallelApiKey, u as CONVERTIBLE_EXTENSIONS, v as formatParseErrors, x as padding, y as shortenPath } from "./utils-D1p8-H8L.mjs";
7
7
  import { t as sanitizeText } from "./sanitize-text-CfcXzw5X.mjs";
8
8
  import { n as isRecord$2 } from "./type-guards-B9VVjRDK.mjs";
9
9
  import { n as ToolError, r as throwIfAborted, t as ToolAbortError } from "./tool-errors-DoyxivAR.mjs";
@@ -719,7 +719,26 @@ async function getOrCreateSnapshot(shell, env, timeoutMs = SNAPSHOT_TIMEOUT_MS)
719
719
  const cached = cachedSnapshotPaths.get(cacheKey);
720
720
  if (cached && fs.existsSync(cached)) return cached;
721
721
  if (cached) cachedSnapshotPaths.delete(cacheKey);
722
- if (process.platform === "win32") return null;
722
+ if (process.platform === "win32") {
723
+ const uid = process.getuid?.();
724
+ const snapshotDir = path.join(os.tmpdir(), uid === void 0 ? "omp-shell-snapshots" : `omp-shell-snapshots-${uid}`);
725
+ const snapshotPath = path.join(snapshotDir, `snapshot-bash-${crypto.randomUUID()}.sh`);
726
+ try {
727
+ fs.mkdirSync(snapshotDir, {
728
+ recursive: true,
729
+ mode: 448
730
+ });
731
+ fs.writeFileSync(snapshotPath, "# Shell snapshot - generated by omp agent (windows minimal)\n", { mode: 384 });
732
+ cachedSnapshotPaths.set(cacheKey, snapshotPath);
733
+ return snapshotPath;
734
+ } catch (err) {
735
+ runtimeLogger().debug("shell-snapshot: windows snapshot dir unusable", {
736
+ dir: snapshotDir,
737
+ err: String(err)
738
+ });
739
+ return null;
740
+ }
741
+ }
723
742
  const rcFile = getShellConfigFile(shell, env);
724
743
  const uid = process.getuid?.();
725
744
  const snapshotDir = path.join(os.tmpdir(), uid === void 0 ? "omp-shell-snapshots" : `omp-shell-snapshots-${uid}`);
@@ -788,6 +807,85 @@ function cleanupSnapshots() {
788
807
  cachedSnapshotPaths.clear();
789
808
  }
790
809
  //#endregion
810
+ //#region src/tools/bash/rm-safe.ts
811
+ /**
812
+ * rmSafe: rm 重定义脚本的生成与快照注入。
813
+ *
814
+ * 生成一个 bash 函数定义脚本(rm → `node <trash-cli.mjs> "$@"`),
815
+ * 通过快照文件(会话 shell 创建时 source 一次)注入:
816
+ * - POSIX:追加到用户环境快照末尾(幂等,带标记行);
817
+ * - Windows:getOrCreateSnapshot 生成"仅注入"快照文件后同样追加。
818
+ *
819
+ * rmSafe 关闭时调用方不注入,快照保持原样,系统 rm 生效。
820
+ * @module @xiaoso/dsh-tool-plus/bash/rm-safe
821
+ */
822
+ /** 注入标记行:injectRmSafe 以此判断是否已注入(幂等)。 */
823
+ const RM_SAFE_MARKER = "# dsh-tool-plus rmSafe";
824
+ /** rm-safe 脚本缓存目录(tmpdir 下,跨会话复用)。 */
825
+ function rmSafeScriptDir() {
826
+ return path.join(os.tmpdir(), "dsh-bash-plus");
827
+ }
828
+ /** trash-cli 构建产物路径(lib/trash-cli.mjs,与主 bundle 同目录)。 */
829
+ function rmSafeCliPath() {
830
+ return fileURLToPath(new URL("./trash-cli.mjs", import.meta.url));
831
+ }
832
+ /** 单引号转义(bash 单引号内 `'\''` 表示一个字面单引号)。 */
833
+ function quote(s) {
834
+ return `'${s.replace(/'/g, `'\\''`)}'`;
835
+ }
836
+ /**
837
+ * 生成 rm 重定义脚本内容:bash 函数 `rm()` 把参数原样转交 trash-cli。
838
+ * 逃生口:`command rm`(bash 的 command 关键字跳过函数/alias 查找)或
839
+ * 绝对路径(/usr/bin/rm)走系统真删除。注意 `\rm` 反斜杠只抑制 alias
840
+ * 展开、不抑制函数查找,对函数重定义无效——不是逃生口。
841
+ */
842
+ function rmSafeScript(nodePath, cliPath) {
843
+ return [
844
+ RM_SAFE_MARKER,
845
+ "rm() {",
846
+ ` ${quote(nodePath)} ${quote(cliPath)} "$@"`,
847
+ "}",
848
+ ""
849
+ ].join("\n");
850
+ }
851
+ /**
852
+ * 确保 rm-safe 脚本存在(内容不变时幂等,不重写文件)。
853
+ * 返回脚本路径;目录/写入失败时返回 null(不抛异常,调用方降级并告警)。
854
+ */
855
+ function ensureRmSafeScript(dir, nodePath, cliPath) {
856
+ const target = path.join(dir, "rm-safe.sh");
857
+ const content = rmSafeScript(nodePath, cliPath);
858
+ try {
859
+ if (fs.readFileSync(target, "utf8") === content) return target;
860
+ } catch {}
861
+ try {
862
+ fs.mkdirSync(dir, { recursive: true });
863
+ fs.writeFileSync(target, content, { mode: 384 });
864
+ return target;
865
+ } catch {
866
+ return null;
867
+ }
868
+ }
869
+ /**
870
+ * 向快照文件追加 `source <rm-safe.sh>`(幂等:已有标记则跳过)。
871
+ * 返回是否注入成功;快照文件不可读/不可写时返回 false(不抛异常)。
872
+ */
873
+ function injectRmSafe(snapshotPath, scriptPath) {
874
+ let content;
875
+ try {
876
+ content = fs.readFileSync(snapshotPath, "utf8");
877
+ } catch {
878
+ return false;
879
+ }
880
+ if (content.includes("# dsh-tool-plus rmSafe")) return true;
881
+ try {
882
+ fs.appendFileSync(snapshotPath, `\n${RM_SAFE_MARKER}\nsource ${quote(scriptPath)}\n`);
883
+ return true;
884
+ } catch {
885
+ return false;
886
+ }
887
+ }
888
+ //#endregion
791
889
  //#region node_modules/.pnpm/@oh-my-pi+pi-utils@17.3.5/node_modules/@oh-my-pi/pi-utils/src/path.ts
792
890
  const WINDOWS_DRIVE_EXTENDED_PREFIX = /^\\\\[?]\\([A-Za-z]:[\\/].*)$/;
793
891
  const WINDOWS_UNC_EXTENDED_PREFIX = /^\\\\[?]\\UNC[\\/]([^\\/]+)[\\/](.+)$/i;
@@ -2325,7 +2423,19 @@ async function executeBash(command, options) {
2325
2423
  const baseShellConfig = getShellConfig();
2326
2424
  const { shell, args, env: shellEnv, prefix } = options?.useUserShell === true ? resolveUserShellConfig(baseShellConfig) : baseShellConfig;
2327
2425
  const bashShell = isBashShell(shell);
2328
- const snapshotPath = bashShell && options?.snapshotEnabled !== false ? await getOrCreateSnapshot(shell, shellEnv) : null;
2426
+ let snapshotPath = bashShell && options?.snapshotEnabled !== false ? await getOrCreateSnapshot(shell, shellEnv) : null;
2427
+ let rmSafeInjectionFailed = false;
2428
+ if (snapshotPath !== null && options?.rmSafe === true) {
2429
+ const scriptPath = ensureRmSafeScript(rmSafeScriptDir(), process.execPath, rmSafeCliPath());
2430
+ if (scriptPath === null || !injectRmSafe(snapshotPath, scriptPath)) {
2431
+ rmSafeInjectionFailed = true;
2432
+ runtimeLogger().warn("rmSafe: injection failed, rm will delete permanently", {
2433
+ scriptPath: scriptPath ?? null,
2434
+ snapshotPath
2435
+ });
2436
+ }
2437
+ }
2438
+ const injectionFailedMarker = rmSafeInjectionFailed ? { rmSafeInjectionFailed: true } : {};
2329
2439
  const minimizerSettings = options?.minimizerSettings ?? DEFAULT_MINIMIZER_SETTINGS;
2330
2440
  const minimizer = options?.minimizerEnabled ?? minimizerSettings.enabled ? buildMinimizerOptions(minimizerSettings) : void 0;
2331
2441
  const commandCwd = resolveShellCwd(options?.cwd);
@@ -2354,6 +2464,7 @@ async function executeBash(command, options) {
2354
2464
  if (options?.signal?.aborted) return {
2355
2465
  exitCode: void 0,
2356
2466
  cancelled: true,
2467
+ ...injectionFailedMarker,
2357
2468
  ...await settle("Command cancelled")
2358
2469
  };
2359
2470
  const shellOptions = {
@@ -2432,6 +2543,7 @@ async function executeBash(command, options) {
2432
2543
  return {
2433
2544
  exitCode: void 0,
2434
2545
  cancelled: true,
2546
+ ...injectionFailedMarker,
2435
2547
  ...winner.kind === "timeout" ? { timedOut: true } : {},
2436
2548
  ...await settle(winner.kind === "timeout" && deadlineTimeoutMs !== void 0 ? `Command timed out after ${Math.round(deadlineTimeoutMs / 1e3)} seconds` : "Command cancelled")
2437
2549
  };
@@ -2448,6 +2560,7 @@ async function executeBash(command, options) {
2448
2560
  exitCode: void 0,
2449
2561
  cancelled: true,
2450
2562
  timedOut: true,
2563
+ ...injectionFailedMarker,
2451
2564
  ...await settle(annotation)
2452
2565
  };
2453
2566
  }
@@ -2457,6 +2570,7 @@ async function executeBash(command, options) {
2457
2570
  return {
2458
2571
  exitCode: void 0,
2459
2572
  cancelled: true,
2573
+ ...injectionFailedMarker,
2460
2574
  ...await settle("Command cancelled")
2461
2575
  };
2462
2576
  }
@@ -2473,6 +2587,7 @@ async function executeBash(command, options) {
2473
2587
  return {
2474
2588
  exitCode: winner.result.exitCode,
2475
2589
  cancelled: false,
2590
+ ...injectionFailedMarker,
2476
2591
  minimized: minimized && minimized.text !== minimized.originalText ? {
2477
2592
  filter: minimized.filter,
2478
2593
  inputBytes: minimized.inputBytes,
@@ -3166,6 +3281,7 @@ function renderBashResult(value) {
3166
3281
  const truncation = formatTruncationNotice(value.output);
3167
3282
  if (truncation !== void 0) notices.push(...truncation);
3168
3283
  else if (value.output.spillPath !== void 0) notices.push(`[full raw stream saved: ${value.output.spillPath}]`);
3284
+ if (value.rmSafeInjectionFailed === true) notices.push("[Safe rm injection failed: rm deletes permanently — use `command rm` to bypass]");
3169
3285
  const markers = [];
3170
3286
  if (value.timedOut) markers.push(`[timed out after ${value.timeoutMs ?? "?"}ms]`);
3171
3287
  else if (value.exitCode !== 0) markers.push(`[exit code: ${value.exitCode}]`);
@@ -3229,6 +3345,15 @@ const TOOL_PLUS_FIELDS = [
3229
3345
  group: "behavior",
3230
3346
  tool: "bash"
3231
3347
  },
3348
+ {
3349
+ name: "rmSafe",
3350
+ kind: "boolean",
3351
+ default: true,
3352
+ labelKey: "rmSafe",
3353
+ hintKey: "rmSafeHint",
3354
+ group: "behavior",
3355
+ tool: "bash"
3356
+ },
3232
3357
  {
3233
3358
  name: "useShellCommandWrapper",
3234
3359
  kind: "boolean",
@@ -3895,6 +4020,7 @@ const Config = z.object({
3895
4020
  interceptorEnabled: z.boolean().default(fieldDefault("interceptorEnabled", true)),
3896
4021
  nonInteractiveEnv: z.boolean().default(fieldDefault("nonInteractiveEnv", true)),
3897
4022
  snapshotEnabled: z.boolean().default(fieldDefault("snapshotEnabled", true)),
4023
+ rmSafe: z.boolean().default(fieldDefault("rmSafe", true)),
3898
4024
  useShellCommandWrapper: z.boolean().default(fieldDefault("useShellCommandWrapper", false)),
3899
4025
  maxBackgroundJobs: z.number().default(fieldDefault("maxBackgroundJobs", 15)),
3900
4026
  outputTruncateStrategy: z.union(["bytes", "lines"]).default(fieldDefault("outputTruncateStrategy", "bytes")),
@@ -3987,6 +4113,7 @@ function resolveConfig(config) {
3987
4113
  interceptorEnabled: config.interceptorEnabled ?? fieldDefault("interceptorEnabled", true),
3988
4114
  nonInteractiveEnv: config.nonInteractiveEnv ?? fieldDefault("nonInteractiveEnv", true),
3989
4115
  snapshotEnabled: config.snapshotEnabled ?? fieldDefault("snapshotEnabled", true),
4116
+ rmSafe: config.rmSafe ?? fieldDefault("rmSafe", true),
3990
4117
  useShellCommandWrapper: config.useShellCommandWrapper ?? fieldDefault("useShellCommandWrapper", false),
3991
4118
  maxBackgroundJobs: config.maxBackgroundJobs ?? fieldDefault("maxBackgroundJobs", 15),
3992
4119
  outputTruncate: {
@@ -4196,27 +4323,145 @@ function probeBrowsers(env = defaultBrowserDetectEnv()) {
4196
4323
  return found;
4197
4324
  }
4198
4325
  //#endregion
4326
+ //#region src/tools/bash/rm-safe-status.ts
4327
+ /**
4328
+ * rmSafe 注入状态查询:文件层(快照标记 + 脚本 + trash-cli 产物)与
4329
+ * 运行时层(一次性 bash 会话 source 快照后探测 `rm` 是否被重定义)。
4330
+ *
4331
+ * 分层语义:
4332
+ * - 文件层成功只证明"注入已写入快照",不证明"会话里生效"——快照是会话
4333
+ * 创建时 source 的,注入只对之后创建的会话生效;trash-cli 产物缺失或
4334
+ * node 路径不可用时函数调用会直接失败。
4335
+ * - 运行时层用一次性 bash 会话模拟"新会话创建"(source 快照 + `type rm`),
4336
+ * 验证的正是注入承诺的语义:新会话里 `rm` 是函数而非系统命令。探测
4337
+ * 无副作用,不触碰任何真实会话。
4338
+ * @module @xiaoso/dsh-tool-plus/bash/rm-safe-status
4339
+ */
4340
+ /**
4341
+ * 解析 `type rm` / `declare -f rm` 探测输出。
4342
+ * `rm is a function` → 函数重定义生效;其余(`rm is /usr/bin/rm`、
4343
+ * `rm is hashed (...)`、空输出)→ 系统命令。
4344
+ */
4345
+ function parseProbeOutput(stdout) {
4346
+ return /rm is a function/.test(stdout) ? "function" : "system";
4347
+ }
4348
+ /**
4349
+ * Windows 路径转 Git Bash 形式(`C:\Users\x` → `/c/Users/x`)。
4350
+ * 非 Windows 原样返回;非盘符路径仅把反斜杠转正斜杠。
4351
+ */
4352
+ function toGitBashPath(p) {
4353
+ if (process.platform !== "win32") return p;
4354
+ const drive = /^([A-Za-z]):[\\/](.*)$/.exec(p);
4355
+ if (drive === null) return p.replace(/\\/g, "/");
4356
+ return `/${drive[1].toLowerCase()}/${drive[2].replace(/\\/g, "/")}`;
4357
+ }
4358
+ /** 默认 spawn 实现:收集 stdout,超时 kill 并标记 timedOut。 */
4359
+ const defaultSpawnDeps = { spawnShell: (shell, args, timeoutMs) => new Promise((resolve, reject) => {
4360
+ const child = spawn(shell, args, { stdio: [
4361
+ "ignore",
4362
+ "pipe",
4363
+ "pipe"
4364
+ ] });
4365
+ let stdout = "";
4366
+ let timedOut = false;
4367
+ const timer = setTimeout(() => {
4368
+ timedOut = true;
4369
+ child.kill();
4370
+ }, timeoutMs);
4371
+ child.stdout.on("data", (chunk) => {
4372
+ stdout += chunk.toString();
4373
+ });
4374
+ child.on("error", (error) => {
4375
+ clearTimeout(timer);
4376
+ reject(error);
4377
+ });
4378
+ child.on("close", () => {
4379
+ clearTimeout(timer);
4380
+ resolve({
4381
+ stdout,
4382
+ timedOut
4383
+ });
4384
+ });
4385
+ }) };
4386
+ /**
4387
+ * 运行时探测:在一次性 bash 会话里 source 快照(模拟新会话创建),
4388
+ * 然后 `type rm` + `declare -f rm` 看 `rm` 是否被重定义为函数。
4389
+ * spawn 失败或超时 → 'unknown'(探测不可用,不代表注入失败)。
4390
+ */
4391
+ async function probeRmSafeRuntime(shell, snapshotPath, deps = defaultSpawnDeps, timeoutMs = 5e3) {
4392
+ const script = `source ${quote(toGitBashPath(snapshotPath))}; type rm; declare -f rm`;
4393
+ try {
4394
+ const { stdout, timedOut } = await deps.spawnShell(shell, ["-c", script], timeoutMs);
4395
+ if (timedOut) return "unknown";
4396
+ return parseProbeOutput(stdout);
4397
+ } catch {
4398
+ return "unknown";
4399
+ }
4400
+ }
4401
+ /**
4402
+ * 查询 rmSafe 注入状态(查询即确保注入:幂等,与 executeBash 的注入
4403
+ * 路径一致)。rmSafe 关闭时调用方不应调用本函数(返回 disabled 由
4404
+ * 调用方短路);此处仍兜底返回 disabled 语义由调用方保证。
4405
+ */
4406
+ async function queryRmSafeStatus(deps) {
4407
+ const snapshotPath = await deps.getOrCreateSnapshot();
4408
+ if (snapshotPath === null) return {
4409
+ status: "failed",
4410
+ reason: "snapshot-unavailable"
4411
+ };
4412
+ if (!deps.cliExists()) return {
4413
+ status: "failed",
4414
+ reason: "cli-missing"
4415
+ };
4416
+ const scriptPath = deps.ensureScript();
4417
+ if (scriptPath === null) return {
4418
+ status: "failed",
4419
+ reason: "script-write-failed"
4420
+ };
4421
+ if (!deps.inject(snapshotPath, scriptPath)) return {
4422
+ status: "failed",
4423
+ reason: "snapshot-write-failed"
4424
+ };
4425
+ const runtime = await deps.probe(snapshotPath);
4426
+ if (runtime === "system") return {
4427
+ status: "failed",
4428
+ reason: "runtime-not-effective"
4429
+ };
4430
+ return {
4431
+ status: "injected",
4432
+ runtime
4433
+ };
4434
+ }
4435
+ //#endregion
4199
4436
  //#region src/host/browser-probe-rpc.ts
4200
4437
  /**
4201
- * Install the browser/detect RPC endpoint. Returns a disposer that removes
4438
+ * Host half of the tool-plus RPC channel: registers the `/tool-plus` logical
4439
+ * channel on the official Connection transport and answers
4440
+ * `browser/detect` (probe the machine for usable browsers) and
4441
+ * `rmSafe/status` (query = ensure + verify the rmSafe injection). The
4442
+ * settings panel (client half) calls these through
4443
+ * `createWebConnectionRpc().call(...)` — the documented public RPC surface
4444
+ * of `@deepseek-ai/dsh-client-connection`, not a private plugin hook.
4445
+ *
4446
+ * Trust policy is `loopback`: the probe only reports local filesystem paths,
4447
+ * so it must never be reachable from a non-loopback host. Registration is
4448
+ * best-effort — if `connection` is missing (non-web deployments) or the
4449
+ * channel is already taken, the endpoints are simply unavailable and the
4450
+ * panel shows "Unavailable".
4451
+ * @module @xiaoso/dsh-tool-plus/host/browser-probe-rpc
4452
+ */
4453
+ /**
4454
+ * Install the tool-plus RPC endpoints. Returns a disposer that removes
4202
4455
  * the registration (call it from the plugin's `ctx.effect` teardown).
4203
4456
  * Never throws for "unavailable" — the return value tells the caller whether
4204
4457
  * the channel is actually live.
4205
4458
  */
4206
- function installBrowserProbeRpc(ctx) {
4459
+ function installBrowserProbeRpc(ctx, deps) {
4207
4460
  let disposer;
4208
4461
  ctx.inject(["connection"], (connectionCtx) => {
4209
4462
  try {
4210
4463
  const handler = async (endpoint, _payload, _signal) => {
4211
- if (endpoint !== "browser/detect") return {
4212
- ok: false,
4213
- error: {
4214
- code: "bad-request",
4215
- message: `Unknown tool-plus endpoint: ${endpoint}`,
4216
- details: { issues: [] }
4217
- }
4218
- };
4219
- try {
4464
+ if (endpoint === "browser/detect") try {
4220
4465
  return {
4221
4466
  ok: true,
4222
4467
  value: { found: probeBrowsers() }
@@ -4231,6 +4476,42 @@ function installBrowserProbeRpc(ctx) {
4231
4476
  }
4232
4477
  };
4233
4478
  }
4479
+ if (endpoint === "rmSafe/status") {
4480
+ if (!deps.getRmSafe()) return {
4481
+ ok: true,
4482
+ value: { status: "disabled" }
4483
+ };
4484
+ try {
4485
+ const { shell, env } = getShellConfig();
4486
+ return {
4487
+ ok: true,
4488
+ value: await queryRmSafeStatus({
4489
+ getOrCreateSnapshot: () => getOrCreateSnapshot(shell, env),
4490
+ cliExists: () => fs.existsSync(rmSafeCliPath()),
4491
+ ensureScript: () => ensureRmSafeScript(rmSafeScriptDir(), process.execPath, rmSafeCliPath()),
4492
+ inject: (snapshotPath, scriptPath) => injectRmSafe(snapshotPath, scriptPath),
4493
+ probe: (snapshotPath) => probeRmSafeRuntime(shell, snapshotPath)
4494
+ })
4495
+ };
4496
+ } catch (error) {
4497
+ return {
4498
+ ok: false,
4499
+ error: {
4500
+ code: "internal",
4501
+ message: error instanceof Error ? error.message : String(error),
4502
+ details: {}
4503
+ }
4504
+ };
4505
+ }
4506
+ }
4507
+ return {
4508
+ ok: false,
4509
+ error: {
4510
+ code: "bad-request",
4511
+ message: `Unknown tool-plus endpoint: ${endpoint}`,
4512
+ details: { issues: [] }
4513
+ }
4514
+ };
4234
4515
  };
4235
4516
  disposer = connectionCtx.connection.rpc.handle(TOOL_PLUS_RPC_CHANNEL, handler, { authority: "loopback" });
4236
4517
  } catch {
@@ -14555,7 +14836,7 @@ let specialHandlersPromise;
14555
14836
  * requires a special handler, so we keep them out of the cold-startup graph.
14556
14837
  */
14557
14838
  function loadSpecialHandlers() {
14558
- specialHandlersPromise ??= import("./scrapers-DrSYgPyf.mjs").then((m) => m.specialHandlers);
14839
+ specialHandlersPromise ??= import("./scrapers-DI8yWlu_.mjs").then((m) => m.specialHandlers);
14559
14840
  return specialHandlersPromise;
14560
14841
  }
14561
14842
  /**
@@ -25704,6 +25985,7 @@ function buildForeground(result, wallTimeMs, timeoutMs) {
25704
25985
  wallTimeMs,
25705
25986
  ...result.workingDir !== void 0 ? { workingDir: result.workingDir } : {},
25706
25987
  ...result.minimized !== void 0 ? { minimized: result.minimized } : {},
25988
+ ...result.rmSafeInjectionFailed === true ? { rmSafeInjectionFailed: true } : {},
25707
25989
  output: {
25708
25990
  text: result.output,
25709
25991
  truncated: result.truncated,
@@ -25772,7 +26054,7 @@ function apply(ctx, config = {}) {
25772
26054
  cfg = current();
25773
26055
  registerModeSensitive();
25774
26056
  });
25775
- const disposeBrowserProbe = installBrowserProbeRpc(ctx);
26057
+ const disposeBrowserProbe = installBrowserProbeRpc(ctx, { getRmSafe: () => cfg.rmSafe });
25776
26058
  ctx.systemPrompt.section({
25777
26059
  name: "tool:bash",
25778
26060
  order: 105,
@@ -26028,6 +26310,7 @@ function apply(ctx, config = {}) {
26028
26310
  headBytes: cfg.outputSinkHeadBytes,
26029
26311
  useShellCommandWrapper: cfg.useShellCommandWrapper,
26030
26312
  snapshotEnabled: cfg.snapshotEnabled,
26313
+ rmSafe: cfg.rmSafe,
26031
26314
  nonInteractiveEnv: cfg.nonInteractiveEnv,
26032
26315
  artifactPath: allocateSpillFile()
26033
26316
  });
@@ -26085,6 +26368,7 @@ function apply(ctx, config = {}) {
26085
26368
  headBytes: cfg.outputSinkHeadBytes,
26086
26369
  useShellCommandWrapper: cfg.useShellCommandWrapper,
26087
26370
  snapshotEnabled: cfg.snapshotEnabled,
26371
+ rmSafe: cfg.rmSafe,
26088
26372
  nonInteractiveEnv: cfg.nonInteractiveEnv,
26089
26373
  artifactPath: allocateSpillFile()
26090
26374
  });
@@ -3,7 +3,7 @@ __installBunShim();
3
3
  import { n as fs_promises_shim_exports } from "./fs-promises-shim-Bx0fpxuk.mjs";
4
4
  import { B as USER_AGENT, G as getDocsRsCacheDir, N as formatNumber, P as $env, T as warn, c as combineSignals, j as formatBytes, l as exec, s as Snowflake, x as parseFrontmatter, y as isEnoent } from "./src-D_e1qMwi.mjs";
5
5
  import { V as tryParseJson } from "./utils-fnV2zzp3.mjs";
6
- import { a as fetchBinary, c as getParallelExtractContent, i as convertWithMarkit, l as ensureTool, m as settings, n as asRecord, o as extractWithParallel, r as asString, s as findParallelApiKey, t as asNumber } from "./utils-DSEEKy3G.mjs";
6
+ import { a as fetchBinary, c as getParallelExtractContent, i as convertWithMarkit, l as ensureTool, m as settings, n as asRecord, o as extractWithParallel, r as asString, s as findParallelApiKey, t as asNumber } from "./utils-D1p8-H8L.mjs";
7
7
  import { n as isRecord } from "./type-guards-B9VVjRDK.mjs";
8
8
  import { r as throwIfAborted, t as ToolAbortError } from "./tool-errors-DoyxivAR.mjs";
9
9
  import { c as getLocalizedText, d as looksLikeHtml, i as decodeHtmlEntities, l as htmlToBasicMarkdown, o as formatIsoDate, r as buildResult, s as formatMediaDuration, u as loadPage } from "./types-BUO0JXO-.mjs";
@@ -0,0 +1,281 @@
1
+ import * as fs from "node:fs";
2
+ //#region src/tools/bash/trash-cli.ts
3
+ const SHORT_OPTIONS = {
4
+ r: "recursive",
5
+ R: "recursive",
6
+ f: "force",
7
+ i: "interactive",
8
+ I: "interactive",
9
+ v: "verbose",
10
+ d: "dir"
11
+ };
12
+ const LONG_OPTIONS = {
13
+ "--recursive": {
14
+ field: "recursive",
15
+ value: true
16
+ },
17
+ "--force": {
18
+ field: "force",
19
+ value: true
20
+ },
21
+ "--interactive": {
22
+ field: "interactive",
23
+ value: true
24
+ },
25
+ "--verbose": {
26
+ field: "verbose",
27
+ value: true
28
+ },
29
+ "--dir": {
30
+ field: "dir",
31
+ value: true
32
+ },
33
+ "--preserve-root": {
34
+ field: "preserveRoot",
35
+ value: true
36
+ },
37
+ "--no-preserve-root": {
38
+ field: "preserveRoot",
39
+ value: false
40
+ },
41
+ "--one-file-system": {
42
+ field: "oneFileSystem",
43
+ value: true
44
+ }
45
+ };
46
+ /** --interactive=WHEN 的合法取值(GNU rm 语义)。 */
47
+ const INTERACTIVE_WHEN = /* @__PURE__ */ new Set([
48
+ "once",
49
+ "always",
50
+ "never"
51
+ ]);
52
+ /**
53
+ * 解析 rm 风格参数(coreutils rm 语义):
54
+ * - 短选项支持组合(`-rf`),`-r`/`-R`/`--recursive` 等价,`-i`/`-I` 等价;
55
+ * - `--` 结束符后全部视为路径(允许 `-` 开头的文件名);
56
+ * - `--interactive[=once|always|never]` 接受(回收站模式忽略交互);
57
+ * - 未知短选项 → invalid-option,未知长选项 → unrecognized-option;
58
+ * - 无路径 → missing-operand。
59
+ */
60
+ function parseRmArgs(argv) {
61
+ const parsed = {
62
+ recursive: false,
63
+ force: false,
64
+ interactive: false,
65
+ verbose: false,
66
+ dir: false,
67
+ preserveRoot: true,
68
+ oneFileSystem: false,
69
+ paths: []
70
+ };
71
+ let afterDoubleDash = false;
72
+ for (const arg of argv) {
73
+ if (afterDoubleDash) {
74
+ parsed.paths.push(arg);
75
+ continue;
76
+ }
77
+ if (arg === "--") {
78
+ afterDoubleDash = true;
79
+ continue;
80
+ }
81
+ if (arg.startsWith("--")) {
82
+ const eq = arg.indexOf("=");
83
+ const name = eq >= 0 ? arg.slice(0, eq) : arg;
84
+ const value = eq >= 0 ? arg.slice(eq + 1) : void 0;
85
+ if (name === "---presume-input-tty") continue;
86
+ const flag = LONG_OPTIONS[name];
87
+ if (flag === void 0) {
88
+ parsed.error = {
89
+ kind: "unrecognized-option",
90
+ option: name
91
+ };
92
+ return parsed;
93
+ }
94
+ if (value !== void 0) {
95
+ if (name === "--interactive") {
96
+ if (!INTERACTIVE_WHEN.has(value)) {
97
+ parsed.error = {
98
+ kind: "invalid-argument",
99
+ option: name,
100
+ value
101
+ };
102
+ return parsed;
103
+ }
104
+ parsed.interactive = true;
105
+ } else if (name === "--preserve-root") {
106
+ if (value === "all") parsed.preserveRoot = true;
107
+ else {
108
+ parsed.error = {
109
+ kind: "invalid-argument",
110
+ option: name,
111
+ value
112
+ };
113
+ return parsed;
114
+ }
115
+ } else {
116
+ parsed.error = {
117
+ kind: "option-argument",
118
+ option: name
119
+ };
120
+ return parsed;
121
+ }
122
+ } else parsed[flag.field] = flag.value;
123
+ continue;
124
+ }
125
+ if (arg.startsWith("-") && arg.length > 1) {
126
+ for (const ch of arg.slice(1)) {
127
+ const flag = SHORT_OPTIONS[ch];
128
+ if (flag === void 0) {
129
+ parsed.error = {
130
+ kind: "invalid-option",
131
+ option: ch
132
+ };
133
+ return parsed;
134
+ }
135
+ parsed[flag] = true;
136
+ }
137
+ continue;
138
+ }
139
+ parsed.paths.push(arg);
140
+ }
141
+ if (parsed.paths.length === 0 && parsed.error === void 0) parsed.error = { kind: "missing-operand" };
142
+ return parsed;
143
+ }
144
+ /** 规范化 verbose 输出的目录尾部斜杠(`a///` → `a/`,对齐 rm)。 */
145
+ function normalizeTrailingSlash(p) {
146
+ return p.replace(/[\\/]+$/, "/");
147
+ }
148
+ function errorMessage(err) {
149
+ return err instanceof Error ? err.message : String(err);
150
+ }
151
+ /** 路径规范化后以 `.` 或 `..` 结尾(r-4 移植:拒绝删除)。 */
152
+ function isDotOrDotDot(p) {
153
+ const last = p.replace(/[\\/]+$/, "").split(/[\\/]/).pop();
154
+ return last === "." || last === "..";
155
+ }
156
+ /**
157
+ * 路径是 `/` 或其同义词(`//`、`///`、符号链接/junction 指向根目录)。
158
+ * 根判定用 dev/ino 比较(GNU preserve-root 的精确语义):字符串比较在
159
+ * Windows 上会漏判(junction 指向 C:\ 时 realpath 是 "C:\\" 而非 "/"),
160
+ * 而 dev/ino 在 Windows 与 POSIX 上都正确。空串不是根。
161
+ *
162
+ * Windows 语义说明:Node 的 `/` 解析为当前盘根(随 cwd 变化),而 Git
163
+ * bash 的 `/` 是 MSYS 根——两者都只保护"字面 `/` 及其 dev/ino 等价物",
164
+ * 与 GNU rm 在 Git bash 的行为一致(GNU 同样不保护指向盘根的链接)。
165
+ */
166
+ async function isRootPath(p, deps) {
167
+ if (p.length > 0 && p.replace(/[\\/]+$/, "") === "") return true;
168
+ const st = await deps.stat(p);
169
+ if (st === null) return false;
170
+ const root = await deps.stat("/");
171
+ return root !== null && st.dev === root.dev && st.ino === root.ino;
172
+ }
173
+ /**
174
+ * 执行一次 rm 语义的回收站删除。
175
+ *
176
+ * 行为对齐 coreutils rm:
177
+ * - 无 -r/-d 时目录报 `Is a directory` 并保留(rm4);
178
+ * - 不存在路径:-f 静默忽略(f-1),否则报 `No such file or directory`;
179
+ * - `.`/`..` 结尾路径拒绝(r-4);`/` 及同义词拒绝(r-root,preserve-root 默认开);
180
+ * - 符号链接按链接本身处理(lstat,dangling-symlink 移植);
181
+ * - trash 失败:-f 静默但退出 1,否则报 `cannot remove`;
182
+ * - -v 输出 `removed 'x'` / `removed directory 'x'`(r-1/d-1/v-slash);
183
+ * - 退出码:全部成功 0,任一失败 1。
184
+ */
185
+ async function runTrashCli(argv, deps) {
186
+ const parsed = parseRmArgs(argv);
187
+ if (parsed.error !== void 0) {
188
+ if (parsed.error.kind === "missing-operand") deps.stderr("rm: missing operand");
189
+ else if (parsed.error.kind === "invalid-option") deps.stderr(`rm: invalid option -- '${parsed.error.option}'`);
190
+ else if (parsed.error.kind === "unrecognized-option") deps.stderr(`rm: unrecognized option '${parsed.error.option}'`);
191
+ else if (parsed.error.kind === "invalid-argument") deps.stderr(`rm: invalid argument '${parsed.error.value}' for '${parsed.error.option}'`);
192
+ else deps.stderr(`rm: option '${parsed.error.option}' doesn't allow an argument`);
193
+ deps.exit(1);
194
+ return;
195
+ }
196
+ const { recursive, force, verbose, dir, preserveRoot, paths } = parsed;
197
+ const items = [];
198
+ let failed = false;
199
+ for (const p of paths) {
200
+ if (isDotOrDotDot(p)) {
201
+ deps.stderr(`rm: refusing to remove '.' or '..' directory: skipping '${p}'`);
202
+ failed = true;
203
+ continue;
204
+ }
205
+ if (preserveRoot && await isRootPath(p, deps)) {
206
+ deps.stderr("rm: it is dangerous to operate recursively on '/'");
207
+ deps.stderr("rm: use --no-preserve-root to override this failsafe");
208
+ failed = true;
209
+ continue;
210
+ }
211
+ const st = await deps.lstat(p);
212
+ if (st === null) {
213
+ if (!force) {
214
+ deps.stderr(`rm: cannot remove '${p}': No such file or directory`);
215
+ failed = true;
216
+ }
217
+ continue;
218
+ }
219
+ if (st.isDirectory && !recursive && !dir) {
220
+ deps.stderr(`rm: cannot remove '${p}': Is a directory`);
221
+ failed = true;
222
+ continue;
223
+ }
224
+ items.push({
225
+ path: p,
226
+ isDir: st.isDirectory
227
+ });
228
+ }
229
+ if (items.length > 0) try {
230
+ await deps.trash(items.map((item) => item.path));
231
+ } catch (err) {
232
+ deps.stderr(`rm: cannot remove '${items[0].path}': ${errorMessage(err)}`);
233
+ failed = true;
234
+ }
235
+ if (verbose && !failed) for (const { path: p, isDir } of items) deps.stdout(isDir ? `removed directory '${normalizeTrailingSlash(p)}'` : `removed '${p}'`);
236
+ deps.exit(failed ? 1 : 0);
237
+ }
238
+ //#endregion
239
+ //#region src/tools/bash/trash-cli-main.ts
240
+ /**
241
+ * rmSafe CLI 入口(构建产物 lib/trash-cli.mjs,被注入的 bash `rm()` 函数调用)。
242
+ *
243
+ * trash 包在此动态加载:单测只 import trash-cli.ts(纯逻辑),
244
+ * 不会触发 trash 依赖解析。
245
+ * @module @xiaoso/dsh-tool-plus/bash/trash-cli-main
246
+ */
247
+ async function main() {
248
+ const { default: trash } = await import("trash");
249
+ await runTrashCli(process.argv.slice(2), {
250
+ trash,
251
+ lstat: async (p) => {
252
+ try {
253
+ return { isDirectory: (await fs.promises.lstat(p)).isDirectory() };
254
+ } catch {
255
+ return null;
256
+ }
257
+ },
258
+ stat: async (p) => {
259
+ try {
260
+ const st = await fs.promises.stat(p);
261
+ return {
262
+ dev: st.dev,
263
+ ino: st.ino
264
+ };
265
+ } catch {
266
+ return null;
267
+ }
268
+ },
269
+ stdout: (s) => process.stdout.write(`${s}\n`),
270
+ stderr: (s) => process.stderr.write(`${s}\n`),
271
+ exit: (c) => {
272
+ process.exitCode = c;
273
+ }
274
+ });
275
+ }
276
+ main().catch((err) => {
277
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
278
+ process.exitCode = 1;
279
+ });
280
+ //#endregion
281
+ export {};
@@ -7202,7 +7202,7 @@ const settings = {
7202
7202
  };
7203
7203
  //#endregion
7204
7204
  //#region package.json
7205
- var version = "0.1.0-beta.7";
7205
+ var version = "0.1.1";
7206
7206
  /** `.tmp` files older than this are treated as orphaned writes and swept. */
7207
7207
  const TMP_ORPHAN_MAX_AGE_MS = 3e5;
7208
7208
  function markitConversionCacheKey(bytes, extension) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xiaoso/dsh-tool-plus",
3
3
  "description": "Oh My Pi bash runtime ported as a deepseek-harness tool plugin: persistent shell session, output minimizer, managed background jobs, command interception",
4
- "version": "0.1.0-beta.7",
4
+ "version": "0.1.1",
5
5
  "license": "MIT",
6
6
  "keywords": [
7
7
  "deepseek-harness",
@@ -109,6 +109,7 @@
109
109
  "@oh-my-pi/pi-utils": "17.3.5",
110
110
  "json5": "^2.2.3",
111
111
  "puppeteer-core": "^25.3.0",
112
+ "trash": "^10.1.1",
112
113
  "yaml": "^2.9.0"
113
114
  },
114
115
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaoso/dsh-tool-plus-presets",
3
- "version": "0.1.0-beta.7",
3
+ "version": "0.1.1",
4
4
  "description": "Agent-preset installer for dsh-tool-plus: installs the standard / PTC enhanced preset templates into a DeepSeek Harness home.",
5
5
  "license": "MIT",
6
6
  "keywords": [