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,75 @@
1
+ """api:端点表逃生舱 —— 表里没有的调用(或想自己拼参数时)走这里。
2
+
3
+ 设计取舍:**不留未覆盖端点**。新 proto 先于 CLI 发布时,用 `rathflow api <Key>`
4
+ 即可调通;CLI 补上具名命令后再切回来。端点 key 见 `rathflow api --list`。
5
+
6
+ 本模块只暴露两个普通函数,由 cli.py 直接注册成根命令(不是 Typer 子组):
7
+ 这样 `rathflow api <Key>` 才是最短形态,不必多打一层 `call`。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import typer
13
+
14
+ from .. import endpoints, output
15
+ from ..commands._common import BODY_FILE_OPT, BODY_OPT, PATH_OPT, QUERY_OPT, body_of, pairs
16
+ from ..errors import UsageError
17
+ from ..state import current
18
+
19
+
20
+ def list_endpoints(filter: str | None = None) -> None:
21
+ """列出全部端点 key(含是否流式)。本函数不是命令,由 call() 调用。"""
22
+ st = current()
23
+ rows = [
24
+ {
25
+ "key": key,
26
+ "method": spec[0],
27
+ "path": spec[1],
28
+ "stream": "stream" if endpoints.is_streaming(key) else "",
29
+ }
30
+ for key, spec in sorted(endpoints.ENDPOINTS.items())
31
+ if not filter or filter.lower() in key.lower()
32
+ ]
33
+ output.emit_table(
34
+ rows,
35
+ [("KEY", "key"), ("方法", "method"), ("路径", "path"), ("流", "stream")],
36
+ json_out=st.json_out,
37
+ )
38
+
39
+
40
+ def call(
41
+ key: str = typer.Argument(None, help="端点 key,如 sandbox.RunCommand"),
42
+ list_: bool = typer.Option(False, "--list", "-l", help="列端点而不调用"),
43
+ filter: str = typer.Option(None, "--filter", "-f", help="配合 --list:按子串过滤 key"),
44
+ path: list[str] = PATH_OPT,
45
+ query: list[str] = QUERY_OPT,
46
+ body: str = BODY_OPT,
47
+ body_file: str = BODY_FILE_OPT,
48
+ stream: bool = typer.Option(None, "--stream/--no-stream", help="强制流式;缺省按端点表判定"),
49
+ url: str = typer.Option(None, "--url", help="直接给路径,配合 --method(绕过端点表)"),
50
+ method: str = typer.Option(None, "--method", "-X", help="配合 --url 用"),
51
+ ) -> None:
52
+ """调一个端点;`--url`/`--method` 可绕过端点表。"""
53
+ if list_:
54
+ list_endpoints(filter)
55
+ return
56
+ if not key and not url:
57
+ raise UsageError("给端点 key,或 --url/--method;列端点用 `rathflow api --list`")
58
+ st = current()
59
+ payload_body = body_of(body, body_file)
60
+ common = {"body": payload_body, "query": pairs(query)}
61
+
62
+ if url:
63
+ is_stream = bool(stream)
64
+ target = dict(common, method=method or "GET", path=url)
65
+ else:
66
+ if stream is False and endpoints.is_streaming(key):
67
+ raise UsageError(f"{key} 是流式端点,不能 --no-stream(其响应体是逐行 JSON)")
68
+ is_stream = endpoints.is_streaming(key) if stream is None else stream
69
+ target = dict(common, key=key, path_params=pairs(path))
70
+
71
+ if is_stream:
72
+ for frame in st.client().stream(**target):
73
+ output.emit_json(frame)
74
+ return
75
+ output.emit_json(st.client().call(**target))
@@ -0,0 +1,449 @@
1
+ """asset:资产(skill / mcp_server / plugin)、版本、可见性与装配。
2
+
3
+ 管理面(/admin/...)与消费面(/api/v1/...)同放一组命令:
4
+ 能不能跑通由服务端权限决定,CLI 不预先拦(403 会带原因回来)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+
11
+ import typer
12
+
13
+ from .. import output
14
+ from ..commands._common import (
15
+ BODY_FILE_OPT,
16
+ BODY_OPT,
17
+ PAGE_SIZE_OPT,
18
+ PAGE_TOKEN_OPT,
19
+ body_of,
20
+ pagination,
21
+ )
22
+ from ..errors import UsageError
23
+ from ..state import current
24
+
25
+ app = typer.Typer(no_args_is_help=True, help="资产 / 版本 / 装配")
26
+
27
+ KINDS = "skill | mcp_server | plugin"
28
+ TIERS = "core | optional"
29
+ LIFECYCLES = "active | retired"
30
+ SCOPES = "global | plan | org"
31
+ POLICIES = "immediate | on_idle | manual"
32
+
33
+ COLUMNS = [
34
+ ("ASSET ID", "assetId"),
35
+ ("类型", "kind"),
36
+ ("层级", "tier"),
37
+ ("名称", "name"),
38
+ ("启用", "enabled"),
39
+ ("生效版本", "effectiveVersion"),
40
+ ]
41
+
42
+
43
+ # ------------------------------------------------------------------ 消费面
44
+
45
+ @app.command("list")
46
+ def list_(
47
+ kind: str = typer.Option(None, "--kind", help=KINDS),
48
+ tier: str = typer.Option(None, "--tier", help=TIERS),
49
+ page_size: int = PAGE_SIZE_OPT,
50
+ page_token: str = PAGE_TOKEN_OPT,
51
+ ) -> None:
52
+ """列资产(含未启用)。"""
53
+ st = current()
54
+ query = pagination(page_size or 50, page_token, {"kind": _enum("ASSET_KIND", kind), "tier": _enum("ASSET_TIER", tier)})
55
+ payload = st.client().call("assets.ListAssets", query=query)
56
+ output.emit_items(payload, items_key="assets", columns=COLUMNS, json_out=st.json_out)
57
+
58
+
59
+ @app.command("enabled")
60
+ def enabled(
61
+ page_size: int = PAGE_SIZE_OPT,
62
+ page_token: str = PAGE_TOKEN_OPT,
63
+ ) -> None:
64
+ """只看本项目已启用的资产。"""
65
+ st = current()
66
+ payload = st.client().call(
67
+ "assets.ListEnabledAssets", query=pagination(page_size or 50, page_token)
68
+ )
69
+ output.emit_items(payload, items_key="assets", columns=COLUMNS, json_out=st.json_out)
70
+
71
+
72
+ @app.command("get")
73
+ def get(asset_id: str = typer.Argument(...)) -> None:
74
+ """资产详情。"""
75
+ st = current()
76
+ payload = st.client().call("assets.GetAsset", path_params={"asset_id": asset_id})
77
+ output.emit_object(payload, json_out=st.json_out)
78
+
79
+
80
+ @app.command("pin")
81
+ def pin(
82
+ asset_id: str = typer.Argument(...),
83
+ version_id: str = typer.Option(..., "--version-id", help="要钉住的版本 id"),
84
+ ) -> None:
85
+ """把资产钉到指定版本。"""
86
+ st = current()
87
+ payload = st.client().call(
88
+ "assets.PinAssetVersion", path_params={"asset_id": asset_id}, body={"versionId": version_id}
89
+ )
90
+ output.emit_object(payload, json_out=st.json_out)
91
+
92
+
93
+ @app.command("set-config")
94
+ def set_config(
95
+ asset_id: str = typer.Argument(...),
96
+ body: str = BODY_OPT,
97
+ body_file: str = BODY_FILE_OPT,
98
+ ) -> None:
99
+ """写资产配置(Struct,用 --body 传)。"""
100
+ st = current()
101
+ config = body_of(body, body_file)
102
+ if config is None:
103
+ raise UsageError('需要 --body \'{"...":...}\' 或 --body-file')
104
+ payload = st.client().call(
105
+ "assets.SetAssetConfig", path_params={"asset_id": asset_id}, body={"config": config}
106
+ )
107
+ output.emit_object(payload, json_out=st.json_out)
108
+
109
+
110
+ @app.command("enable")
111
+ def enable(asset_id: str = typer.Argument(...)) -> None:
112
+ """在本项目启用资产。"""
113
+ _set_enabled(asset_id, True)
114
+
115
+
116
+ @app.command("disable")
117
+ def disable(asset_id: str = typer.Argument(...)) -> None:
118
+ """在本项目停用资产。"""
119
+ _set_enabled(asset_id, False)
120
+
121
+
122
+ @app.command("set-credential")
123
+ def set_credential(
124
+ asset_id: str = typer.Argument(...),
125
+ cred_ref: str = typer.Option(..., "--cred-ref", help="凭据引用(服务端密钥库里的键)"),
126
+ inject_as: str = typer.Option(None, "--inject-as"),
127
+ inject_key: str = typer.Option(None, "--inject-key"),
128
+ ) -> None:
129
+ """绑定凭据引用(**只填引用名,不传密钥本体**)。"""
130
+ st = current()
131
+ body = {"credRef": cred_ref}
132
+ if inject_as:
133
+ body["injectAs"] = inject_as
134
+ if inject_key:
135
+ body["injectKey"] = inject_key
136
+ payload = st.client().call(
137
+ "assets.SetCredential", path_params={"asset_id": asset_id}, body=body
138
+ )
139
+ output.emit_object(payload, json_out=st.json_out)
140
+
141
+
142
+ # ------------------------------------------------------------------ 管理面
143
+
144
+ @app.command("create")
145
+ def create(
146
+ kind: str = typer.Option(..., "--kind", help=KINDS),
147
+ tier: str = typer.Option(..., "--tier", help=TIERS),
148
+ name: str = typer.Option(..., "--name", "-n"),
149
+ description: str = typer.Option(None, "--description", "-d"),
150
+ body: str = BODY_OPT,
151
+ body_file: str = BODY_FILE_OPT,
152
+ ) -> None:
153
+ """建资产(payload 用 --body 传)。"""
154
+ st = current()
155
+ payload_body = {
156
+ "kind": _enum("ASSET_KIND", kind),
157
+ "tier": _enum("ASSET_TIER", tier),
158
+ "name": name,
159
+ }
160
+ if description:
161
+ payload_body["description"] = description
162
+ payload = body_of(body, body_file)
163
+ if payload:
164
+ payload_body["payload"] = payload
165
+ out = st.client().call("assets.CreateAsset", body=payload_body)
166
+ if st.json_out:
167
+ output.emit_json(out)
168
+ return
169
+ typer.echo(out.get("assetId") or "")
170
+
171
+
172
+ @app.command("create-version")
173
+ def create_version(
174
+ asset_id: str = typer.Argument(...),
175
+ version: str = typer.Option(..., "--version", "-v", help="版本号(语义化字符串)"),
176
+ engine_compat: str = typer.Option(None, "--engine-compat"),
177
+ content_digest: str = typer.Option(None, "--content-digest"),
178
+ body: str = BODY_OPT,
179
+ body_file: str = BODY_FILE_OPT,
180
+ ) -> None:
181
+ """建版本(contribution_spec 用 --body 传)。"""
182
+ st = current()
183
+ payload_body = {"version": version}
184
+ if engine_compat:
185
+ payload_body["engineCompat"] = engine_compat
186
+ if content_digest:
187
+ payload_body["contentDigest"] = content_digest
188
+ spec = body_of(body, body_file)
189
+ if spec:
190
+ payload_body["contributionSpec"] = spec
191
+ out = st.client().call(
192
+ "assets.CreateAssetVersion", path_params={"asset_id": asset_id}, body=payload_body
193
+ )
194
+ if st.json_out:
195
+ output.emit_json(out)
196
+ return
197
+ typer.echo(out.get("versionId") or "")
198
+
199
+
200
+ @app.command("update")
201
+ def update(
202
+ asset_id: str = typer.Argument(...),
203
+ body: str = BODY_OPT,
204
+ body_file: str = BODY_FILE_OPT,
205
+ ) -> None:
206
+ """改资产元信息(PATCH)。"""
207
+ st = current()
208
+ payload_body = body_of(body, body_file)
209
+ if not payload_body:
210
+ raise UsageError("需要 --body(name/description/payload 中至少一项)")
211
+ payload = st.client().call(
212
+ "assets.UpdateAsset", path_params={"asset_id": asset_id}, body=payload_body
213
+ )
214
+ output.emit_object(payload, json_out=st.json_out)
215
+
216
+
217
+ @app.command("put-content")
218
+ def put_content(
219
+ asset_id: str = typer.Argument(...),
220
+ version_id: str = typer.Option(..., "--version-id"),
221
+ file: str = typer.Option(..., "--file", "-f", help="本地文件路径(- = 读 stdin)"),
222
+ content_type: str = typer.Option(None, "--content-type"),
223
+ ) -> None:
224
+ """上传版本内容(本地文件 → bytes;base64 编码由 CLI 负责)。"""
225
+ import sys
226
+
227
+ if file == "-":
228
+ data = sys.stdin.buffer.read()
229
+ else:
230
+ try:
231
+ with open(file, "rb") as fh:
232
+ data = fh.read()
233
+ except OSError as exc:
234
+ raise UsageError(f"读取失败:{exc}") from None
235
+ body = {
236
+ "content": base64.b64encode(data).decode(),
237
+ }
238
+ if content_type:
239
+ body["contentType"] = content_type
240
+ payload = st.client().call(
241
+ "assets.PutAssetContent",
242
+ path_params={"asset_id": asset_id, "version_id": version_id},
243
+ body=body,
244
+ )
245
+ if st.json_out:
246
+ output.emit_json(payload)
247
+ return
248
+ typer.echo(f"已上传 {len(data)} 字节 → {asset_id}/{version_id}")
249
+
250
+
251
+ @app.command("publish")
252
+ def publish(
253
+ asset_id: str = typer.Argument(...),
254
+ version_id: str = typer.Option(..., "--version-id"),
255
+ ) -> None:
256
+ """发布版本。"""
257
+ _version_action("assets.PublishVersion", asset_id, version_id, "已发布")
258
+
259
+
260
+ @app.command("yank")
261
+ def yank(
262
+ asset_id: str = typer.Argument(...),
263
+ version_id: str = typer.Option(..., "--version-id"),
264
+ ) -> None:
265
+ """撤回版本。"""
266
+ _version_action("assets.YankVersion", asset_id, version_id, "已撤回")
267
+
268
+
269
+ @app.command("set-lifecycle")
270
+ def set_lifecycle(
271
+ asset_id: str = typer.Argument(...),
272
+ lifecycle: str = typer.Option(..., "--lifecycle", help=LIFECYCLES),
273
+ ) -> None:
274
+ """改资产生命周期。"""
275
+ st = current()
276
+ payload = st.client().call(
277
+ "assets.SetAssetLifecycle",
278
+ path_params={"asset_id": asset_id},
279
+ body={"lifecycle": _enum("ASSET_LIFECYCLE", lifecycle)},
280
+ )
281
+ output.emit_object(payload, json_out=st.json_out)
282
+
283
+
284
+ @app.command("set-visibility")
285
+ def set_visibility(
286
+ asset_id: str = typer.Argument(...),
287
+ scope: str = typer.Option(..., "--scope", help=SCOPES),
288
+ scope_ref: str = typer.Option(None, "--scope-ref", help="plan/org 作用域的目标 id"),
289
+ visible: bool = typer.Option(True, "--visible/--hidden"),
290
+ ) -> None:
291
+ """改可见性。"""
292
+ st = current()
293
+ body = {"scope": _enum("VISIBILITY_SCOPE", scope), "visible": visible}
294
+ if scope_ref:
295
+ body["scopeRef"] = scope_ref
296
+ payload = st.client().call(
297
+ "assets.SetVisibility", path_params={"asset_id": asset_id}, body=body
298
+ )
299
+ output.emit_object(payload, json_out=st.json_out)
300
+
301
+
302
+ @app.command("sync-core")
303
+ def sync_core() -> None:
304
+ """把内置 core 资产同步进库。"""
305
+ st = current()
306
+ payload = st.client().call("assets.SyncCoreAssets", body={})
307
+ if st.json_out:
308
+ output.emit_json(payload)
309
+ return
310
+ typer.echo(f"新增 {payload.get('added', 0)},更新 {payload.get('updated', 0)}")
311
+
312
+
313
+ # ------------------------------------------------------------------ 装配
314
+
315
+ @app.command("assembly-status")
316
+ def assembly_status() -> None:
317
+ """看装配状态(desired/applied revision)。"""
318
+ st = current()
319
+ payload = st.client().call("assets.GetAssemblyStatus")
320
+ output.emit_object(payload, json_out=st.json_out)
321
+
322
+
323
+ @app.command("assembly-dispatch")
324
+ def assembly_dispatch(
325
+ project_id: str = typer.Option(None, "--project-id", help="缺省用当前作用域项目"),
326
+ org_id: str = typer.Option(None, "--org-id"),
327
+ reason: str = typer.Option(None, "--reason"),
328
+ restart_policy: str = typer.Option(None, "--restart-policy", help=POLICIES),
329
+ ) -> None:
330
+ """触发一次装配下发。"""
331
+ st = current()
332
+ body: dict = {}
333
+ pid = project_id or st.project
334
+ if pid:
335
+ body["projectId"] = pid
336
+ if org_id:
337
+ body["orgId"] = org_id
338
+ if reason:
339
+ body["reason"] = reason
340
+ if restart_policy:
341
+ body["restartPolicy"] = _enum("RESTART_POLICY", restart_policy)
342
+ payload = st.client().call("assets.TriggerAssemblyDispatch", body=body)
343
+ if st.json_out:
344
+ output.emit_json(payload)
345
+ return
346
+ dispatch = payload.get("dispatch") or {}
347
+ typer.echo(f"已触发:revision={dispatch.get('revision')} state={dispatch.get('state')}")
348
+
349
+
350
+ @app.command("assembly-revisions")
351
+ def assembly_revisions(
352
+ project_id: str = typer.Option(None, "--project-id"),
353
+ org_id: str = typer.Option(None, "--org-id"),
354
+ page_size: int = PAGE_SIZE_OPT,
355
+ page_token: str = PAGE_TOKEN_OPT,
356
+ ) -> None:
357
+ """列装配 revision 历史。"""
358
+ st = current()
359
+ query = pagination(page_size or 20, page_token, {"project_id": project_id or st.project, "org_id": org_id})
360
+ payload = st.client().call("assets.ListAssemblyRevisions", query=query)
361
+ output.emit_items(
362
+ payload,
363
+ items_key="revisions",
364
+ columns=[
365
+ ("REV", "revision"),
366
+ ("状态", "state"),
367
+ ("需重启", "restartRequired"),
368
+ ("原因", "reason"),
369
+ ("创建时间", "createdAt"),
370
+ ],
371
+ json_out=st.json_out,
372
+ )
373
+
374
+
375
+ @app.command("assembly-artifact")
376
+ def assembly_artifact(
377
+ project_id: str = typer.Argument(...),
378
+ revision: int = typer.Argument(...),
379
+ out: str = typer.Option(None, "--out", "-o", help="写入文件;缺省 stdout"),
380
+ meta: bool = typer.Option(False, "--meta", help="只打印元信息,不吐 patch_content"),
381
+ ) -> None:
382
+ """取装配产物(patch_content 是 bytes)。"""
383
+ st = current()
384
+ payload = st.client().call(
385
+ "assets.GetAssemblyArtifact",
386
+ path_params={"project_id": project_id, "revision": revision},
387
+ )
388
+ if st.json_out and not meta:
389
+ output.emit_json(payload)
390
+ return
391
+ info = {k: v for k, v in payload.items() if k != "patchContent"}
392
+ output.emit_object(info, json_out=st.json_out)
393
+ raw = payload.get("patchContent")
394
+ if meta or not raw:
395
+ return
396
+ try:
397
+ data = base64.b64decode(raw)
398
+ except Exception:
399
+ data = str(raw).encode()
400
+ if out:
401
+ with open(out, "wb") as fh:
402
+ fh.write(data)
403
+ typer.echo(f"已写入 {out}({len(data)} 字节)", err=True)
404
+ else:
405
+ output.write_bytes(data)
406
+
407
+
408
+ # ------------------------------------------------------------------ 内部
409
+
410
+ _ENUM_PREFIX = {
411
+ "ASSET_KIND": "ASSET_KIND_",
412
+ "ASSET_TIER": "ASSET_TIER_",
413
+ "ASSET_LIFECYCLE": "ASSET_LIFECYCLE_",
414
+ "VISIBILITY_SCOPE": "VISIBILITY_SCOPE_",
415
+ "RESTART_POLICY": "RESTART_POLICY_",
416
+ }
417
+ # CLI 上写的短名 → 枚举全名(protojson 认全名,也认短名,但只认大写)
418
+ _ENUM_ALIAS = {"global": "GLOBAL", "plan": "PLAN", "org": "ORG"}
419
+
420
+
421
+ def _enum(prefix: str, value: str | None) -> str | None:
422
+ """'mcp_server' → 'ASSET_KIND_MCP_SERVER';已是大写全名则原样。"""
423
+ if not value:
424
+ return None
425
+ text = _ENUM_ALIAS.get(value.lower(), value.upper())
426
+ full = _ENUM_PREFIX[prefix]
427
+ return text if text.startswith(full) else full + text
428
+
429
+
430
+ def _set_enabled(asset_id: str, enabled: bool) -> None:
431
+ st = current()
432
+ payload = st.client().call(
433
+ "assets.SetAssetEnabled", path_params={"asset_id": asset_id}, body={"enabled": enabled}
434
+ )
435
+ if st.json_out:
436
+ output.emit_json(payload)
437
+ return
438
+ typer.echo(f"已{'启用' if enabled else '停用'} {asset_id}")
439
+
440
+
441
+ def _version_action(key: str, asset_id: str, version_id: str, done: str) -> None:
442
+ st = current()
443
+ payload = st.client().call(
444
+ key, path_params={"asset_id": asset_id, "version_id": version_id}, body={}
445
+ )
446
+ if st.json_out:
447
+ output.emit_json(payload)
448
+ return
449
+ typer.echo(f"{done} {asset_id}/{version_id}")
@@ -0,0 +1,160 @@
1
+ """auth 与 whoami。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .. import config, output
8
+ from ..state import current
9
+
10
+ app = typer.Typer(no_args_is_help=True, help="登录 / 登出 / 注册 / 身份")
11
+
12
+
13
+ @app.command("login")
14
+ def login(
15
+ email: str = typer.Option(None, "--email", "-e", help="邮箱(缺省交互输入)"),
16
+ password: str = typer.Option(None, "--password", help="密码(缺省交互输入,不回显)"),
17
+ ) -> None:
18
+ """用邮箱密码换取 JWT(响应体令牌交给 CLI,见 gateway cookiepost.go)。"""
19
+ st = current()
20
+ email = email or typer.prompt("邮箱")
21
+ password = password or typer.prompt("密码", hide_input=True)
22
+ client = st.client()
23
+ payload = client.login(email, password)
24
+ st.remember_identity(payload)
25
+ st.profile["user"] = {**st.profile.get("user", {}), "email": email}
26
+ config.save(st.data)
27
+ role = "平台管理员" if payload.get("isPlatformAdmin") else "普通用户"
28
+ typer.echo(f"已登录 {email}({role})")
29
+ if payload.get("defaultProjectId"):
30
+ typer.echo(f"默认项目:{payload['defaultProjectId']}")
31
+
32
+
33
+ @app.command("logout")
34
+ def logout() -> None:
35
+ """清本地令牌并通知服务端(幂等)。"""
36
+ st = current()
37
+ client = st.client()
38
+ client.logout()
39
+ st.profile.pop("user", None)
40
+ config.save(st.data)
41
+ typer.echo("已登出")
42
+
43
+
44
+ @app.command("register")
45
+ def register(
46
+ email: str = typer.Option(..., "--email", "-e"),
47
+ password: str = typer.Option(None, "--password", help="缺省交互输入"),
48
+ display_name: str = typer.Option(None, "--display-name"),
49
+ ) -> None:
50
+ """注册新账号(成功后自动登录)。"""
51
+ st = current()
52
+ password = password or typer.prompt("密码", hide_input=True)
53
+ body = {"email": email, "password": password}
54
+ if display_name:
55
+ body["display_name"] = display_name
56
+ payload = st.client().call("auth.Register", body=body, auth=False)
57
+ if st.json_out:
58
+ output.emit_json(payload)
59
+ else:
60
+ typer.echo(f"注册成功:user={payload.get('userId')} org={payload.get('orgId')}")
61
+ typer.echo(f"默认项目:{payload.get('projectId')}")
62
+ # 注册响应里没有令牌,接着登录一次;两边的身份信息一起落盘
63
+ st.remember_identity(st.client().login(email, password))
64
+ prof = st.profile
65
+ prof["user"] = {**prof.get("user", {}), "email": email}
66
+ st.data["profiles"][st.profile_name] = prof
67
+ config.save(st.data)
68
+ typer.echo("已自动登录。")
69
+
70
+
71
+ # ------------------------------------------------------------------ API key
72
+ # 注意:签发/吊销都走 JWT(IssueAPIKey 的认证是 jwt),所以必须先用邮箱登录。
73
+ # 密钥只在签发那一次出现,之后服务端只留摘要 —— 落盘责任在调用方。
74
+
75
+
76
+ @app.command("keys")
77
+ def keys() -> None:
78
+ """列本账号的 API key(不含密钥本体)。"""
79
+ st = current()
80
+ payload = st.client().call("auth.ListAPIKeys", query={"page_size": 100})
81
+ output.emit_table(
82
+ payload.get("keys") or [],
83
+ [
84
+ ("KEY ID", "keyId"),
85
+ ("名称", "name"),
86
+ ("项目", "projectIds"),
87
+ ("创建时间", "createdAt"),
88
+ ("最后使用", "lastUsedAt"),
89
+ ("已吊销", "revokedAt"),
90
+ ],
91
+ json_out=st.json_out,
92
+ )
93
+
94
+
95
+ @app.command("key-create")
96
+ def key_create(
97
+ name: str = typer.Option(..., "--name", "-n", help="用途备注,便于日后吊销"),
98
+ project_ids: list[str] = typer.Option(None, "--project-id", help="限定项目,可重复;缺省不限定"),
99
+ ) -> None:
100
+ """签发 API key(**明文只打印这一次**)。"""
101
+ st = current()
102
+ body: dict = {"name": name}
103
+ if project_ids:
104
+ body["projectIds"] = ",".join(project_ids)
105
+ payload = st.client().call("auth.IssueAPIKey", body=body)
106
+ if st.json_out:
107
+ output.emit_json(payload)
108
+ return
109
+ typer.echo(payload.get("apiKey") or "", err=True)
110
+ typer.echo(f"key_id={payload.get('keyId')}(明文只此一次,请立即存好)", err=True)
111
+
112
+
113
+ @app.command("key-revoke")
114
+ def key_revoke(
115
+ key_id: str = typer.Argument(...),
116
+ yes: bool = typer.Option(False, "--yes", "-y"),
117
+ ) -> None:
118
+ """吊销 API key。"""
119
+ st = current()
120
+ if not yes:
121
+ typer.confirm(f"确认吊销 {key_id}?", abort=True)
122
+ st.client().call("auth.RevokeAPIKey", path_params={"key_id": key_id})
123
+ typer.echo(f"已吊销 {key_id}")
124
+
125
+
126
+ def whoami() -> None:
127
+ """显示登录身份 + 作用域 + 可访问项目(后者顺带证明令牌有效)。"""
128
+ st = current()
129
+ prof_user = st.profile.get("user") or {}
130
+ payload = st.client().call(
131
+ "tenant.ListProjects", query={"page_size": 50}
132
+ )
133
+ projects = payload.get("projects") or []
134
+ if st.json_out:
135
+ output.emit_json(
136
+ {
137
+ "baseUrl": st.base_url,
138
+ "profile": st.profile_name,
139
+ "user": prof_user,
140
+ "project": st.project,
141
+ "projects": projects,
142
+ }
143
+ )
144
+ return
145
+ typer.echo(f"地址 {st.base_url}")
146
+ typer.echo(f"配置档 {st.profile_name}")
147
+ if prof_user.get("email"):
148
+ typer.echo(f"用户 {prof_user['email']}")
149
+ if prof_user.get("user_id"):
150
+ typer.echo(f"用户 id {prof_user['user_id']}")
151
+ if prof_user.get("org_id"):
152
+ typer.echo(f"组织 id {prof_user['org_id']}")
153
+ typer.echo(f"平台管理员 {'是' if prof_user.get('is_platform_admin') else '否'}")
154
+ typer.echo(f"当前项目 {st.project or '(未设置 —— 用 rathflow project use <id>)'}")
155
+ typer.echo("")
156
+ output.emit_table(
157
+ projects,
158
+ [("PROJECT ID", "projectId"), ("名称", "name"), ("创建时间", "createdAt")],
159
+ json_out=False,
160
+ )