rathflow-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.
- rathflow_cli/__init__.py +6 -0
- rathflow_cli/__main__.py +6 -0
- rathflow_cli/cli.py +161 -0
- rathflow_cli/commands/__init__.py +0 -0
- rathflow_cli/commands/_common.py +78 -0
- rathflow_cli/commands/admin.py +83 -0
- rathflow_cli/commands/agent.py +288 -0
- rathflow_cli/commands/api_cmd.py +75 -0
- rathflow_cli/commands/asset.py +449 -0
- rathflow_cli/commands/auth_cmd.py +160 -0
- rathflow_cli/commands/billing.py +71 -0
- rathflow_cli/commands/config_cmd.py +98 -0
- rathflow_cli/commands/memory.py +275 -0
- rathflow_cli/commands/org.py +70 -0
- rathflow_cli/commands/project.py +223 -0
- rathflow_cli/commands/sandbox.py +282 -0
- rathflow_cli/commands/session.py +237 -0
- rathflow_cli/commands/workflow.py +81 -0
- rathflow_cli/config.py +69 -0
- rathflow_cli/endpoints.py +207 -0
- rathflow_cli/errors.py +70 -0
- rathflow_cli/http.py +294 -0
- rathflow_cli/output.py +95 -0
- rathflow_cli/selftest.py +120 -0
- rathflow_cli/state.py +129 -0
- rathflow_cli-0.1.0.dist-info/METADATA +164 -0
- rathflow_cli-0.1.0.dist-info/RECORD +31 -0
- rathflow_cli-0.1.0.dist-info/WHEEL +5 -0
- rathflow_cli-0.1.0.dist-info/entry_points.txt +2 -0
- rathflow_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- rathflow_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""project:项目(租户单元)的增删改查与作用域切换。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .. import config, output
|
|
8
|
+
from ..commands._common import PAGE_SIZE_OPT, PAGE_TOKEN_OPT, BODY_FILE_OPT, BODY_OPT, body_of, pagination
|
|
9
|
+
from ..errors import UsageError
|
|
10
|
+
from ..state import current
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(no_args_is_help=True, help="项目管理与作用域切换")
|
|
13
|
+
|
|
14
|
+
_COLUMNS = [("PROJECT ID", "projectId"), ("名称", "name"), ("创建时间", "createdAt")]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("list")
|
|
18
|
+
def list_(
|
|
19
|
+
page_size: int = PAGE_SIZE_OPT,
|
|
20
|
+
page_token: str = PAGE_TOKEN_OPT,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""列出当前账号可访问的项目。"""
|
|
23
|
+
st = current()
|
|
24
|
+
payload = st.client().call(
|
|
25
|
+
"tenant.ListProjects", query=pagination(page_size or 50, page_token)
|
|
26
|
+
)
|
|
27
|
+
output.emit_items(payload, items_key="projects", columns=_COLUMNS, json_out=st.json_out)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("get")
|
|
31
|
+
def get(project_id: str = typer.Argument(None, help="缺省用当前作用域项目")) -> None:
|
|
32
|
+
"""查看项目详情。"""
|
|
33
|
+
st = current()
|
|
34
|
+
pid = project_id or _require_project(st.project)
|
|
35
|
+
payload = st.client().call("tenant.GetProject", path_params={"project_id": pid})
|
|
36
|
+
output.emit_object(payload, json_out=st.json_out)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("create")
|
|
40
|
+
def create(
|
|
41
|
+
name: str = typer.Option(..., "--name", "-n", help="项目名"),
|
|
42
|
+
) -> None:
|
|
43
|
+
"""建项目(创建者自动成为 owner)。"""
|
|
44
|
+
st = current()
|
|
45
|
+
payload = st.client().call("tenant.CreateProject", body={"name": name})
|
|
46
|
+
if st.json_out:
|
|
47
|
+
output.emit_json(payload)
|
|
48
|
+
return
|
|
49
|
+
typer.echo(f"已创建 {payload.get('projectId')} {payload.get('name') or name}")
|
|
50
|
+
typer.echo(f"切过去:rathflow project use {payload.get('projectId')}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command("update")
|
|
54
|
+
def update(
|
|
55
|
+
project_id: str = typer.Argument(None),
|
|
56
|
+
name: str = typer.Option(None, "--name", "-n"),
|
|
57
|
+
body: str = BODY_OPT,
|
|
58
|
+
body_file: str = BODY_FILE_OPT,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""改项目名(或用 --body 传整包)。"""
|
|
61
|
+
st = current()
|
|
62
|
+
pid = project_id or _require_project(st.project)
|
|
63
|
+
payload_body = body_of(body, body_file)
|
|
64
|
+
if payload_body is None:
|
|
65
|
+
if not name:
|
|
66
|
+
raise UsageError("至少给 --name,或用 --body 传整包")
|
|
67
|
+
payload_body = {"name": name}
|
|
68
|
+
payload = st.client().call(
|
|
69
|
+
"tenant.UpdateProject", body=payload_body, path_params={"project_id": pid}
|
|
70
|
+
)
|
|
71
|
+
output.emit_object(payload, json_out=st.json_out)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command("delete")
|
|
75
|
+
def delete(
|
|
76
|
+
project_id: str = typer.Argument(None),
|
|
77
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认"),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""删项目(不可逆)。"""
|
|
80
|
+
st = current()
|
|
81
|
+
pid = project_id or _require_project(st.project)
|
|
82
|
+
if not yes:
|
|
83
|
+
typer.confirm(f"确认删除项目 {pid}?此操作不可逆", abort=True)
|
|
84
|
+
st.client().call("tenant.DeleteProject", path_params={"project_id": pid})
|
|
85
|
+
typer.echo(f"已删除 {pid}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("use")
|
|
89
|
+
def use(project_id: str = typer.Argument(..., help="写入配置档的默认作用域")) -> None:
|
|
90
|
+
"""把项目设为默认作用域(此后所有命令都带它)。"""
|
|
91
|
+
st = current()
|
|
92
|
+
prof = st.profile
|
|
93
|
+
prof["project"] = project_id
|
|
94
|
+
st.data["profiles"][st.profile_name] = prof
|
|
95
|
+
config.save(st.data)
|
|
96
|
+
typer.echo(f"当前项目 → {project_id}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command("config-get")
|
|
100
|
+
def config_get(project_id: str = typer.Argument(None)) -> None:
|
|
101
|
+
"""读项目配置(模型、限额等)。"""
|
|
102
|
+
st = current()
|
|
103
|
+
pid = project_id or _require_project(st.project)
|
|
104
|
+
payload = st.client().call("tenant.GetProjectConfig", path_params={"project_id": pid})
|
|
105
|
+
output.emit_object(payload, json_out=st.json_out)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command("config-set")
|
|
109
|
+
def config_set(
|
|
110
|
+
project_id: str = typer.Argument(None),
|
|
111
|
+
body: str = BODY_OPT,
|
|
112
|
+
body_file: str = BODY_FILE_OPT,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""改项目配置(PATCH 语义,只传要改的字段)。"""
|
|
115
|
+
st = current()
|
|
116
|
+
pid = project_id or _require_project(st.project)
|
|
117
|
+
payload_body = body_of(body, body_file)
|
|
118
|
+
if not payload_body:
|
|
119
|
+
raise UsageError('需要 --body \'{"...":...}\'(只传要改的字段)')
|
|
120
|
+
payload = st.client().call(
|
|
121
|
+
"tenant.UpdateProjectConfig", body=payload_body, path_params={"project_id": pid}
|
|
122
|
+
)
|
|
123
|
+
output.emit_object(payload, json_out=st.json_out)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------ 分享链接
|
|
127
|
+
# 链接 = 能力凭证:明文 `link` 只在**创建/轮换那一次**的响应里出现,服务端只
|
|
128
|
+
# 存摘要(同 API key 的「只显示一次」纪律)。撤销/过期后 `project shared` 里
|
|
129
|
+
# 也不再出现。
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command("share-set")
|
|
133
|
+
def share_set(
|
|
134
|
+
project_id: str = typer.Argument(None),
|
|
135
|
+
permission: str = typer.Option(None, "--permission", help="SharePermission 枚举,如 VIEW/EDIT"),
|
|
136
|
+
password: str = typer.Option(None, "--password", help="设密码;给空串=清除(不给=保留原值)"),
|
|
137
|
+
ttl: int = typer.Option(None, "--ttl", help="有效期秒数;<=0 永不过期(仅创建时生效)"),
|
|
138
|
+
rotate: bool = typer.Option(False, "--rotate", help="重发令牌,旧链接立即失效"),
|
|
139
|
+
) -> None:
|
|
140
|
+
"""建/改本项目的分享链接。
|
|
141
|
+
|
|
142
|
+
只传要改的字段(PATCH 语义)。要换有效期得用 --rotate 重发。
|
|
143
|
+
"""
|
|
144
|
+
st = current()
|
|
145
|
+
pid = project_id or _require_project(st.project)
|
|
146
|
+
body: dict = {}
|
|
147
|
+
if permission:
|
|
148
|
+
body["permission"] = permission
|
|
149
|
+
# password 是三态(保留/清除/覆盖),故判 None 而非真假
|
|
150
|
+
if password is not None:
|
|
151
|
+
body["password"] = password
|
|
152
|
+
if ttl is not None:
|
|
153
|
+
body["ttlSeconds"] = ttl
|
|
154
|
+
if rotate:
|
|
155
|
+
body["rotate"] = True
|
|
156
|
+
if not body:
|
|
157
|
+
raise UsageError("至少给一项要改的字段(--permission/--password/--ttl/--rotate)")
|
|
158
|
+
payload = st.client().call(
|
|
159
|
+
"tenant.UpdateProjectShare", path_params={"project_id": pid}, body=body
|
|
160
|
+
)
|
|
161
|
+
if st.json_out:
|
|
162
|
+
output.emit_json(payload)
|
|
163
|
+
return
|
|
164
|
+
link = payload.get("link")
|
|
165
|
+
if link:
|
|
166
|
+
typer.echo(link, err=True)
|
|
167
|
+
typer.echo("(明文只此一次,请立即存好)", err=True)
|
|
168
|
+
output.emit_object({k: v for k, v in payload.items() if k != "link"}, json_out=False)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.command("share-revoke")
|
|
172
|
+
def share_revoke(
|
|
173
|
+
project_id: str = typer.Argument(None),
|
|
174
|
+
yes: bool = typer.Option(False, "--yes", "-y"),
|
|
175
|
+
) -> None:
|
|
176
|
+
"""撤销分享(已在用的人立即失去入口)。"""
|
|
177
|
+
st = current()
|
|
178
|
+
pid = project_id or _require_project(st.project)
|
|
179
|
+
if not yes:
|
|
180
|
+
typer.confirm(f"确认撤销项目 {pid} 的分享链接?", abort=True)
|
|
181
|
+
st.client().call("tenant.RevokeProjectShare", path_params={"project_id": pid})
|
|
182
|
+
typer.echo(f"已撤销 {pid} 的分享")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.command("shared")
|
|
186
|
+
def shared(
|
|
187
|
+
page_size: int = PAGE_SIZE_OPT,
|
|
188
|
+
page_token: str = PAGE_TOKEN_OPT,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""我认领过的共享 project(分享被撤销/过期后不再出现)。"""
|
|
191
|
+
st = current()
|
|
192
|
+
payload = st.client().call(
|
|
193
|
+
"tenant.ListSharedProjects", query=pagination(page_size or 50, page_token)
|
|
194
|
+
)
|
|
195
|
+
output.emit_items(payload, items_key="projects", columns=_COLUMNS, json_out=st.json_out)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@app.command("claim")
|
|
199
|
+
def claim(
|
|
200
|
+
token: str = typer.Argument(..., help="分享链接里的不透明令牌(整条链接也行)"),
|
|
201
|
+
password: str = typer.Option(None, "--password", help="链接设了密码时必填"),
|
|
202
|
+
) -> None:
|
|
203
|
+
"""认领一个共享 project(认领后进入 `project list`)。"""
|
|
204
|
+
st = current()
|
|
205
|
+
body: dict = {"token": token.rstrip("/").rsplit("/", 1)[-1]}
|
|
206
|
+
if password is not None:
|
|
207
|
+
body["password"] = password
|
|
208
|
+
payload = st.client().call("tenant.JoinSharedProject", body=body)
|
|
209
|
+
if st.json_out:
|
|
210
|
+
output.emit_json(payload)
|
|
211
|
+
return
|
|
212
|
+
project = payload.get("project") or {}
|
|
213
|
+
typer.echo(f"已认领 {project.get('projectId') or '?'} {project.get('name') or ''}")
|
|
214
|
+
if project.get("projectId"):
|
|
215
|
+
typer.echo(f"切过去:rathflow project use {project['projectId']}")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _require_project(pid: str | None) -> str:
|
|
219
|
+
if not pid:
|
|
220
|
+
raise UsageError(
|
|
221
|
+
"无项目作用域:`rathflow project use <id>`,或加 --project <id>,或设 RATHFLOW_PROJECT"
|
|
222
|
+
)
|
|
223
|
+
return pid
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""sandbox:沙箱生命周期、执行(流式)、文件读取与日志。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from .. import output
|
|
11
|
+
from ..commands._common import (
|
|
12
|
+
BODY_FILE_OPT,
|
|
13
|
+
BODY_OPT,
|
|
14
|
+
PAGE_SIZE_OPT,
|
|
15
|
+
PAGE_TOKEN_OPT,
|
|
16
|
+
body_of,
|
|
17
|
+
pagination,
|
|
18
|
+
)
|
|
19
|
+
from ..errors import ApiError, UsageError
|
|
20
|
+
from ..state import current
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(no_args_is_help=True, help="沙箱 / 执行 / 文件 / 日志")
|
|
23
|
+
|
|
24
|
+
SANDBOX_COLUMNS = [
|
|
25
|
+
("SANDBOX ID", "sandboxId"),
|
|
26
|
+
("名称", "name"),
|
|
27
|
+
("状态", "status"),
|
|
28
|
+
("隔离级别", "effectiveIsolation"),
|
|
29
|
+
("到期", "expiresAt"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("list")
|
|
34
|
+
def list_(
|
|
35
|
+
page_size: int = PAGE_SIZE_OPT,
|
|
36
|
+
page_token: str = PAGE_TOKEN_OPT,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""列本项目沙箱(含已终止)。"""
|
|
39
|
+
st = current()
|
|
40
|
+
payload = st.client().call("sandbox.List", query=pagination(page_size or 50, page_token))
|
|
41
|
+
output.emit_items(payload, items_key="sandboxes", columns=SANDBOX_COLUMNS, json_out=st.json_out)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command("get")
|
|
45
|
+
def get(sandbox_id: str = typer.Argument(...)) -> None:
|
|
46
|
+
"""沙箱详情。"""
|
|
47
|
+
st = current()
|
|
48
|
+
payload = st.client().call("sandbox.Get", path_params={"sandbox_id": sandbox_id})
|
|
49
|
+
output.emit_object(payload, json_out=st.json_out)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("create")
|
|
53
|
+
def create(
|
|
54
|
+
name: str = typer.Option(None, "--name", "-n"),
|
|
55
|
+
body: str = BODY_OPT,
|
|
56
|
+
body_file: str = BODY_FILE_OPT,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""建沙箱(隔离级别等用 --body 传)。"""
|
|
59
|
+
st = current()
|
|
60
|
+
payload_body = body_of(body, body_file) or {}
|
|
61
|
+
if name:
|
|
62
|
+
payload_body.setdefault("name", name)
|
|
63
|
+
payload = st.client().call("sandbox.Create", body=payload_body)
|
|
64
|
+
if st.json_out:
|
|
65
|
+
output.emit_json(payload)
|
|
66
|
+
return
|
|
67
|
+
typer.echo(payload.get("sandboxId") or "")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@app.command("terminate")
|
|
71
|
+
def terminate(
|
|
72
|
+
sandbox_id: str = typer.Argument(...),
|
|
73
|
+
yes: bool = typer.Option(False, "--yes", "-y"),
|
|
74
|
+
) -> None:
|
|
75
|
+
"""终止沙箱。"""
|
|
76
|
+
st = current()
|
|
77
|
+
if not yes:
|
|
78
|
+
typer.confirm(f"确认终止沙箱 {sandbox_id}?", abort=True)
|
|
79
|
+
st.client().call("sandbox.Terminate", path_params={"sandbox_id": sandbox_id})
|
|
80
|
+
typer.echo(f"已终止 {sandbox_id}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("renew")
|
|
84
|
+
def renew(
|
|
85
|
+
sandbox_id: str = typer.Argument(...),
|
|
86
|
+
body: str = BODY_OPT,
|
|
87
|
+
body_file: str = BODY_FILE_OPT,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""续期沙箱。"""
|
|
90
|
+
st = current()
|
|
91
|
+
payload = st.client().call(
|
|
92
|
+
"sandbox.Renew", path_params={"sandbox_id": sandbox_id}, body=body_of(body, body_file) or {}
|
|
93
|
+
)
|
|
94
|
+
output.emit_object(payload, json_out=st.json_out)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("expose-port")
|
|
98
|
+
def expose_port(
|
|
99
|
+
sandbox_id: str = typer.Argument(...),
|
|
100
|
+
port: int = typer.Argument(..., help="沙箱内端口"),
|
|
101
|
+
protocol: str = typer.Option(None, "--protocol"),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""暴露端口,打印可达地址。"""
|
|
104
|
+
st = current()
|
|
105
|
+
body = {"port": port}
|
|
106
|
+
if protocol:
|
|
107
|
+
body["protocol"] = protocol
|
|
108
|
+
payload = st.client().call(
|
|
109
|
+
"sandbox.ExposePort", path_params={"sandbox_id": sandbox_id}, body=body
|
|
110
|
+
)
|
|
111
|
+
if st.json_out:
|
|
112
|
+
output.emit_json(payload)
|
|
113
|
+
return
|
|
114
|
+
typer.echo(payload.get("url") or f"(backend 未上报地址;assigned_port={payload.get('assignedPort')})")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ 执行
|
|
118
|
+
|
|
119
|
+
@app.command("exec")
|
|
120
|
+
def exec_(
|
|
121
|
+
sandbox_id: str = typer.Argument(...),
|
|
122
|
+
command: str = typer.Argument(..., help="要执行的命令(经 shell 解释)"),
|
|
123
|
+
workdir: str = typer.Option(None, "--workdir", "-w"),
|
|
124
|
+
env: list[str] = typer.Option(None, "--env", "-e", help="环境变量 k=v,可重复"),
|
|
125
|
+
) -> None:
|
|
126
|
+
"""在沙箱里执行命令(流式;远端非零退出码原样透传为 CLI 退出码)。"""
|
|
127
|
+
st = current()
|
|
128
|
+
body = {"command": command}
|
|
129
|
+
if workdir:
|
|
130
|
+
body["workdir"] = workdir
|
|
131
|
+
if env:
|
|
132
|
+
body["env"] = dict(item.partition("=")[::2] for item in env)
|
|
133
|
+
if st.json_out:
|
|
134
|
+
_stream_json(st, "sandbox.RunCommand", sandbox_id, body)
|
|
135
|
+
return
|
|
136
|
+
code = _pump(st, "sandbox.RunCommand", sandbox_id, body)
|
|
137
|
+
if code:
|
|
138
|
+
raise SystemExit(code)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command("code")
|
|
142
|
+
def code(
|
|
143
|
+
sandbox_id: str = typer.Argument(...),
|
|
144
|
+
language: str = typer.Option(..., "--language", "-l", help="python / bash / ..."),
|
|
145
|
+
file: str = typer.Argument(None, help="源码文件;缺省或 - 读 stdin"),
|
|
146
|
+
) -> None:
|
|
147
|
+
"""在沙箱里跑一段源码(流式)。"""
|
|
148
|
+
st = current()
|
|
149
|
+
if file and file != "-":
|
|
150
|
+
try:
|
|
151
|
+
with open(file, encoding="utf-8") as fh:
|
|
152
|
+
source = fh.read()
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
raise UsageError(f"读取源码失败:{exc}") from None
|
|
155
|
+
else:
|
|
156
|
+
source = sys.stdin.read()
|
|
157
|
+
body = {"language": language, "code": source}
|
|
158
|
+
if st.json_out:
|
|
159
|
+
_stream_json(st, "sandbox.RunCode", sandbox_id, body)
|
|
160
|
+
return
|
|
161
|
+
exit_code = _pump(st, "sandbox.RunCode", sandbox_id, body)
|
|
162
|
+
if exit_code:
|
|
163
|
+
raise SystemExit(exit_code)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("logs")
|
|
167
|
+
def logs(
|
|
168
|
+
sandbox_id: str = typer.Argument(...),
|
|
169
|
+
tail: int = typer.Option(None, "--tail", help="回看行数(服务端支持时生效)"),
|
|
170
|
+
) -> None:
|
|
171
|
+
"""沙箱日志(流式;stderr 分流到 stderr)。Ctrl-C 退出。
|
|
172
|
+
|
|
173
|
+
注意:当前 backend 未实现沙箱级日志面(返回 CODE_UNIMPLEMENTED),
|
|
174
|
+
要取实时输出请用 `sandbox exec` 的 stdout/stderr 流帧。
|
|
175
|
+
"""
|
|
176
|
+
st = current()
|
|
177
|
+
query = {"tail": tail} if tail is not None else None
|
|
178
|
+
for chunk in st.client().stream(
|
|
179
|
+
"sandbox.StreamLogs", path_params={"sandbox_id": sandbox_id}, query=query
|
|
180
|
+
):
|
|
181
|
+
if st.json_out:
|
|
182
|
+
output.emit_json(chunk)
|
|
183
|
+
continue
|
|
184
|
+
data = _bytes(chunk.get("data"))
|
|
185
|
+
if not data:
|
|
186
|
+
continue
|
|
187
|
+
if chunk.get("stderr"):
|
|
188
|
+
output.write_bytes(data, to_stderr=True)
|
|
189
|
+
else:
|
|
190
|
+
output.write_bytes(data)
|
|
191
|
+
if chunk.get("truncated"):
|
|
192
|
+
typer.echo("\n[截断:输出达服务端上限]", err=True)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------ 文件
|
|
196
|
+
|
|
197
|
+
@app.command("ls")
|
|
198
|
+
def ls(
|
|
199
|
+
sandbox_id: str = typer.Argument(...),
|
|
200
|
+
path: str = typer.Option("/", "--path", "-p", help="相对沙箱根;缺省根目录"),
|
|
201
|
+
) -> None:
|
|
202
|
+
"""列目录。"""
|
|
203
|
+
st = current()
|
|
204
|
+
# 该 backend 要求 path 必填(传空报 CODE_INVALID_ARGUMENT),故缺省给根
|
|
205
|
+
query = {"path": path}
|
|
206
|
+
payload = st.client().call(
|
|
207
|
+
"sandbox.ListDir", path_params={"sandbox_id": sandbox_id}, query=query
|
|
208
|
+
)
|
|
209
|
+
output.emit_table(
|
|
210
|
+
payload.get("entries") or [],
|
|
211
|
+
[("类型", "type"), ("大小", "sizeBytes"), ("时间", "modifiedAt"), ("名称", "name")],
|
|
212
|
+
json_out=st.json_out,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@app.command("stat")
|
|
217
|
+
def stat(
|
|
218
|
+
sandbox_id: str = typer.Argument(...),
|
|
219
|
+
path: str = typer.Option(..., "--path", "-p"),
|
|
220
|
+
) -> None:
|
|
221
|
+
"""看单个路径的元信息(须为单文件路径,见 StatRequest 注释)。"""
|
|
222
|
+
st = current()
|
|
223
|
+
payload = st.client().call(
|
|
224
|
+
"sandbox.Stat", path_params={"sandbox_id": sandbox_id}, query={"path": path}
|
|
225
|
+
)
|
|
226
|
+
output.emit_object(payload.get("entry") or payload, json_out=st.json_out)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.command("cat")
|
|
230
|
+
def cat(
|
|
231
|
+
sandbox_id: str = typer.Argument(...),
|
|
232
|
+
path: str = typer.Option(..., "--path", "-p", help="沙箱内相对路径"),
|
|
233
|
+
) -> None:
|
|
234
|
+
"""读文件到 stdout(流式分片直写,不整包缓冲)。"""
|
|
235
|
+
st = current()
|
|
236
|
+
for chunk in st.client().stream(
|
|
237
|
+
"sandbox.ReadFile", path_params={"sandbox_id": sandbox_id, "path": path}
|
|
238
|
+
):
|
|
239
|
+
if st.json_out:
|
|
240
|
+
output.emit_json(chunk)
|
|
241
|
+
continue
|
|
242
|
+
data = _bytes(chunk.get("data"))
|
|
243
|
+
if data:
|
|
244
|
+
output.write_bytes(data)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------ 内部
|
|
248
|
+
|
|
249
|
+
def _pump(st, key: str, sandbox_id: str, body: dict) -> int:
|
|
250
|
+
"""流式抽干一个执行流:stdout→stdout,stderr→stderr,返回远端退出码。"""
|
|
251
|
+
exit_code = 0
|
|
252
|
+
for chunk in st.client().stream(key, path_params={"sandbox_id": sandbox_id}, body=body):
|
|
253
|
+
stdout = _bytes(chunk.get("stdout"))
|
|
254
|
+
stderr = _bytes(chunk.get("stderr"))
|
|
255
|
+
if stdout:
|
|
256
|
+
output.write_bytes(stdout)
|
|
257
|
+
if stderr:
|
|
258
|
+
output.write_bytes(stderr, to_stderr=True)
|
|
259
|
+
if chunk.get("errorCode"):
|
|
260
|
+
raise ApiError(0, chunk["errorCode"], chunk.get("errorMessage") or "执行失败")
|
|
261
|
+
if chunk.get("truncated"):
|
|
262
|
+
typer.echo("[截断:输出达服务端上限]", err=True)
|
|
263
|
+
if chunk.get("exitCode") is not None:
|
|
264
|
+
exit_code = int(chunk["exitCode"])
|
|
265
|
+
return exit_code
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _stream_json(st, key: str, sandbox_id: str, body: dict) -> None:
|
|
269
|
+
for chunk in st.client().stream(key, path_params={"sandbox_id": sandbox_id}, body=body):
|
|
270
|
+
output.emit_json(chunk)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _bytes(value) -> bytes:
|
|
274
|
+
"""protojson 把 bytes 编成 base64;解失败就当纯文本用。"""
|
|
275
|
+
if not value:
|
|
276
|
+
return b""
|
|
277
|
+
if isinstance(value, bytes):
|
|
278
|
+
return value
|
|
279
|
+
try:
|
|
280
|
+
return base64.b64decode(value, validate=True)
|
|
281
|
+
except Exception:
|
|
282
|
+
return str(value).encode()
|