zspace-cli 0.1.0__py3-none-any.whl
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.
- zspace_cli/__init__.py +6 -0
- zspace_cli/auth.py +61 -0
- zspace_cli/cli.py +271 -0
- zspace_cli/client.py +220 -0
- zspace_cli/mcp_server.py +286 -0
- zspace_cli-0.1.0.dist-info/METADATA +291 -0
- zspace_cli-0.1.0.dist-info/RECORD +11 -0
- zspace_cli-0.1.0.dist-info/WHEEL +5 -0
- zspace_cli-0.1.0.dist-info/entry_points.txt +3 -0
- zspace_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- zspace_cli-0.1.0.dist-info/top_level.txt +1 -0
zspace_cli/__init__.py
ADDED
zspace_cli/auth.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Authentication helpers — reads credentials from the ZSpace desktop client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Credentials:
|
|
13
|
+
token: str
|
|
14
|
+
nas_id: str
|
|
15
|
+
device_id: str
|
|
16
|
+
username: str = ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_DEFAULT_CONFIG_DIR = Path.home() / "Library" / "Application Support" / "zspace"
|
|
20
|
+
_VUEX_FILENAME = "vuex.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def locate_config(config_dir: Path | str | None = None) -> Path:
|
|
24
|
+
"""Return the path to vuex.json, raising FileNotFoundError if missing."""
|
|
25
|
+
d = Path(config_dir) if config_dir else _DEFAULT_CONFIG_DIR
|
|
26
|
+
vuex = d / _VUEX_FILENAME
|
|
27
|
+
if not vuex.exists():
|
|
28
|
+
raise FileNotFoundError(
|
|
29
|
+
f"极空间客户端配置未找到: {vuex}\n"
|
|
30
|
+
"请确认已安装并登录极空间桌面客户端。"
|
|
31
|
+
)
|
|
32
|
+
return vuex
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_credentials(config_dir: Path | str | None = None) -> Credentials:
|
|
36
|
+
"""Load auth credentials from the ZSpace desktop client config."""
|
|
37
|
+
vuex_path = locate_config(config_dir)
|
|
38
|
+
data = json.loads(vuex_path.read_text(encoding="utf-8"))
|
|
39
|
+
|
|
40
|
+
state = data.get("state", data)
|
|
41
|
+
user = state["user"]
|
|
42
|
+
nas = state["nas"]
|
|
43
|
+
app = state.get("app", {})
|
|
44
|
+
|
|
45
|
+
return Credentials(
|
|
46
|
+
token=user["token"],
|
|
47
|
+
nas_id=nas["nasId"],
|
|
48
|
+
device_id=app.get("deviceId", ""),
|
|
49
|
+
username=user.get("username", ""),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_client_running(base_url: str = "http://127.0.0.1:13579") -> bool:
|
|
54
|
+
"""Quick check if the ZSpace desktop client proxy is reachable."""
|
|
55
|
+
import httpx
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
r = httpx.get(f"{base_url}/home/", timeout=3)
|
|
59
|
+
return r.status_code < 500
|
|
60
|
+
except (httpx.ConnectError, httpx.TimeoutException):
|
|
61
|
+
return False
|
zspace_cli/cli.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Beautiful CLI for ZSpace NAS — powered by Typer + Rich."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.tree import Tree
|
|
12
|
+
from rich import box
|
|
13
|
+
|
|
14
|
+
from zspace_cli.client import ZSpaceClient, ZSpaceError
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="zs",
|
|
18
|
+
help="ZSpace NAS CLI — 在终端管理你的极空间 NAS 文件",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
rich_markup_mode="rich",
|
|
21
|
+
)
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
DEFAULT_PATH = "/sata11/my/data"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _client() -> ZSpaceClient:
|
|
28
|
+
try:
|
|
29
|
+
return ZSpaceClient()
|
|
30
|
+
except FileNotFoundError as e:
|
|
31
|
+
console.print(f"[red]✗[/red] {e}")
|
|
32
|
+
raise typer.Exit(1)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
console.print(f"[red]✗ 连接失败:[/red] {e}")
|
|
35
|
+
raise typer.Exit(1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _size_str(size: int) -> str:
|
|
39
|
+
if size >= 1 << 30:
|
|
40
|
+
return f"{size / (1 << 30):.1f} GB"
|
|
41
|
+
if size >= 1 << 20:
|
|
42
|
+
return f"{size / (1 << 20):.1f} MB"
|
|
43
|
+
if size >= 1 << 10:
|
|
44
|
+
return f"{size / (1 << 10):.1f} KB"
|
|
45
|
+
return f"{size} B"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.command()
|
|
49
|
+
def check():
|
|
50
|
+
"""检查极空间客户端连接状态"""
|
|
51
|
+
with _client() as c:
|
|
52
|
+
if not c.is_connected():
|
|
53
|
+
console.print("[red]✗ 极空间客户端代理不可达[/red]")
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
|
|
56
|
+
console.print("[green]✓ 极空间客户端已连接[/green]")
|
|
57
|
+
try:
|
|
58
|
+
pool = c.pool_info()
|
|
59
|
+
for p in pool["data"]["pool_list"]:
|
|
60
|
+
total = p["total_size"] / (1024**4)
|
|
61
|
+
free = p["free_size"] / (1024**4)
|
|
62
|
+
used_pct = (1 - free / total) * 100 if total else 0
|
|
63
|
+
console.print(
|
|
64
|
+
f" [bold]{p['name']}[/bold]: "
|
|
65
|
+
f"{total:.1f} TB 总容量, {free:.1f} TB 可用 "
|
|
66
|
+
f"([{'red' if used_pct > 80 else 'yellow' if used_pct > 60 else 'green'}]"
|
|
67
|
+
f"{used_pct:.0f}% 已用[/])"
|
|
68
|
+
)
|
|
69
|
+
except ZSpaceError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command()
|
|
74
|
+
def ls(
|
|
75
|
+
path: str = typer.Argument(DEFAULT_PATH, help="目录路径"),
|
|
76
|
+
hidden: bool = typer.Option(False, "--hidden", "-a", help="显示隐藏文件"),
|
|
77
|
+
long: bool = typer.Option(False, "--long", "-l", help="详细信息"),
|
|
78
|
+
):
|
|
79
|
+
"""列出目录内容"""
|
|
80
|
+
with _client() as c:
|
|
81
|
+
try:
|
|
82
|
+
entries = c.ls(path, show_hidden=hidden)
|
|
83
|
+
except ZSpaceError as e:
|
|
84
|
+
console.print(f"[red]✗[/red] {e}")
|
|
85
|
+
raise typer.Exit(1)
|
|
86
|
+
|
|
87
|
+
if long:
|
|
88
|
+
table = Table(box=box.SIMPLE, show_header=True, header_style="bold cyan")
|
|
89
|
+
table.add_column("类型", width=5)
|
|
90
|
+
table.add_column("大小", justify="right", width=10)
|
|
91
|
+
table.add_column("名称")
|
|
92
|
+
table.add_column("路径", style="dim")
|
|
93
|
+
for e in entries:
|
|
94
|
+
icon = "📁" if e.is_dir else "📄"
|
|
95
|
+
size = "" if e.is_dir else _size_str(e.size)
|
|
96
|
+
table.add_row(icon, size, e.name, e.path)
|
|
97
|
+
console.print(table)
|
|
98
|
+
else:
|
|
99
|
+
for e in entries:
|
|
100
|
+
if e.is_dir:
|
|
101
|
+
console.print(f" [bold blue]{e.name}/[/bold blue]")
|
|
102
|
+
else:
|
|
103
|
+
console.print(f" {e.name} [dim]{_size_str(e.size)}[/dim]")
|
|
104
|
+
|
|
105
|
+
console.print(f"\n[dim]共 {len(entries)} 项[/dim]")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def info(path: str = typer.Argument(..., help="文件或目录路径")):
|
|
110
|
+
"""查看文件/目录详细信息"""
|
|
111
|
+
with _client() as c:
|
|
112
|
+
try:
|
|
113
|
+
data = c.info(path)
|
|
114
|
+
except ZSpaceError as e:
|
|
115
|
+
console.print(f"[red]✗[/red] {e}")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
table = Table(box=box.ROUNDED, show_header=False, title=data.get("name", path))
|
|
119
|
+
table.add_column("属性", style="bold")
|
|
120
|
+
table.add_column("值")
|
|
121
|
+
table.add_row("路径", data.get("path", ""))
|
|
122
|
+
table.add_row("类型", "目录" if data.get("is_dir") == "1" else "文件")
|
|
123
|
+
if data.get("size"):
|
|
124
|
+
table.add_row("大小", _size_str(int(data["size"])))
|
|
125
|
+
if data.get("modify_time"):
|
|
126
|
+
table.add_row("修改时间", data["modify_time"])
|
|
127
|
+
if data.get("create_time"):
|
|
128
|
+
table.add_row("创建时间", data["create_time"])
|
|
129
|
+
console.print(table)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def rename(
|
|
134
|
+
path: str = typer.Argument(..., help="文件或目录路径"),
|
|
135
|
+
new_name: str = typer.Argument(..., help="新名称"),
|
|
136
|
+
):
|
|
137
|
+
"""重命名文件或目录"""
|
|
138
|
+
with _client() as c:
|
|
139
|
+
try:
|
|
140
|
+
result = c.rename(path, new_name)
|
|
141
|
+
console.print(f"[green]✓[/green] 已重命名为 [bold]{result.name}[/bold]")
|
|
142
|
+
except ZSpaceError as e:
|
|
143
|
+
console.print(f"[red]✗[/red] {e}")
|
|
144
|
+
raise typer.Exit(1)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command()
|
|
148
|
+
def mv(
|
|
149
|
+
src: str = typer.Argument(..., help="源路径"),
|
|
150
|
+
dest: str = typer.Argument(..., help="目标目录"),
|
|
151
|
+
):
|
|
152
|
+
"""移动文件或目录"""
|
|
153
|
+
with _client() as c:
|
|
154
|
+
try:
|
|
155
|
+
c.move(src, dest)
|
|
156
|
+
console.print(f"[green]✓[/green] 已移动到 [bold]{dest}[/bold]")
|
|
157
|
+
except ZSpaceError as e:
|
|
158
|
+
console.print(f"[red]✗[/red] {e}")
|
|
159
|
+
raise typer.Exit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.command()
|
|
163
|
+
def cp(
|
|
164
|
+
src: str = typer.Argument(..., help="源路径"),
|
|
165
|
+
dest: str = typer.Argument(..., help="目标目录"),
|
|
166
|
+
):
|
|
167
|
+
"""复制文件或目录"""
|
|
168
|
+
with _client() as c:
|
|
169
|
+
try:
|
|
170
|
+
c.copy(src, dest)
|
|
171
|
+
console.print(f"[green]✓[/green] 复制到 [bold]{dest}[/bold]")
|
|
172
|
+
except ZSpaceError as e:
|
|
173
|
+
console.print(f"[red]✗[/red] {e}")
|
|
174
|
+
raise typer.Exit(1)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.command()
|
|
178
|
+
def mkdir(
|
|
179
|
+
parent: str = typer.Argument(..., help="父目录路径"),
|
|
180
|
+
name: str = typer.Argument(..., help="新目录名"),
|
|
181
|
+
):
|
|
182
|
+
"""创建新目录"""
|
|
183
|
+
with _client() as c:
|
|
184
|
+
try:
|
|
185
|
+
result = c.mkdir(parent, name)
|
|
186
|
+
console.print(f"[green]✓[/green] 已创建 [bold]{result.path}[/bold]")
|
|
187
|
+
except ZSpaceError as e:
|
|
188
|
+
console.print(f"[red]✗[/red] {e}")
|
|
189
|
+
raise typer.Exit(1)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def rm(
|
|
194
|
+
path: str = typer.Argument(..., help="要删除的路径"),
|
|
195
|
+
force: bool = typer.Option(False, "--force", "-f", help="跳过确认"),
|
|
196
|
+
):
|
|
197
|
+
"""删除文件或目录"""
|
|
198
|
+
if not force:
|
|
199
|
+
confirm = typer.confirm(f"确定要删除 {path}?")
|
|
200
|
+
if not confirm:
|
|
201
|
+
raise typer.Abort()
|
|
202
|
+
|
|
203
|
+
with _client() as c:
|
|
204
|
+
try:
|
|
205
|
+
c.remove(path)
|
|
206
|
+
console.print(f"[green]✓[/green] 已删除")
|
|
207
|
+
except ZSpaceError as e:
|
|
208
|
+
console.print(f"[red]✗[/red] {e}")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.command()
|
|
213
|
+
def find(
|
|
214
|
+
keyword: str = typer.Argument(..., help="搜索关键词"),
|
|
215
|
+
path: str = typer.Argument(DEFAULT_PATH, help="搜索目录"),
|
|
216
|
+
):
|
|
217
|
+
"""搜索文件名"""
|
|
218
|
+
with _client() as c:
|
|
219
|
+
try:
|
|
220
|
+
results = c.search(keyword, path)
|
|
221
|
+
except ZSpaceError as e:
|
|
222
|
+
console.print(f"[red]✗[/red] {e}")
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
if not results:
|
|
226
|
+
console.print(f"[yellow]未找到匹配 '{keyword}' 的文件[/yellow]")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
for e in results:
|
|
230
|
+
icon = "📁" if e.is_dir else "📄"
|
|
231
|
+
console.print(f" {icon} {e.path}")
|
|
232
|
+
console.print(f"\n[dim]找到 {len(results)} 项[/dim]")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def tree(
|
|
237
|
+
path: str = typer.Argument(DEFAULT_PATH, help="根目录"),
|
|
238
|
+
depth: int = typer.Option(2, "--depth", "-d", help="递归深度"),
|
|
239
|
+
):
|
|
240
|
+
"""树形显示目录结构"""
|
|
241
|
+
with _client() as c:
|
|
242
|
+
try:
|
|
243
|
+
nodes = c.tree(path, max_depth=depth)
|
|
244
|
+
except ZSpaceError as e:
|
|
245
|
+
console.print(f"[red]✗[/red] {e}")
|
|
246
|
+
raise typer.Exit(1)
|
|
247
|
+
|
|
248
|
+
root_name = path.rsplit("/", 1)[-1] or path
|
|
249
|
+
rich_tree = Tree(f"[bold]{root_name}/[/bold]")
|
|
250
|
+
_build_rich_tree(rich_tree, nodes, 0)
|
|
251
|
+
console.print(rich_tree)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _build_rich_tree(parent: Tree, nodes: list[dict], depth: int) -> None:
|
|
255
|
+
"""Convert flat node list (with depth) into a Rich Tree."""
|
|
256
|
+
stack: list[tuple[Tree, int]] = [(parent, -1)]
|
|
257
|
+
for node in nodes:
|
|
258
|
+
d = node["depth"]
|
|
259
|
+
while stack and stack[-1][1] >= d:
|
|
260
|
+
stack.pop()
|
|
261
|
+
current_parent = stack[-1][0] if stack else parent
|
|
262
|
+
if node["is_dir"]:
|
|
263
|
+
label = f"[bold blue]{node['name']}/[/bold blue]"
|
|
264
|
+
else:
|
|
265
|
+
label = f"{node['name']} [dim]{_size_str(node.get('size', 0))}[/dim]"
|
|
266
|
+
branch = current_parent.add(label)
|
|
267
|
+
stack.append((branch, d))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
if __name__ == "__main__":
|
|
271
|
+
app()
|
zspace_cli/client.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Core SDK client for the ZSpace NAS API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import random
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from zspace_cli.auth import Credentials, check_client_running, load_credentials
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FileEntry:
|
|
18
|
+
name: str
|
|
19
|
+
path: str
|
|
20
|
+
is_dir: bool
|
|
21
|
+
size: int = 0
|
|
22
|
+
modify_time: str = ""
|
|
23
|
+
create_time: str = ""
|
|
24
|
+
ext: str = ""
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_api(cls, d: dict[str, Any]) -> FileEntry:
|
|
28
|
+
return cls(
|
|
29
|
+
name=d["name"],
|
|
30
|
+
path=d["path"],
|
|
31
|
+
is_dir=d.get("is_dir", "0") == "1",
|
|
32
|
+
size=int(d.get("size", 0)),
|
|
33
|
+
modify_time=d.get("modify_time", ""),
|
|
34
|
+
create_time=d.get("create_time", ""),
|
|
35
|
+
ext=d.get("ext", ""),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ZSpaceError(Exception):
|
|
40
|
+
"""Raised when the ZSpace API returns a non-200 code."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, code: str, msg: str):
|
|
43
|
+
self.code = code
|
|
44
|
+
self.msg = msg
|
|
45
|
+
super().__init__(f"[{code}] {msg}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ZSpaceClient:
|
|
49
|
+
"""High-level client for ZSpace NAS file operations.
|
|
50
|
+
|
|
51
|
+
Connects through the local desktop client proxy (127.0.0.1:13579).
|
|
52
|
+
All operations are pure API calls — no SSH or WebDAV needed.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
base_url: str = "http://127.0.0.1:13579",
|
|
58
|
+
credentials: Credentials | None = None,
|
|
59
|
+
config_dir: Path | str | None = None,
|
|
60
|
+
):
|
|
61
|
+
self.base_url = base_url.rstrip("/")
|
|
62
|
+
self._creds = credentials or load_credentials(config_dir)
|
|
63
|
+
self._http = httpx.Client(
|
|
64
|
+
base_url=self.base_url,
|
|
65
|
+
cookies={"token": self._creds.token},
|
|
66
|
+
timeout=30,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
self._http.close()
|
|
71
|
+
|
|
72
|
+
def __enter__(self) -> ZSpaceClient:
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def __exit__(self, *exc: Any) -> None:
|
|
76
|
+
self.close()
|
|
77
|
+
|
|
78
|
+
# ── internal ──
|
|
79
|
+
|
|
80
|
+
def _common_params(self) -> dict[str, str]:
|
|
81
|
+
return {
|
|
82
|
+
"token": self._creds.token,
|
|
83
|
+
"nasid": self._creds.nas_id,
|
|
84
|
+
"plat": "web",
|
|
85
|
+
"version": "2.3.2026042401",
|
|
86
|
+
"device_id": self._creds.device_id,
|
|
87
|
+
"_l": "zh_cn",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def _url(self, endpoint: str) -> str:
|
|
91
|
+
rnd = f"{int(time.time())}{random.randint(1000,9999)}_{random.randint(1000,9999)}"
|
|
92
|
+
return f"{endpoint}?&rnd={rnd}&webagent=v2"
|
|
93
|
+
|
|
94
|
+
def _post(self, endpoint: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
95
|
+
data = self._common_params()
|
|
96
|
+
if extra:
|
|
97
|
+
data.update(extra)
|
|
98
|
+
resp = self._http.post(
|
|
99
|
+
self._url(endpoint),
|
|
100
|
+
data=data,
|
|
101
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
102
|
+
)
|
|
103
|
+
resp.raise_for_status()
|
|
104
|
+
body = resp.json()
|
|
105
|
+
if body.get("code") != "200":
|
|
106
|
+
raise ZSpaceError(body.get("code", "?"), body.get("msg", "unknown error"))
|
|
107
|
+
return body
|
|
108
|
+
|
|
109
|
+
def _post_with_array(
|
|
110
|
+
self, endpoint: str, paths: list[str], extra: dict[str, str] | None = None
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""POST with paths[] array parameter (used by move/copy/delete)."""
|
|
113
|
+
params = self._common_params()
|
|
114
|
+
if extra:
|
|
115
|
+
params.update(extra)
|
|
116
|
+
parts: list[str] = []
|
|
117
|
+
for k, v in params.items():
|
|
118
|
+
parts.append(f"{_urlencode(k)}={_urlencode(v)}")
|
|
119
|
+
for p in paths:
|
|
120
|
+
parts.append(f"paths%5B%5D={_urlencode(p)}")
|
|
121
|
+
body_str = "&".join(parts)
|
|
122
|
+
|
|
123
|
+
resp = self._http.post(
|
|
124
|
+
self._url(endpoint),
|
|
125
|
+
content=body_str,
|
|
126
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
127
|
+
)
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
body = resp.json()
|
|
130
|
+
if body.get("code") != "200":
|
|
131
|
+
raise ZSpaceError(body.get("code", "?"), body.get("msg", "unknown error"))
|
|
132
|
+
return body
|
|
133
|
+
|
|
134
|
+
# ── public API ──
|
|
135
|
+
|
|
136
|
+
def is_connected(self) -> bool:
|
|
137
|
+
"""Check if the ZSpace desktop client proxy is reachable."""
|
|
138
|
+
return check_client_running(self.base_url)
|
|
139
|
+
|
|
140
|
+
def pool_info(self) -> dict[str, Any]:
|
|
141
|
+
"""Get storage pool information."""
|
|
142
|
+
return self._post("/zspool/info")
|
|
143
|
+
|
|
144
|
+
def disk_stats(self) -> dict[str, Any]:
|
|
145
|
+
"""Get disk statistics."""
|
|
146
|
+
return self._post("/disk/statics")
|
|
147
|
+
|
|
148
|
+
def ls(self, path: str = "/sata11/my/data", show_hidden: bool = False) -> list[FileEntry]:
|
|
149
|
+
"""List directory contents."""
|
|
150
|
+
body = self._post("/v2/file/list", {
|
|
151
|
+
"path": path,
|
|
152
|
+
"show_hidden": "1" if show_hidden else "0",
|
|
153
|
+
})
|
|
154
|
+
return [FileEntry.from_api(f) for f in body["data"]["list"]]
|
|
155
|
+
|
|
156
|
+
def info(self, path: str) -> dict[str, Any]:
|
|
157
|
+
"""Get detailed file/directory info."""
|
|
158
|
+
return self._post("/v2/file/info", {"path": path})["data"]
|
|
159
|
+
|
|
160
|
+
def rename(self, path: str, new_name: str) -> FileEntry:
|
|
161
|
+
"""Rename a file or directory."""
|
|
162
|
+
body = self._post("/v2/file/modify", {"path": path, "newname": new_name})
|
|
163
|
+
return FileEntry.from_api(body["data"])
|
|
164
|
+
|
|
165
|
+
def mkdir(self, parent: str, name: str) -> FileEntry:
|
|
166
|
+
"""Create a new directory."""
|
|
167
|
+
body = self._post("/v2/file/newdir", {"parent": parent, "name": name, "rename": "0"})
|
|
168
|
+
return FileEntry.from_api(body["data"])
|
|
169
|
+
|
|
170
|
+
def move(self, paths: list[str] | str, to: str) -> dict[str, Any]:
|
|
171
|
+
"""Move files/directories to a destination directory."""
|
|
172
|
+
if isinstance(paths, str):
|
|
173
|
+
paths = [paths]
|
|
174
|
+
return self._post_with_array("/v2/file/move", paths, {"to": to})
|
|
175
|
+
|
|
176
|
+
def copy(self, paths: list[str] | str, to: str) -> dict[str, Any]:
|
|
177
|
+
"""Copy files/directories to a destination directory."""
|
|
178
|
+
if isinstance(paths, str):
|
|
179
|
+
paths = [paths]
|
|
180
|
+
return self._post_with_array("/v2/file/copy", paths, {"to": to})
|
|
181
|
+
|
|
182
|
+
def remove(self, paths: list[str] | str) -> dict[str, Any]:
|
|
183
|
+
"""Delete files/directories (moves to trash)."""
|
|
184
|
+
if isinstance(paths, str):
|
|
185
|
+
paths = [paths]
|
|
186
|
+
return self._post_with_array("/v2/file/remove", paths)
|
|
187
|
+
|
|
188
|
+
def search(self, keyword: str, path: str = "/sata11/my/data") -> list[FileEntry]:
|
|
189
|
+
"""Search for files by name within a directory (client-side filtering)."""
|
|
190
|
+
entries = self.ls(path, show_hidden=True)
|
|
191
|
+
kw = keyword.lower()
|
|
192
|
+
return [e for e in entries if kw in e.name.lower()]
|
|
193
|
+
|
|
194
|
+
def tree(self, path: str = "/sata11/my/data", max_depth: int = 2) -> list[dict[str, Any]]:
|
|
195
|
+
"""Recursively list directory structure up to max_depth."""
|
|
196
|
+
result: list[dict[str, Any]] = []
|
|
197
|
+
self._tree_walk(path, 0, max_depth, result)
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
def _tree_walk(
|
|
201
|
+
self, path: str, depth: int, max_depth: int, acc: list[dict[str, Any]]
|
|
202
|
+
) -> None:
|
|
203
|
+
if depth >= max_depth:
|
|
204
|
+
return
|
|
205
|
+
try:
|
|
206
|
+
entries = self.ls(path)
|
|
207
|
+
except ZSpaceError:
|
|
208
|
+
return
|
|
209
|
+
for e in entries:
|
|
210
|
+
node: dict[str, Any] = {"name": e.name, "path": e.path, "is_dir": e.is_dir, "depth": depth}
|
|
211
|
+
if not e.is_dir:
|
|
212
|
+
node["size"] = e.size
|
|
213
|
+
acc.append(node)
|
|
214
|
+
if e.is_dir:
|
|
215
|
+
self._tree_walk(e.path, depth + 1, max_depth, acc)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _urlencode(s: str) -> str:
|
|
219
|
+
from urllib.parse import quote
|
|
220
|
+
return quote(str(s), safe="")
|
zspace_cli/mcp_server.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""MCP Server for ZSpace NAS — expose file operations as MCP tools.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m zspace_cli.mcp_server
|
|
5
|
+
# or via the CLI:
|
|
6
|
+
zs-mcp
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mcp.server import Server
|
|
15
|
+
from mcp.server.stdio import stdio_server
|
|
16
|
+
from mcp.types import TextContent, Tool
|
|
17
|
+
|
|
18
|
+
from zspace_cli.client import ZSpaceClient, ZSpaceError
|
|
19
|
+
|
|
20
|
+
server = Server("zspace-nas")
|
|
21
|
+
|
|
22
|
+
TOOLS = [
|
|
23
|
+
Tool(
|
|
24
|
+
name="zspace_check",
|
|
25
|
+
description="检查极空间 NAS 连接状态和存储信息 / Check ZSpace NAS connection status and storage info",
|
|
26
|
+
inputSchema={"type": "object", "properties": {}, "required": []},
|
|
27
|
+
),
|
|
28
|
+
Tool(
|
|
29
|
+
name="zspace_ls",
|
|
30
|
+
description="列出极空间 NAS 目录内容 / List directory contents on ZSpace NAS",
|
|
31
|
+
inputSchema={
|
|
32
|
+
"type": "object",
|
|
33
|
+
"properties": {
|
|
34
|
+
"path": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "目录路径,如 /sata11/my/data",
|
|
37
|
+
"default": "/sata11/my/data",
|
|
38
|
+
},
|
|
39
|
+
"show_hidden": {"type": "boolean", "default": False},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
),
|
|
43
|
+
Tool(
|
|
44
|
+
name="zspace_info",
|
|
45
|
+
description="查看文件或目录详细信息 / Get detailed file/directory info",
|
|
46
|
+
inputSchema={
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"path": {"type": "string", "description": "文件或目录路径"},
|
|
50
|
+
},
|
|
51
|
+
"required": ["path"],
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
Tool(
|
|
55
|
+
name="zspace_rename",
|
|
56
|
+
description="重命名文件或目录 / Rename a file or directory",
|
|
57
|
+
inputSchema={
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"path": {"type": "string", "description": "文件/目录路径"},
|
|
61
|
+
"new_name": {"type": "string", "description": "新名称"},
|
|
62
|
+
},
|
|
63
|
+
"required": ["path", "new_name"],
|
|
64
|
+
},
|
|
65
|
+
),
|
|
66
|
+
Tool(
|
|
67
|
+
name="zspace_mkdir",
|
|
68
|
+
description="在极空间 NAS 上创建新目录 / Create a new directory",
|
|
69
|
+
inputSchema={
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {
|
|
72
|
+
"parent": {"type": "string", "description": "父目录路径"},
|
|
73
|
+
"name": {"type": "string", "description": "新目录名"},
|
|
74
|
+
},
|
|
75
|
+
"required": ["parent", "name"],
|
|
76
|
+
},
|
|
77
|
+
),
|
|
78
|
+
Tool(
|
|
79
|
+
name="zspace_move",
|
|
80
|
+
description="移动文件或目录 / Move files or directories",
|
|
81
|
+
inputSchema={
|
|
82
|
+
"type": "object",
|
|
83
|
+
"properties": {
|
|
84
|
+
"paths": {
|
|
85
|
+
"oneOf": [
|
|
86
|
+
{"type": "string"},
|
|
87
|
+
{"type": "array", "items": {"type": "string"}},
|
|
88
|
+
],
|
|
89
|
+
"description": "源路径(单个字符串或数组)",
|
|
90
|
+
},
|
|
91
|
+
"to": {"type": "string", "description": "目标目录"},
|
|
92
|
+
},
|
|
93
|
+
"required": ["paths", "to"],
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
Tool(
|
|
97
|
+
name="zspace_copy",
|
|
98
|
+
description="复制文件或目录 / Copy files or directories",
|
|
99
|
+
inputSchema={
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
"paths": {
|
|
103
|
+
"oneOf": [
|
|
104
|
+
{"type": "string"},
|
|
105
|
+
{"type": "array", "items": {"type": "string"}},
|
|
106
|
+
],
|
|
107
|
+
"description": "源路径(单个字符串或数组)",
|
|
108
|
+
},
|
|
109
|
+
"to": {"type": "string", "description": "目标目录"},
|
|
110
|
+
},
|
|
111
|
+
"required": ["paths", "to"],
|
|
112
|
+
},
|
|
113
|
+
),
|
|
114
|
+
Tool(
|
|
115
|
+
name="zspace_remove",
|
|
116
|
+
description="删除文件或目录 / Delete files or directories",
|
|
117
|
+
inputSchema={
|
|
118
|
+
"type": "object",
|
|
119
|
+
"properties": {
|
|
120
|
+
"paths": {
|
|
121
|
+
"oneOf": [
|
|
122
|
+
{"type": "string"},
|
|
123
|
+
{"type": "array", "items": {"type": "string"}},
|
|
124
|
+
],
|
|
125
|
+
"description": "要删除的路径(单个字符串或数组)",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
"required": ["paths"],
|
|
129
|
+
},
|
|
130
|
+
),
|
|
131
|
+
Tool(
|
|
132
|
+
name="zspace_search",
|
|
133
|
+
description="按文件名搜索 / Search files by name",
|
|
134
|
+
inputSchema={
|
|
135
|
+
"type": "object",
|
|
136
|
+
"properties": {
|
|
137
|
+
"keyword": {"type": "string", "description": "搜索关键词"},
|
|
138
|
+
"path": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"description": "搜索目录",
|
|
141
|
+
"default": "/sata11/my/data",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
"required": ["keyword"],
|
|
145
|
+
},
|
|
146
|
+
),
|
|
147
|
+
Tool(
|
|
148
|
+
name="zspace_tree",
|
|
149
|
+
description="树形展示目录结构 / Show directory tree",
|
|
150
|
+
inputSchema={
|
|
151
|
+
"type": "object",
|
|
152
|
+
"properties": {
|
|
153
|
+
"path": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"description": "根目录",
|
|
156
|
+
"default": "/sata11/my/data",
|
|
157
|
+
},
|
|
158
|
+
"depth": {"type": "integer", "default": 2, "description": "递归深度"},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
),
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _ok(data: Any) -> list[TextContent]:
|
|
166
|
+
return [TextContent(type="text", text=json.dumps(data, ensure_ascii=False, indent=2))]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _err(e: Exception) -> list[TextContent]:
|
|
170
|
+
return [TextContent(type="text", text=f"Error: {e}")]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@server.list_tools()
|
|
174
|
+
async def list_tools() -> list[Tool]:
|
|
175
|
+
return TOOLS
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@server.call_tool()
|
|
179
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
180
|
+
try:
|
|
181
|
+
with ZSpaceClient() as c:
|
|
182
|
+
if name == "zspace_check":
|
|
183
|
+
connected = c.is_connected()
|
|
184
|
+
result: dict[str, Any] = {"connected": connected}
|
|
185
|
+
if connected:
|
|
186
|
+
try:
|
|
187
|
+
pool = c.pool_info()
|
|
188
|
+
result["pools"] = [
|
|
189
|
+
{
|
|
190
|
+
"name": p["name"],
|
|
191
|
+
"total_tb": round(p["total_size"] / (1024**4), 1),
|
|
192
|
+
"free_tb": round(p["free_size"] / (1024**4), 1),
|
|
193
|
+
}
|
|
194
|
+
for p in pool["data"]["pool_list"]
|
|
195
|
+
]
|
|
196
|
+
except ZSpaceError:
|
|
197
|
+
pass
|
|
198
|
+
return _ok(result)
|
|
199
|
+
|
|
200
|
+
elif name == "zspace_ls":
|
|
201
|
+
entries = c.ls(
|
|
202
|
+
arguments.get("path", "/sata11/my/data"),
|
|
203
|
+
show_hidden=arguments.get("show_hidden", False),
|
|
204
|
+
)
|
|
205
|
+
return _ok([
|
|
206
|
+
{
|
|
207
|
+
"name": e.name,
|
|
208
|
+
"path": e.path,
|
|
209
|
+
"is_dir": e.is_dir,
|
|
210
|
+
"size": e.size,
|
|
211
|
+
}
|
|
212
|
+
for e in entries
|
|
213
|
+
])
|
|
214
|
+
|
|
215
|
+
elif name == "zspace_info":
|
|
216
|
+
data = c.info(arguments["path"])
|
|
217
|
+
return _ok(data)
|
|
218
|
+
|
|
219
|
+
elif name == "zspace_rename":
|
|
220
|
+
result = c.rename(arguments["path"], arguments["new_name"])
|
|
221
|
+
return _ok({"name": result.name, "path": result.path})
|
|
222
|
+
|
|
223
|
+
elif name == "zspace_mkdir":
|
|
224
|
+
result = c.mkdir(arguments["parent"], arguments["name"])
|
|
225
|
+
return _ok({"name": result.name, "path": result.path})
|
|
226
|
+
|
|
227
|
+
elif name == "zspace_move":
|
|
228
|
+
paths = arguments["paths"]
|
|
229
|
+
if isinstance(paths, str):
|
|
230
|
+
paths = [paths]
|
|
231
|
+
c.move(paths, arguments["to"])
|
|
232
|
+
return _ok({"status": "moved", "paths": paths, "to": arguments["to"]})
|
|
233
|
+
|
|
234
|
+
elif name == "zspace_copy":
|
|
235
|
+
paths = arguments["paths"]
|
|
236
|
+
if isinstance(paths, str):
|
|
237
|
+
paths = [paths]
|
|
238
|
+
c.copy(paths, arguments["to"])
|
|
239
|
+
return _ok({"status": "copied", "paths": paths, "to": arguments["to"]})
|
|
240
|
+
|
|
241
|
+
elif name == "zspace_remove":
|
|
242
|
+
paths = arguments["paths"]
|
|
243
|
+
if isinstance(paths, str):
|
|
244
|
+
paths = [paths]
|
|
245
|
+
c.remove(paths)
|
|
246
|
+
return _ok({"status": "removed", "paths": paths})
|
|
247
|
+
|
|
248
|
+
elif name == "zspace_search":
|
|
249
|
+
results = c.search(
|
|
250
|
+
arguments["keyword"],
|
|
251
|
+
arguments.get("path", "/sata11/my/data"),
|
|
252
|
+
)
|
|
253
|
+
return _ok([
|
|
254
|
+
{"name": e.name, "path": e.path, "is_dir": e.is_dir}
|
|
255
|
+
for e in results
|
|
256
|
+
])
|
|
257
|
+
|
|
258
|
+
elif name == "zspace_tree":
|
|
259
|
+
nodes = c.tree(
|
|
260
|
+
arguments.get("path", "/sata11/my/data"),
|
|
261
|
+
max_depth=arguments.get("depth", 2),
|
|
262
|
+
)
|
|
263
|
+
return _ok(nodes)
|
|
264
|
+
|
|
265
|
+
else:
|
|
266
|
+
return _err(ValueError(f"Unknown tool: {name}"))
|
|
267
|
+
|
|
268
|
+
except ZSpaceError as e:
|
|
269
|
+
return _err(e)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
return _err(e)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def _async_main() -> None:
|
|
275
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
276
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def main() -> None:
|
|
280
|
+
"""Entry point for the MCP server (used by zs-mcp console script)."""
|
|
281
|
+
import asyncio
|
|
282
|
+
asyncio.run(_async_main())
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
if __name__ == "__main__":
|
|
286
|
+
main()
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zspace-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI & SDK for ZSpace (极空间) NAS — manage files from your terminal or AI agents
|
|
5
|
+
Author: skyzhao
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/skyzhao/zspace-cli
|
|
8
|
+
Project-URL: Documentation, https://github.com/skyzhao/zspace-cli#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/skyzhao/zspace-cli
|
|
10
|
+
Project-URL: Issues, https://github.com/skyzhao/zspace-cli/issues
|
|
11
|
+
Keywords: zspace,nas,cli,sdk,mcp,极空间,file-manager
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: System :: Filesystems
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: httpx>=0.24
|
|
29
|
+
Requires-Dist: typer>=0.9
|
|
30
|
+
Requires-Dist: rich>=13.0
|
|
31
|
+
Provides-Extra: mcp
|
|
32
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
36
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# zspace-cli
|
|
40
|
+
|
|
41
|
+
**CLI, SDK & MCP Server for ZSpace (极空间) NAS**
|
|
42
|
+
|
|
43
|
+
> Manage your 极空间 NAS files from the terminal, Python scripts, or AI agents — zero config, no SSH needed.
|
|
44
|
+
|
|
45
|
+
[English](#features) | [中文](#功能特性)
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Why zspace-cli?
|
|
50
|
+
|
|
51
|
+
ZSpace (极空间) is a popular NAS brand in China, but it lacks official CLI tools or developer APIs.
|
|
52
|
+
|
|
53
|
+
**zspace-cli** reverse-engineered the internal API used by the ZSpace desktop client and wraps it into:
|
|
54
|
+
|
|
55
|
+
- **`zs` CLI** — 10 commands covering all file operations, powered by [Rich](https://github.com/Textualize/rich) for beautiful output
|
|
56
|
+
- **Python SDK** — `ZSpaceClient` with a clean, typed interface for automation scripts
|
|
57
|
+
- **MCP Server** — one-line config to add ZSpace file management to Claude, Cursor, or any MCP-compatible AI agent
|
|
58
|
+
|
|
59
|
+
### Zero Configuration
|
|
60
|
+
|
|
61
|
+
Just install and run. zspace-cli reads auth tokens directly from your running ZSpace desktop client — no passwords, no SSH keys, no port forwarding.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
pip install zspace-cli
|
|
65
|
+
zs check # ✓ Connected, 11.8 TB total, 1.2 TB free
|
|
66
|
+
zs ls # List your NAS files
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Features
|
|
72
|
+
|
|
73
|
+
| Operation | CLI | SDK | MCP |
|
|
74
|
+
|-----------|-----|-----|-----|
|
|
75
|
+
| List directory | `zs ls [path]` | `client.ls(path)` | `zspace_ls` |
|
|
76
|
+
| File info | `zs info <path>` | `client.info(path)` | `zspace_info` |
|
|
77
|
+
| Rename | `zs rename <path> <name>` | `client.rename(path, name)` | `zspace_rename` |
|
|
78
|
+
| Create dir | `zs mkdir <parent> <name>` | `client.mkdir(parent, name)` | `zspace_mkdir` |
|
|
79
|
+
| Move | `zs mv <src> <dest>` | `client.move(src, dest)` | `zspace_move` |
|
|
80
|
+
| Copy | `zs cp <src> <dest>` | `client.copy(src, dest)` | `zspace_copy` |
|
|
81
|
+
| Delete | `zs rm <path>` | `client.remove(path)` | `zspace_remove` |
|
|
82
|
+
| Search | `zs find <keyword>` | `client.search(kw)` | `zspace_search` |
|
|
83
|
+
| Tree view | `zs tree [path]` | `client.tree(path)` | `zspace_tree` |
|
|
84
|
+
| Health check | `zs check` | `client.is_connected()` | `zspace_check` |
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Installation
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install zspace-cli
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Prerequisites:** ZSpace desktop client running on macOS (logged in).
|
|
95
|
+
|
|
96
|
+
### With MCP Server support
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install "zspace-cli[mcp]"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Quick Start
|
|
105
|
+
|
|
106
|
+
### CLI
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Check connection
|
|
110
|
+
zs check
|
|
111
|
+
|
|
112
|
+
# List files
|
|
113
|
+
zs ls /sata11/my/data
|
|
114
|
+
zs ls -l /sata11/my/data/影视 # detailed view
|
|
115
|
+
|
|
116
|
+
# File operations
|
|
117
|
+
zs mkdir /sata11/my/data 新建文件夹
|
|
118
|
+
zs rename /sata11/my/data/old_name new_name
|
|
119
|
+
zs mv /sata11/my/data/file.mp4 /sata11/my/data/影视
|
|
120
|
+
zs cp /sata11/my/data/important /sata11/my/data/backup
|
|
121
|
+
zs rm /sata11/my/data/temp
|
|
122
|
+
|
|
123
|
+
# Search & explore
|
|
124
|
+
zs find "权力的游戏"
|
|
125
|
+
zs tree /sata11/my/data -d 3
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Python SDK
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from zspace_cli import ZSpaceClient
|
|
132
|
+
|
|
133
|
+
with ZSpaceClient() as zs:
|
|
134
|
+
# List files
|
|
135
|
+
for f in zs.ls("/sata11/my/data"):
|
|
136
|
+
print(f"{'📁' if f.is_dir else '📄'} {f.name}")
|
|
137
|
+
|
|
138
|
+
# Batch rename videos
|
|
139
|
+
for f in zs.ls("/sata11/my/data/影视"):
|
|
140
|
+
if f.name.startswith("[raw]"):
|
|
141
|
+
new_name = f.name.replace("[raw]", "").strip()
|
|
142
|
+
zs.rename(f.path, new_name)
|
|
143
|
+
print(f"Renamed: {new_name}")
|
|
144
|
+
|
|
145
|
+
# Organize files
|
|
146
|
+
zs.mkdir("/sata11/my/data", "已整理")
|
|
147
|
+
zs.move("/sata11/my/data/散文件.pdf", "/sata11/my/data/已整理")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### MCP Server (for AI Agents)
|
|
151
|
+
|
|
152
|
+
Add to your Claude Desktop / Cursor MCP config:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"mcpServers": {
|
|
157
|
+
"zspace": {
|
|
158
|
+
"command": "zs-mcp",
|
|
159
|
+
"args": []
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Then ask your AI: "帮我把 NAS 上影视文件夹里的视频按年份整理一下"
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## How It Works
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
┌─────────────┐ ┌──────────────────┐ ┌──────────┐
|
|
173
|
+
│ zs CLI │ │ ZSpace Desktop │ │ ZSpace │
|
|
174
|
+
│ Python SDK ├─────►│ Client (proxy) ├─────►│ NAS │
|
|
175
|
+
│ MCP Server │ HTTP │ 127.0.0.1:13579 │ P2P │ Device │
|
|
176
|
+
└─────────────┘ └──────────────────┘ └──────────┘
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The ZSpace desktop client maintains an encrypted tunnel to your NAS and exposes a local HTTP proxy. zspace-cli communicates with this proxy using the same API the official web UI uses.
|
|
180
|
+
|
|
181
|
+
**No direct network access to the NAS is needed** — works even when your NAS is behind NAT or on a different network.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## API Reference
|
|
186
|
+
|
|
187
|
+
All operations go through the ZSpace internal API at `127.0.0.1:13579`.
|
|
188
|
+
|
|
189
|
+
| Endpoint | Method | Key Parameters |
|
|
190
|
+
|----------|--------|---------------|
|
|
191
|
+
| `/v2/file/list` | POST | `path`, `show_hidden` |
|
|
192
|
+
| `/v2/file/info` | POST | `path` |
|
|
193
|
+
| `/v2/file/modify` | POST | `path`, `newname` |
|
|
194
|
+
| `/v2/file/newdir` | POST | `parent` (not path!), `name`, `rename=0` |
|
|
195
|
+
| `/v2/file/move` | POST | `paths[]` (array), `to` (not dest!) |
|
|
196
|
+
| `/v2/file/copy` | POST | `paths[]` (array), `to` (not dest!) |
|
|
197
|
+
| `/v2/file/remove` | POST | `paths[]` (array) |
|
|
198
|
+
|
|
199
|
+
> **Key discovery:** The parameter names are non-standard — `newdir` uses `parent` instead of `path`, and `move`/`copy` use `to` instead of `dest`. These were found by reverse-engineering the ZSpace web UI.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Roadmap
|
|
204
|
+
|
|
205
|
+
- [ ] File upload/download support
|
|
206
|
+
- [ ] Linux client support
|
|
207
|
+
- [ ] Windows client support
|
|
208
|
+
- [ ] Docker image for headless deployment
|
|
209
|
+
- [ ] Batch operations with glob patterns
|
|
210
|
+
- [ ] File watching / sync triggers
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Contributing
|
|
215
|
+
|
|
216
|
+
Contributions are welcome! Please open an issue first to discuss what you'd like to change.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
<details>
|
|
227
|
+
<summary><h2>中文说明</h2></summary>
|
|
228
|
+
|
|
229
|
+
## 功能特性
|
|
230
|
+
|
|
231
|
+
**zspace-cli** 是极空间 NAS 的命令行工具、Python SDK 和 MCP Server。
|
|
232
|
+
|
|
233
|
+
通过逆向工程极空间桌面客户端的内部 API,实现了**完整的文件管理功能**——无需 SSH,无需 WebDAV,零配置即可使用。
|
|
234
|
+
|
|
235
|
+
### 安装
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
pip install zspace-cli
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**前提条件:** macOS 上已安装并登录极空间桌面客户端。
|
|
242
|
+
|
|
243
|
+
### 使用方法
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
# 检查连接
|
|
247
|
+
zs check
|
|
248
|
+
|
|
249
|
+
# 文件操作
|
|
250
|
+
zs ls # 列出目录
|
|
251
|
+
zs ls -l /sata11/my/data/影视 # 详细模式
|
|
252
|
+
zs info /sata11/my/data/影视 # 查看详情
|
|
253
|
+
zs mkdir /sata11/my/data 新文件夹 # 创建目录
|
|
254
|
+
zs rename /path/old new_name # 重命名
|
|
255
|
+
zs mv /path/src /path/dest # 移动
|
|
256
|
+
zs cp /path/src /path/dest # 复制
|
|
257
|
+
zs rm /path/to/delete # 删除
|
|
258
|
+
zs find "关键词" # 搜索
|
|
259
|
+
zs tree /sata11/my/data -d 3 # 树形视图
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### MCP Server 配置(AI 智能体)
|
|
263
|
+
|
|
264
|
+
在 Claude Desktop 或 Cursor 的 MCP 配置中添加:
|
|
265
|
+
|
|
266
|
+
```json
|
|
267
|
+
{
|
|
268
|
+
"mcpServers": {
|
|
269
|
+
"zspace": {
|
|
270
|
+
"command": "zs-mcp",
|
|
271
|
+
"args": []
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
配置完成后,你可以用自然语言让 AI 管理你的 NAS 文件:
|
|
278
|
+
- "帮我把影视文件夹里的电影按年份分类"
|
|
279
|
+
- "查找所有大于 10GB 的文件"
|
|
280
|
+
- "把百度网盘文件夹里的文档移到文档同步文件夹"
|
|
281
|
+
|
|
282
|
+
### 工作原理
|
|
283
|
+
|
|
284
|
+
极空间桌面客户端在本机 `127.0.0.1:13579` 建立了到 NAS 的加密代理。zspace-cli 通过这个代理调用和官方 Web UI 完全相同的 API,因此:
|
|
285
|
+
|
|
286
|
+
- 不需要 NAS 在局域网内
|
|
287
|
+
- 不需要配置 DDNS 或端口转发
|
|
288
|
+
- 不需要开启 SSH
|
|
289
|
+
- 只要桌面客户端在运行,就能操作
|
|
290
|
+
|
|
291
|
+
</details>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
zspace_cli/__init__.py,sha256=yNbNxF0I8FLon2jOntTbWpwamQSy9YKIu9rwOtFKCfc,179
|
|
2
|
+
zspace_cli/auth.py,sha256=63UE1B1qQ0Q3eRd7yLTXEJBuqPp77nGQ0S3oy-Om_B4,1768
|
|
3
|
+
zspace_cli/cli.py,sha256=_zZMmmzXnQ8bltXykD23dpxgBrdlxXQgVRw8lxVLQEo,8697
|
|
4
|
+
zspace_cli/client.py,sha256=baelFWaOhMtVvNWCXbJTRM61jK17p3iDJMwgtQorVKQ,7414
|
|
5
|
+
zspace_cli/mcp_server.py,sha256=0sZiySpcsdoKM0T4ekBlVXhlMjza5htWBX-rj1Yo9n4,9535
|
|
6
|
+
zspace_cli-0.1.0.dist-info/licenses/LICENSE,sha256=GdTKqt3IjvlxTIpk_tdIxpj7eVNl41bxIgXVn8FkP8Q,1064
|
|
7
|
+
zspace_cli-0.1.0.dist-info/METADATA,sha256=ocxzvxclKU9aaE6wlyNVCoBny1G6fscrpLf99ulObZI,8799
|
|
8
|
+
zspace_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
zspace_cli-0.1.0.dist-info/entry_points.txt,sha256=f2FlYYCsomK9zdGcnW3gE-w7E5G6Ipx8IWeCaxSE2IQ,78
|
|
10
|
+
zspace_cli-0.1.0.dist-info/top_level.txt,sha256=1tQGk75bTnkcGLdXJ0m3egDGO2KqUKmSmV6opnqgDSU,11
|
|
11
|
+
zspace_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 skyzhao
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
zspace_cli
|