@josephyan/qingflow-cli 0.2.0-beta.1000
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 +31 -0
- package/docs/local-agent-install.md +309 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow.mjs +5 -0
- package/npm/lib/runtime.mjs +346 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow +15 -0
- package/src/qingflow_mcp/__init__.py +37 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +649 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +1846 -0
- package/src/qingflow_mcp/builder_facade/service.py +16502 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +112 -0
- package/src/qingflow_mcp/cli/commands/builder.py +539 -0
- package/src/qingflow_mcp/cli/commands/chart.py +18 -0
- package/src/qingflow_mcp/cli/commands/common.py +62 -0
- package/src/qingflow_mcp/cli/commands/imports.py +96 -0
- package/src/qingflow_mcp/cli/commands/portal.py +25 -0
- package/src/qingflow_mcp/cli/commands/record.py +331 -0
- package/src/qingflow_mcp/cli/commands/repo.py +80 -0
- package/src/qingflow_mcp/cli/commands/task.py +141 -0
- package/src/qingflow_mcp/cli/commands/view.py +18 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
- package/src/qingflow_mcp/cli/context.py +60 -0
- package/src/qingflow_mcp/cli/formatters.py +573 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +186 -0
- package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
- package/src/qingflow_mcp/config.py +407 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/id_utils.py +49 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/public_surface.py +243 -0
- package/src/qingflow_mcp/repository_store.py +71 -0
- package/src/qingflow_mcp/response_trim.py +841 -0
- package/src/qingflow_mcp/server.py +216 -0
- package/src/qingflow_mcp/server_app_builder.py +543 -0
- package/src/qingflow_mcp/server_app_user.py +386 -0
- package/src/qingflow_mcp/session_store.py +369 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2398 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +855 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
- package/src/qingflow_mcp/tools/app_tools.py +926 -0
- package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
- package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
- package/src/qingflow_mcp/tools/base.py +281 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
- package/src/qingflow_mcp/tools/directory_tools.py +675 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
- package/src/qingflow_mcp/tools/file_tools.py +409 -0
- package/src/qingflow_mcp/tools/import_tools.py +2223 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
- package/src/qingflow_mcp/tools/package_tools.py +326 -0
- package/src/qingflow_mcp/tools/portal_tools.py +158 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
- package/src/qingflow_mcp/tools/record_tools.py +14291 -0
- package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
- package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
- package/src/qingflow_mcp/tools/role_tools.py +112 -0
- package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
- package/src/qingflow_mcp/tools/task_tools.py +889 -0
- package/src/qingflow_mcp/tools/view_tools.py +335 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any, Callable, TextIO
|
|
7
|
+
|
|
8
|
+
from ..errors import QingflowApiError
|
|
9
|
+
from ..public_surface import cli_public_tool_spec_from_namespace
|
|
10
|
+
from ..response_trim import resolve_cli_tool_name, trim_error_response, trim_public_response
|
|
11
|
+
from .context import CliContext, build_cli_context
|
|
12
|
+
from .formatters import emit_json_result, emit_text_result
|
|
13
|
+
from .commands import register_all_commands
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Handler = Callable[[argparse.Namespace, CliContext], dict[str, Any]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(prog="qingflow", description="Qingflow CLI")
|
|
21
|
+
parser.add_argument("--profile", default="default", help="会话 profile,默认 default")
|
|
22
|
+
parser.add_argument("--json", action="store_true", help="输出 JSON")
|
|
23
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
register_all_commands(subparsers)
|
|
25
|
+
return parser
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> None:
|
|
29
|
+
raise SystemExit(run(argv))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(
|
|
33
|
+
argv: list[str] | None = None,
|
|
34
|
+
*,
|
|
35
|
+
context_factory: Callable[[], CliContext] = build_cli_context,
|
|
36
|
+
stdout: TextIO | None = None,
|
|
37
|
+
stderr: TextIO | None = None,
|
|
38
|
+
) -> int:
|
|
39
|
+
out = stdout or sys.stdout
|
|
40
|
+
err = stderr or sys.stderr
|
|
41
|
+
parser = build_parser()
|
|
42
|
+
normalized_argv = _normalize_global_args(list(argv) if argv is not None else sys.argv[1:])
|
|
43
|
+
try:
|
|
44
|
+
args = parser.parse_args(normalized_argv)
|
|
45
|
+
except SystemExit as exc:
|
|
46
|
+
return int(exc.code or 0)
|
|
47
|
+
setattr(args, "_stdin", sys.stdin)
|
|
48
|
+
setattr(args, "_stdout_stream", out)
|
|
49
|
+
setattr(args, "_stderr_stream", err)
|
|
50
|
+
handler = getattr(args, "handler", None)
|
|
51
|
+
if handler is None:
|
|
52
|
+
parser.print_help(out)
|
|
53
|
+
return 2
|
|
54
|
+
context = context_factory()
|
|
55
|
+
try:
|
|
56
|
+
if not bool(args.json):
|
|
57
|
+
_emit_cli_effective_context_notice(args, context, stream=err)
|
|
58
|
+
result = handler(args, context)
|
|
59
|
+
except RuntimeError as exc:
|
|
60
|
+
payload = trim_error_response(_parse_error_payload(exc))
|
|
61
|
+
return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
|
|
62
|
+
except QingflowApiError as exc:
|
|
63
|
+
payload = trim_error_response(exc.to_dict())
|
|
64
|
+
return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
|
|
65
|
+
finally:
|
|
66
|
+
context.close()
|
|
67
|
+
|
|
68
|
+
exit_code = _result_exit_code(result)
|
|
69
|
+
trimmed_result = trim_public_response(resolve_cli_tool_name(args), result) if isinstance(result, dict) else result
|
|
70
|
+
stream = out if bool(args.json) or exit_code == 0 else err
|
|
71
|
+
if bool(args.json):
|
|
72
|
+
emit_json_result(trimmed_result, stream=stream)
|
|
73
|
+
else:
|
|
74
|
+
emit_text_result(trimmed_result, hint=getattr(args, "format_hint", ""), stream=stream)
|
|
75
|
+
return exit_code
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize_global_args(argv: list[str]) -> list[str]:
|
|
79
|
+
global_args: list[str] = []
|
|
80
|
+
remaining: list[str] = []
|
|
81
|
+
index = 0
|
|
82
|
+
while index < len(argv):
|
|
83
|
+
token = argv[index]
|
|
84
|
+
if token == "--json":
|
|
85
|
+
global_args.append(token)
|
|
86
|
+
index += 1
|
|
87
|
+
continue
|
|
88
|
+
if token == "--profile":
|
|
89
|
+
global_args.append(token)
|
|
90
|
+
if index + 1 >= len(argv):
|
|
91
|
+
global_args.append("")
|
|
92
|
+
break
|
|
93
|
+
global_args.append(argv[index + 1])
|
|
94
|
+
index += 2
|
|
95
|
+
continue
|
|
96
|
+
if token.startswith("--profile="):
|
|
97
|
+
global_args.append(token)
|
|
98
|
+
index += 1
|
|
99
|
+
continue
|
|
100
|
+
remaining.append(token)
|
|
101
|
+
index += 1
|
|
102
|
+
return global_args + remaining
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_error_payload(exc: RuntimeError) -> dict[str, Any]:
|
|
106
|
+
raw = str(exc)
|
|
107
|
+
try:
|
|
108
|
+
payload = json.loads(raw)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
return {"category": "runtime", "message": raw}
|
|
111
|
+
return payload if isinstance(payload, dict) else {"category": "runtime", "message": raw}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _emit_error(payload: dict[str, Any], *, json_mode: bool, stdout: TextIO, stderr: TextIO) -> int:
|
|
115
|
+
exit_code = _error_exit_code(payload)
|
|
116
|
+
if json_mode:
|
|
117
|
+
emit_json_result(payload, stream=stdout)
|
|
118
|
+
return exit_code
|
|
119
|
+
lines = [
|
|
120
|
+
f"Category: {payload.get('category') or 'error'}",
|
|
121
|
+
f"Message: {payload.get('message') or 'Unknown error'}",
|
|
122
|
+
]
|
|
123
|
+
if payload.get("backend_code") is not None:
|
|
124
|
+
lines.append(f"Backend Code: {payload.get('backend_code')}")
|
|
125
|
+
if payload.get("request_id"):
|
|
126
|
+
lines.append(f"Request ID: {payload.get('request_id')}")
|
|
127
|
+
details = payload.get("details")
|
|
128
|
+
if isinstance(details, dict):
|
|
129
|
+
for key, value in details.items():
|
|
130
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
131
|
+
lines.append(f"{key}: {value}")
|
|
132
|
+
stderr.write("\n".join(lines) + "\n")
|
|
133
|
+
return exit_code
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _error_exit_code(payload: dict[str, Any]) -> int:
|
|
137
|
+
category = str(payload.get("category") or "").lower()
|
|
138
|
+
if category in {"auth", "workspace"}:
|
|
139
|
+
return 3
|
|
140
|
+
return 4
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _result_exit_code(result: dict[str, Any]) -> int:
|
|
144
|
+
if not isinstance(result, dict):
|
|
145
|
+
return 0
|
|
146
|
+
if result.get("ok") is False:
|
|
147
|
+
return 4
|
|
148
|
+
status = str(result.get("status") or "").lower()
|
|
149
|
+
if status in {"failed", "blocked"}:
|
|
150
|
+
return 4
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliContext, *, stream: TextIO) -> None:
|
|
155
|
+
spec = cli_public_tool_spec_from_namespace(args)
|
|
156
|
+
if spec is None or not spec.cli_show_effective_context:
|
|
157
|
+
return
|
|
158
|
+
hide_context_line = bool(getattr(args, "hide_effective_context_line", False))
|
|
159
|
+
sessions = getattr(context, "sessions", None)
|
|
160
|
+
if sessions is None or not hasattr(sessions, "get_profile"):
|
|
161
|
+
return
|
|
162
|
+
profile_name = str(getattr(args, "profile", "default") or "default")
|
|
163
|
+
try:
|
|
164
|
+
session_profile = sessions.get_profile(profile_name)
|
|
165
|
+
except Exception:
|
|
166
|
+
session_profile = None
|
|
167
|
+
workspace_id = getattr(session_profile, "selected_ws_id", None) if session_profile is not None else None
|
|
168
|
+
workspace_name = getattr(session_profile, "selected_ws_name", None) if session_profile is not None else None
|
|
169
|
+
if workspace_id is None:
|
|
170
|
+
workspace_label = "(not selected)"
|
|
171
|
+
elif workspace_name:
|
|
172
|
+
workspace_label = f"{workspace_name} ({workspace_id})"
|
|
173
|
+
else:
|
|
174
|
+
workspace_label = str(workspace_id)
|
|
175
|
+
lines: list[str] = []
|
|
176
|
+
if not hide_context_line:
|
|
177
|
+
lines.append(f"Context: profile={profile_name} workspace={workspace_label}")
|
|
178
|
+
if spec.cli_context_write and profile_name == "default":
|
|
179
|
+
lines.append("Warning: using default profile for a workspace-sensitive write command")
|
|
180
|
+
if not lines:
|
|
181
|
+
return
|
|
182
|
+
stream.write("\n".join(lines) + "\n")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
main()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import PKCS1_v1_5
|
|
8
|
+
from Crypto.PublicKey import RSA
|
|
9
|
+
|
|
10
|
+
from ..backend_client import BackendClient
|
|
11
|
+
from ..config import get_default_base_url, get_timeout_seconds, normalize_base_url
|
|
12
|
+
from ..errors import QingflowApiError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class QingflowNativeLoginResult:
|
|
17
|
+
token: str
|
|
18
|
+
user_info: dict[str, Any]
|
|
19
|
+
login_token: str | None = None
|
|
20
|
+
flow: str = "qingflow_password"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QingflowNativeLoginHelper:
|
|
24
|
+
def __init__(self, *, backend: BackendClient | None = None) -> None:
|
|
25
|
+
self._owns_backend = backend is None
|
|
26
|
+
self._backend = backend or BackendClient(timeout=get_timeout_seconds())
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
if self._owns_backend:
|
|
30
|
+
self._backend.close()
|
|
31
|
+
|
|
32
|
+
def login_with_password(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str | None,
|
|
36
|
+
email: str,
|
|
37
|
+
password: str,
|
|
38
|
+
) -> QingflowNativeLoginResult:
|
|
39
|
+
normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
|
|
40
|
+
normalized_email = str(email or "").strip()
|
|
41
|
+
normalized_password = str(password or "")
|
|
42
|
+
if not normalized_base_url:
|
|
43
|
+
raise QingflowApiError.config_error("base_url is required or configure default_base_url")
|
|
44
|
+
if not normalized_email:
|
|
45
|
+
raise QingflowApiError.config_error("email is required for Qingflow account login")
|
|
46
|
+
if not normalized_password:
|
|
47
|
+
raise QingflowApiError.config_error("password is required for Qingflow account login")
|
|
48
|
+
|
|
49
|
+
pubkey_payload = self._backend.public_request("GET", normalized_base_url, "/user/pubkey", qf_version=None)
|
|
50
|
+
pubkey = self._extract_pubkey(pubkey_payload)
|
|
51
|
+
encrypted_password = _encrypt_password(normalized_password, pubkey)
|
|
52
|
+
login_payload = self._backend.public_request(
|
|
53
|
+
"POST",
|
|
54
|
+
normalized_base_url,
|
|
55
|
+
"/user/login",
|
|
56
|
+
json_body={"email": normalized_email, "password": encrypted_password},
|
|
57
|
+
qf_version=None,
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(login_payload, dict):
|
|
60
|
+
raise QingflowApiError(category="auth", message="Qingflow login did not return a valid response")
|
|
61
|
+
|
|
62
|
+
token = str(login_payload.get("token") or "").strip()
|
|
63
|
+
login_token = str(login_payload.get("loginToken") or "").strip() or None
|
|
64
|
+
if not token:
|
|
65
|
+
if login_token:
|
|
66
|
+
raise QingflowApiError(
|
|
67
|
+
category="auth",
|
|
68
|
+
message=(
|
|
69
|
+
"Qingflow account login requires additional security verification. "
|
|
70
|
+
"CLI password login currently does not complete the loginToken verification step."
|
|
71
|
+
),
|
|
72
|
+
details={"login_token_present": True},
|
|
73
|
+
)
|
|
74
|
+
raise QingflowApiError(
|
|
75
|
+
category="auth",
|
|
76
|
+
message="Qingflow login succeeded but did not return a token",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
user_info = login_payload.get("userInfo")
|
|
80
|
+
if not isinstance(user_info, dict):
|
|
81
|
+
user_info = {}
|
|
82
|
+
return QingflowNativeLoginResult(
|
|
83
|
+
token=token,
|
|
84
|
+
login_token=login_token,
|
|
85
|
+
user_info=user_info,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _extract_pubkey(self, payload: Any) -> str:
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response is invalid")
|
|
91
|
+
pubkey = str(payload.get("pubkey") or "").strip()
|
|
92
|
+
if not pubkey:
|
|
93
|
+
raise QingflowApiError(category="auth", message="Qingflow pubkey response did not include pubkey")
|
|
94
|
+
return pubkey
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def login_with_qingflow_password(
|
|
98
|
+
*,
|
|
99
|
+
base_url: str | None,
|
|
100
|
+
email: str,
|
|
101
|
+
password: str,
|
|
102
|
+
) -> QingflowNativeLoginResult:
|
|
103
|
+
helper = QingflowNativeLoginHelper()
|
|
104
|
+
try:
|
|
105
|
+
return helper.login_with_password(base_url=base_url, email=email, password=password)
|
|
106
|
+
finally:
|
|
107
|
+
helper.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _encrypt_password(password: str, pubkey: str) -> str:
|
|
111
|
+
public_key = RSA.import_key(
|
|
112
|
+
"-----BEGIN PUBLIC KEY-----\n" + pubkey.strip() + "\n-----END PUBLIC KEY-----\n"
|
|
113
|
+
)
|
|
114
|
+
cipher = PKCS1_v1_5.new(public_key)
|
|
115
|
+
encrypted = cipher.encrypt(password.encode("utf-8"))
|
|
116
|
+
return base64.b64encode(encrypted).decode("ascii")
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Generic, Sequence, TextIO, TypeVar
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import termios
|
|
11
|
+
import tty
|
|
12
|
+
except ImportError: # pragma: no cover - non-POSIX fallback
|
|
13
|
+
termios = None
|
|
14
|
+
tty = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
RAW_TTY_NEWLINE = "\r\n"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class SelectionOption(Generic[T]):
|
|
23
|
+
value: T
|
|
24
|
+
label: str
|
|
25
|
+
hint: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def select_option(
|
|
29
|
+
*,
|
|
30
|
+
title: str,
|
|
31
|
+
options: Sequence[SelectionOption[T]],
|
|
32
|
+
input_stream: TextIO,
|
|
33
|
+
output_stream: TextIO,
|
|
34
|
+
page_size: int = 8,
|
|
35
|
+
) -> T | None:
|
|
36
|
+
if not options:
|
|
37
|
+
return None
|
|
38
|
+
if len(options) == 1:
|
|
39
|
+
return options[0].value
|
|
40
|
+
if not _supports_raw_selection(input_stream=input_stream, output_stream=output_stream):
|
|
41
|
+
return _select_option_via_prompt(title=title, options=options, input_stream=input_stream, output_stream=output_stream)
|
|
42
|
+
return _select_option_via_raw_terminal(
|
|
43
|
+
title=title,
|
|
44
|
+
options=options,
|
|
45
|
+
input_stream=input_stream,
|
|
46
|
+
output_stream=output_stream,
|
|
47
|
+
page_size=max(3, page_size),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _supports_raw_selection(*, input_stream: TextIO, output_stream: TextIO) -> bool:
|
|
52
|
+
if termios is None or tty is None:
|
|
53
|
+
return False
|
|
54
|
+
if not bool(getattr(input_stream, "isatty", lambda: False)()) or not bool(getattr(output_stream, "isatty", lambda: False)()):
|
|
55
|
+
return False
|
|
56
|
+
return hasattr(input_stream, "fileno")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _select_option_via_prompt(
|
|
60
|
+
*,
|
|
61
|
+
title: str,
|
|
62
|
+
options: Sequence[SelectionOption[T]],
|
|
63
|
+
input_stream: TextIO,
|
|
64
|
+
output_stream: TextIO,
|
|
65
|
+
) -> T | None:
|
|
66
|
+
output_stream.write(title + "\n")
|
|
67
|
+
for index, option in enumerate(options, start=1):
|
|
68
|
+
suffix = f" ({option.hint})" if option.hint else ""
|
|
69
|
+
output_stream.write(f"{index}. {option.label}{suffix}\n")
|
|
70
|
+
output_stream.write("请输入编号并回车,留空取消: ")
|
|
71
|
+
output_stream.flush()
|
|
72
|
+
line = input_stream.readline()
|
|
73
|
+
selected = str(line or "").strip()
|
|
74
|
+
if not selected:
|
|
75
|
+
return None
|
|
76
|
+
if not selected.isdigit():
|
|
77
|
+
return None
|
|
78
|
+
index = int(selected) - 1
|
|
79
|
+
if index < 0 or index >= len(options):
|
|
80
|
+
return None
|
|
81
|
+
return options[index].value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _select_option_via_raw_terminal(
|
|
85
|
+
*,
|
|
86
|
+
title: str,
|
|
87
|
+
options: Sequence[SelectionOption[T]],
|
|
88
|
+
input_stream: TextIO,
|
|
89
|
+
output_stream: TextIO,
|
|
90
|
+
page_size: int,
|
|
91
|
+
) -> T | None:
|
|
92
|
+
fd = input_stream.fileno()
|
|
93
|
+
original_mode = termios.tcgetattr(fd)
|
|
94
|
+
selected_index = 0
|
|
95
|
+
output_stream.write("\x1b[?1049h\x1b[?25l")
|
|
96
|
+
output_stream.flush()
|
|
97
|
+
try:
|
|
98
|
+
tty.setraw(fd)
|
|
99
|
+
while True:
|
|
100
|
+
_render_options(
|
|
101
|
+
title=title,
|
|
102
|
+
options=options,
|
|
103
|
+
selected_index=selected_index,
|
|
104
|
+
output_stream=output_stream,
|
|
105
|
+
page_size=page_size,
|
|
106
|
+
)
|
|
107
|
+
key = _read_key(input_stream)
|
|
108
|
+
if key in ("\r", "\n"):
|
|
109
|
+
return options[selected_index].value
|
|
110
|
+
if key in ("\x03", "\x1b", "q", "Q"):
|
|
111
|
+
return None
|
|
112
|
+
if key in ("\x1b[A", "k", "K"):
|
|
113
|
+
selected_index = (selected_index - 1) % len(options)
|
|
114
|
+
continue
|
|
115
|
+
if key in ("\x1b[B", "j", "J"):
|
|
116
|
+
selected_index = (selected_index + 1) % len(options)
|
|
117
|
+
finally:
|
|
118
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, original_mode)
|
|
119
|
+
output_stream.write("\x1b[?25h\x1b[?1049l")
|
|
120
|
+
output_stream.flush()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _render_options(
|
|
124
|
+
*,
|
|
125
|
+
title: str,
|
|
126
|
+
options: Sequence[SelectionOption[object]],
|
|
127
|
+
selected_index: int,
|
|
128
|
+
output_stream: TextIO,
|
|
129
|
+
page_size: int,
|
|
130
|
+
) -> None:
|
|
131
|
+
terminal_width = shutil.get_terminal_size((100, 20)).columns
|
|
132
|
+
total = len(options)
|
|
133
|
+
page_start = max(0, min(selected_index - page_size // 2, max(total - page_size, 0)))
|
|
134
|
+
visible = options[page_start: page_start + page_size]
|
|
135
|
+
lines = [
|
|
136
|
+
title,
|
|
137
|
+
"↑/↓ 或 j/k 选择,Enter 确认,q / Esc 取消",
|
|
138
|
+
"",
|
|
139
|
+
]
|
|
140
|
+
for offset, option in enumerate(visible, start=page_start):
|
|
141
|
+
marker = ">" if offset == selected_index else " "
|
|
142
|
+
suffix = f" [{option.hint}]" if option.hint else ""
|
|
143
|
+
lines.append(_truncate_line(f"{marker} {option.label}{suffix}", width=terminal_width))
|
|
144
|
+
if total > page_size:
|
|
145
|
+
lines.append("")
|
|
146
|
+
lines.append(f"{selected_index + 1}/{total}")
|
|
147
|
+
output_stream.write("\x1b[2J\x1b[H")
|
|
148
|
+
output_stream.write(RAW_TTY_NEWLINE.join(lines))
|
|
149
|
+
output_stream.flush()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _truncate_line(text: str, *, width: int) -> str:
|
|
153
|
+
if width <= 0 or len(text) <= width:
|
|
154
|
+
return text
|
|
155
|
+
if width <= 1:
|
|
156
|
+
return text[:width]
|
|
157
|
+
return text[: width - 1] + "…"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _read_key(input_stream: TextIO) -> str:
|
|
161
|
+
first = input_stream.read(1)
|
|
162
|
+
if first != "\x1b":
|
|
163
|
+
return first
|
|
164
|
+
chunks = [first]
|
|
165
|
+
fd = input_stream.fileno()
|
|
166
|
+
while select.select([fd], [], [], 0.02)[0]:
|
|
167
|
+
chunk = os.read(fd, 1).decode("utf-8", errors="ignore")
|
|
168
|
+
if not chunk:
|
|
169
|
+
break
|
|
170
|
+
chunks.append(chunk)
|
|
171
|
+
if len(chunks) >= 3:
|
|
172
|
+
break
|
|
173
|
+
return "".join(chunks)
|