@qingflow-tech/qingflow-cli 1.1.40 → 1.1.41

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.
package/README.md CHANGED
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
7
7
  ## 安装
8
8
 
9
9
  ```bash
10
- npm install @qingflow-tech/qingflow-cli@1.1.40
10
+ npm install @qingflow-tech/qingflow-cli@1.1.41
11
11
  ```
12
12
 
13
13
  推荐从官网复制这一条一键安装 Qingflow CLI:
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
21
21
  ## 运行
22
22
 
23
23
  ```bash
24
- npx -y -p @qingflow-tech/qingflow-cli@1.1.40 qingflow
24
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.41 qingflow
25
25
  ```
26
26
 
27
27
  全局安装后可以直接运行:
@@ -55,7 +55,7 @@ qingflow-skills install --agent codex --scope user
55
55
  如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
56
56
 
57
57
  ```bash
58
- npx -y -p @qingflow-tech/qingflow-cli@1.1.40 qingflow-skills install --agent codex --scope user --copy
58
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.41 qingflow-skills install --agent codex --scope user --copy
59
59
  ```
60
60
 
61
61
  `qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qingflow-tech/qingflow-cli",
3
- "version": "1.1.40",
3
+ "version": "1.1.41",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "1.1.40"
7
+ version = "1.1.41"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -105,7 +105,7 @@ class SessionStore:
105
105
  persist: bool,
106
106
  ) -> SessionProfile:
107
107
  now = _utcnow()
108
- previous = self.get_profile(profile)
108
+ previous = self.peek_profile(profile)
109
109
  persisted = False
110
110
  if persist:
111
111
  persisted = self._set_secret(self._token_key(profile), token)
@@ -163,6 +163,12 @@ class SessionStore:
163
163
  session_profile = SessionProfile.from_dict(raw_profile) if isinstance(raw_profile, dict) else None
164
164
  return session_profile
165
165
 
166
+ def peek_profile(self, profile: str) -> SessionProfile | None:
167
+ """读取 profile,但不触发缺失、过期会话的自动刷新。"""
168
+ payload = self._load_profiles()
169
+ raw_profile = payload.get("profiles", {}).get(profile)
170
+ return SessionProfile.from_dict(raw_profile) if isinstance(raw_profile, dict) else None
171
+
166
172
  def get_backend_session(self, profile: str) -> BackendSession | None:
167
173
  if profile in self._logged_out_profiles:
168
174
  return None
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- from typing import Any
5
4
  import os
5
+ from typing import Any
6
6
 
7
7
  from mcp.server.fastmcp import FastMCP
8
8
 
@@ -85,7 +85,8 @@ class AuthTools(ToolBase):
85
85
  persist: bool = False,
86
86
  ) -> dict[str, Any]:
87
87
  """执行认证与会话相关逻辑。"""
88
- resolved_base_url, resolved_credential = self._resolve_mcporter_auth_inputs(
88
+ resolved_base_url, resolved_credential = self._resolve_auth_inputs(
89
+ profile=profile,
89
90
  base_url=base_url,
90
91
  credential=credential,
91
92
  )
@@ -306,10 +307,18 @@ class AuthTools(ToolBase):
306
307
  ),
307
308
  }
308
309
 
309
- def _resolve_mcporter_auth_inputs(self, *, base_url: str | None, credential: str | None) -> tuple[str | None, str]:
310
- """从参数或 mcporter 配置解析登录所需 base_url 与 credential。"""
310
+ def _resolve_auth_inputs(
311
+ self,
312
+ *,
313
+ profile: str,
314
+ base_url: str | None,
315
+ credential: str | None,
316
+ ) -> tuple[str | None, str]:
317
+ """按显式参数、环境变量、mcporter、profile 的顺序解析认证输入。"""
311
318
  normalized_base_url = self._normalize_text(base_url)
312
319
  normalized_credential = self._normalize_text(credential)
320
+ if not normalized_credential:
321
+ normalized_credential = self._normalize_text(os.getenv("QINGFLOW_CREDENTIAL"))
313
322
  if normalized_base_url and normalized_credential:
314
323
  return normalized_base_url, normalized_credential
315
324
 
@@ -317,11 +326,13 @@ class AuthTools(ToolBase):
317
326
  if not normalized_base_url:
318
327
  normalized_base_url = self._normalize_text(mcporter_context.get("base_url"))
319
328
  if not normalized_base_url:
320
- normalized_base_url = os.getenv("QINGFLOW_BASE_URL")
329
+ normalized_base_url = self._normalize_text(os.getenv("QINGFLOW_BASE_URL"))
321
330
  if not normalized_credential:
322
331
  normalized_credential = self._normalize_text(mcporter_context.get("credential"))
323
332
  if not normalized_credential:
324
- normalized_credential = os.getenv("QINGFLOW_CREDENTIAL")
333
+ session_profile = self.sessions.peek_profile(profile)
334
+ if session_profile is not None:
335
+ normalized_credential = self._normalize_text(session_profile.credential)
325
336
  return normalized_base_url, normalized_credential or ""
326
337
 
327
338
  def _read_mcporter_qingflow_context(self) -> dict[str, str]:
@@ -355,15 +366,12 @@ class AuthTools(ToolBase):
355
366
  profile: str,
356
367
  session_profile,
357
368
  ) -> bool:
358
- backend_session = self.sessions.get_backend_session(profile) if session_profile is not None else None
359
369
  base_url = session_profile.base_url if session_profile is not None else None
360
- credential = backend_session.credential if backend_session is not None else None
361
370
  persist = bool(session_profile.persisted) if session_profile is not None else False
362
371
  try:
363
372
  self.auth_use_credential(
364
373
  profile=profile,
365
374
  base_url=base_url,
366
- credential=credential,
367
375
  persist=persist,
368
376
  )
369
377
  except (QingflowApiError, RuntimeError):
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  from contextvars import ContextVar
4
5
  from typing import Any, Callable, TypeVar
5
6
 
@@ -29,6 +30,7 @@ class ToolBase:
29
30
  if cloud_context is not None:
30
31
  return cloud_context.as_tool_context(profile)
31
32
 
33
+ self._ensure_environment_credential_matches(profile)
32
34
  session_profile = self.sessions.get_profile(profile)
33
35
  if session_profile is None:
34
36
  raise QingflowApiError.auth_required(profile)
@@ -151,50 +153,106 @@ class ToolBase:
151
153
  if not credential:
152
154
  return False
153
155
  try:
154
- response = self.backend.public_request_with_meta(
155
- "POST",
156
- session_profile.base_url,
157
- "/mcp/auth/context",
158
- json_body={"credential": credential},
159
- qf_version=backend_session.qf_version,
160
- )
161
- payload = self._unwrap_context_payload(response.data)
162
- token = self._normalize_text(payload.get("token"))
163
- ws_id = self._coerce_positive_int(payload.get("wsId"))
164
- uid = self._coerce_positive_int(payload.get("uid")) or session_profile.uid
165
- if not token or ws_id is None:
166
- return False
167
- backend_qf_version = self._normalize_text(payload.get("qfVersion")) or response.qf_response_version
168
- qf_version = backend_qf_version if backend_qf_version is not None else backend_session.qf_version
169
- qf_version_source = (
170
- "backend_response"
171
- if backend_qf_version is not None
172
- else backend_session.qf_version_source
173
- )
174
- base_url = self._normalize_text(payload.get("baseUrl")) or session_profile.base_url
175
- ws_name = self._normalize_text(payload.get("wsName")) or session_profile.selected_ws_name
176
- self.sessions.save_session(
177
- profile=profile,
178
- base_url=base_url,
179
- qf_version=qf_version,
180
- qf_version_source=qf_version_source,
181
- token=token,
182
- login_token=None,
156
+ self._exchange_session_from_credential(
157
+ profile,
158
+ session_profile=session_profile,
183
159
  credential=credential,
184
- uid=uid,
185
- email=self._normalize_text(payload.get("email")) or session_profile.email,
186
- nick_name=(
187
- self._normalize_text(payload.get("nickName"))
188
- or self._normalize_text(payload.get("displayName"))
189
- or self._normalize_text(payload.get("name"))
190
- or session_profile.nick_name
191
- ),
192
- persist=session_profile.persisted,
193
160
  )
194
- self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=ws_name)
195
- return True
196
161
  except QingflowApiError:
197
162
  return False
163
+ return True
164
+
165
+ def _ensure_environment_credential_matches(self, profile: str) -> None:
166
+ environment_credential = self._normalize_text(os.getenv("QINGFLOW_CREDENTIAL"))
167
+ if environment_credential is None:
168
+ return
169
+ session_profile = self.sessions.peek_profile(profile)
170
+ if session_profile is None:
171
+ return
172
+ if self._normalize_text(session_profile.credential) == environment_credential:
173
+ return
174
+ try:
175
+ self._exchange_session_from_credential(
176
+ profile,
177
+ session_profile=session_profile,
178
+ credential=environment_credential,
179
+ preserve_existing_metadata=False,
180
+ )
181
+ except QingflowApiError as error:
182
+ raise QingflowApiError(
183
+ category="auth",
184
+ message=(
185
+ f"QINGFLOW_CREDENTIAL changed for profile '{profile}', "
186
+ "but the session could not be refreshed."
187
+ ),
188
+ backend_code=error.backend_code,
189
+ request_id=error.request_id,
190
+ http_status=error.http_status,
191
+ details={
192
+ "reason": "environment_credential_changed",
193
+ },
194
+ ) from error
195
+
196
+ def _exchange_session_from_credential(
197
+ self,
198
+ profile: str,
199
+ *,
200
+ session_profile: SessionProfile,
201
+ credential: str,
202
+ preserve_existing_metadata: bool = True,
203
+ ) -> None:
204
+ response = self.backend.public_request_with_meta(
205
+ "POST",
206
+ session_profile.base_url,
207
+ "/mcp/auth/context",
208
+ json_body={"credential": credential},
209
+ qf_version=session_profile.qf_version,
210
+ )
211
+ payload = self._unwrap_context_payload(response.data)
212
+ token = self._normalize_text(payload.get("token"))
213
+ ws_id = self._coerce_positive_int(payload.get("wsId"))
214
+ response_uid = self._coerce_positive_int(payload.get("uid"))
215
+ if not token or ws_id is None or (response_uid is None and not preserve_existing_metadata):
216
+ raise QingflowApiError(
217
+ category="auth",
218
+ message="Credential context did not return a valid user session and workspace.",
219
+ request_id=response.request_id,
220
+ http_status=response.http_status,
221
+ )
222
+ uid = response_uid or session_profile.uid
223
+ backend_qf_version = self._normalize_text(payload.get("qfVersion")) or response.qf_response_version
224
+ qf_version = backend_qf_version if backend_qf_version is not None else session_profile.qf_version
225
+ qf_version_source = (
226
+ "backend_response"
227
+ if backend_qf_version is not None
228
+ else session_profile.qf_version_source
229
+ )
230
+ base_url = self._normalize_text(payload.get("baseUrl")) or session_profile.base_url
231
+ ws_name = self._normalize_text(payload.get("wsName"))
232
+ email = self._normalize_text(payload.get("email"))
233
+ nick_name = (
234
+ self._normalize_text(payload.get("nickName"))
235
+ or self._normalize_text(payload.get("displayName"))
236
+ or self._normalize_text(payload.get("name"))
237
+ )
238
+ if preserve_existing_metadata:
239
+ ws_name = ws_name or session_profile.selected_ws_name
240
+ email = email or session_profile.email
241
+ nick_name = nick_name or session_profile.nick_name
242
+ self.sessions.save_session(
243
+ profile=profile,
244
+ base_url=base_url,
245
+ qf_version=qf_version,
246
+ qf_version_source=qf_version_source,
247
+ token=token,
248
+ login_token=None,
249
+ credential=credential,
250
+ uid=uid,
251
+ email=email,
252
+ nick_name=nick_name,
253
+ persist=session_profile.persisted,
254
+ )
255
+ self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=ws_name)
198
256
 
199
257
  def _unwrap_context_payload(self, payload: Any) -> dict[str, Any]:
200
258
  if not isinstance(payload, dict):