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/http.py ADDED
@@ -0,0 +1,294 @@
1
+ """HTTP 层:JWT 登录/续期 + 错误信封 + 流式解码。
2
+
3
+ 契约依据:
4
+ - contracts/docs/error-envelope.md(错误信封与 HTTP 状态映射)
5
+ - contracts/docs/streaming.md §三(读取端最小实现要求,逐条落实)
6
+ - gateway/internal/gateway/cookiepost.go:「响应体 token 原样保留(SDK/CLI 走 Bearer)」
7
+ → CLI 只读响应体,**不碰 cookie**;也因此天然豁免 CSRF 门。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import time
14
+ from typing import Iterator
15
+
16
+ import httpx
17
+
18
+ from . import endpoints
19
+ from .errors import ApiError, UsageError, from_response
20
+
21
+ # 普通调用:读超时给足(上游 gateway 默认 10s,orchestrator 30s)
22
+ _TIMEOUT = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
23
+ # 流式:读超时关闭(沙箱命令可能长时间无输出仍存活)
24
+ _STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0)
25
+
26
+ # 到期前多久就主动刷新(秒)
27
+ _REFRESH_MARGIN = 60
28
+
29
+
30
+ class Client:
31
+ """一次进程内的 API 客户端(令牌变更经 persist 回调落盘)。"""
32
+
33
+ def __init__(
34
+ self,
35
+ base_url: str,
36
+ *,
37
+ project: str | None = None,
38
+ access_token: str | None = None,
39
+ refresh_token: str | None = None,
40
+ expires_at: float = 0.0,
41
+ persist=None,
42
+ ):
43
+ self.base_url = base_url.rstrip("/")
44
+ self.project = project or None
45
+ self.access_token = access_token or None
46
+ self.refresh_token = refresh_token or None
47
+ self.expires_at = float(expires_at or 0)
48
+ self._persist = persist
49
+ # 客户端级超时取流式那份(read=None:沙箱里跑长命令时中途没输出也该活着);
50
+ # 普通调用在 _open 里逐次传 _TIMEOUT 盖掉它。
51
+ self._http = httpx.Client(follow_redirects=False, timeout=_STREAM_TIMEOUT)
52
+
53
+ def close(self) -> None:
54
+ self._http.close()
55
+
56
+ def __enter__(self) -> "Client":
57
+ return self
58
+
59
+ def __exit__(self, *exc) -> None:
60
+ self.close()
61
+
62
+ # ---------------------------------------------------------------- 令牌
63
+
64
+ def _absorb_tokens(self, payload: dict) -> None:
65
+ """从 LoginResponse 吸收令牌。protojson 把 int64 编成 JSON 字符串。"""
66
+ access = payload.get("accessToken") or payload.get("access_token")
67
+ refresh = payload.get("refreshToken") or payload.get("refresh_token")
68
+ if access:
69
+ self.access_token = access
70
+ if refresh:
71
+ self.refresh_token = refresh
72
+ ttl = payload.get("expiresInSeconds", payload.get("expires_in_seconds"))
73
+ try:
74
+ seconds = int(ttl) if ttl is not None else 0
75
+ except (TypeError, ValueError):
76
+ seconds = 0
77
+ if seconds > 0:
78
+ self.expires_at = time.time() + seconds - _REFRESH_MARGIN
79
+ elif access:
80
+ self.expires_at = time.time() + 15 * 60 - _REFRESH_MARGIN
81
+ if self._persist:
82
+ self._persist(self)
83
+
84
+ def login(self, email: str, password: str) -> dict:
85
+ payload = self._call_raw(
86
+ "POST",
87
+ endpoints.render_path("auth.Login", {}),
88
+ body={"email": email, "password": password},
89
+ allow_retry=False,
90
+ )
91
+ self._absorb_tokens(payload)
92
+ return payload
93
+
94
+ def logout(self) -> None:
95
+ if self.access_token:
96
+ try:
97
+ self._call_raw("POST", endpoints.render_path("auth.Logout", {}), body={})
98
+ except ApiError:
99
+ pass # 幂等:服务端不可达也清本地
100
+ self.access_token = None
101
+ self.refresh_token = None
102
+ self.expires_at = 0.0
103
+ if self._persist:
104
+ self._persist(self)
105
+
106
+ def refresh(self) -> bool:
107
+ """用 refresh token 换新令牌。成功返回 True。refresh 会轮换 → 必须存回。"""
108
+ if not self.refresh_token:
109
+ return False
110
+ try:
111
+ payload = self._call_raw(
112
+ "POST",
113
+ endpoints.render_path("auth.Refresh", {}),
114
+ body={"refreshToken": self.refresh_token},
115
+ allow_retry=False,
116
+ )
117
+ except ApiError:
118
+ return False
119
+ self._absorb_tokens(payload)
120
+ return True
121
+
122
+ def ensure_token(self) -> None:
123
+ """无令牌 → 报未登录;临期且有 refresh → 先刷新。"""
124
+ if not self.access_token:
125
+ raise UsageError("未登录:先跑 `rathflow auth login`")
126
+ if self.refresh_token and self.expires_at and time.time() >= self.expires_at:
127
+ self.refresh()
128
+
129
+ # ---------------------------------------------------------------- 请求
130
+
131
+ def _headers(self) -> dict:
132
+ headers = {
133
+ "Accept": "application/json",
134
+ "User-Agent": "rathflow-cli/0.1.0",
135
+ }
136
+ if self.access_token:
137
+ headers["Authorization"] = f"Bearer {self.access_token}"
138
+ if self.project:
139
+ # 作用域解析:路径 {project_id} 优先,其次本头(gateway/internal/resolve)
140
+ headers["X-RathFlow-Project"] = self.project
141
+ return headers
142
+
143
+ def _send(self, method: str, path: str, *, params=None, body=None, streaming: bool = False):
144
+ """带令牌发请求;401 → 刷新一次并重放。streaming 时响应未读体(调用方 close)。"""
145
+ self.ensure_token()
146
+ url = self.base_url + path
147
+ headers = self._headers()
148
+ for attempt in (0, 1):
149
+ try:
150
+ if streaming:
151
+ req = self._http.build_request(
152
+ method, url, headers=headers, params=params, json=body
153
+ )
154
+ resp = self._http.send(req, stream=True)
155
+ else:
156
+ resp = self._http.request(
157
+ method, url, headers=headers, params=params, json=body, timeout=_TIMEOUT
158
+ )
159
+ except httpx.HTTPError as exc:
160
+ raise ApiError(0, "UNAVAILABLE", f"无法连接 {self.base_url}:{exc}") from None
161
+ if resp.status_code == 401 and attempt == 0 and self.refresh():
162
+ resp.close()
163
+ continue
164
+ return resp
165
+ return resp # pragma: no cover — 循环必返回
166
+
167
+ def _send_unauthed(self, method: str, path: str, *, params=None, body=None):
168
+ """免登录端点:不发令牌、不做 401 重放(否则登录/注册会自我递归)。"""
169
+ try:
170
+ return self._http.request(
171
+ method,
172
+ self.base_url + path,
173
+ headers=self._headers(),
174
+ params=params,
175
+ json=body,
176
+ timeout=_TIMEOUT,
177
+ )
178
+ except httpx.HTTPError as exc:
179
+ raise ApiError(0, "UNAVAILABLE", f"无法连接 {self.base_url}:{exc}") from None
180
+
181
+ def _call_raw(
182
+ self, method: str, path: str, *, params=None, body=None, allow_retry: bool = True
183
+ ) -> dict:
184
+ """非流式调用:返回解码后的 JSON(空体 → {})。"""
185
+ if allow_retry:
186
+ resp = self._send(method, path, params=params, body=body)
187
+ else:
188
+ resp = self._send_unauthed(method, path, params=params, body=body)
189
+ try:
190
+ raw = resp.read()
191
+ payload = _decode(raw) if raw else {}
192
+ if resp.status_code >= 400:
193
+ raise from_response(resp.status_code, payload)
194
+ return payload
195
+ finally:
196
+ resp.close()
197
+
198
+ def call(
199
+ self,
200
+ key: str | None = None,
201
+ *,
202
+ method: str | None = None,
203
+ path: str | None = None,
204
+ body: dict | None = None,
205
+ query: dict | None = None,
206
+ path_params: dict | None = None,
207
+ auth: bool = True,
208
+ ) -> dict:
209
+ """按端点 key(或显式 method/path)调用一次,返回响应 JSON。
210
+
211
+ auth=False 用于免登录端点(register / 公开读),不要求也不刷新令牌。
212
+ """
213
+ method, path = _resolve(key, method, path, path_params)
214
+ return self._call_raw(
215
+ method, path, params=_clean(query), body=body, allow_retry=auth
216
+ )
217
+
218
+ def stream(
219
+ self,
220
+ key: str | None = None,
221
+ *,
222
+ method: str | None = None,
223
+ path: str | None = None,
224
+ body: dict | None = None,
225
+ query: dict | None = None,
226
+ path_params: dict | None = None,
227
+ ) -> Iterator[dict]:
228
+ """流式调用:逐帧产出 `result` payload。
229
+
230
+ 读取端纪律(streaming.md §三):按行切帧;只认 result/error 两键;
231
+ 未知键拒绝该帧;error 帧即流终结并按信封报错。
232
+ """
233
+ method, path = _resolve(key, method, path, path_params)
234
+ resp = self._send(method, path, params=_clean(query), body=body, streaming=True)
235
+ # 流式只认 GET/POST(其余方法本就不出流);此处不额外校验,交给服务端
236
+ try:
237
+ if resp.status_code >= 400:
238
+ payload = _decode(resp.read())
239
+ raise from_response(resp.status_code, payload)
240
+ for line in resp.iter_lines():
241
+ line = line.strip()
242
+ if not line:
243
+ continue
244
+ try:
245
+ frame = json.loads(line)
246
+ except json.JSONDecodeError:
247
+ raise ApiError(
248
+ resp.status_code, "UNKNOWN", "流中断:收到非 JSON 帧"
249
+ ) from None
250
+ if not isinstance(frame, dict):
251
+ raise ApiError(resp.status_code, "UNKNOWN", "流中断:帧不是对象")
252
+ if set(frame) == {"result"}:
253
+ yield frame["result"]
254
+ elif set(frame) == {"error"}:
255
+ raise from_response(resp.status_code, frame)
256
+ else:
257
+ raise ApiError(
258
+ resp.status_code,
259
+ "UNKNOWN",
260
+ f"流帧含未知键 {sorted(frame)}(前后端契约漂移,拒绝该帧)",
261
+ )
262
+ finally:
263
+ resp.close()
264
+
265
+
266
+ def _resolve(key, method, path, path_params) -> tuple[str, str]:
267
+ if key:
268
+ params = dict(path_params or {})
269
+ return endpoints.method_of(key), endpoints.render_path(key, params)
270
+ if not (method and path):
271
+ raise UsageError("需要端点 key,或显式 --method/--path")
272
+ return method.upper(), path
273
+
274
+
275
+ def _clean(query: dict | None) -> dict | None:
276
+ """去掉 None 值;int64 类查询参数转字符串(protojson 惯例)。"""
277
+ if not query:
278
+ return None
279
+ out = {}
280
+ for k, v in query.items():
281
+ if v is None:
282
+ continue
283
+ out[k] = str(v).lower() if isinstance(v, bool) else v
284
+ return out or None
285
+
286
+
287
+ def _decode(raw: bytes) -> dict:
288
+ if not raw:
289
+ return {}
290
+ try:
291
+ data = json.loads(raw)
292
+ except json.JSONDecodeError:
293
+ return {}
294
+ return data if isinstance(data, dict) else {"data": data}
rathflow_cli/output.py ADDED
@@ -0,0 +1,95 @@
1
+ """输出渲染:默认人类可读,`--json` 原样透传(脚本消费的唯一稳定形态)。
2
+
3
+ `--json` **不重新编码**:protojson 已把 int64 编成字符串、Timestamp 编成 RFC3339、
4
+ bytes 编成 base64;重新编码会失真,故直接 json.dumps 透传。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+
12
+ _MAX_CELL = 48
13
+
14
+
15
+ def _get(obj, dotted: str):
16
+ cur = obj
17
+ for part in dotted.split("."):
18
+ if isinstance(cur, dict):
19
+ cur = cur.get(part)
20
+ else:
21
+ return None
22
+ if cur is None:
23
+ return None
24
+ return cur
25
+
26
+
27
+ def _cell(value) -> str:
28
+ if value is None:
29
+ return ""
30
+ if isinstance(value, bool):
31
+ return "true" if value else "false"
32
+ if isinstance(value, (dict, list)):
33
+ text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
34
+ else:
35
+ text = str(value)
36
+ text = text.replace("\n", " ")
37
+ if len(text) > _MAX_CELL:
38
+ text = text[: _MAX_CELL - 1] + "…"
39
+ return text
40
+
41
+
42
+ def emit_json(payload) -> None:
43
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
44
+ sys.stdout.write("\n")
45
+
46
+
47
+ def emit_object(obj: dict, *, json_out: bool) -> None:
48
+ if json_out:
49
+ emit_json(obj)
50
+ return
51
+ if not isinstance(obj, dict):
52
+ print(obj)
53
+ return
54
+ width = max((len(k) for k in obj), default=0)
55
+ for key, value in obj.items():
56
+ if value is None or value == "" or value == [] or value == {}:
57
+ continue
58
+ print(f"{key.ljust(width)} {_cell(value)}")
59
+
60
+
61
+ def emit_table(rows, columns, *, json_out: bool) -> None:
62
+ """rows = 对象列表;columns = [(表头, 点分路径)]。"""
63
+ if json_out:
64
+ emit_json(rows)
65
+ return
66
+ rows = list(rows or [])
67
+ if not rows:
68
+ print("(空)")
69
+ return
70
+ headers = [h for h, _ in columns]
71
+ body = [[_cell(_get(r, path)) for _, path in columns] for r in rows]
72
+ widths = [
73
+ max(len(headers[i]), max((len(row[i]) for row in body), default=0))
74
+ for i in range(len(headers))
75
+ ]
76
+ print(" ".join(headers[i].ljust(widths[i]) for i in range(len(headers))))
77
+ for row in body:
78
+ print(" ".join(row[i].ljust(widths[i]) for i in range(len(headers))))
79
+
80
+
81
+ def emit_items(payload, *, items_key: str, columns, json_out: bool) -> None:
82
+ """分页响应:取 items_key 列表渲染;JSON 模式透传整包(保留 nextPageToken)。"""
83
+ if json_out:
84
+ emit_json(payload)
85
+ return
86
+ emit_table((payload or {}).get(items_key) or [], columns, json_out=False)
87
+ token = (payload or {}).get("nextPageToken")
88
+ if token:
89
+ print(f"\n(还有下一页:--page-token {token})")
90
+
91
+
92
+ def write_bytes(data: bytes, *, to_stderr: bool = False) -> None:
93
+ stream = sys.stderr.buffer if to_stderr else sys.stdout.buffer
94
+ stream.write(data)
95
+ stream.flush()
@@ -0,0 +1,120 @@
1
+ """轻量自检(不联服务端):``python -m rathflow_cli.selftest``
2
+
3
+ 查两件事,都是「装得上但会打错 URL」的那类毛病:
4
+
5
+ 1. **命令实现里不写 URL 字面量** —— 所有请求必须走 ``endpoints.py`` 的端点 key;
6
+ 2. **端点表与生成物一致** —— 生成物 ``endpoints.ts`` 由上游 proto 生成,本包的
7
+ ``endpoints.py`` 手工同步;两者漂移就会打错 URL。生成物在本仓库中不存在
8
+ (它是上游 monorepo 的产物),此时这一项跳过,可用环境变量
9
+ ``RATHFLOW_ENDPOINTS_TS`` 指向它来启用比对。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import endpoints
20
+
21
+ _COMMANDS = Path(__file__).resolve().parent / "commands"
22
+
23
+
24
+ def _find_generated() -> Path | None:
25
+ """定位上游生成的 endpoints.ts;找不到就返回 None(跳过比对)。"""
26
+ override = os.environ.get("RATHFLOW_ENDPOINTS_TS")
27
+ if override:
28
+ candidate = Path(override)
29
+ return candidate if candidate.exists() else None
30
+ for base in Path(__file__).resolve().parents:
31
+ candidate = base / "web" / "lib" / "api" / "generated" / "endpoints.ts"
32
+ if candidate.exists():
33
+ return candidate
34
+ return None
35
+
36
+
37
+ _GENERATED = _find_generated()
38
+
39
+ _ROW = re.compile(
40
+ r'\{ key: "(?P<key>[^"]+)", method: "(?P<method>\w+)", path: "(?P<path>[^"]+)",'
41
+ r" pathParams: \[(?P<pp>[^\]]*)\], multiParams: \[(?P<mp>[^\]]*)\],"
42
+ r".*?stream: (?P<stream>true|false),"
43
+ )
44
+ _URL_LITERAL = re.compile(r'"(?:/api/|/admin/api/)')
45
+
46
+
47
+ def main() -> int:
48
+ generated_problems, compared = _check_generated()
49
+ problems = generated_problems + _check_no_url_literals()
50
+ if problems:
51
+ print(f"自检未通过({len(problems)} 项):", file=sys.stderr)
52
+ for line in problems:
53
+ print(f" - {line}", file=sys.stderr)
54
+ return 1
55
+ total = len(endpoints.ENDPOINTS)
56
+ if compared:
57
+ print(f"自检通过:端点表与生成物一致({total} 个端点)")
58
+ else:
59
+ print(f"自检通过:命令层无 URL 字面量({total} 个端点;未找到生成物,跳过端点表比对)")
60
+ return 0
61
+
62
+
63
+ def _check_generated() -> tuple[list[str], bool]:
64
+ """比对端点表与上游生成物;返回 (问题列表, 是否真的比对了)。"""
65
+ if _GENERATED is None or not _GENERATED.exists():
66
+ return [], False
67
+ rows = {}
68
+ for line in _GENERATED.read_text(encoding="utf-8").splitlines():
69
+ m = _ROW.search(line)
70
+ if not m:
71
+ continue
72
+ rows[m["key"]] = {
73
+ "method": m["method"],
74
+ "path": m["path"],
75
+ "multi": _names(m["mp"]),
76
+ "stream": m["stream"] == "true",
77
+ }
78
+
79
+ problems = []
80
+ missing = sorted(rows.keys() - endpoints.ENDPOINTS.keys())
81
+ extra = sorted(endpoints.ENDPOINTS.keys() - rows.keys())
82
+ if missing:
83
+ problems.append(f"endpoints.py 缺 {len(missing)} 个端点:{missing[:5]}…")
84
+ if extra:
85
+ problems.append(f"endpoints.py 多出 {len(extra)} 个端点:{extra[:5]}…")
86
+
87
+ for key in sorted(rows.keys() & endpoints.ENDPOINTS.keys()):
88
+ want, got = rows[key], endpoints.ENDPOINTS[key]
89
+ if (got[1], got[0]) != (want["path"], want["method"]):
90
+ problems.append(f"{key}: 路径/方法不一致 {got} vs {want['path']}/{want['method']}")
91
+ have_multi = set(endpoints.MULTI_PARAMS.get(key, frozenset()))
92
+ if have_multi != want["multi"]:
93
+ problems.append(f"{key}: multiParams 不一致 {sorted(have_multi)} vs {sorted(want['multi'])}")
94
+ if endpoints.is_streaming(key) != want["stream"]:
95
+ problems.append(f"{key}: stream 标记不一致 {endpoints.is_streaming(key)} vs {want['stream']}")
96
+
97
+ # 模板参数与 MULTI_PARAMS 声明必须对得上(多段参数一定出现在模板里)
98
+ for key in endpoints.ENDPOINTS:
99
+ declared = set(endpoints.MULTI_PARAMS.get(key, frozenset()))
100
+ unknown = declared - set(endpoints.path_params(key))
101
+ if unknown:
102
+ problems.append(f"{key}: MULTI_PARAMS 声明了模板里没有的参数 {sorted(unknown)}")
103
+ return problems, True
104
+
105
+
106
+ def _check_no_url_literals() -> list[str]:
107
+ problems = []
108
+ for path in sorted(_COMMANDS.glob("*.py")):
109
+ for no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
110
+ if _URL_LITERAL.search(line) and not line.lstrip().startswith("#"):
111
+ problems.append(f"{path.name}:{no} 出现 URL 字面量(该走端点 key)")
112
+ return problems
113
+
114
+
115
+ def _names(raw: str) -> set[str]:
116
+ return set(re.findall(r'"([^"]+)"', raw))
117
+
118
+
119
+ if __name__ == "__main__":
120
+ raise SystemExit(main())
rathflow_cli/state.py ADDED
@@ -0,0 +1,129 @@
1
+ """进程内状态:合并「flag > 环境变量 > profile > 默认」并承载惰性 Client。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import config
6
+ from .http import Client
7
+
8
+
9
+ class State:
10
+ def __init__(
11
+ self,
12
+ *,
13
+ profile_name: str | None = None,
14
+ base_url: str | None = None,
15
+ project: str | None = None,
16
+ json_out: bool = False,
17
+ quiet: bool = False,
18
+ ):
19
+ self.data = config.load()
20
+ self.json_out = json_out
21
+ self.quiet = quiet
22
+ self._client: Client | None = None
23
+ self._flags = {"base_url": base_url, "project": project}
24
+ self.apply(profile_name=profile_name)
25
+ self.token_override = _env(config.ENV_TOKEN)
26
+
27
+ def apply(
28
+ self,
29
+ *,
30
+ profile_name: str | None = None,
31
+ base_url: str | None = None,
32
+ project: str | None = None,
33
+ json_out: bool = False,
34
+ quiet: bool = False,
35
+ ) -> None:
36
+ """合并一级选项。根回调先跑;子应用回调再跑一次(命令后写的选项)。
37
+
38
+ 子应用层的显式选项优先;「优先级」仍是 flag > env > profile > 默认。
39
+ """
40
+ if profile_name:
41
+ self.data["current"] = profile_name
42
+ if base_url:
43
+ self._flags["base_url"] = base_url
44
+ if project:
45
+ self._flags["project"] = project
46
+ self.json_out = self.json_out or json_out
47
+ self.quiet = self.quiet or quiet
48
+
49
+ prof = self.profile
50
+ self.base_url = (
51
+ self._flags.get("base_url")
52
+ or _env(config.ENV_BASE_URL)
53
+ or prof.get("base_url")
54
+ or config.DEFAULT_BASE_URL
55
+ )
56
+ self.project = self._flags.get("project") or _env(config.ENV_PROJECT) or prof.get("project")
57
+
58
+ @property
59
+ def profile_name(self) -> str:
60
+ return self.data["current"]
61
+
62
+ @property
63
+ def profile(self) -> dict:
64
+ return config.profile(self.data, self.profile_name)
65
+
66
+ def client(self) -> Client:
67
+ if self._client is None:
68
+ prof = self.profile
69
+ if self.token_override:
70
+ # CI 场景:直接用环境变量令牌,不落盘、不刷新
71
+ self._client = Client(
72
+ self.base_url, project=self.project, access_token=self.token_override
73
+ )
74
+ else:
75
+ self._client = Client(
76
+ self.base_url,
77
+ project=self.project,
78
+ access_token=prof.get("access_token"),
79
+ refresh_token=prof.get("refresh_token"),
80
+ expires_at=prof.get("expires_at", 0),
81
+ persist=self._persist,
82
+ )
83
+ return self._client
84
+
85
+ def _persist(self, client: Client) -> None:
86
+ """令牌变更落盘(refresh 会轮换 refresh token,必须存回)。"""
87
+ prof = self.profile
88
+ prof["access_token"] = client.access_token
89
+ prof["refresh_token"] = client.refresh_token
90
+ prof["expires_at"] = client.expires_at
91
+ config.save(self.data)
92
+
93
+ def remember_identity(self, login_payload: dict) -> None:
94
+ """记下登录响应里的身份信息(whoami 的数据源)。"""
95
+ prof = self.profile
96
+ prof["user"] = {
97
+ "user_id": login_payload.get("userId"),
98
+ "org_id": login_payload.get("orgId"),
99
+ "is_platform_admin": bool(login_payload.get("isPlatformAdmin")),
100
+ }
101
+ if login_payload.get("defaultProjectId") and not prof.get("project"):
102
+ prof["project"] = login_payload["defaultProjectId"]
103
+ self.data["profiles"][self.profile_name] = prof
104
+ config.save(self.data)
105
+
106
+
107
+ def _env(name: str) -> str | None:
108
+ import os
109
+
110
+ return os.environ.get(name) or None
111
+
112
+
113
+ # 当前进程的 State:根回调在派发子命令前写入,命令实现用 current() 取。
114
+ # 用模块级单例而非 click 的 ctx 链:typer 0.16 起自带的 click 已不再是独立包,
115
+ # 取「当前 Context」需要私有路径,跨版本不可靠。CLI 是单次进程,单例足够。
116
+ _CURRENT: State | None = None
117
+
118
+
119
+ def install(state: State) -> None:
120
+ """根回调调用(每个进程一次)。"""
121
+ global _CURRENT
122
+ _CURRENT = state
123
+
124
+
125
+ def current() -> State:
126
+ """取当前命令的 State(由根回调注入)。"""
127
+ if _CURRENT is None:
128
+ raise RuntimeError("State 未初始化(内部错误)")
129
+ return _CURRENT