dsh-zspace 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Plugin configuration: the schema the host validates against, the code-side
3
+ * defaults (so direct construction works without the loader), and the bounds
4
+ * check that fails at load time instead of at the first tool call.
5
+ *
6
+ * @module dsh-zspace/config
7
+ */
8
+
9
+ import z from "@deepseek-ai/schemastery";
10
+
11
+ import { DEFAULT_API_VERSION, DEFAULT_BASE_URL } from "./auth.js";
12
+
13
+ /**
14
+ * Code-side defaults.
15
+ *
16
+ * The Cordis loader already applies `Config`'s defaults, but `apply` is also
17
+ * reachable through direct construction (tests, tooling), so the plugin
18
+ * resolves its own defaults instead of trusting the caller.
19
+ *
20
+ * @type {Record<string, any>}
21
+ */
22
+ const DEFAULTS = Object.freeze({
23
+ baseUrl: DEFAULT_BASE_URL,
24
+ configDir: "",
25
+ apiVersion: DEFAULT_API_VERSION,
26
+ homePath: "",
27
+ publicPath: "",
28
+ downloadDir: "",
29
+ readMaxBytes: 262_144,
30
+ writeMaxBytes: 5 * 1024 * 1024,
31
+ listMaxEntries: 2000,
32
+ timeoutMs: 60_000,
33
+ maxRetries: 2,
34
+ smallUploadMaxBytes: 8 * 1024 * 1024,
35
+ sliceSize: 2 * 1024 * 1024,
36
+ promptEnabled: true,
37
+ promptOrder: 60,
38
+ });
39
+
40
+ export const Config = z.object({
41
+ /** Desktop client local proxy. */
42
+ baseUrl: z.string().default(DEFAULT_BASE_URL),
43
+ /** Explicit directory holding `vuex.json`; empty means auto-detect. */
44
+ configDir: z.string().default(""),
45
+ /** NAS web API version sent as the `version` parameter. */
46
+ apiVersion: z.string().default(DEFAULT_API_VERSION),
47
+ /** Personal-space root override; empty means discover from storage pools. */
48
+ homePath: z.string().default(""),
49
+ /** Public-space root override; empty means discover from storage pools. */
50
+ publicPath: z.string().default(""),
51
+ /** Default local directory for downloads; empty means `~/Downloads/zspace`. */
52
+ downloadDir: z.string().default(""),
53
+ /** Byte cap for `zspace_read`. */
54
+ readMaxBytes: z.natural().default(262_144),
55
+ /** Byte cap for `zspace_write` text content (bigger writes belong in `zspace_upload`). */
56
+ writeMaxBytes: z.natural().default(5 * 1024 * 1024),
57
+ /** Entry ceiling for one listing/tree call. */
58
+ listMaxEntries: z.natural().default(2000),
59
+ /** Per-request timeout in milliseconds. */
60
+ timeoutMs: z.natural().default(60_000),
61
+ /** Retries for transient transport/5xx failures. */
62
+ maxRetries: z.natural().default(2),
63
+ /** Files above this size upload with the sliced protocol. */
64
+ smallUploadMaxBytes: z.natural().default(8 * 1024 * 1024),
65
+ /** Slice size for the sliced upload protocol (NAS remote mode caps at 2 MB). */
66
+ sliceSize: z.natural().default(2 * 1024 * 1024),
67
+ /** Whether to contribute the NAS guide to the system prompt. */
68
+ promptEnabled: z.boolean().default(true),
69
+ /** Ordering of that section. */
70
+ promptOrder: z.natural().default(60),
71
+ });
72
+
73
+ /**
74
+ * Merge caller config over the defaults, ignoring explicit `undefined`.
75
+ *
76
+ * @param {Record<string, any>|undefined} config - caller config.
77
+ * @returns {Record<string, any>} effective config.
78
+ */
79
+ export function resolveConfig(config) {
80
+ const given = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
81
+ return { ...DEFAULTS, ...given };
82
+ }
83
+
84
+ /**
85
+ * Reject unusable configuration at load time instead of at the first tool call.
86
+ *
87
+ * @param {Record<string, any>} config - schema-validated config.
88
+ * @throws {Error} when a numeric bound is not a positive integer.
89
+ */
90
+ export function validateConfig(config) {
91
+ const bounds = [
92
+ ["readMaxBytes", config.readMaxBytes],
93
+ ["writeMaxBytes", config.writeMaxBytes],
94
+ ["listMaxEntries", config.listMaxEntries],
95
+ ["timeoutMs", config.timeoutMs],
96
+ ["maxRetries", config.maxRetries],
97
+ ["smallUploadMaxBytes", config.smallUploadMaxBytes],
98
+ ["sliceSize", config.sliceSize],
99
+ ["promptOrder", config.promptOrder],
100
+ ];
101
+ for (const [field, value] of bounds) {
102
+ if (!Number.isInteger(value) || value < 0 || (field !== "maxRetries" && value < 1)) {
103
+ throw new Error(`zspace: 配置项 ${field}=${value} 必须是非负整数(maxRetries 可为 0,其余至少为 1)`);
104
+ }
105
+ }
106
+ if (!/^https?:\/\//.test(config.baseUrl)) {
107
+ throw new Error(`zspace: baseUrl 必须以 http:// 或 https:// 开头(当前 ${config.baseUrl})`);
108
+ }
109
+ }
package/lib/index.js CHANGED
@@ -5,146 +5,27 @@
5
5
  * proxy, which relays to the NAS over 极空间's cloud/P2P channel. No LAN
