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.
@@ -0,0 +1,237 @@
1
+ """session:会话、block、事件流与 block 分享。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .. import output
8
+ from ..commands._common import (
9
+ BODY_FILE_OPT,
10
+ BODY_OPT,
11
+ PAGE_SIZE_OPT,
12
+ PAGE_TOKEN_OPT,
13
+ body_of,
14
+ pagination,
15
+ )
16
+ from ..errors import UsageError
17
+ from ..state import current
18
+
19
+ app = typer.Typer(no_args_is_help=True, help="会话 / block / 事件流")
20
+
21
+ SESSION_COLUMNS = [
22
+ ("SESSION ID", "sessionId"),
23
+ ("状态", "status"),
24
+ ("标题", "title"),
25
+ ("AGENT", "agentDefId"),
26
+ ("创建时间", "createdAt"),
27
+ ]
28
+ BLOCK_COLUMNS = [
29
+ ("BLOCK ID", "blockId"),
30
+ ("标题", "title"),
31
+ ("事件区间", "fromSeq"),
32
+ ("到", "toSeq"),
33
+ ("创建时间", "createdAt"),
34
+ ]
35
+
36
+
37
+ # ------------------------------------------------------------------ 会话
38
+
39
+ @app.command("list")
40
+ def list_(
41
+ page_size: int = PAGE_SIZE_OPT,
42
+ page_token: str = PAGE_TOKEN_OPT,
43
+ status: str = typer.Option(None, "--status", help="按状态过滤"),
44
+ ) -> None:
45
+ """列出本项目会话。"""
46
+ st = current()
47
+ payload = st.client().call(
48
+ "session.ListSessions", query=pagination(page_size or 50, page_token, {"status": status})
49
+ )
50
+ output.emit_items(payload, items_key="sessions", columns=SESSION_COLUMNS, json_out=st.json_out)
51
+
52
+
53
+ @app.command("get")
54
+ def get(session_id: str = typer.Argument(...)) -> None:
55
+ """会话详情。"""
56
+ st = current()
57
+ payload = st.client().call("session.GetSession", path_params={"session_id": session_id})
58
+ output.emit_object(payload, json_out=st.json_out)
59
+
60
+
61
+ @app.command("create")
62
+ def create(
63
+ body: str = BODY_OPT,
64
+ body_file: str = BODY_FILE_OPT,
65
+ title: str = typer.Option(None, "--title", help="标题(等价于 --body 里给 title)"),
66
+ ) -> None:
67
+ """建会话。"""
68
+ st = current()
69
+ payload_body = body_of(body, body_file)
70
+ if payload_body is None:
71
+ payload_body = {"title": title} if title else {}
72
+ payload = st.client().call("session.CreateSession", body=payload_body)
73
+ if st.json_out:
74
+ output.emit_json(payload)
75
+ return
76
+ typer.echo(payload.get("sessionId") or "")
77
+
78
+
79
+ @app.command("delete")
80
+ def delete(
81
+ session_id: str = typer.Argument(...),
82
+ yes: bool = typer.Option(False, "--yes", "-y"),
83
+ ) -> None:
84
+ """删会话。"""
85
+ st = current()
86
+ if not yes:
87
+ typer.confirm(f"确认删除会话 {session_id}?", abort=True)
88
+ st.client().call("session.DeleteSession", path_params={"session_id": session_id})
89
+ typer.echo(f"已删除 {session_id}")
90
+
91
+
92
+ @app.command("archive")
93
+ def archive(session_id: str = typer.Argument(...)) -> None:
94
+ """归档会话。"""
95
+ st = current()
96
+ payload = st.client().call("session.ArchiveSession", path_params={"session_id": session_id})
97
+ output.emit_object(payload, json_out=st.json_out)
98
+
99
+
100
+ @app.command("lineage")
101
+ def lineage(session_id: str = typer.Argument(...)) -> None:
102
+ """血统(fork/派生关系)。"""
103
+ st = current()
104
+ payload = st.client().call("session.GetSessionLineage", path_params={"session_id": session_id})
105
+ output.emit_object(payload, json_out=st.json_out)
106
+
107
+
108
+ @app.command("interrupt")
109
+ def interrupt(session_id: str = typer.Argument(...)) -> None:
110
+ """打断正在跑的 run(agent.Interrupt)。"""
111
+ st = current()
112
+ payload = st.client().call("agent.Interrupt", path_params={"session_id": session_id}, body={})
113
+ output.emit_object(payload, json_out=st.json_out)
114
+
115
+
116
+ # ------------------------------------------------------------------ block
117
+
118
+ @app.command("blocks")
119
+ def blocks(
120
+ session_id: str = typer.Argument(...),
121
+ page_size: int = PAGE_SIZE_OPT,
122
+ page_token: str = PAGE_TOKEN_OPT,
123
+ ) -> None:
124
+ """列会话下的 block。"""
125
+ st = current()
126
+ payload = st.client().call(
127
+ "session.ListBlocks",
128
+ path_params={"session_id": session_id},
129
+ query=pagination(page_size or 50, page_token),
130
+ )
131
+ output.emit_items(payload, items_key="blocks", columns=BLOCK_COLUMNS, json_out=st.json_out)
132
+
133
+
134
+ @app.command("block")
135
+ def block_get(block_id: str = typer.Argument(...)) -> None:
136
+ """看单个 block。"""
137
+ st = current()
138
+ payload = st.client().call("session.GetBlock", path_params={"block_id": block_id})
139
+ output.emit_object(payload, json_out=st.json_out)
140
+
141
+
142
+ @app.command("block-create")
143
+ def block_create(
144
+ session_id: str = typer.Argument(...),
145
+ body: str = BODY_OPT,
146
+ body_file: str = BODY_FILE_OPT,
147
+ ) -> None:
148
+ """给会话加 block(内容用 --body/--body-file 传)。"""
149
+ st = current()
150
+ payload_body = body_of(body, body_file)
151
+ if payload_body is None:
152
+ raise UsageError('需要 --body \'{"type":...,...}\' 或 --body-file <路径|->')
153
+ payload = st.client().call(
154
+ "session.CreateBlock", path_params={"session_id": session_id}, body=payload_body
155
+ )
156
+ if st.json_out:
157
+ output.emit_json(payload)
158
+ return
159
+ typer.echo(payload.get("blockId") or "")
160
+
161
+
162
+ @app.command("block-delete")
163
+ def block_delete(
164
+ block_id: str = typer.Argument(...),
165
+ yes: bool = typer.Option(False, "--yes", "-y"),
166
+ ) -> None:
167
+ """删 block。"""
168
+ st = current()
169
+ if not yes:
170
+ typer.confirm(f"确认删除 block {block_id}?", abort=True)
171
+ st.client().call("session.DeleteBlock", path_params={"block_id": block_id})
172
+ typer.echo(f"已删除 {block_id}")
173
+
174
+
175
+ @app.command("share")
176
+ def share(
177
+ block_id: str = typer.Argument(...),
178
+ body: str = BODY_OPT,
179
+ body_file: str = BODY_FILE_OPT,
180
+ ) -> None:
181
+ """把 block 分享给别人(scope 用 --body 传)。"""
182
+ st = current()
183
+ payload = st.client().call(
184
+ "session.ShareBlock", path_params={"block_id": block_id}, body=body_of(body, body_file) or {}
185
+ )
186
+ output.emit_object(payload, json_out=st.json_out)
187
+
188
+
189
+ @app.command("unshare")
190
+ def unshare(share_id: str = typer.Argument(...)) -> None:
191
+ """撤销 block 分享。"""
192
+ st = current()
193
+ st.client().call("session.RevokeBlockShare", path_params={"share_id": share_id})
194
+ typer.echo(f"已撤销 {share_id}")
195
+
196
+
197
+ # ------------------------------------------------------------------ 事件
198
+
199
+ @app.command("events")
200
+ def events(
201
+ session_id: str = typer.Argument(...),
202
+ page_size: int = PAGE_SIZE_OPT,
203
+ page_token: str = PAGE_TOKEN_OPT,
204
+ ) -> None:
205
+ """拉历史事件(一次性)。"""
206
+ st = current()
207
+ payload = st.client().call(
208
+ "session.GetSessionEvents",
209
+ path_params={"session_id": session_id},
210
+ query=pagination(page_size or 100, page_token),
211
+ )
212
+ output.emit_items(
213
+ payload,
214
+ items_key="events",
215
+ columns=[("SEQ", "seq"), ("数据", "payloadJson"), ("时间", "createdAt")],
216
+ json_out=st.json_out,
217
+ )
218
+
219
+
220
+ @app.command("watch")
221
+ def watch(
222
+ session_id: str = typer.Argument(...),
223
+ raw: bool = typer.Option(False, "--raw", help="逐帧原样打印 JSON,不渲染"),
224
+ from_seq: int = typer.Option(None, "--from-seq", help="从该 seq 续传(断线重连用)"),
225
+ ) -> None:
226
+ """跟事件流(:stream,长连,Ctrl-C 退出)。"""
227
+ st = current()
228
+ query = {"from_seq": from_seq} if from_seq is not None else None
229
+ for frame in st.client().stream(
230
+ "session.StreamSessionEvents", path_params={"session_id": session_id}, query=query
231
+ ):
232
+ if st.json_out or raw:
233
+ output.emit_json(frame)
234
+ continue
235
+ seq = frame.get("seq")
236
+ prefix = f"{str(seq).rjust(6)} " if seq is not None else ""
237
+ typer.echo(prefix + output._cell(frame.get("payloadJson") or frame))
@@ -0,0 +1,81 @@
1
+ """workflow:工作流定义与删除。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .. import output
8
+ from ..commands._common import (
9
+ BODY_FILE_OPT,
10
+ BODY_OPT,
11
+ PAGE_SIZE_OPT,
12
+ PAGE_TOKEN_OPT,
13
+ body_of,
14
+ pagination,
15
+ )
16
+ from ..state import current
17
+
18
+ app = typer.Typer(no_args_is_help=True, help="工作流")
19
+
20
+ COLUMNS = [
21
+ ("WORKFLOW ID", "workflowId"),
22
+ ("标题", "title"),
23
+ ("状态", "status"),
24
+ ("更新时间", "updatedAt"),
25
+ ]
26
+
27
+
28
+ @app.command("list")
29
+ def list_(
30
+ page_size: int = PAGE_SIZE_OPT,
31
+ page_token: str = PAGE_TOKEN_OPT,
32
+ ) -> None:
33
+ """列工作流。"""
34
+ st = current()
35
+ payload = st.client().call("workflow.ListWorkflows", query=pagination(page_size or 50, page_token))
36
+ output.emit_items(payload, items_key="workflows", columns=COLUMNS, json_out=st.json_out)
37
+
38
+
39
+ @app.command("get")
40
+ def get(workflow_id: str = typer.Argument(...)) -> None:
41
+ """工作流详情。"""
42
+ st = current()
43
+ payload = st.client().call("workflow.GetWorkflow", path_params={"workflow_id": workflow_id})
44
+ output.emit_object(payload, json_out=st.json_out)
45
+
46
+
47
+ @app.command("create")
48
+ def create(
49
+ title: str = typer.Option(None, "--title", "-t"),
50
+ body: str = BODY_OPT,
51
+ body_file: str = BODY_FILE_OPT,
52
+ ) -> None:
53
+ """建工作流(图定义用 --body 传)。"""
54
+ st = current()
55
+ payload_body = body_of(body, body_file) or {}
56
+ if title:
57
+ payload_body.setdefault("title", title)
58
+ payload = st.client().call("workflow.CreateWorkflow", body=payload_body)
59
+ if st.json_out:
60
+ output.emit_json(payload)
61
+ return
62
+ typer.echo(payload.get("workflowId") or "")
63
+
64
+
65
+ @app.command("delete")
66
+ def delete(
67
+ workflow_id: str = typer.Argument(...),
68
+ yes: bool = typer.Option(False, "--yes", "-y"),
69
+ ) -> None:
70
+ """删工作流(连同其会话与沙箱;响应会报删了多少)。"""
71
+ st = current()
72
+ if not yes:
73
+ typer.confirm(f"确认删除工作流 {workflow_id}?其下会话与沙箱一并清理", abort=True)
74
+ payload = st.client().call("workflow.DeleteWorkflow", path_params={"workflow_id": workflow_id})
75
+ if st.json_out:
76
+ output.emit_json(payload)
77
+ return
78
+ typer.echo(
79
+ f"已删除;清理会话 {payload.get('sessionsDeleted', 0)} 个、"
80
+ f"沙箱 {payload.get('sandboxesTerminated', 0)} 个"
81
+ )
rathflow_cli/config.py ADDED
@@ -0,0 +1,69 @@
1
+ """本地配置:~/.config/rathflow/config.json(多 profile)。
2
+
3
+ 选 JSON 而非 TOML:tomllib 只读,写 TOML 需额外依赖(违反 P4)。
4
+
5
+ 优先级(由调用方在 cli.py 统一合并):flag > 环境变量 > profile > 内建默认。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import stat
13
+ from pathlib import Path
14
+
15
+ # 面向公开发布的内建默认:托管网关。本地开发用 --base-url 或 RATHFLOW_BASE_URL
16
+ # 指回 http://127.0.0.1:8080。
17
+ DEFAULT_BASE_URL = "https://rathflow.lynwe.com"
18
+
19
+ # 环境变量名与 Get Started 页 curl 片段逐字同名,页面片段可直接喂 CLI。
20
+ ENV_BASE_URL = "RATHFLOW_BASE_URL"
21
+ ENV_PROJECT = "RATHFLOW_PROJECT"
22
+ ENV_TOKEN = "RATHFLOW_TOKEN"
23
+ ENV_CONFIG_DIR = "RATHFLOW_CONFIG_DIR"
24
+
25
+
26
+ def config_dir() -> Path:
27
+ return Path(os.environ.get(ENV_CONFIG_DIR) or (Path.home() / ".config" / "rathflow"))
28
+
29
+
30
+ def config_path() -> Path:
31
+ return config_dir() / "config.json"
32
+
33
+
34
+ def load() -> dict:
35
+ p = config_path()
36
+ if not p.exists():
37
+ return {"current": "default", "profiles": {}}
38
+ try:
39
+ data = json.loads(p.read_text(encoding="utf-8"))
40
+ except (OSError, json.JSONDecodeError) as exc:
41
+ raise SystemExit(f"配置文件损坏:{p}({exc})")
42
+ data.setdefault("current", "default")
43
+ data.setdefault("profiles", {})
44
+ return data
45
+
46
+
47
+ def save(data: dict) -> Path:
48
+ """落盘并收紧权限到 0600(内含令牌,等同口令)。"""
49
+ p = config_path()
50
+ p.parent.mkdir(parents=True, exist_ok=True)
51
+ tmp = p.with_suffix(".json.tmp")
52
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
53
+ tmp.replace(p)
54
+ try:
55
+ p.chmod(stat.S_IRUSR | stat.S_IWUSR)
56
+ except OSError:
57
+ pass # 非 POSIX 文件系统:尽力而为
58
+ return p
59
+
60
+
61
+ def profile(data: dict, name: str | None = None) -> dict:
62
+ return data["profiles"].setdefault(name or data["current"], {})
63
+
64
+
65
+ def mask(secret: str | None) -> str:
66
+ """令牌/密钥打码显示:保留前 8 位。"""
67
+ if not secret:
68
+ return "(未设置)"
69
+ return secret[:8] + "…" if len(secret) > 8 else "…"
@@ -0,0 +1,207 @@
1
+ """REST 端点表 —— 全 CLI 的路径唯一事实源(**禁止在命令实现里写 URL 字面量**)。
2
+
3
+ 内容与 web/lib/api/generated/endpoints.ts 同源(proto → grpc-gateway 生成)。
4
+ 契约变了改本文件;后续可改为从 proto 生成(设计文档 §3.5)。
5
+
6
+ 值 = (HTTP 方法, 路径模板)。模板里的 `{name}` 是 proto 字段名(snake_case)。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from urllib.parse import quote
13
+
14
+ from .errors import UsageError
15
+
16
+ # key → (method, path)
17
+ ENDPOINTS: dict[str, tuple[str, str]] = {
18
+ # ---- auth ----
19
+ "auth.IssueAPIKey": ("POST", "/api/v1/api-keys"),
20
+ "auth.ListAPIKeys": ("GET", "/api/v1/api-keys"),
21
+ "auth.Login": ("POST", "/api/v1/auth/login"),
22
+ "auth.Logout": ("POST", "/api/v1/auth/logout"),
23
+ "auth.Refresh": ("POST", "/api/v1/auth/refresh"),
24
+ "auth.Register": ("POST", "/api/v1/auth/register"),
25
+ "auth.RevokeAPIKey": ("DELETE", "/api/v1/api-keys/{key_id}"),
26
+ # ---- tenant:分享链接(能力凭证,明文只在创建/轮换时出现一次)----
27
+ "tenant.JoinSharedProject": ("POST", "/api/v1/shared-projects"),
28
+ "tenant.ListSharedProjects": ("GET", "/api/v1/shared-projects"),
29
+ "tenant.RevokeProjectShare": ("DELETE", "/api/v1/projects/{project_id}/share"),
30
+ "tenant.UpdateProjectShare": ("PATCH", "/api/v1/projects/{project_id}/share"),
31
+ # ---- tenant:org 成员身份 ----
32
+ "tenant.AcceptOrgInvitation": ("POST", "/api/v1/orgs/join"),
33
+ "tenant.CreateOrgInvitation": ("POST", "/api/v1/orgs/{org_id}/invitations"),
34
+ "tenant.ListMyOrgs": ("GET", "/api/v1/orgs"),
35
+ # ---- tenant:project ----
36
+ "tenant.CreateProject": ("POST", "/api/v1/projects"),
37
+ "tenant.DeleteProject": ("DELETE", "/api/v1/projects/{project_id}"),
38
+ "tenant.GetProject": ("GET", "/api/v1/projects/{project_id}"),
39
+ "tenant.GetProjectConfig": ("GET", "/api/v1/projects/{project_id}/config"),
40
+ "tenant.ListProjects": ("GET", "/api/v1/projects"),
41
+ "tenant.UpdateProject": ("PATCH", "/api/v1/projects/{project_id}"),
42
+ "tenant.UpdateProjectConfig": ("PATCH", "/api/v1/projects/{project_id}/config"),
43
+ # ---- session ----
44
+ "session.ArchiveSession": ("POST", "/api/v1/sessions/{session_id}:archive"),
45
+ "session.CreateBlock": ("POST", "/api/v1/sessions/{session_id}/blocks"),
46
+ "session.CreateSession": ("POST", "/api/v1/sessions"),
47
+ "session.DeleteBlock": ("DELETE", "/api/v1/blocks/{block_id}"),
48
+ "session.DeleteSession": ("DELETE", "/api/v1/sessions/{session_id}"),
49
+ "session.GetBlock": ("GET", "/api/v1/blocks/{block_id}"),
50
+ "session.GetSession": ("GET", "/api/v1/sessions/{session_id}"),
51
+ "session.GetSessionEvents": ("GET", "/api/v1/sessions/{session_id}/events"),
52
+ "session.GetSessionLineage": ("GET", "/api/v1/sessions/{session_id}/lineage"),
53
+ "session.ListBlocks": ("GET", "/api/v1/sessions/{session_id}/blocks"),
54
+ "session.ListSessions": ("GET", "/api/v1/sessions"),
55
+ "session.RevokeBlockShare": ("DELETE", "/api/v1/block-shares/{share_id}"),
56
+ "session.ShareBlock": ("POST", "/api/v1/blocks/{block_id}/shares"),
57
+ "session.StreamSessionEvents": ("GET", "/api/v1/sessions/{session_id}/events:stream"),
58
+ # ---- workflow ----
59
+ "workflow.CreateWorkflow": ("POST", "/api/v1/workflows"),
60
+ "workflow.DeleteWorkflow": ("DELETE", "/api/v1/workflows/{workflow_id}"),
61
+ "workflow.GetWorkflow": ("GET", "/api/v1/workflows/{workflow_id}"),
62
+ "workflow.ListWorkflows": ("GET", "/api/v1/workflows"),
63
+ "workflow.UpdateWorkflow": ("PATCH", "/api/v1/workflows/{workflow_id}"),
64
+ # ---- agent ----
65
+ "agent.CreateAgentDef": ("POST", "/api/v1/agents"),
66
+ "agent.DeleteAgentDef": ("DELETE", "/api/v1/agents/{agent_def_id}"),
67
+ "agent.GetAgentDef": ("GET", "/api/v1/agents/{agent_def_id}"),
68
+ "agent.GetAgentDefVersion": ("GET", "/api/v1/agents/{agent_def_id}/versions/{version}"),
69
+ "agent.GetRun": ("GET", "/api/v1/runs/{run_id}"),
70
+ "agent.Interrupt": ("POST", "/api/v1/sessions/{session_id}:interrupt"),
71
+ "agent.ListAgentDefs": ("GET", "/api/v1/agents"),
72
+ "agent.ListAgentDefVersions": ("GET", "/api/v1/agents/{agent_def_id}/versions"),
73
+ "agent.ListAgentDirectory": ("GET", "/api/v1/agent-directory"),
74
+ "agent.ListAgentMessages": ("GET", "/api/v1/agent-directory/{agent_id}/messages"),
75
+ "agent.ListRuns": ("GET", "/api/v1/sessions/{session_id}/runs"),
76
+ "agent.Prompt": ("POST", "/api/v1/sessions/{session_id}:prompt"),
77
+ "agent.ResolveAgentAddress": ("GET", "/api/v1/agent-directory/{agent_id}"),
78
+ "agent.UpdateAgentDef": ("PATCH", "/api/v1/agents/{agent_def_id}"),
79
+ # ---- memory ----
80
+ "memory.CommitSession": ("POST", "/api/v1/memories:commit"),
81
+ "memory.Delete": ("DELETE", "/api/v1/memories/{memory_path}"),
82
+ "memory.GetTask": ("GET", "/api/v1/memory-tasks/{task_id}"),
83
+ "memory.List": ("GET", "/api/v1/memories"),
84
+ "memory.ListTasks": ("GET", "/api/v1/memory-tasks"),
85
+ "memory.Read": ("GET", "/api/v1/memories/{memory_path}"),
86
+ "memory.Search": ("POST", "/api/v1/memories:search"),
87
+ "memory.Tree": ("POST", "/api/v1/memories:tree"),
88
+ "memory.WatchTask": ("GET", "/api/v1/memory-tasks/{task_id}:watch"),
89
+ "memory.Write": ("PUT", "/api/v1/memories/{memory_path}"),
90
+ # ---- sandbox ----
91
+ "sandbox.Create": ("POST", "/api/v1/sandboxes"),
92
+ "sandbox.ExposePort": ("POST", "/api/v1/sandboxes/{sandbox_id}/ports"),
93
+ "sandbox.Get": ("GET", "/api/v1/sandboxes/{sandbox_id}"),
94
+ "sandbox.List": ("GET", "/api/v1/sandboxes"),
95
+ "sandbox.ListDir": ("GET", "/api/v1/sandboxes/{sandbox_id}/directory"),
96
+ "sandbox.ReadFile": ("GET", "/api/v1/sandboxes/{sandbox_id}/files/{path}"),
97
+ "sandbox.Renew": ("POST", "/api/v1/sandboxes/{sandbox_id}:renew"),
98
+ "sandbox.RunCode": ("POST", "/api/v1/sandboxes/{sandbox_id}/code"),
99
+ "sandbox.RunCommand": ("POST", "/api/v1/sandboxes/{sandbox_id}/exec"),
100
+ "sandbox.Stat": ("GET", "/api/v1/sandboxes/{sandbox_id}/stat"),
101
+ "sandbox.StreamLogs": ("GET", "/api/v1/sandboxes/{sandbox_id}/logs"),
102
+ "sandbox.Terminate": ("DELETE", "/api/v1/sandboxes/{sandbox_id}"),
103
+ # ---- assets ----
104
+ "assets.CreateAsset": ("POST", "/admin/api/v1/assets"),
105
+ "assets.CreateAssetVersion": ("POST", "/admin/api/v1/assets/{asset_id}/versions"),
106
+ "assets.GetAssemblyArtifact": (
107
+ "GET",
108
+ "/admin/api/v1/assembly-artifacts/{project_id}/revisions/{revision}",
109
+ ),
110
+ "assets.GetAssemblyStatus": ("GET", "/api/v1/assembly-status"),
111
+ "assets.GetAsset": ("GET", "/api/v1/assets/{asset_id}"),
112
+ "assets.ListAssemblyRevisions": ("GET", "/admin/api/v1/assembly-revisions"),
113
+ "assets.ListAssets": ("GET", "/api/v1/assets"),
114
+ "assets.ListEnabledAssets": ("GET", "/api/v1/assets:enabled"),
115
+ "assets.PinAssetVersion": ("POST", "/api/v1/assets/{asset_id}:pin-version"),
116
+ "assets.PublishVersion": (
117
+ "POST",
118
+ "/admin/api/v1/assets/{asset_id}/versions/{version_id}:publish",
119
+ ),
120
+ "assets.PutAssetContent": (
121
+ "PUT",
122
+ "/admin/api/v1/assets/{asset_id}/versions/{version_id}/content",
123
+ ),
124
+ "assets.SetAssetConfig": ("POST", "/api/v1/assets/{asset_id}:set-config"),
125
+ "assets.SetAssetEnabled": ("POST", "/api/v1/assets/{asset_id}:set-enabled"),
126
+ "assets.SetAssetLifecycle": ("POST", "/admin/api/v1/assets/{asset_id}:set-lifecycle"),
127
+ "assets.SetCredential": ("POST", "/api/v1/assets/{asset_id}:set-credential"),
128
+ "assets.SetVisibility": ("POST", "/admin/api/v1/assets/{asset_id}:set-visibility"),
129
+ "assets.SyncCoreAssets": ("POST", "/admin/api/v1/assets:sync-core"),
130
+ "assets.TriggerAssemblyDispatch": ("POST", "/admin/api/v1/assembly-dispatch:trigger"),
131
+ "assets.UpdateAsset": ("PATCH", "/admin/api/v1/assets/{asset_id}"),
132
+ "assets.YankVersion": ("POST", "/admin/api/v1/assets/{asset_id}/versions/{version_id}:yank"),
133
+ # ---- billing ----
134
+ "billing.GetSubscription": ("GET", "/api/v1/billing/subscription"),
135
+ "billing.GetUsage": ("GET", "/api/v1/billing/usage"),
136
+ "billing.ListInvoices": ("GET", "/api/v1/billing/invoices"),
137
+ # ---- admin ----
138
+ "admin.ListUsers": ("GET", "/admin/api/v1/users"),
139
+ "admin.PromoteUser": ("POST", "/admin/api/v1/users/{user_id}:promote"),
140
+ "admin.RotateContextTokenKey": ("POST", "/admin/api/v1/context-token-keys:rotate"),
141
+ "admin.SetUserStatus": ("PUT", "/admin/api/v1/users/{user_id}/status"),
142
+ }
143
+
144
+ # 多段通配参数(模板里写作 {name=**}):值含 `/`,须**逐段 encode 后以 `/` 连接**,
145
+ # 不可整体 encodeURIComponent(否则 `notes/a.md` 会被压成单个 segment)。
146
+ MULTI_PARAMS: dict[str, frozenset[str]] = {
147
+ "memory.Delete": frozenset({"memory_path"}),
148
+ "memory.Read": frozenset({"memory_path"}),
149
+ "memory.Write": frozenset({"memory_path"}),
150
+ "sandbox.ReadFile": frozenset({"path"}),
151
+ }
152
+
153
+ # 响应为流的端点(唯一事实源:contracts/gen/go-gateway 里 forward_*_0 =
154
+ # runtime.ForwardResponseStream 的那几条)。流式端点必须走 Client.stream():
155
+ # 走 call() 会拿到逐行 JSON 的未解析正文。
156
+ STREAMING: frozenset[str] = frozenset(
157
+ {
158
+ "sandbox.RunCommand",
159
+ "sandbox.RunCode",
160
+ "sandbox.ReadFile",
161
+ "sandbox.StreamLogs",
162
+ "session.StreamSessionEvents",
163
+ "memory.Search",
164
+ "memory.WatchTask",
165
+ }
166
+ )
167
+
168
+
169
+ def is_streaming(key: str) -> bool:
170
+ return key in STREAMING
171
+
172
+
173
+ _TEMPLATE_RE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)(=\*\*)?\}")
174
+
175
+
176
+ def path_params(key: str) -> list[str]:
177
+ """路径模板需要的参数名。"""
178
+ return [m.group(1) for m in _TEMPLATE_RE.finditer(_spec(key)[1])]
179
+
180
+
181
+ def _spec(key: str) -> tuple[str, str]:
182
+ try:
183
+ return ENDPOINTS[key]
184
+ except KeyError:
185
+ raise UsageError(f"未知端点 {key!r}(用 `rathflow api --list` 查看全部)") from None
186
+
187
+
188
+ def render_path(key: str, params: dict) -> str:
189
+ """把模板渲染成真实路径,逐参数 encode。缺参数 → UsageError。"""
190
+ _method, template = _spec(key)
191
+ multi = MULTI_PARAMS.get(key, frozenset())
192
+
193
+ def repl(m: re.Match) -> str:
194
+ name = m.group(1)
195
+ value = params.get(name)
196
+ if value is None or value == "":
197
+ raise UsageError(f"{key} 需要路径参数 --path {name}=<值>")
198
+ text = str(value)
199
+ if name in multi:
200
+ return "/".join(quote(seg, safe="") for seg in text.split("/"))
201
+ return quote(text, safe="")
202
+
203
+ return _TEMPLATE_RE.sub(repl, template)
204
+
205
+
206
+ def method_of(key: str) -> str:
207
+ return _spec(key)[0]
rathflow_cli/errors.py ADDED
@@ -0,0 +1,70 @@
1
+ """错误信封与退出码(contracts/docs/error-envelope.md)。
2
+
3
+ 信封形状:{"error": {"code","message","details"}}。终端只显示 message,code 供程序判定。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ # 退出码约定(docs:CLI 设计 §3.8)
9
+ EXIT_OK = 0
10
+ EXIT_ERROR = 1 # 未分类
11
+ EXIT_USAGE = 2 # 参数/缺 project/未登录
12
+ EXIT_AUTH = 3 # 401(刷新也无效)
13
+ EXIT_FORBIDDEN = 4 # 403
14
+ EXIT_NOT_FOUND = 5 # 404
15
+ EXIT_INVALID = 6 # 400 INVALID_ARGUMENT / FAILED_PRECONDITION / OUT_OF_RANGE
16
+ EXIT_CONFLICT = 7 # 409
17
+ EXIT_RATE_LIMIT = 8 # 429
18
+ EXIT_SERVER = 9 # 5xx
19
+
20
+ _BY_STATUS = {
21
+ 400: EXIT_INVALID,
22
+ 401: EXIT_AUTH,
23
+ 403: EXIT_FORBIDDEN,
24
+ 404: EXIT_NOT_FOUND,
25
+ 409: EXIT_CONFLICT,
26
+ 429: EXIT_RATE_LIMIT,
27
+ 499: EXIT_ERROR,
28
+ 500: EXIT_SERVER,
29
+ 501: EXIT_SERVER,
30
+ 503: EXIT_SERVER,
31
+ 504: EXIT_SERVER,
32
+ }
33
+
34
+
35
+ class ApiError(Exception):
36
+ """一次失败的 API 调用(信封已解出,或传输层失败)。"""
37
+
38
+ def __init__(self, status: int, code: str, message: str, details=None):
39
+ super().__init__(message)
40
+ self.status = status
41
+ self.code = code
42
+ self.message = message
43
+ self.details = list(details or [])
44
+
45
+ @property
46
+ def exit_code(self) -> int:
47
+ return _BY_STATUS.get(self.status, EXIT_ERROR)
48
+
49
+ def __str__(self) -> str:
50
+ if self.code and self.code not in ("UNKNOWN", ""):
51
+ return f"{self.message} [{self.code}]"
52
+ return self.message
53
+
54
+
55
+ class UsageError(Exception):
56
+ """本地用法错误(未登录、缺 project、参数不合法)。退出码 2。"""
57
+
58
+
59
+ def from_response(status: int, payload) -> ApiError:
60
+ """从响应体解出信封;形状不符时退回通用文案(不外泄内部细节)。"""
61
+ body = payload if isinstance(payload, dict) else {}
62
+ err = body.get("error")
63
+ if not isinstance(err, dict):
64
+ return ApiError(status, "UNKNOWN", f"请求失败(HTTP {status})")
65
+ return ApiError(
66
+ status,
67
+ str(err.get("code") or "UNKNOWN"),
68
+ str(err.get("message") or f"请求失败(HTTP {status})"),
69
+ err.get("details"),
70
+ )