rathflow-cli 0.1.2__tar.gz → 0.1.3__tar.gz
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-0.1.2/rathflow_cli.egg-info → rathflow_cli-0.1.3}/PKG-INFO +5 -5
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/README.md +4 -4
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/pyproject.toml +1 -1
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/__init__.py +1 -1
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/cli.py +4 -1
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/agent.py +44 -1
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/auth_cmd.py +101 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/org.py +16 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/workflow.py +13 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/endpoints.py +20 -1
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/selftest.py +54 -2
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3/rathflow_cli.egg-info}/PKG-INFO +5 -5
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/LICENSE +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/__main__.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/__init__.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/_common.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/admin.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/api_cmd.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/asset.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/billing.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/config_cmd.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/memory.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/project.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/sandbox.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/commands/session.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/config.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/errors.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/http.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/output.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli/state.py +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli.egg-info/SOURCES.txt +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli.egg-info/dependency_links.txt +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli.egg-info/entry_points.txt +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli.egg-info/requires.txt +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/rathflow_cli.egg-info/top_level.txt +0 -0
- {rathflow_cli-0.1.2 → rathflow_cli-0.1.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rathflow-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: RathFlow command line client: sessions, memory, sandboxes, assets and billing over the RathFlow REST API.
|
|
5
5
|
Author: Rath Team
|
|
6
6
|
License-Expression: MIT
|
|
@@ -75,17 +75,17 @@ The default gateway is `https://rathflow.lynwe.com`. Point it elsewhere with
|
|
|
75
75
|
|
|
76
76
|
| Group | What it covers |
|
|
77
77
|
| --- | --- |
|
|
78
|
-
| `auth` | log in / out, register,
|
|
78
|
+
| `auth` | log in / out, register, profile (`profile`, `profile-update`, `change-password`, `set-email`, `set-avatar`), API keys |
|
|
79
79
|
| `config` | local profiles (`list`, `show`, `set`, `use`) |
|
|
80
|
-
| `org` | organizations
|
|
80
|
+
| `org` | organizations (`list`, `create`, `invite`, `accept`) |
|
|
81
81
|
| `project` | projects, scope switching, project config, share links |
|
|
82
82
|
| `session` | sessions, blocks, events |
|
|
83
83
|
| `memory` | memory entries, search, commit tasks |
|
|
84
84
|
| `sandbox` | sandboxes, exec, code, files, logs |
|
|
85
|
-
| `agent` | agent definitions, versions, prompts, runs |
|
|
85
|
+
| `agent` | agent definitions, versions, prompts, runs, attachments |
|
|
86
86
|
| `asset` | assets, versions, credentials, publish |
|
|
87
87
|
| `billing` | subscription, usage, invoices |
|
|
88
|
-
| `workflow` | workflows |
|
|
88
|
+
| `workflow` | workflows (`list`, `get`, `create`, `delete`, `counts`) |
|
|
89
89
|
| `admin` | platform administration (platform admins only) |
|
|
90
90
|
|
|
91
91
|
Two more entry points sit at the root:
|
|
@@ -44,17 +44,17 @@ The default gateway is `https://rathflow.lynwe.com`. Point it elsewhere with
|
|
|
44
44
|
|
|
45
45
|
| Group | What it covers |
|
|
46
46
|
| --- | --- |
|
|
47
|
-
| `auth` | log in / out, register,
|
|
47
|
+
| `auth` | log in / out, register, profile (`profile`, `profile-update`, `change-password`, `set-email`, `set-avatar`), API keys |
|
|
48
48
|
| `config` | local profiles (`list`, `show`, `set`, `use`) |
|
|
49
|
-
| `org` | organizations
|
|
49
|
+
| `org` | organizations (`list`, `create`, `invite`, `accept`) |
|
|
50
50
|
| `project` | projects, scope switching, project config, share links |
|
|
51
51
|
| `session` | sessions, blocks, events |
|
|
52
52
|
| `memory` | memory entries, search, commit tasks |
|
|
53
53
|
| `sandbox` | sandboxes, exec, code, files, logs |
|
|
54
|
-
| `agent` | agent definitions, versions, prompts, runs |
|
|
54
|
+
| `agent` | agent definitions, versions, prompts, runs, attachments |
|
|
55
55
|
| `asset` | assets, versions, credentials, publish |
|
|
56
56
|
| `billing` | subscription, usage, invoices |
|
|
57
|
-
| `workflow` | workflows |
|
|
57
|
+
| `workflow` | workflows (`list`, `get`, `create`, `delete`, `counts`) |
|
|
58
58
|
| `admin` | platform administration (platform admins only) |
|
|
59
59
|
|
|
60
60
|
Two more entry points sit at the root:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "rathflow-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "RathFlow command line client: sessions, memory, sandboxes, assets and billing over the RathFlow REST API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -101,7 +101,10 @@ def _value_opts() -> set[str]:
|
|
|
101
101
|
for param in getattr(cmd, "params", []):
|
|
102
102
|
if getattr(param, "is_flag", False) or getattr(param, "count", False):
|
|
103
103
|
continue
|
|
104
|
-
|
|
104
|
+
# 只认真正的选项(以 "-" 开头)。位置参数的 opts 是它自己的名字
|
|
105
|
+
# (例如 `auth profile` 的 `profile`),把它当吃值的选项会让
|
|
106
|
+
# `rathflow auth profile --json` 里的 --json 不被前移而报错。
|
|
107
|
+
found.update(o for o in getattr(param, "opts", []) if o.startswith("-"))
|
|
105
108
|
for sub in getattr(cmd, "commands", {}).values():
|
|
106
109
|
found |= walk(sub)
|
|
107
110
|
return found
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
"""agent:Agent
|
|
1
|
+
"""agent:Agent 定义、版本、运行、Agent 目录与附件。"""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
6
|
+
|
|
5
7
|
import typer
|
|
6
8
|
|
|
7
9
|
from .. import output
|
|
@@ -271,6 +273,47 @@ def messages(
|
|
|
271
273
|
)
|
|
272
274
|
|
|
273
275
|
|
|
276
|
+
@app.command("attachment")
|
|
277
|
+
def attachment(
|
|
278
|
+
attachment_id: str = typer.Argument(..., help="sha256:<hex>,取自事件里的 attachmentRef"),
|
|
279
|
+
out: str = typer.Option(None, "--out", "-o", help="写文件;缺省写 stdout"),
|
|
280
|
+
) -> None:
|
|
281
|
+
"""读附件字节(流式,分片直写,不整包缓冲)。"""
|
|
282
|
+
st = current()
|
|
283
|
+
fh = None
|
|
284
|
+
try:
|
|
285
|
+
if out:
|
|
286
|
+
fh = open(out, "wb")
|
|
287
|
+
for chunk in st.client().stream(
|
|
288
|
+
"agent.ReadAttachment", path_params={"attachment_id": attachment_id}
|
|
289
|
+
):
|
|
290
|
+
if st.json_out:
|
|
291
|
+
output.emit_json(chunk)
|
|
292
|
+
continue
|
|
293
|
+
data = _bytes(chunk.get("data"))
|
|
294
|
+
if not data:
|
|
295
|
+
continue
|
|
296
|
+
if fh:
|
|
297
|
+
fh.write(data)
|
|
298
|
+
else:
|
|
299
|
+
output.write_bytes(data)
|
|
300
|
+
finally:
|
|
301
|
+
if fh:
|
|
302
|
+
fh.close()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _bytes(value) -> bytes:
|
|
306
|
+
"""protojson 把 bytes 编成 base64;解失败就当纯文本用。"""
|
|
307
|
+
if not value:
|
|
308
|
+
return b""
|
|
309
|
+
if isinstance(value, bytes):
|
|
310
|
+
return value
|
|
311
|
+
try:
|
|
312
|
+
return base64.b64decode(value, validate=True)
|
|
313
|
+
except Exception:
|
|
314
|
+
return str(value).encode()
|
|
315
|
+
|
|
316
|
+
|
|
274
317
|
def _text_of(text: str | None, file: str | None) -> str | None:
|
|
275
318
|
"""config_json 是自由 JSON 文本:字面量或文件(- 读 stdin)。"""
|
|
276
319
|
if text and file:
|
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
6
|
+
import mimetypes
|
|
7
|
+
import sys
|
|
8
|
+
|
|
5
9
|
import typer
|
|
6
10
|
|
|
7
11
|
from .. import config, output
|
|
12
|
+
from ..errors import UsageError
|
|
8
13
|
from ..state import current
|
|
9
14
|
|
|
10
15
|
app = typer.Typer(no_args_is_help=True, help="登录 / 登出 / 注册 / 身份")
|
|
@@ -123,6 +128,102 @@ def key_revoke(
|
|
|
123
128
|
typer.echo(f"已吊销 {key_id}")
|
|
124
129
|
|
|
125
130
|
|
|
131
|
+
# ------------------------------------------------------------------ 个人资料
|
|
132
|
+
|
|
133
|
+
_AVATAR_MEDIA_TYPES = {"image/png", "image/jpeg", "image/webp", "image/gif"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@app.command("profile")
|
|
137
|
+
def profile() -> None:
|
|
138
|
+
"""看当前账号资料(头像以 base64 内联,这里只显示「有/类型」,字节走 --json)。"""
|
|
139
|
+
st = current()
|
|
140
|
+
payload = st.client().call("auth.GetProfile")
|
|
141
|
+
if st.json_out:
|
|
142
|
+
output.emit_json(payload)
|
|
143
|
+
return
|
|
144
|
+
view = {k: v for k, v in payload.items() if k != "avatarBytes"}
|
|
145
|
+
if payload.get("avatarBytes"):
|
|
146
|
+
view["avatar"] = payload.get("avatarMediaType") or "有(--json 取字节)"
|
|
147
|
+
output.emit_object(view, json_out=False)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("profile-update")
|
|
151
|
+
def profile_update(
|
|
152
|
+
display_name: str = typer.Option(None, "--display-name", help="显示名"),
|
|
153
|
+
username: str = typer.Option(None, "--username"),
|
|
154
|
+
phone: str = typer.Option(None, "--phone"),
|
|
155
|
+
) -> None:
|
|
156
|
+
"""改资料;只发显式给出的字段(都不给 = 用法错,避免空 PATCH)。"""
|
|
157
|
+
st = current()
|
|
158
|
+
body: dict = {}
|
|
159
|
+
if display_name is not None:
|
|
160
|
+
body["displayName"] = display_name
|
|
161
|
+
if username is not None:
|
|
162
|
+
body["username"] = username
|
|
163
|
+
if phone is not None:
|
|
164
|
+
body["phone"] = phone
|
|
165
|
+
if not body:
|
|
166
|
+
raise UsageError("至少给一个:--display-name / --username / --phone")
|
|
167
|
+
output.emit_object(st.client().call("auth.UpdateProfile", body=body), json_out=st.json_out)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command("change-password")
|
|
171
|
+
def change_password(
|
|
172
|
+
current_password: str = typer.Option(None, "--current-password", help="缺省交互输入,不进 shell 历史"),
|
|
173
|
+
new_password: str = typer.Option(None, "--new-password", help="缺省交互输入;>=8 位且含大小写"),
|
|
174
|
+
) -> None:
|
|
175
|
+
"""改密码(旧密码证明是本人)。"""
|
|
176
|
+
st = current()
|
|
177
|
+
current_password = current_password or typer.prompt("当前密码", hide_input=True)
|
|
178
|
+
new_password = new_password or typer.prompt("新密码", hide_input=True, confirmation_prompt=True)
|
|
179
|
+
st.client().call(
|
|
180
|
+
"auth.ChangePassword",
|
|
181
|
+
body={"currentPassword": current_password, "newPassword": new_password},
|
|
182
|
+
)
|
|
183
|
+
typer.echo("密码已更新")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command("set-email")
|
|
187
|
+
def set_email(
|
|
188
|
+
new_email: str = typer.Option(..., "--email", "-e", help="新登录邮箱"),
|
|
189
|
+
current_password: str = typer.Option(None, "--current-password", help="缺省交互输入"),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""换登录邮箱(要当前密码,防止被盗会话直接改)。"""
|
|
192
|
+
st = current()
|
|
193
|
+
current_password = current_password or typer.prompt("当前密码", hide_input=True)
|
|
194
|
+
payload = st.client().call(
|
|
195
|
+
"auth.UpdateEmail",
|
|
196
|
+
body={"newEmail": new_email, "currentPassword": current_password},
|
|
197
|
+
)
|
|
198
|
+
output.emit_object(payload, json_out=st.json_out)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.command("set-avatar")
|
|
202
|
+
def set_avatar(
|
|
203
|
+
file: str = typer.Option(None, "--file", "-f", help="图片文件;传 - 读 stdin"),
|
|
204
|
+
media_type: str = typer.Option(None, "--media-type", help="缺省按扩展名猜"),
|
|
205
|
+
clear: bool = typer.Option(False, "--clear", help="清除头像"),
|
|
206
|
+
) -> None:
|
|
207
|
+
"""换/清头像(base64 内联,服务端限 64KB 方图)。"""
|
|
208
|
+
st = current()
|
|
209
|
+
if clear and file:
|
|
210
|
+
raise UsageError("--clear 与 --file 互斥")
|
|
211
|
+
if not clear and not file:
|
|
212
|
+
raise UsageError("给 --file <图片>,或用 --clear 清除头像")
|
|
213
|
+
body: dict = {}
|
|
214
|
+
if clear:
|
|
215
|
+
body = {"data": "", "mediaType": ""}
|
|
216
|
+
else:
|
|
217
|
+
raw = sys.stdin.buffer.read() if file == "-" else open(file, "rb").read()
|
|
218
|
+
mt = media_type or (mimetypes.guess_type(file)[0] if file != "-" else None)
|
|
219
|
+
if mt not in _AVATAR_MEDIA_TYPES:
|
|
220
|
+
raise UsageError(
|
|
221
|
+
f"媒体类型 {mt or '(未知)'} 不在白名单:{', '.join(sorted(_AVATAR_MEDIA_TYPES))}(用 --media-type 指定)"
|
|
222
|
+
)
|
|
223
|
+
body = {"data": base64.b64encode(raw).decode(), "mediaType": mt}
|
|
224
|
+
output.emit_object(st.client().call("auth.UpdateAvatar", body=body), json_out=st.json_out)
|
|
225
|
+
|
|
226
|
+
|
|
126
227
|
def whoami() -> None:
|
|
127
228
|
"""显示登录身份 + 作用域 + 可访问项目(后者顺带证明令牌有效)。"""
|
|
128
229
|
st = current()
|
|
@@ -30,6 +30,22 @@ def list_() -> None:
|
|
|
30
30
|
output.emit_items(payload, items_key="orgs", columns=_COLUMNS, json_out=st.json_out)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
@app.command("create")
|
|
34
|
+
def create(
|
|
35
|
+
name: str = typer.Option(..., "--name", "-n", help="展示名(1..60 字符;slug 服务端派生)"),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""新建组织(自己是 OWNER,且会附带一个默认 project)。"""
|
|
38
|
+
st = current()
|
|
39
|
+
payload = st.client().call("tenant.CreateOrg", body={"name": name})
|
|
40
|
+
if st.json_out:
|
|
41
|
+
output.emit_json(payload)
|
|
42
|
+
return
|
|
43
|
+
org = payload.get("org") or {}
|
|
44
|
+
typer.echo(f"已创建 {org.get('orgId') or '?'} {org.get('name') or name}")
|
|
45
|
+
if payload.get("defaultProjectId"):
|
|
46
|
+
typer.echo(f"切过去:rathflow project use {payload['defaultProjectId']}")
|
|
47
|
+
|
|
48
|
+
|
|
33
49
|
@app.command("invite")
|
|
34
50
|
def invite(
|
|
35
51
|
org_id: str = typer.Argument(...),
|
|
@@ -44,6 +44,19 @@ def get(workflow_id: str = typer.Argument(...)) -> None:
|
|
|
44
44
|
output.emit_object(payload, json_out=st.json_out)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
@app.command("counts")
|
|
48
|
+
def counts() -> None:
|
|
49
|
+
"""每个 project 的存活工作流数(服务端不返回 0 的行 = 没有)。"""
|
|
50
|
+
st = current()
|
|
51
|
+
payload = st.client().call("workflow.CountWorkflowsByProject")
|
|
52
|
+
output.emit_items(
|
|
53
|
+
payload,
|
|
54
|
+
items_key="counts",
|
|
55
|
+
columns=[("PROJECT ID", "projectId"), ("数量", "count")],
|
|
56
|
+
json_out=st.json_out,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
47
60
|
@app.command("create")
|
|
48
61
|
def create(
|
|
49
62
|
title: str = typer.Option(None, "--title", "-t"),
|
|
@@ -21,6 +21,11 @@ ENDPOINTS: dict[str, tuple[str, str]] = {
|
|
|
21
21
|
"auth.Login": ("POST", "/api/v1/auth/login"),
|
|
22
22
|
"auth.Logout": ("POST", "/api/v1/auth/logout"),
|
|
23
23
|
"auth.Refresh": ("POST", "/api/v1/auth/refresh"),
|
|
24
|
+
"auth.ChangePassword": ("POST", "/api/v1/profile/password"),
|
|
25
|
+
"auth.GetProfile": ("GET", "/api/v1/profile"),
|
|
26
|
+
"auth.UpdateAvatar": ("PUT", "/api/v1/profile/avatar"),
|
|
27
|
+
"auth.UpdateEmail": ("POST", "/api/v1/profile/email"),
|
|
28
|
+
"auth.UpdateProfile": ("PATCH", "/api/v1/profile"),
|
|
24
29
|
"auth.Register": ("POST", "/api/v1/auth/register"),
|
|
25
30
|
"auth.RevokeAPIKey": ("DELETE", "/api/v1/api-keys/{key_id}"),
|
|
26
31
|
# ---- tenant:分享链接(能力凭证,明文只在创建/轮换时出现一次)----
|
|
@@ -30,6 +35,7 @@ ENDPOINTS: dict[str, tuple[str, str]] = {
|
|
|
30
35
|
"tenant.UpdateProjectShare": ("PATCH", "/api/v1/projects/{project_id}/share"),
|
|
31
36
|
# ---- tenant:org 成员身份 ----
|
|
32
37
|
"tenant.AcceptOrgInvitation": ("POST", "/api/v1/orgs/join"),
|
|
38
|
+
"tenant.CreateOrg": ("POST", "/api/v1/orgs"),
|
|
33
39
|
"tenant.CreateOrgInvitation": ("POST", "/api/v1/orgs/{org_id}/invitations"),
|
|
34
40
|
"tenant.ListMyOrgs": ("GET", "/api/v1/orgs"),
|
|
35
41
|
# ---- tenant:project ----
|
|
@@ -56,6 +62,7 @@ ENDPOINTS: dict[str, tuple[str, str]] = {
|
|
|
56
62
|
"session.ShareBlock": ("POST", "/api/v1/blocks/{block_id}/shares"),
|
|
57
63
|
"session.StreamSessionEvents": ("GET", "/api/v1/sessions/{session_id}/events:stream"),
|
|
58
64
|
# ---- workflow ----
|
|
65
|
+
"workflow.CountWorkflowsByProject": ("GET", "/api/v1/workflows:counts"),
|
|
59
66
|
"workflow.CreateWorkflow": ("POST", "/api/v1/workflows"),
|
|
60
67
|
"workflow.DeleteWorkflow": ("DELETE", "/api/v1/workflows/{workflow_id}"),
|
|
61
68
|
"workflow.GetWorkflow": ("GET", "/api/v1/workflows/{workflow_id}"),
|
|
@@ -63,6 +70,7 @@ ENDPOINTS: dict[str, tuple[str, str]] = {
|
|
|
63
70
|
"workflow.UpdateWorkflow": ("PATCH", "/api/v1/workflows/{workflow_id}"),
|
|
64
71
|
# ---- agent ----
|
|
65
72
|
"agent.CreateAgentDef": ("POST", "/api/v1/agents"),
|
|
73
|
+
"agent.ReadAttachment": ("GET", "/api/v1/attachments/{attachment_id}"),
|
|
66
74
|
"agent.DeleteAgentDef": ("DELETE", "/api/v1/agents/{agent_def_id}"),
|
|
67
75
|
"agent.GetAgentDef": ("GET", "/api/v1/agents/{agent_def_id}"),
|
|
68
76
|
"agent.GetAgentDefVersion": ("GET", "/api/v1/agents/{agent_def_id}/versions/{version}"),
|
|
@@ -155,6 +163,7 @@ MULTI_PARAMS: dict[str, frozenset[str]] = {
|
|
|
155
163
|
# 走 call() 会拿到逐行 JSON 的未解析正文。
|
|
156
164
|
STREAMING: frozenset[str] = frozenset(
|
|
157
165
|
{
|
|
166
|
+
"agent.ReadAttachment",
|
|
158
167
|
"sandbox.RunCommand",
|
|
159
168
|
"sandbox.RunCode",
|
|
160
169
|
"sandbox.ReadFile",
|
|
@@ -197,7 +206,17 @@ def render_path(key: str, params: dict) -> str:
|
|
|
197
206
|
raise UsageError(f"{key} 需要路径参数 --path {name}=<值>")
|
|
198
207
|
text = str(value)
|
|
199
208
|
if name in multi:
|
|
200
|
-
|
|
209
|
+
segments = text.split("/")
|
|
210
|
+
# `.` / `..` / 空段会被 HTTP 客户端按 URL 语义归一化(`a/b/../c` → `a/c`,
|
|
211
|
+
# `//x` → `/x`),于是实际请求会跑到别的端点上,还顺手绕过上面那层
|
|
212
|
+
# 「首段在域内」的检查。逐段 encode 拦不住它们(`.` 是 unreserved,
|
|
213
|
+
# quote 不会动),所以在这里直接拒掉,要求相对、干净的路径。
|
|
214
|
+
for seg in segments:
|
|
215
|
+
if seg in ("", ".", ".."):
|
|
216
|
+
raise UsageError(
|
|
217
|
+
f"{name} 不能含空段或 '.'/'..':{text!r}(要相对路径,如 memories/notes/a.md)"
|
|
218
|
+
)
|
|
219
|
+
return "/".join(quote(seg, safe="") for seg in segments)
|
|
201
220
|
return quote(text, safe="")
|
|
202
221
|
|
|
203
222
|
return _TEMPLATE_RE.sub(repl, template)
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"""轻量自检(不联服务端):``python -m rathflow_cli.selftest``
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
查三件事,都是「装得上但会打错 URL」的那类毛病:
|
|
4
4
|
|
|
5
5
|
1. **命令实现里不写 URL 字面量** —— 所有请求必须走 ``endpoints.py`` 的端点 key;
|
|
6
6
|
2. **端点表与生成物一致** —— 生成物 ``endpoints.ts`` 由上游 proto 生成,本包的
|
|
7
7
|
``endpoints.py`` 手工同步;两者漂移就会打错 URL。生成物在本仓库中不存在
|
|
8
8
|
(它是上游 monorepo 的产物),此时这一项跳过,可用环境变量
|
|
9
9
|
``RATHFLOW_ENDPOINTS_TS`` 指向它来启用比对。
|
|
10
|
+
3. **多段路径参数拦住点段** —— ``..`` 会被 HTTP 客户端按 URL 语义归一化,
|
|
11
|
+
把请求打到别的端点上(还会绕过命令自己的域检查)。
|
|
10
12
|
"""
|
|
11
13
|
|
|
12
14
|
from __future__ import annotations
|
|
@@ -16,6 +18,8 @@ import os
|
|
|
16
18
|
import sys
|
|
17
19
|
from pathlib import Path
|
|
18
20
|
|
|
21
|
+
from .errors import UsageError
|
|
22
|
+
|
|
19
23
|
from . import endpoints
|
|
20
24
|
|
|
21
25
|
_COMMANDS = Path(__file__).resolve().parent / "commands"
|
|
@@ -46,7 +50,12 @@ _URL_LITERAL = re.compile(r'"(?:/api/|/admin/api/)')
|
|
|
46
50
|
|
|
47
51
|
def main() -> int:
|
|
48
52
|
generated_problems, compared = _check_generated()
|
|
49
|
-
problems =
|
|
53
|
+
problems = (
|
|
54
|
+
generated_problems
|
|
55
|
+
+ _check_no_url_literals()
|
|
56
|
+
+ _check_path_guard()
|
|
57
|
+
+ _check_hoist_flags()
|
|
58
|
+
)
|
|
50
59
|
if problems:
|
|
51
60
|
print(f"自检未通过({len(problems)} 项):", file=sys.stderr)
|
|
52
61
|
for line in problems:
|
|
@@ -112,6 +121,49 @@ def _check_no_url_literals() -> list[str]:
|
|
|
112
121
|
return problems
|
|
113
122
|
|
|
114
123
|
|
|
124
|
+
def _check_hoist_flags() -> list[str]:
|
|
125
|
+
"""全局旗标写在子命令后面也必须能用(脚本里最常见的写法)。"""
|
|
126
|
+
from . import cli
|
|
127
|
+
|
|
128
|
+
problems = []
|
|
129
|
+
for argv in (
|
|
130
|
+
["rathflow", "auth", "profile", "--json"],
|
|
131
|
+
["rathflow", "project", "list", "--json"],
|
|
132
|
+
["rathflow", "config", "use", "default", "--json"],
|
|
133
|
+
["rathflow", "session", "get", "S1", "--json"],
|
|
134
|
+
):
|
|
135
|
+
hoisted = cli._hoist_globals(list(argv))
|
|
136
|
+
if hoisted[1:2] != ["--json"]:
|
|
137
|
+
problems.append(f"{' '.join(argv)}: 末尾 --json 未被前移(会报 no such option)")
|
|
138
|
+
return problems
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_path_guard() -> list[str]:
|
|
142
|
+
"""多段路径里的 ``.``/``..``/空段必须被拒,正常路径仍要能渲染。"""
|
|
143
|
+
cases = {"memory.Read": "memory_path", "sandbox.ReadFile": "path"}
|
|
144
|
+
bad_inputs = ("a/../b", "../x", "/abs", "a//b", "a/./b")
|
|
145
|
+
problems = []
|
|
146
|
+
for key, name in cases.items():
|
|
147
|
+
others = {p: "X1" for p in endpoints.path_params(key) if p != name}
|
|
148
|
+
for bad in bad_inputs:
|
|
149
|
+
try:
|
|
150
|
+
rendered = endpoints.render_path(key, {name: bad, **others})
|
|
151
|
+
except UsageError:
|
|
152
|
+
continue
|
|
153
|
+
except Exception as exc: # noqa: BLE001 - 自检要报「类型不对」
|
|
154
|
+
problems.append(f"{key}: {name}={bad!r} 抛了 {type(exc).__name__},应抛 UsageError")
|
|
155
|
+
else:
|
|
156
|
+
problems.append(
|
|
157
|
+
f"{key}: {name}={bad!r} 未被拒绝,会渲染成 {rendered}(URL 归一化后打到别的端点)"
|
|
158
|
+
)
|
|
159
|
+
good = endpoints.render_path(key, {name: "a/b c.md", **others})
|
|
160
|
+
if not good.endswith("a/b%20c.md"):
|
|
161
|
+
problems.append(f"{key}: 正常路径渲染异常 {good}")
|
|
162
|
+
return problems
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
115
167
|
def _names(raw: str) -> set[str]:
|
|
116
168
|
return set(re.findall(r'"([^"]+)"', raw))
|
|
117
169
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rathflow-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: RathFlow command line client: sessions, memory, sandboxes, assets and billing over the RathFlow REST API.
|
|
5
5
|
Author: Rath Team
|
|
6
6
|
License-Expression: MIT
|
|
@@ -75,17 +75,17 @@ The default gateway is `https://rathflow.lynwe.com`. Point it elsewhere with
|
|
|
75
75
|
|
|
76
76
|
| Group | What it covers |
|
|
77
77
|
| --- | --- |
|
|
78
|
-
| `auth` | log in / out, register,
|
|
78
|
+
| `auth` | log in / out, register, profile (`profile`, `profile-update`, `change-password`, `set-email`, `set-avatar`), API keys |
|
|
79
79
|
| `config` | local profiles (`list`, `show`, `set`, `use`) |
|
|
80
|
-
| `org` | organizations
|
|
80
|
+
| `org` | organizations (`list`, `create`, `invite`, `accept`) |
|
|
81
81
|
| `project` | projects, scope switching, project config, share links |
|
|
82
82
|
| `session` | sessions, blocks, events |
|
|
83
83
|
| `memory` | memory entries, search, commit tasks |
|
|
84
84
|
| `sandbox` | sandboxes, exec, code, files, logs |
|
|
85
|
-
| `agent` | agent definitions, versions, prompts, runs |
|
|
85
|
+
| `agent` | agent definitions, versions, prompts, runs, attachments |
|
|
86
86
|
| `asset` | assets, versions, credentials, publish |
|
|
87
87
|
| `billing` | subscription, usage, invoices |
|
|
88
|
-
| `workflow` | workflows |
|
|
88
|
+
| `workflow` | workflows (`list`, `get`, `create`, `delete`, `counts`) |
|
|
89
89
|
| `admin` | platform administration (platform admins only) |
|
|
90
90
|
|
|
91
91
|
Two more entry points sit at the root:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|