@qingflow-tech/qingflow-cli 1.1.43-beta.5 → 1.1.44-beta.1

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.43-beta.5
10
+ npm install @qingflow-tech/qingflow-cli@1.1.44-beta.1
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.43-beta.5 qingflow
24
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.44-beta.1 qingflow
25
25
  ```
26
26
 
27
27
  全局安装后可以直接运行:
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
62
62
  如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
63
63
 
64
64
  ```bash
65
- npx -y -p @qingflow-tech/qingflow-cli@1.1.43-beta.5 qingflow-skills install --agent codex --scope user --copy
65
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.44-beta.1 qingflow-skills install --agent codex --scope user --copy
66
66
  ```
67
67
 
68
68
  `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.43-beta.5",
3
+ "version": "1.1.44-beta.1",
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.43b5"
7
+ version = "1.1.44b1"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -246,6 +246,10 @@ current CLI payload.
246
246
  | `q_linker` | **`config.qLinkerBinding`**:`inputs`、`request`、**`outputs[]` 每项须含 `targetField`**(能解析到本表单字段) | `request.url` 等须符合**租户/后端远程查询策略**;以字段类型 Schema 返回的配置为准 |
247
247
  | `code_block` | **`config.codeBlockBinding`**:`inputs`、`code`、**`outputs[]` + `targetField`**;配置 outputs 时,代码必须显式写 **`qf_output = {...}`**,仅 `return {...}` 不会产生可回写结果,且每个 `outputs[].path` 必须从 `qf_output` 取值 | 以字段类型 Schema 返回的 camelCase 配置为准 |
248
248
 
249
+ 部门字段的 `defaultDepartments` 也遵循同一约束:`custom` 模式必须包含至少一个部门。读取历史后端数据时,如果默认部门值存在但部门列表为空,AppForm 归一化会将其视为“未配置默认部门”并省略 `defaultDepartments`;不要在新声明中写 `departments: []`。
250
+
251
+ 读取历史数据时,空的应用/段落/字段标题会按稳定 ID 生成兼容名称,空行和空筛选组会被忽略;选择项、子表列、序列号组件、Q-Linker 输出和代码源码等语义必需值无法安全推断时,读取会返回结构化 `UNSUPPORTED_FIELD_CONFIG`,不会伪造声明值。`validate/apply` 对新声明仍保持严格 Schema 约束。
252
+
249
253
  别名:契约会把 `title`/`label` 映射到 **`name`** 等;字段类型自然别名见 §1.1。
250
254
 
251
255
  ### 2.1 `relation` 选择器形态