6
6
  * address, no DDNS, no WebDAV, no SSH, no Python.
7
7
  *
8
- * This module is the thin host adapter: it validates config, owns the client
9
- * lifecycle, contributes one system-prompt section, and converts the plain
10
- * tool specs from `./tools.js` into registered host tools.
8
+ * This entry point only wires things together: it validates config, owns the
9
+ * client lifecycle, contributes the guide section, and registers the tools
10
+ * described in `./tools/`. Configuration lives in `./config.js`, the guide text
11
+ * in `./prompt.js`, the protocol in `./client/`.
11
12
  *
12
13
  * @module dsh-zspace
13
14
  */
14
15
 
15
- import z from "@deepseek-ai/schemastery";
16
16
  import { defineTool } from "@deepseek-ai/dsh-tools";
17
17
 
18
- import { DEFAULT_API_VERSION, DEFAULT_BASE_URL } from "./auth.js";
19
18
  import { ZSpaceClient } from "./client.js";
19
+ import { Config, resolveConfig, validateConfig } from "./config.js";
20
+ import { guideText } from "./prompt.js";
20
21
  import { createToolSpecs } from "./tools.js";
21
22
 
22
23
  export const name = "zspace";
23
24
 
24
25
  export const inject = ["tools", "systemPrompt"];
25
26
 
26
- export const Config = z.object({
27
- /** Desktop client local proxy. */
28
- baseUrl: z.string().default(DEFAULT_BASE_URL),
29
- /** Explicit directory holding `vuex.json`; empty means auto-detect. */
30
- configDir: z.string().default(""),
31
- /** NAS web API version sent as the `version` parameter. */
32
- apiVersion: z.string().default(DEFAULT_API_VERSION),
33
- /** Personal-space root override; empty means discover from storage pools. */
34
- homePath: z.string().default(""),
35
- /** Public-space root override; empty means discover from storage pools. */
36
- publicPath: z.string().default(""),
37
- /** Default local directory for downloads; empty means `~/Downloads/zspace`. */
38
- downloadDir: z.string().default(""),
39
- /** Byte cap for `zspace_read`. */
40
- readMaxBytes: z.natural().default(262_144),
41
- /** Entry ceiling for one listing/tree call. */
42
- listMaxEntries: z.natural().default(2000),
43
- /** Per-request timeout in milliseconds. */
44
- timeoutMs: z.natural().default(60_000),
45
- /** Retries for transient transport/5xx failures. */
46
- maxRetries: z.natural().default(2),
47
- /** Files above this size upload with the sliced protocol. */
48
- smallUploadMaxBytes: z.natural().default(8 * 1024 * 1024),
49
- /** Slice size for the sliced upload protocol (NAS remote mode caps at 2 MB). */
50
- sliceSize: z.natural().default(2 * 1024 * 1024),
51
- /** Whether to contribute the NAS guide to the system prompt. */
52
- promptEnabled: z.boolean().default(true),
53
- /** Ordering of that section. */
54
- promptOrder: z.natural().default(60),
55
- });
27
+ export { Config };
56
28
 
