yuxi-cli 0.1.0__tar.gz

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,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: yuxi-cli
3
+ Version: 0.1.0
4
+ Summary: Yuxi command line client
5
+ Author: Wenjie Zhang
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/xerrors/Yuxi
8
+ Project-URL: Repository, https://github.com/xerrors/Yuxi
9
+ Project-URL: Issues, https://github.com/xerrors/Yuxi/issues
10
+ Keywords: yuxi,cli,rag,knowledge-base,agent
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: langfuse>=4.0.0
23
+ Requires-Dist: packaging>=24
24
+ Requires-Dist: questionary>=2.1.0
25
+ Requires-Dist: rich>=13.7
26
+ Requires-Dist: typer>=0.16
27
+
28
+ # yuxi-cli
29
+
30
+ Yuxi command line client.
31
+
32
+ First-stage scope:
33
+
34
+ - remote management through `~/.yuxi/config.toml`
35
+ - browser login
36
+ - API Key import through `--api-key`
37
+ - `whoami`, `status`, and `logout`
38
+ - server discovery and compatibility check for Yuxi `>=0.7.1`
39
+ - `yuxi agent eval` for running existing Langfuse dataset experiments with a logged-in remote
40
+
41
+ ## Next
42
+
43
+ - yuxi kb upload <dir>
@@ -0,0 +1,16 @@
1
+ # yuxi-cli
2
+
3
+ Yuxi command line client.
4
+
5
+ First-stage scope:
6
+
7
+ - remote management through `~/.yuxi/config.toml`
8
+ - browser login
9
+ - API Key import through `--api-key`
10
+ - `whoami`, `status`, and `logout`
11
+ - server discovery and compatibility check for Yuxi `>=0.7.1`
12
+ - `yuxi agent eval` for running existing Langfuse dataset experiments with a logged-in remote
13
+
14
+ ## Next
15
+
16
+ - yuxi kb upload <dir>
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "yuxi-cli"
7
+ version = "0.1.0"
8
+ description = "Yuxi command line client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ authors = [{ name = "Wenjie Zhang" }]
12
+ license = "MIT"
13
+ keywords = ["yuxi", "cli", "rag", "knowledge-base", "agent"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Utilities",
23
+ ]
24
+ dependencies = [
25
+ "httpx>=0.27",
26
+ "langfuse>=4.0.0",
27
+ "packaging>=24",
28
+ "questionary>=2.1.0",
29
+ "rich>=13.7",
30
+ "typer>=0.16",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://github.com/xerrors/Yuxi"
35
+ Repository = "https://github.com/xerrors/Yuxi"
36
+ Issues = "https://github.com/xerrors/Yuxi/issues"
37
+
38
+ [project.scripts]
39
+ yuxi = "yuxi_cli.main:app"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+
47
+ [dependency-groups]
48
+ test = [
49
+ "pytest>=8",
50
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Yuxi CLI package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from yuxi_cli.main import app
2
+
3
+ app()
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import uuid
5
+ from dataclasses import dataclass
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from langfuse import Langfuse
10
+ from rich.console import Console
11
+
12
+ from yuxi_cli.client import YuxiClient
13
+ from yuxi_cli.config import ConfigStore
14
+
15
+
16
+ class AgentEvalError(Exception):
17
+ pass
18
+
19
+
20
+ @dataclass
21
+ class AgentEvalOptions:
22
+ dataset_name: str
23
+ agent_slug: str
24
+ experiment_name: str | None = None
25
+ max_concurrency: int = 1
26
+ timeout_seconds: float = 900
27
+
28
+
29
+ def _env(name: str, default: str | None = None) -> str | None:
30
+ value = os.getenv(name)
31
+ return value if value else default
32
+
33
+
34
+ def build_langfuse_client() -> Langfuse:
35
+ kwargs: dict[str, Any] = {
36
+ "public_key": _env("LANGFUSE_PUBLIC_KEY"),
37
+ "secret_key": _env("LANGFUSE_SECRET_KEY"),
38
+ }
39
+ host = _env("LANGFUSE_BASE_URL")
40
+ if host:
41
+ kwargs["host"] = host
42
+ return Langfuse(**kwargs)
43
+
44
+
45
+ def extract_query(item_input: Any) -> str:
46
+ if isinstance(item_input, str):
47
+ return item_input
48
+ if isinstance(item_input, dict):
49
+ for key in ("input", "query", "question", "prompt"):
50
+ value = item_input.get(key)
51
+ if isinstance(value, str) and value.strip():
52
+ return value
53
+ raise AgentEvalError(f"无法从 Langfuse dataset item input 中提取 query: {item_input!r}")
54
+
55
+
56
+ def run_langfuse_agent_experiment(
57
+ store: ConfigStore,
58
+ remote_name: str | None,
59
+ options: AgentEvalOptions,
60
+ console: Console,
61
+ *,
62
+ langfuse_factory=build_langfuse_client,
63
+ client_factory=YuxiClient,
64
+ ) -> None:
65
+ config = store.load()
66
+ remote = config.get_remote(remote_name)
67
+ if not remote.api_key:
68
+ raise AgentEvalError(f"remote 尚未登录: {remote.name}。请先运行 yuxi login。")
69
+
70
+ if options.max_concurrency < 1:
71
+ raise AgentEvalError("--max-concurrency 必须大于等于 1")
72
+ if options.timeout_seconds <= 0:
73
+ raise AgentEvalError("--timeout-seconds 必须大于 0")
74
+
75
+ experiment_name = options.experiment_name or f"yuxi-agent-eval-{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}"
76
+ langfuse_client = langfuse_factory()
77
+ dataset = langfuse_client.get_dataset(options.dataset_name)
78
+
79
+ def task(*, item, **_kwargs):
80
+ return _run_agent_eval_item(
81
+ remote=remote,
82
+ agent_slug=options.agent_slug,
83
+ dataset_name=options.dataset_name,
84
+ experiment_name=experiment_name,
85
+ item=item,
86
+ timeout_seconds=options.timeout_seconds,
87
+ client_factory=client_factory,
88
+ )
89
+
90
+ result = dataset.run_experiment(
91
+ name=experiment_name,
92
+ task=task,
93
+ max_concurrency=options.max_concurrency,
94
+ metadata={
95
+ "source": "agent_evaluation",
96
+ "agent_slug": options.agent_slug,
97
+ "dataset_name": options.dataset_name,
98
+ "remote": remote.name,
99
+ },
100
+ )
101
+ console.print(result.format(include_item_results=True))
102
+ langfuse_client.flush()
103
+ processed_count = len(result.item_results)
104
+ total_count = len(dataset.items)
105
+ if processed_count != total_count:
106
+ raise AgentEvalError(f"Langfuse experiment 部分失败: {processed_count}/{total_count} 个 item 成功写入")
107
+
108
+
109
+ def _run_agent_eval_item(
110
+ *,
111
+ remote,
112
+ agent_slug: str,
113
+ dataset_name: str,
114
+ experiment_name: str,
115
+ item: Any,
116
+ timeout_seconds: float,
117
+ client_factory,
118
+ ) -> str:
119
+ query = extract_query(item.input)
120
+ item_id = str(getattr(item, "id", "") or "")
121
+ request_id = f"eval-{uuid.uuid4()}"
122
+ evaluation = {
123
+ "dataset_name": dataset_name,
124
+ "dataset_item_id": item_id,
125
+ "experiment_name": experiment_name,
126
+ }
127
+ with client_factory(remote, timeout=timeout_seconds) as client:
128
+ result = client.run_agent_eval(
129
+ query=query,
130
+ agent_slug=agent_slug,
131
+ evaluation=evaluation,
132
+ meta={"request_id": request_id},
133
+ timeout_seconds=timeout_seconds,
134
+ )
135
+
136
+ if result.get("status") != "completed":
137
+ raise AgentEvalError(f"Agent eval run failed for dataset item {item_id}: {result}")
138
+ return str(result.get("output") or "")
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+ from urllib.parse import urlencode
7
+
8
+ import httpx
9
+
10
+ from yuxi_cli.config import Remote, build_url
11
+
12
+
13
+ class ClientError(Exception):
14
+ def __init__(self, message: str, *, error_code: str | None = None, status_code: int | None = None):
15
+ super().__init__(message)
16
+ self.error_code = error_code
17
+ self.status_code = status_code
18
+
19
+
20
+ @dataclass
21
+ class CLIAuthSession:
22
+ device_code: str
23
+ user_code: str
24
+ verification_uri: str
25
+ expires_in: int
26
+ interval: int
27
+
28
+ @property
29
+ def authorize_path(self) -> str:
30
+ params = urlencode({"user_code": self.user_code})
31
+ separator = "&" if "?" in self.verification_uri else "?"
32
+ return f"{self.verification_uri}{separator}{params}"
33
+
34
+
35
+ class YuxiClient:
36
+ def __init__(self, remote: Remote, timeout: float = 30.0):
37
+ self.remote = remote
38
+ self.client = httpx.Client(timeout=timeout)
39
+
40
+ def close(self) -> None:
41
+ self.client.close()
42
+
43
+ def __enter__(self) -> YuxiClient:
44
+ return self
45
+
46
+ def __exit__(self, *_exc) -> None:
47
+ self.close()
48
+
49
+ def health(self) -> dict:
50
+ return self._request("GET", "/system/health", auth=False)
51
+
52
+ def discovery(self) -> dict:
53
+ return self._request("GET", "/system/discovery", auth=False)
54
+
55
+ def me(self, api_key: str | None = None) -> dict:
56
+ return self._request("GET", "/auth/me", api_key=api_key)
57
+
58
+ def create_cli_session(self) -> CLIAuthSession:
59
+ data = self._request("POST", "/auth/cli/sessions", json={}, auth=False)
60
+ return CLIAuthSession(
61
+ device_code=data["device_code"],
62
+ user_code=data["user_code"],
63
+ verification_uri=data["verification_uri"],
64
+ expires_in=int(data.get("expires_in") or 600),
65
+ interval=int(data.get("interval") or 2),
66
+ )
67
+
68
+ def exchange_cli_token(self, device_code: str) -> dict:
69
+ return self._request("POST", "/auth/cli/sessions/token", json={"device_code": device_code}, auth=False)
70
+
71
+ def delete_api_key(self, api_key_id: str) -> dict:
72
+ return self._request("DELETE", f"/user/apikey/{api_key_id}")
73
+
74
+ def get_database(self, kb_id: str) -> dict:
75
+ return self._request("GET", f"/knowledge/databases/{kb_id}")
76
+
77
+ def list_databases(self) -> dict:
78
+ return self._request("GET", "/knowledge/databases")
79
+
80
+ def get_knowledge_base_types(self) -> dict:
81
+ return self._request("GET", "/knowledge/types")
82
+
83
+ def get_supported_file_types(self) -> dict:
84
+ return self._request("GET", "/knowledge/files/supported-types")
85
+
86
+ def upload_knowledge_file(self, kb_id: str, path: Path, *, timeout_seconds: float = 300) -> dict:
87
+ with path.open("rb") as fp:
88
+ return self._request(
89
+ "POST",
90
+ "/knowledge/files/upload",
91
+ params={"kb_id": kb_id},
92
+ files={"file": (path.name, fp, "application/octet-stream")},
93
+ timeout=timeout_seconds,
94
+ )
95
+
96
+ def add_uploaded_documents(self, kb_id: str, items: list[str], params: dict) -> dict:
97
+ return self._request(
98
+ "POST",
99
+ f"/knowledge/databases/{kb_id}/documents/add",
100
+ json={"items": items, "params": params},
101
+ )
102
+
103
+ def run_agent_eval(
104
+ self,
105
+ *,
106
+ query: str,
107
+ agent_slug: str,
108
+ evaluation: dict,
109
+ meta: dict | None = None,
110
+ image_content: str | None = None,
111
+ model_spec: str | None = None,
112
+ timeout_seconds: float = 900,
113
+ ) -> dict:
114
+ payload = {
115
+ "query": query,
116
+ "agent_slug": agent_slug,
117
+ "evaluation": evaluation,
118
+ "meta": meta or {},
119
+ "image_content": image_content,
120
+ "model_spec": model_spec,
121
+ }
122
+ return self._request("POST", "/agent/eval/runs", json=payload, timeout=timeout_seconds)
123
+
124
+ def authorize_url(self, session: CLIAuthSession) -> str:
125
+ return build_url(self.remote.url, session.authorize_path)
126
+
127
+ def _request(
128
+ self,
129
+ method: str,
130
+ path: str,
131
+ *,
132
+ auth: bool = True,
133
+ api_key: str | None = None,
134
+ json: Any | None = None,
135
+ params: dict | None = None,
136
+ files: dict | None = None,
137
+ data: dict | None = None,
138
+ timeout: float | None = None,
139
+ ) -> dict:
140
+ headers = {}
141
+ token = api_key if api_key is not None else self.remote.api_key
142
+ if auth and token:
143
+ headers["Authorization"] = f"Bearer {token}"
144
+
145
+ url = f"{self.remote.api_base_url}{path if path.startswith('/') else f'/{path}'}"
146
+ request_kwargs: dict[str, Any] = {"headers": headers}
147
+ if params is not None:
148
+ request_kwargs["params"] = params
149
+ if files is not None:
150
+ request_kwargs["files"] = files
151
+ if data is not None:
152
+ request_kwargs["data"] = data
153
+ if json is not None:
154
+ request_kwargs["json"] = json
155
+ if timeout is not None:
156
+ request_kwargs["timeout"] = timeout
157
+ try:
158
+ response = self.client.request(method, url, **request_kwargs)
159
+ except httpx.HTTPError as exc:
160
+ # 网络层错误(连接失败、超时等)没有 HTTP 状态码,视为可重试的瞬时错误。
161
+ raise ClientError(f"请求远程失败: {exc}") from exc
162
+
163
+ if response.status_code >= 400:
164
+ error_code, error_message = _parse_http_error(response)
165
+ raise ClientError(error_message, error_code=error_code, status_code=response.status_code)
166
+ if not response.content:
167
+ return {}
168
+ try:
169
+ data = response.json()
170
+ except ValueError as exc:
171
+ raise ClientError("远程响应不是 JSON") from exc
172
+ if not isinstance(data, dict):
173
+ raise ClientError("远程响应格式无效")
174
+ return data
175
+
176
+
177
+ def _parse_http_error(response: httpx.Response) -> tuple[str | None, str]:
178
+ """解析远程错误,返回 (机器可读 error code, 人类可读 message)。"""
179
+ try:
180
+ detail = response.json().get("detail")
181
+ except ValueError:
182
+ detail = response.text.strip()
183
+
184
+ if isinstance(detail, dict):
185
+ error = detail.get("error")
186
+ message = detail.get("message")
187
+ if error and message:
188
+ return str(error), f"{error}: {message}"
189
+ if error:
190
+ return str(error), str(error)
191
+ if message:
192
+ return None, str(message)
193
+ if detail:
194
+ return None, str(detail)
195
+ return None, f"HTTP {response.status_code}"
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import time
5
+ import webbrowser
6
+ from collections.abc import Callable
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from yuxi_cli.client import ClientError, YuxiClient
12
+ from yuxi_cli.config import ConfigStore, Remote
13
+ from yuxi_cli.discovery import ServerCompatibilityError, ensure_server_compatible
14
+
15
+ PENDING_ERRORS = ("authorization_pending", "slow_down")
16
+
17
+
18
+ class CommandError(Exception):
19
+ pass
20
+
21
+
22
+ def remote_add(store: ConfigStore, name: str, url: str) -> Remote:
23
+ config = store.load()
24
+ remote = config.set_remote(name, url)
25
+ store.save(config)
26
+ return remote
27
+
28
+
29
+ def remote_use(store: ConfigStore, name: str) -> Remote:
30
+ config = store.load()
31
+ remote = config.use_remote(name)
32
+ store.save(config)
33
+ return remote
34
+
35
+
36
+ def remote_list(store: ConfigStore, console: Console) -> None:
37
+ config = store.load()
38
+ table = Table(show_header=True, header_style="bold")
39
+ table.add_column("Current", width=7)
40
+ table.add_column("Name")
41
+ table.add_column("URL")
42
+ table.add_column("Auth")
43
+ for name, remote in config.remotes.items():
44
+ table.add_row("*" if name == config.current else "", name, remote.url, "API Key" if remote.has_api_key else "-")
45
+ console.print(table)
46
+
47
+
48
+ def remote_ping(store: ConfigStore, name: str | None, console: Console, client_factory=YuxiClient) -> None:
49
+ config = store.load()
50
+ remote = config.get_remote(name)
51
+ with client_factory(remote) as client:
52
+ data = client.health()
53
+ console.print(f"{remote.name}: {data.get('status', 'ok')} {data.get('version', '')}".rstrip())
54
+
55
+
56
+ def login_with_api_key(
57
+ store: ConfigStore,
58
+ remote_name: str | None,
59
+ api_key: str,
60
+ console: Console,
61
+ client_factory=YuxiClient,
62
+ ) -> Remote:
63
+ if not api_key.startswith("yxkey_"):
64
+ raise CommandError("API Key 格式无效,应以 yxkey_ 开头")
65
+
66
+ config = store.load()
67
+ remote = config.get_remote(remote_name)
68
+ with client_factory(remote) as client:
69
+ _ensure_server_compatible(client, "cli.api_key_auth")
70
+ client.me(api_key=api_key) # 校验 Key 是否可用
71
+
72
+ remote.api_key = api_key
73
+ remote.api_key_id = ""
74
+ store.save(config)
75
+ console.print(f"已保存 {remote.name} 的 API Key。")
76
+ return remote
77
+
78
+
79
+ def login_with_browser(
80
+ store: ConfigStore,
81
+ remote_name: str | None,
82
+ no_open: bool,
83
+ console: Console,
84
+ *,
85
+ client_factory=YuxiClient,
86
+ open_browser: Callable[[str], bool] = webbrowser.open,
87
+ sleep: Callable[[float], None] = time.sleep,
88
+ monotonic: Callable[[], float] = time.monotonic,
89
+ ) -> Remote:
90
+ config = store.load()
91
+ remote = config.get_remote(remote_name)
92
+
93
+ with client_factory(remote) as client:
94
+ _ensure_server_compatible(client, "cli.browser_login")
95
+ session = client.create_cli_session()
96
+ authorize_url = client.authorize_url(session)
97
+ console.print(f"授权码: {session.user_code}")
98
+ console.print(f"浏览器授权地址: {authorize_url}")
99
+ if not no_open:
100
+ open_browser(authorize_url)
101
+
102
+ deadline = monotonic() + session.expires_in
103
+ while monotonic() < deadline:
104
+ try:
105
+ data = client.exchange_cli_token(session.device_code)
106
+ api_key = data.get("secret") or ""
107
+ api_key_meta = data.get("api_key") or {}
108
+ if not api_key:
109
+ raise CommandError("远程未返回 API Key secret")
110
+
111
+ remote.api_key = api_key
112
+ remote.api_key_id = str(api_key_meta.get("id") or "")
113
+ store.save(config)
114
+ console.print(f"已完成 {remote.name} 的浏览器登录。")
115
+ return remote
116
+ except ClientError as exc:
117
+ if not _should_keep_polling(exc):
118
+ raise
119
+ sleep(max(1, session.interval))
120
+
121
+ raise CommandError("浏览器授权超时")
122
+
123
+
124
+ def _should_keep_polling(exc: ClientError) -> bool:
125
+ """轮询期间是否应继续重试:等待授权、限流,或瞬时网络/服务端错误。"""
126
+ if exc.error_code in PENDING_ERRORS or str(exc).startswith(PENDING_ERRORS):
127
+ return True
128
+ # status_code 为 None 表示网络层错误;5xx 为服务端瞬时错误,均可重试。
129
+ return exc.status_code is None or exc.status_code >= 500
130
+
131
+
132
+ def whoami(store: ConfigStore, remote_name: str | None, console: Console, client_factory=YuxiClient) -> None:
133
+ config = store.load()
134
+ remote = config.get_remote(remote_name)
135
+ if not remote.api_key:
136
+ raise CommandError(f"remote 尚未登录: {remote.name}")
137
+ with client_factory(remote) as client:
138
+ user = client.me()
139
+ console.print(f"{user.get('username')} ({user.get('uid')}) - {user.get('role')}")
140
+
141
+
142
+ def status(store: ConfigStore, remote_name: str | None, console: Console, client_factory=YuxiClient) -> None:
143
+ config = store.load()
144
+ remote = config.get_remote(remote_name)
145
+ with client_factory(remote) as client:
146
+ health = client.health()
147
+ auth = "未登录"
148
+ if remote.api_key:
149
+ try:
150
+ user = client.me()
151
+ auth = f"{user.get('username')} ({user.get('uid')})"
152
+ except ClientError:
153
+ auth = "API Key 无效"
154
+
155
+ table = Table(show_header=False)
156
+ table.add_row("Remote", remote.name)
157
+ table.add_row("URL", remote.url)
158
+ table.add_row("Health", f"{health.get('status', 'ok')} {health.get('version', '')}".rstrip())
159
+ table.add_row("Auth", auth)
160
+ console.print(table)
161
+
162
+
163
+ def logout(
164
+ store: ConfigStore,
165
+ remote_name: str | None,
166
+ local_only: bool,
167
+ console: Console,
168
+ client_factory=YuxiClient,
169
+ ) -> Remote:
170
+ config = store.load()
171
+ remote = config.get_remote(remote_name)
172
+ if remote.api_key and remote.api_key_id and not local_only:
173
+ with client_factory(remote) as client:
174
+ client.delete_api_key(remote.api_key_id)
175
+
176
+ remote.api_key = ""
177
+ remote.api_key_id = ""
178
+ store.save(config)
179
+ console.print(f"已退出 {remote.name}。")
180
+ return remote
181
+
182
+
183
+ def select_login_mode(console: Console) -> str:
184
+ console.print("选择登录方式:")
185
+ console.print("> 1. 浏览器登录(推荐)")
186
+ console.print(" 2. API Key")
187
+ if not sys.stdin.isatty():
188
+ return "browser"
189
+ value = input("直接回车使用浏览器登录,输入 2 使用 API Key: ").strip()
190
+ return "api_key" if value == "2" else "browser"
191
+
192
+
193
+ def _ensure_server_compatible(client: YuxiClient, required_capability: str) -> None:
194
+ try:
195
+ discovery = client.discovery()
196
+ except ClientError as exc:
197
+ raise CommandError(f"无法读取服务端 discovery,请确认远程是 Yuxi 0.7.1 或更高版本: {exc}") from exc
198
+ try:
199
+ ensure_server_compatible(discovery, required_capability)
200
+ except ServerCompatibilityError as exc:
201
+ raise CommandError(str(exc)) from exc