@@ -0,0 +1,522 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Callable
5
+
6
+ from .errors import QingflowApiError
7
+ from .oauth_profile_store import OAuthProfileState, OAuthProfileStore
8
+ from .oauth_token_store import OAuthTokenBundle, OAuthTokenStore
9
+ from .session_store import BackendSession, SessionProfile, SessionStore
10
+ from .cli.oauth_client import CLI_OAUTH_CLIENT_ID, CLI_OAUTH_SOURCE_CODE, OAuthClient
11
+
12
+
13
+ OAUTH_REFRESH_SKEW = timedelta(seconds=90)
14
+
15
+
16
+ class OAuthSessionProvider:
17
+ def __init__(
18
+ self,
19
+ profile_store: OAuthProfileStore,
20
+ token_store: OAuthTokenStore,
21
+ oauth_client: OAuthClient,
22
+ ) -> None:
23
+ self.profile_store = profile_store
24
+ self.token_store = token_store
25
+ self.oauth_client = oauth_client
26
+
27
+ def get_state(self, profile: str) -> OAuthProfileState | None:
28
+ return self.profile_store.get(profile)
29
+
30
+ def get_profile(self, profile: str, *, refresh: bool = True) -> SessionProfile:
31
+ state, bundle = self._resolve(profile, refresh=refresh)
32
+ return self._session_profile(state, token=bundle.access_token if refresh else None)
33
+
34
+ def get_backend_session(self, profile: str) -> BackendSession:
35
+ state, bundle = self._resolve(profile, refresh=True)
36
+ return BackendSession(
37
+ token=bundle.access_token,
38
+ login_token=None,
39
+ credential=None,
40
+ profile=profile,
41
+ base_url=state.base_url or "",
42
+ qf_version=state.qf_version,
43
+ qf_version_source=state.qf_version_source,
44
+ )
45
+
46
+ def activate(
47
+ self,
48
+ *,
49
+ profile: str,
50
+ base_url: str,
51
+ qf_version: str | None,
52
+ qf_version_source: str | None,
53
+ uid: int,
54
+ email: str | None,
55
+ nick_name: str | None,
56
+ selected_ws_id: int | None,
57
+ selected_ws_name: str | None,
58
+ issuer: str,
59
+ resource: str,
60
+ bundle: OAuthTokenBundle,
61
+ ) -> OAuthProfileState:
62
+ with self.profile_store.lock(profile):
63
+ previous_state = self.profile_store.get(profile)
64
+ previous_bundle = self.token_store.load(profile)
65
+ previous_storage = (
66
+ previous_state.secret_storage
67
+ if previous_state is not None and previous_state.active_auth_type == "oauth"
68
+ else None
69
+ )
70
+ storage = self.token_store.save(profile, bundle, storage=previous_storage, locked=True)
71
+ now = datetime.now(timezone.utc).isoformat()
72
+ state = OAuthProfileState(
73
+ active_auth_type="oauth",
74
+ profile=profile,
75
+ base_url=base_url,
76
+ qf_version=qf_version,
77
+ qf_version_source=qf_version_source,
78
+ uid=uid,
79
+ email=email,
80
+ nick_name=nick_name,
81
+ selected_ws_id=selected_ws_id,
82
+ selected_ws_name=selected_ws_name,
83
+ persisted=True,
84
+ secret_storage=storage,
85
+ issuer=issuer,
86
+ client_id=CLI_OAUTH_CLIENT_ID,
87
+ resource=resource,
88
+ source_code=CLI_OAUTH_SOURCE_CODE,
89
+ access_expires_at=bundle.access_expires_at,
90
+ created_at=previous_state.created_at if previous_state is not None else now,
91
+ updated_at=now,
92
+ )
93
+ try:
94
+ self.profile_store.save(state, locked=True)
95
+ except Exception:
96
+ if previous_bundle is None:
97
+ self.token_store.delete(profile, locked=True)
98
+ else:
99
+ self.token_store.save(
100
+ profile,
101
+ previous_bundle,
102
+ storage=previous_storage,
103
+ locked=True,
104
+ )
105
+ if previous_state is None:
106
+ self.profile_store.delete(profile, locked=True)
107
+ else:
108
+ self.profile_store.save(previous_state, locked=True)
109
+ raise
110
+ return state
111
+
112
+ def refresh(self, profile: str, *, force: bool, observed_generation: int | None = None) -> bool:
113
+ with self.profile_store.lock(profile):
114
+ state = self._require_oauth_state(profile)
115
+ bundle = self._require_bundle(profile)
116
+ if observed_generation is not None and bundle.generation > observed_generation:
117
+ return True
118
+ if not force and not self._needs_refresh(bundle):
119
+ return True
120
+ metadata = self.oauth_client.discover(state.base_url or "")
121
+ try:
122
+ refreshed = self.oauth_client.refresh(
123
+ metadata,
124
+ refresh_token=bundle.refresh_token,
125
+ generation=bundle.generation,
126
+ )
127
+ except QingflowApiError as error:
128
+ oauth_error = (error.details or {}).get("oauth_error")
129
+ failure = "invalid_grant" if oauth_error == "invalid_grant" else "uncertain_result"
130
+ self._invalidate_refresh_bundle(profile)
131
+ details = {"reason": "oauth_relogin_required", "refresh_failure": failure}
132
+ if oauth_error:
133
+ details["oauth_error"] = oauth_error
134
+ raise QingflowApiError(
135
+ category="auth",
136
+ message=(
137
+ f"OAuth session for profile '{profile}' could not be safely refreshed. "
138
+ "Run auth login --method oauth again."
139
+ ),
140
+ backend_code=error.backend_code,
141
+ request_id=error.request_id,
142
+ http_status=error.http_status,
143
+ details=details,
144
+ ) from error
145
+ try:
146
+ storage = self.token_store.save(
147
+ profile,
148
+ refreshed,
149
+ storage=state.secret_storage,
150
+ locked=True,
151
+ )
152
+ state.secret_storage = storage
153
+ state.access_expires_at = refreshed.access_expires_at
154
+ self.profile_store.save(state, locked=True)
155
+ except Exception as error:
156
+ self._invalidate_refresh_bundle(profile)
157
+ raise QingflowApiError(
158
+ category="auth",
159
+ message=(
160
+ f"OAuth session for profile '{profile}' could not persist its refreshed token. "
161
+ "Run auth login --method oauth again."
162
+ ),
163
+ details={
164
+ "reason": "oauth_relogin_required",
165
+ "refresh_failure": "local_persistence_failed",
166
+ },
167
+ ) from error
168
+ return True
169
+
170
+ def _invalidate_refresh_bundle(self, profile: str) -> None:
171
+ try:
172
+ self.token_store.delete(profile, locked=True)
173
+ except QingflowApiError:
174
+ pass
175
+ self.profile_store.set_none(profile, locked=True)
176
+
177
+ def update_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
178
+ with self.profile_store.lock(profile):
179
+ state = self._require_oauth_state(profile)
180
+ state.selected_ws_id = ws_id
181
+ state.selected_ws_name = ws_name
182
+ self.profile_store.save(state, locked=True)
183
+ return self.get_profile(profile, refresh=False)
184
+
185
+ def update_route(
186
+ self,
187
+ profile: str,
188
+ *,
189
+ qf_version: str | None,
190
+ qf_version_source: str | None,
191
+ ) -> SessionProfile:
192
+ with self.profile_store.lock(profile):
193
+ state = self._require_oauth_state(profile)
194
+ state.qf_version = _text(qf_version)
195
+ state.qf_version_source = _text(qf_version_source)
196
+ self.profile_store.save(state, locked=True)
197
+ return self.get_profile(profile, refresh=False)
198
+
199
+ def update_metadata(
200
+ self,
201
+ profile: str,
202
+ *,
203
+ uid: int | object,
204
+ email: str | None | object,
205
+ nick_name: str | None | object,
206
+ selected_ws_id: int | None | object,
207
+ selected_ws_name: str | None | object,
208
+ unset: object,
209
+ ) -> SessionProfile:
210
+ with self.profile_store.lock(profile):
211
+ state = self._require_oauth_state(profile)
212
+ if uid is not unset:
213
+ state.uid = int(uid)
214
+ if email is not unset:
215
+ state.email = email if isinstance(email, str) or email is None else state.email
216
+ if nick_name is not unset:
217
+ state.nick_name = nick_name if isinstance(nick_name, str) or nick_name is None else state.nick_name
218
+ if selected_ws_id is not unset:
219
+ state.selected_ws_id = (
220
+ int(selected_ws_id)
221
+ if isinstance(selected_ws_id, int) and not isinstance(selected_ws_id, bool)
222
+ else None
223
+ )
224
+ if selected_ws_name is not unset:
225
+ state.selected_ws_name = (
226
+ selected_ws_name
227
+ if isinstance(selected_ws_name, str) or selected_ws_name is None
228
+ else state.selected_ws_name
229
+ )
230
+ self.profile_store.save(state, locked=True)
231
+ return self.get_profile(profile, refresh=False)
232
+
233
+ def logout(self, profile: str) -> list[dict[str, str]]:
234
+ with self.profile_store.lock(profile):
235
+ state = self._require_oauth_state(profile)
236
+ warnings: list[dict[str, str]] = []
237
+ bundle = self.token_store.load(profile)
238
+ if bundle is not None:
239
+ try:
240
+ self.oauth_client.revoke(state.base_url or "", bundle.refresh_token)
241
+ except QingflowApiError as error:
242
+ warnings.append(
243
+ {
244
+ "code": "OAUTH_REMOTE_REVOCATION_FAILED",
245
+ "message": error.message,
246
+ }
247
+ )
248
+ self.token_store.delete(profile, locked=True)
249
+ self.profile_store.set_none(profile, locked=True)
250
+ return warnings
251
+
252
+ def deactivate_for_legacy(self, profile: str) -> list[dict[str, str]]:
253
+ with self.profile_store.lock(profile):
254
+ state = self.get_state(profile)
255
+ if state is None:
256
+ return []
257
+ warnings: list[dict[str, str]] = []
258
+ if state.active_auth_type == "oauth":
259
+ bundle = self.token_store.load(profile)
260
+ if bundle is not None:
261
+ try:
262
+ self.oauth_client.revoke(state.base_url or "", bundle.refresh_token)
263
+ except QingflowApiError as error:
264
+ warnings.append(
265
+ {
266
+ "code": "OAUTH_REMOTE_REVOCATION_FAILED",
267
+ "message": error.message,
268
+ }
269
+ )
270
+ self.token_store.delete(profile, locked=True)
271
+ self.profile_store.delete(profile, locked=True)
272
+ return warnings
273
+
274
+ def _resolve(self, profile: str, *, refresh: bool) -> tuple[OAuthProfileState, OAuthTokenBundle]:
275
+ state = self._require_oauth_state(profile)
276
+ bundle = self._require_bundle(profile)
277
+ if refresh and self._needs_refresh(bundle):
278
+ try:
279
+ self.refresh(profile, force=False, observed_generation=bundle.generation)
280
+ state = self._require_oauth_state(profile)
281
+ bundle = self._require_bundle(profile)
282
+ except QingflowApiError as error:
283
+ if error.category != "network" or self._is_expired(bundle):
284
+ raise
285
+ return state, bundle
286
+
287
+ def _require_oauth_state(self, profile: str) -> OAuthProfileState:
288
+ state = self.get_state(profile)
289
+ if state is None or state.active_auth_type != "oauth":
290
+ raise QingflowApiError.auth_required(profile)
291
+ return state
292
+
293
+ def _require_bundle(self, profile: str) -> OAuthTokenBundle:
294
+ bundle = self.token_store.load(profile)
295
+ if bundle is None:
296
+ raise QingflowApiError(
297
+ category="auth",
298
+ message=(
299
+ f"OAuth session for profile '{profile}' is missing its token bundle. "
300
+ "Run auth login --method oauth again."
301
+ ),
302
+ details={"reason": "oauth_relogin_required"},
303
+ )
304
+ return bundle
305
+
306
+ def _session_profile(self, state: OAuthProfileState, *, token: str | None) -> SessionProfile:
307
+ return SessionProfile(
308
+ profile=state.profile,
309
+ base_url=state.base_url or "",
310
+ qf_version=state.qf_version,
311
+ qf_version_source=state.qf_version_source,
312
+ token=token,
313
+ login_token=None,
314
+ credential=None,
315
+ uid=int(state.uid or 0),
316
+ email=state.email,
317
+ nick_name=state.nick_name,
318
+ selected_ws_id=state.selected_ws_id,
319
+ selected_ws_name=state.selected_ws_name,
320
+ persisted=True,
321
+ created_at=state.created_at,
322
+ updated_at=state.updated_at,
323
+ )
324
+
325
+ def _needs_refresh(self, bundle: OAuthTokenBundle) -> bool:
326
+ return self._expires_at(bundle) <= datetime.now(timezone.utc) + OAUTH_REFRESH_SKEW
327
+
328
+ def _is_expired(self, bundle: OAuthTokenBundle) -> bool:
329
+ return self._expires_at(bundle) <= datetime.now(timezone.utc)
330
+
331
+ def _expires_at(self, bundle: OAuthTokenBundle) -> datetime:
332
+ try:
333
+ value = datetime.fromisoformat(bundle.access_expires_at)
334
+ except ValueError as exc:
335
+ raise QingflowApiError.config_error("OAuth access_expires_at is invalid.") from exc
336
+ if value.tzinfo is None:
337
+ value = value.replace(tzinfo=timezone.utc)
338
+ return value.astimezone(timezone.utc)
339
+
340
+
341
+ class CliSessionFacade:
342
+ def __init__(self, legacy: SessionStore, oauth: OAuthSessionProvider) -> None:
343
+ self.legacy = legacy
344
+ self.oauth = oauth
345
+ self._pending_legacy_profiles: set[str] = set()
346
+
347
+ def set_profile_refresher(self, refresher: Callable[[str, SessionProfile | None], bool] | None) -> None:
348
+ self.legacy.set_profile_refresher(refresher)
349
+
350
+ def oauth_state(self, profile: str) -> str | None:
351
+ state = self.oauth.get_state(profile)
352
+ return state.active_auth_type if state is not None else None
353
+
354
+ def uses_oauth(self, profile: str) -> bool:
355
+ return profile not in self._pending_legacy_profiles and self.oauth_state(profile) == "oauth"
356
+
357
+ def blocks_legacy(self, profile: str) -> bool:
358
+ return profile not in self._pending_legacy_profiles and self.oauth_state(profile) in {"oauth", "none"}
359
+
360
+ def save_session(self, **kwargs) -> SessionProfile: # type: ignore[no-untyped-def]
361
+ profile = str(kwargs.get("profile") or "")
362
+ oauth_state = self.oauth_state(profile)
363
+ saved = self.legacy.save_session(**kwargs)
364
+ if oauth_state in {"oauth", "none"}:
365
+ self._pending_legacy_profiles.add(profile)
366
+ return saved
367
+
368
+ def get_profile(self, profile: str) -> SessionProfile | None:
369
+ if profile in self._pending_legacy_profiles:
370
+ return self.legacy.get_profile(profile)
371
+ state = self.oauth_state(profile)
372
+ if state == "oauth":
373
+ return self.oauth.get_profile(profile)
374
+ if state == "none":
375
+ return None
376
+ return self.legacy.get_profile(profile)
377
+
378
+ def peek_profile(self, profile: str) -> SessionProfile | None:
379
+ if profile in self._pending_legacy_profiles:
380
+ return self.legacy.peek_profile(profile)
381
+ state = self.oauth_state(profile)
382
+ if state == "oauth":
383
+ return self.oauth.get_profile(profile, refresh=False)
384
+ if state == "none":
385
+ return None
386
+ return self.legacy.peek_profile(profile)
387
+
388
+ def get_backend_session(self, profile: str) -> BackendSession | None:
389
+ if profile in self._pending_legacy_profiles:
390
+ return self.legacy.get_backend_session(profile)
391
+ state = self.oauth_state(profile)
392
+ if state == "oauth":
393
+ return self.oauth.get_backend_session(profile)
394
+ if state == "none":
395
+ return None
396
+ return self.legacy.get_backend_session(profile)
397
+
398
+ def select_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
399
+ if self.uses_oauth(profile):
400
+ return self.oauth.update_workspace(profile, ws_id, ws_name)
401
+ if self.blocks_legacy(profile):
402
+ raise KeyError(profile)
403
+ return self.legacy.select_workspace(profile, ws_id, ws_name)
404
+
405
+ def update_route(
406
+ self,
407
+ profile: str,
408
+ *,
409
+ qf_version: str | None,
410
+ qf_version_source: str | None,
411
+ ) -> SessionProfile:
412
+ if self.uses_oauth(profile):
413
+ return self.oauth.update_route(
414
+ profile,
415
+ qf_version=qf_version,
416
+ qf_version_source=qf_version_source,
417
+ )
418
+ if self.blocks_legacy(profile):
419
+ raise KeyError(profile)
420
+ return self.legacy.update_route(
421
+ profile,
422
+ qf_version=qf_version,
423
+ qf_version_source=qf_version_source,
424
+ )
425
+
426
+ def update_profile_metadata(self, profile: str, **kwargs) -> SessionProfile: # type: ignore[no-untyped-def]
427
+ if self.uses_oauth(profile):
428
+ unset = object()
429
+ return self.oauth.update_metadata(
430
+ profile,
431
+ uid=kwargs.get("uid", unset),
432
+ email=kwargs.get("email", unset),
433
+ nick_name=kwargs.get("nick_name", unset),
434
+ selected_ws_id=kwargs.get("selected_ws_id", unset),
435
+ selected_ws_name=kwargs.get("selected_ws_name", unset),
436
+ unset=unset,
437
+ )
438
+ if self.blocks_legacy(profile):
439
+ raise KeyError(profile)
440
+ return self.legacy.update_profile_metadata(profile, **kwargs)
441
+
442
+ def logout(self, profile: str, forget_persisted: bool = False) -> None:
443
+ if self.uses_oauth(profile):
444
+ self.oauth.logout(profile)
445
+ return
446
+ if self.oauth_state(profile) == "none":
447
+ return
448
+ self.legacy.logout(profile, forget_persisted=forget_persisted)
449
+
450
+ def invalidate(self, profile: str) -> None:
451
+ if self.uses_oauth(profile):
452
+ self.oauth.token_store.delete(profile)
453
+ return
454
+ if self.oauth_state(profile) == "none":
455
+ return
456
+ self.legacy.invalidate(profile)
457
+
458
+ def has_profile(self, profile: str) -> bool:
459
+ if self.uses_oauth(profile):
460
+ return True
461
+ if self.oauth_state(profile) == "none":
462
+ return False
463
+ return self.legacy.has_profile(profile)
464
+
465
+ def activate_oauth(self, **kwargs) -> OAuthProfileState: # type: ignore[no-untyped-def]
466
+ profile = str(kwargs.get("profile") or "")
467
+ state = self.oauth.activate(**kwargs)
468
+ self._pending_legacy_profiles.discard(profile)
469
+ return state
470
+
471
+ def complete_legacy_login(self, profile: str) -> list[dict[str, str]]:
472
+ warnings = self.oauth.deactivate_for_legacy(profile)
473
+ self._pending_legacy_profiles.discard(profile)
474
+ return warnings
475
+
476
+ def begin_legacy_login(self, profile: str) -> None:
477
+ self._pending_legacy_profiles.add(profile)
478
+
479
+ def cancel_legacy_login(self, profile: str) -> None:
480
+ self._pending_legacy_profiles.discard(profile)
481
+
482
+ def oauth_logout(self, profile: str) -> list[dict[str, str]]:
483
+ self._pending_legacy_profiles.discard(profile)
484
+ return self.oauth.logout(profile)
485
+
486
+ def refresh_oauth_session(self, profile: str, *, force: bool = True) -> bool:
487
+ if not self.uses_oauth(profile):
488
+ return False
489
+ backend = self.oauth.token_store.load(profile)
490
+ generation = backend.generation if backend is not None else None
491
+ return self.oauth.refresh(profile, force=force, observed_generation=generation)
492
+
493
+ def active_auth_type(self, profile: str) -> str:
494
+ if self.uses_oauth(profile):
495
+ return "oauth"
496
+ if self.oauth_state(profile) == "none":
497
+ return "none"
498
+ session_profile = self.legacy.peek_profile(profile)
499
+ if session_profile is None:
500
+ return "none"
501
+ return "credential" if _text(session_profile.credential) else "legacy_token"
502
+
503
+ def oauth_details(self, profile: str) -> dict[str, object]:
504
+ state = self.oauth.get_state(profile)
505
+ if state is None or state.active_auth_type != "oauth":
506
+ return {}
507
+ return {
508
+ "active_auth_type": "oauth",
509
+ "auth_source": "oauth_pkce",
510
+ "source_code": state.source_code,
511
+ "access_expires_at": state.access_expires_at,
512
+ }
513
+
514
+ def close(self) -> None:
515
+ self.oauth.oauth_client.close()
516
+
517
+
518
+ def _text(value: object) -> str | None:
519
+ if value is None:
520
+ return None
521
+ normalized = str(value).strip()
522
+ return normalized or None