57
- /**
58
- * Code-side defaults.
59
- *
60
- * The Cordis loader already applies `Config`'s defaults, but `apply` is also
61
- * reachable through direct construction (tests, tooling), so the plugin
62
- * resolves its own defaults instead of trusting the caller.
63
- *
64
- * @type {Record<string, any>}
65
- */
66
- const DEFAULTS = Object.freeze({
67
- baseUrl: DEFAULT_BASE_URL,
68
- configDir: "",
69
- apiVersion: DEFAULT_API_VERSION,
70
- homePath: "",
71
- publicPath: "",
72
- downloadDir: "",
73
- readMaxBytes: 262_144,
74
- listMaxEntries: 2000,
75
- timeoutMs: 60_000,
76
- maxRetries: 2,
77
- smallUploadMaxBytes: 8 * 1024 * 1024,
78
- sliceSize: 2 * 1024 * 1024,
79
- promptEnabled: true,
80
- promptOrder: 60,
81
- });
82
-
83
- /**
84
- * Merge caller config over the defaults, ignoring explicit `undefined`.
85
- *
86
- * @param {Record<string, any>|undefined} config - caller config.
87
- * @returns {Record<string, any>} effective config.
88
- */
89
- function resolveConfig(config) {
90
- const given = Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined));
91
- return { ...DEFAULTS, ...given };
92
- }
93
-
94
- /**
95
- * Reject unusable configuration at load time instead of at the first tool call.
96
- *
97
- * @param {Record<string, any>} config - schema-validated config.
98
- * @throws {Error} when a numeric bound is not a positive integer.
99
- */
100
- function validateConfig(config) {
101
- const bounds = [
102
- ["readMaxBytes", config.readMaxBytes],
103
- ["listMaxEntries", config.listMaxEntries],
104
- ["timeoutMs", config.timeoutMs],
105
- ["maxRetries", config.maxRetries],
106
- ["smallUploadMaxBytes", config.smallUploadMaxBytes],
107
- ["sliceSize", config.sliceSize],
108
- ["promptOrder", config.promptOrder],
109
- ];
110
- for (const [field, value] of bounds) {
111
- if (!Number.isInteger(value) || value < 0 || (field !== "maxRetries" && value < 1)) {
112
- throw new Error(`zspace: 配置项 ${field}=${value} 必须是非负整数(maxRetries 可为 0,其余至少为 1)`);
113
- }
114
- }
115
- if (!/^https?:\/\//.test(config.baseUrl)) {
116
- throw new Error(`zspace: baseUrl 必须以 http:// 或 https:// 开头(当前 ${config.baseUrl})`);
117
- }
118
- }
119
-
120
- /**
121
- * The system-prompt section describing the NAS and its path conveniences.
122
- *
123
- * @param {Record<string, any>} config - plugin config.
124
- * @returns {string} guide text.
125
- */
126
- function guideText(config) {
127
- const down = config.downloadDir || "~/Downloads/zspace";
128
- return [
129
- "### 极空间 NAS(dsh-zspace)",
130
- "已通过极空间桌面客户端的本地代理接入这台 NAS,**跨网络可用**(不需要同一局域网、WebDAV 或 SSH,仅要求本机桌面客户端保持登录)。",
131
- "- 远程路径写法:`home:`=个人空间根、`public:`=公共空间根、相对路径=相对个人空间、以 `/` 开头=NAS 绝对路径;两个根的真实位置先跑 `zspace_status` 查看。",
132
- "- 只读:`zspace_ls`(支持 depth 递归)、`zspace_stat`、`zspace_find`(按名称在目录树里遍历查找,受 depth/scanLimit 预算限制,结果里会报告扫了多少项)、`zspace_read`(小文本读入上下文)、`zspace_download`(下载到本机,默认 " +
133
- down +
134
- ")。",
135
- "- 写入:`zspace_upload`(大文件自动分片、支持中文名)、`zspace_mkdir`、`zspace_rename`、`zspace_move`、`zspace_copy`、`zspace_remove`(进回收站,必须显式 confirm=true)。",
136
- "- 删除/移动/重命名属于破坏性操作:先用 `zspace_ls` 或 `zspace_stat` 核实目标,再执行;批量删除前先向用户确认。",
137
- ].join("\n");
138
- }
139
-
140
- /**
141
- * Mount the plugin: own the client lifecycle, add the guide section, register
142
- * every tool.
143
- *
144
- * @param {import("@deepseek-ai/cordis").Context} ctx - plugin context.
145
- * @param {Record<string, any>} config - validated config.
146
- * @returns {void}
147
- */
148
29
  export function apply(ctx, config) {
149
30
  const settings = resolveConfig(config);
150
31
  validateConfig(settings);
package/lib/prompt.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The system-prompt section that teaches the model this NAS's path conventions
3
+ * and which tools to reach for. Only injected when `promptEnabled` is true.
4
+ *
5
+ * @module dsh-zspace/prompt
6
+ */
7
+
8
+ export /**
9
+ * The system-prompt section describing the NAS and its path conveniences.
10
+ *
11
+ * @param {Record<string, any>} config - plugin config.
12
+ * @returns {string} guide text.
13
+ */
14
+ function guideText(config) {
15
+ const down = config.downloadDir || "~/Downloads/zspace";
16
+ return [
17
+ "### 极空间 NAS(dsh-zspace)",
18
+ "已通过极空间桌面客户端的本地代理接入这台 NAS,**跨网络可用**(不需要同一局域网、WebDAV 或 SSH,仅要求本机桌面客户端保持登录)。",
19
+ "- 远程路径写法:`home:`=个人空间根、`public:`=公共空间根、相对路径=相对个人空间、以 `/` 开头=NAS 绝对路径;两个根的真实位置先跑 `zspace_status` 查看。",
20
+ "- 只读:`zspace_ls`(支持 depth 递归)、`zspace_stat`、`zspace_find`(按名称在目录树里遍历查找,受 depth/scanLimit 预算限制,结果里会报告扫了多少项)、`zspace_read`(小文本读入上下文)、`zspace_download`(下载到本机,默认 " +
21
+ down +
22
+ ")。",
23
+ "- 写入:`zspace_upload`(大文件自动分片、支持中文名)、`zspace_mkdir`、`zspace_rename`、`zspace_move`、`zspace_copy`、`zspace_remove`(进回收站,必须显式 confirm=true)。",
24
+ "- 删除/移动/重命名属于破坏性操作:先用 `zspace_ls` 或 `zspace_stat` 核实目标,再执行;批量删除前先向用户确认。",
25
+ ].join("\n");
26
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `zspace_copy` — one atomic capability.
3
+ *
4
+ * @module dsh-zspace/tools
5
+ */
6
+
7
+ export function createCopyTool(ctx) {
8
+ const { getClient, guarded, resolve } = ctx;
9
+ return {
10
+ name: "zspace_copy",
11
+ description: "Copy files or directories into another directory on the 极空间 / ZSpace NAS (server-side copy, no download).",
12
+ parameters: {
13
+ paths: { type: "array", required: true, items: { type: "string" }, description: "Source paths." },
14
+ to: { type: "string", required: true, description: "Destination directory." },
15
+ },
16
+ outputSchema: {
17
+ type: "object",
18
+ additionalProperties: false,
19
+ properties: {
20
+ ok: { type: "boolean", required: true },
21
+ count: { type: "integer", required: true },
22
+ to: { type: "string", required: true },
23
+ },
24
+ },
25
+ call: (args) => ({ card: "generic", title: `zspace_copy ${(args.paths ?? []).length} 项`, kind: "edit" }),
26
+ render: (_args, value) => [{ type: "text", text: `已复制 ${value.count} 项 → ${value.to}` }],
27
+ execute: (args) =>
28
+ guarded(async () => {
29
+ const client = getClient();
30
+ const sources = await Promise.all((args.paths ?? []).map(item => resolve(item)));
31
+ if (sources.length === 0) throw new Error("zspace_copy: `paths` must not be empty");
32
+ const destination = await resolve(args.to);
33
+ await client.copy(sources, destination);
34
+ return { ok: true, count: sources.length, to: destination };
35
+ }),
36
+ };
37
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `zspace_download` — one atomic capability.
3
+ *
4
+ * @module dsh-zspace/tools
5
+ */
6
+
7
+ import path from "node:path";
8
+ import { humanSize } from "../format.js";
9
+
10
+ export function createDownloadTool(ctx) {
11
+ const { config, downloadDir, expandLocalPath, getClient, guarded, resolve } = ctx;
12
+ return {
13
+ name: "zspace_download",
14
+ description:
15
+ "Download one file from the 极空间 / ZSpace NAS to this machine (跨网络可用). Returns the absolute local path. Default local directory: " +
16
+ `${config.downloadDir || "~/Downloads/zspace"}.`,
17
+ parameters: {
18
+ path: { type: "string", required: true, description: "Remote file path." },
19
+ dir: { type: "string", description: "Local destination directory. Pass an absolute path or `~/…` (a plain relative path resolves against the DSH host process directory). Default: the configured download directory." },
20
+ name: { type: "string", description: "Local file name override (default: the remote base name)." },
21
+ },
22
+ outputSchema: {
23
+ type: "object",
24
+ additionalProperties: false,
25
+ properties: {
26
+ remotePath: { type: "string", required: true },
27
+ localPath: { type: "string", required: true },
28
+ bytes: { type: "integer", required: true },
29
+ },
30
+ },
31
+ call: (args) => ({ card: "generic", title: `zspace_download ${args.path}`, kind: "edit" }),
32
+ render: (_args, value) => [
33
+ { type: "text", text: `已下载 ${value.remotePath} → ${value.localPath}(${humanSize(value.bytes)})` },
34
+ ],
35
+ execute: (args) =>
36
+ guarded(async () => {
37
+ const client = getClient();
38
+ const target = await resolve(args.path);
39
+ const localDir = expandLocalPath(args.dir) || downloadDir();
40
+ const result = await client.download(target, localDir, { name: args.name });
41
+ return { remotePath: target, localPath: result.localPath, bytes: result.bytes };
42
+ }),
43
+ };
44
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `zspace_find` — one atomic capability.
3
+ *
4
+ * @module dsh-zspace/tools
5
+ */
6
+
7
+ export function createFindTool(ctx) {
8
+ const { clampInt, entryLine, findByName, getClient, guarded, resolve } = ctx;
9
+ return {
10
+ name: "zspace_find",
11
+ description:
12
+ "Find files or folders by name inside the 极空间 / ZSpace tree (case-insensitive substring). It scans directories, so it is bounded: pass `path` to narrow the search, and raise `depth` / `scanLimit` when you need more coverage. The result reports how many entries were inspected and whether the scan stopped early — always mention that when reporting a negative result.",
13
+ parameters: {
14
+ keyword: { type: "string", required: true, description: "Name fragment to look for (case-insensitive)." },
15
+ path: { type: "string", description: "Directory to scan (absolute, home:/, public:/, or relative). Default: scan both the personal space and the public space." },
16
+ depth: { type: "integer", description: "Maximum recursion depth, 1-8 (default 4)." },
17
+ limit: { type: "integer", description: "Maximum matches to return, 1-500 (default 50)." },
18
+ scanLimit: { type: "integer", description: "Maximum entries to inspect, 1-20000 (default 2000)." },
19
+ },
20
+ outputSchema: {
21
+ type: "object",
22
+ additionalProperties: false,
23
+ properties: {
24
+ keyword: { type: "string", required: true },
25
+ roots: { type: "array", required: true, items: { type: "string" } },
26
+ matches: {
27
+ type: "array",
28
+ required: true,
29
+ items: {
30
+ type: "object",
31
+ additionalProperties: false,
32
+ properties: {
33
+ name: { type: "string", required: true },
34
+ path: { type: "string", required: true },
35
+ dir: { type: "boolean", required: true },
36
+ size: { type: "integer", required: true },
37
+ modified: { type: "string", required: true },
38
+ },
39
+ },
40
+ },
41
+ scanned: { type: "integer", required: true },
42
+ truncated: { type: "boolean", required: true },
43
+ },
44
+ },
45
+ call: (args) => ({ card: "generic", title: `zspace_find ${args.keyword}`, kind: "search" }),
46
+ render: (_args, value) => {
47
+ const scope = value.roots.join("、");
48
+ const tail = value.truncated ? `(已扫 ${value.scanned} 项后停止,结果可能不完整)` : `(已扫 ${value.scanned} 项)`;
49
+ return [
50
+ {
51
+ type: "text",
52
+ text:
53
+ value.matches.length === 0
54
+ ? `在 ${scope} 中没有找到名称含「${value.keyword}」的文件 ${tail}`
55
+ : `${value.matches.map(entry => entryLine(entry)).join("\n")}\n共 ${value.matches.length} 项,范围 ${scope} ${tail}`,
56
+ },
57
+ ];
58
+ },
59
+ execute: (args) =>
60
+ guarded(async () => {
61
+ const client = getClient();
62
+ const keyword = String(args.keyword ?? "").trim();
63
+ if (keyword === "") throw new Error("zspace_find: `keyword` must not be blank");
64
+ const roots = args.path ? [await resolve(args.path)] : [await client.homePath(), await client.publicPath()];
65
+ const { matches, scanned, truncated } = await findByName({
66
+ client,
67
+ roots,
68
+ keyword,
69
+ depth: clampInt(args.depth, 4, 1, 8),
70
+ limit: clampInt(args.limit, 50, 1, 500),
71
+ scanLimit: clampInt(args.scanLimit, 2000, 1, 20_000),
72
+ });
73
+ return { keyword, roots, matches, scanned, truncated };
74
+ }),
75
+ };
76
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `zspace_ls` — one atomic capability.
3
+ *
4
+ * @module dsh-zspace/tools
5
+ */
6
+
7
+ export function createLsTool(ctx) {
8
+ const { clampInt, config, entryLine, getClient, guarded, resolve, walkDirectory } = ctx;
9
+ return {
10
+ name: "zspace_ls",
11
+ description:
12
+ "List a 极空间 / ZSpace NAS directory (跨网络可用). `path` accepts an absolute NAS path, `home:` / `public:` prefixes, or a path relative to the personal space; omit it for the personal-space root. Set `depth` > 1 to walk a subtree. Large directories are paged automatically.",
13
+ parameters: {
14
+ path: { type: "string", description: "Remote directory. Absolute (/<pool>/my/data/…), home:/…, public:/…, or relative to the personal space. Omit for the personal-space root." },
15
+ depth: { type: "integer", description: "How many levels to list, 1-6 (default 1)." },
16
+ showHidden: { type: "boolean", description: "Include hidden entries (default false)." },
17
+ limit: { type: "integer", description: `Maximum entries to return (default 200, cap ${config.listMaxEntries}).` },
18
+ },
19
+ outputSchema: {
20
+ type: "object",
21
+ additionalProperties: false,
22
+ properties: {
23
+ root: { type: "string", required: true },
24
+ entries: {
25
+ type: "array",
26
+ required: true,
27
+ items: {
28
+ type: "object",
29
+ additionalProperties: false,
30
+ properties: {
31
+ name: { type: "string", required: true },
32
+ path: { type: "string", required: true },
33
+ dir: { type: "boolean", required: true },
34
+ size: { type: "integer", required: true },
35
+ modified: { type: "string", required: true },
36
+ depth: { type: "integer", required: true },
37
+ },
38
+ },
39
+ },
40
+ count: { type: "integer", required: true },
41
+ truncated: { type: "boolean", required: true },
42
+ },
43
+ },
44
+ call: (args) => ({ card: "generic", title: `zspace_ls ${args.path ?? "home:"}`, kind: "search" }),
45
+ render: (_args, value) => {
46
+ const lines = value.entries.map(entry => entryLine(entry, true));
47
+ const tail = value.truncated ? `\n… 结果被截断(只显示前 ${value.count} 项,可提高 limit 或缩小范围)` : "";
48
+ return [
49
+ {
50
+ type: "text",
51
+ text: `${value.root}(${value.count} 项)\n${lines.join("\n") || "(空目录)"}${tail}`,
52
+ },
53
+ ];
54
+ },
55
+ presentationMeta: (_args, value) => ({ count: value.count }),
56
+ execute: (args) =>
57
+ guarded(async () => {
58
+ const client = getClient();
59
+ const root = await resolve(args.path);
60
+ const depth = clampInt(args.depth, 1, 1, 6);
61
+ const budget = clampInt(args.limit, 200, 1, config.listMaxEntries);
62
+ const { entries, truncated } = await walkDirectory({
63
+ client,
64
+ root,
65
+ depth,
66
+ showHidden: args.showHidden === true,
67
+ budget,
68
+ });
69
+ return { root, entries, count: entries.length, truncated };
70
+ }),
71
+ };
72
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `zspace_mkdir` — one atomic capability.
3
+ *
4
+ * @module dsh-zspace/tools
5
+ */
6
+
7
+ export function createMkdirTool(ctx) {
8
+ const { getClient, guarded, resolve } = ctx;
9
+ return {
10
+ name: "zspace_mkdir",
11
+ description: "Create one directory on the 极空间 / ZSpace NAS.",
12
+ parameters: {
13
+ path: { type: "string", required: true, description: "Remote directory to create (absolute, home:/, public:/, or relative to the personal space)." },
14
+ },
15
+ outputSchema: {
16
+ type: "object",
17
+ additionalProperties: false,
18
+ properties: {
19
+ ok: { type: "boolean", required: true },
20
+ path: { type: "string", required: true },
21
+ },
22
+ },
23
+ call: (args) => ({ card: "generic", title: `zspace_mkdir ${args.path}`, kind: "edit" }),
24
+ render: (_args, value) => [{ type: "text", text: `已创建目录 ${value.path}` }],
25
+ execute: (args) =>
26
+ guarded(async () => {
27
+ const client = getClient();
28
+ const target = await resolve(args.path);
29
+ if (target === "/") throw new Error("zspace_mkdir: 不能创建根目录");
30
+ await client.mkdir(target);
31
+ return { ok: true, path: target };
32
+ }),
33
+ };
34
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `zspace_move` — one atomic capability.
3
+ *
4
+ * @module dsh-zspace/tools
5
+ */
6
+
7
+ export function createMoveTool(ctx) {
8
+ const { getClient, guarded, resolve } = ctx;
9
+ return {
10
+ name: "zspace_move",
11
+ description: "Move files or directories into another directory on the 极空间 / ZSpace NAS.",
12
+ parameters: {
13
+ paths: { type: "array", required: true, items: { type: "string" }, description: "Source paths (absolute, home:/, public:/, or relative)." },
14
+ to: { type: "string", required: true, description: "Destination directory." },
15
+ },
16
+ outputSchema: {
17
+ type: "object",
18
+ additionalProperties: false,
19
+ properties: {
20
+ ok: { type: "boolean", required: true },
21
+ count: { type: "integer", required: true },
22
+ to: { type: "string", required: true },
23
+ },
24
+ },
25
+ call: (args) => ({ card: "generic", title: `zspace_move ${(args.paths ?? []).length} 项`, kind: "edit" }),
26
+ render: (_args, value) => [{ type: "text", text: `已移动 ${value.count} 项 → ${value.to}` }],
27
+ execute: (args) =>
28
+ guarded(async () => {
29
+ const client = getClient();
30
+ const sources = await Promise.all((args.paths ?? []).map(item => resolve(item)));
31
+ if (sources.length === 0) throw new Error("zspace_move: `paths` must not be empty");
32
+ const destination = await resolve(args.to);
33
+ await client.move(sources, destination);
34
+ return { ok: true, count: sources.length, to: destination };
35
+ }),
36
+ };
37
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Path conventions: remote prefixes, remote resolution, local path expansion.
3
+ *
4
+ * @module dsh-zspace/tools
5
+ */
6
+
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { joinRemote } from "../format.js";
10
+
11
+ const HOME_PREFIXES = ["home:", "my:", "个人空间:"];
12
+ const PUBLIC_PREFIXES = ["public:", "share:", "公共空间:"];
13
+
14
+ /**
15
+ * Collapse redundant slashes and strip a trailing slash (except for root).
16
+ *
17
+ * @param {string} remotePath - raw remote path.
18
+ * @returns {string} normalized path.
19
+ */
20
+ export function normalizeRemote(remotePath) {
21
+ const collapsed = String(remotePath).replace(/\/{2,}/g, "/");
22
+ if (collapsed.length > 1 && collapsed.endsWith("/")) return collapsed.replace(/\/+$/, "");
23
+ return collapsed;
24
+ }
25
+
26
+ /**
27
+ * Build the remote-path resolver for one plugin instance.
28
+ *
29
+ * Accepted forms:
30
+ * - empty / omitted → personal-space root
31
+ * - `home:` / `my:` → personal space
32
+ * - `public:` / `share:` → public space
33
+ * - a relative path → resolved against the personal space
34
+ * - an absolute `/...` path → used as-is
35
+ *
36
+ * @param {{client: import("./client.js").ZSpaceClient}} context - plugin context.
37
+ * @returns {(input?: string) => Promise<string>} resolver.
38
+ */
39
+ export function createResolver({ client }) {
40
+ return async (input) => {
41
+ const raw = String(input ?? "").trim();
42
+ if (raw === "") return await client.homePath();
43
+ if (raw.startsWith("/")) return normalizeRemote(raw);
44
+ for (const prefix of HOME_PREFIXES) {
45
+ if (raw.toLowerCase().startsWith(prefix)) {
46
+ return normalizeRemote(joinRemote(await client.homePath(), raw.slice(prefix.length)));
47
+ }
48
+ }
49
+ for (const prefix of PUBLIC_PREFIXES) {
50
+ if (raw.toLowerCase().startsWith(prefix)) {
51
+ return normalizeRemote(joinRemote(await client.publicPath(), raw.slice(prefix.length)));
52
+ }
53
+ }
54
+ return normalizeRemote(joinRemote(await client.homePath(), raw));
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Resolve a local path argument.
60
+ *
61
+ * The plugin runs inside the DSH host process, so a relative path resolves
62
+ * against that process's working directory — **not** the agent's session
63
+ * workspace. `~/` is expanded so a model can still say `~/Downloads`.
64
+ *
65
+ * @param {unknown} input - caller-provided path.
66
+ * @returns {string} absolute local path ("" for blank input).
67
+ */
68
+ export function expandLocalPath(input) {
69
+ const raw = String(input ?? "").trim();
70
+ if (raw === "") return "";
71
+ if (raw === "~") return os.homedir();
72
+ if (raw.startsWith("~/")) return path.join(os.homedir(), raw.slice(2));
73
+ return path.resolve(raw);
74
+ }
75
+
76
+ /**
77
+ * Default local directory for downloads when none is configured.
78
+ *
79
+ * @param {Record<string, any>} config - plugin config.
80
+ * @returns {string} absolute local directory.
81
+ */
82
+ export function defaultDownloadDir(config) {
83
+ return config.downloadDir || path.join(os.homedir(), "Downloads", "zspace");
84
+ }