@josephyan/qingflow-cli 0.2.0-beta.998 → 1.0.5
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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +109 -0
- package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
- package/src/qingflow_mcp/builder_facade/models.py +44 -5
- package/src/qingflow_mcp/builder_facade/service.py +21 -8
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/app.py +47 -1
- package/src/qingflow_mcp/cli/commands/builder.py +7 -0
- package/src/qingflow_mcp/cli/commands/exports.py +111 -0
- package/src/qingflow_mcp/cli/commands/record.py +20 -0
- package/src/qingflow_mcp/cli/commands/task.py +644 -22
- package/src/qingflow_mcp/cli/commands/workspace.py +64 -2
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +139 -4
- package/src/qingflow_mcp/cli/interaction.py +72 -0
- package/src/qingflow_mcp/cli/main.py +5 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/public_surface.py +6 -0
- package/src/qingflow_mcp/response_trim.py +40 -1
- package/src/qingflow_mcp/server.py +22 -0
- package/src/qingflow_mcp/server_app_builder.py +4 -0
- package/src/qingflow_mcp/server_app_user.py +104 -8
- package/src/qingflow_mcp/session_store.py +57 -6
- package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
- package/src/qingflow_mcp/tools/auth_tools.py +26 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
- package/src/qingflow_mcp/tools/export_tools.py +1565 -0
- package/src/qingflow_mcp/tools/import_tools.py +42 -2
- package/src/qingflow_mcp/tools/record_tools.py +551 -16
- package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
|
@@ -3,6 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
import argparse
|
|
4
4
|
|
|
5
5
|
from ..context import CliContext
|
|
6
|
+
from ..interaction import cancelled_result, resolve_interactive_selection
|
|
7
|
+
from ..terminal_ui import SelectionOption
|
|
8
|
+
from .common import raise_config_error
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
@@ -20,8 +23,8 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
20
23
|
get_parser.set_defaults(handler=_handle_get, format_hint="workspace_get")
|
|
21
24
|
|
|
22
25
|
select_parser = workspace_subparsers.add_parser("select", help="切换当前工作区")
|
|
23
|
-
select_parser.add_argument("--ws-id", type=int,
|
|
24
|
-
select_parser.set_defaults(handler=_handle_select, format_hint="
|
|
26
|
+
select_parser.add_argument("--ws-id", type=int, default=0, help="不传时在交互终端中选择工作区")
|
|
27
|
+
select_parser.set_defaults(handler=_handle_select, format_hint="workspace_select")
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
|
|
@@ -41,7 +44,66 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
41
44
|
|
|
42
45
|
|
|
43
46
|
def _handle_select(args: argparse.Namespace, context: CliContext) -> dict:
|
|
47
|
+
if int(args.ws_id or 0) <= 0:
|
|
48
|
+
selection = _choose_workspace_interactively(args, context)
|
|
49
|
+
if selection.status == "unavailable":
|
|
50
|
+
raise_config_error(
|
|
51
|
+
"workspace select requires --ws-id, or an interactive terminal to choose a workspace",
|
|
52
|
+
fix_hint="Retry in an interactive terminal, or pass `--ws-id WS_ID` explicitly.",
|
|
53
|
+
)
|
|
54
|
+
if selection.status == "empty":
|
|
55
|
+
raise_config_error(
|
|
56
|
+
selection.message or "workspace select could not open a selector because no workspaces are available.",
|
|
57
|
+
fix_hint="Run `workspace list` to confirm visible workspaces, or retry with `--ws-id WS_ID`.",
|
|
58
|
+
)
|
|
59
|
+
if selection.status == "cancelled":
|
|
60
|
+
return cancelled_result(selection.message or "已取消")
|
|
61
|
+
args.ws_id = int(selection.value or 0)
|
|
44
62
|
return context.workspace.workspace_select(
|
|
45
63
|
profile=args.profile,
|
|
46
64
|
ws_id=int(args.ws_id),
|
|
47
65
|
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _choose_workspace_interactively(args: argparse.Namespace, context: CliContext):
|
|
69
|
+
current_ws_id = None
|
|
70
|
+
sessions = getattr(context, "sessions", None)
|
|
71
|
+
if sessions is not None and hasattr(sessions, "get_profile"):
|
|
72
|
+
try:
|
|
73
|
+
session_profile = sessions.get_profile(args.profile)
|
|
74
|
+
except Exception:
|
|
75
|
+
session_profile = None
|
|
76
|
+
current_ws_id = getattr(session_profile, "selected_ws_id", None) if session_profile is not None else None
|
|
77
|
+
def load_options() -> list[SelectionOption[int]]:
|
|
78
|
+
page = context.workspace.workspace_list(
|
|
79
|
+
profile=args.profile,
|
|
80
|
+
page_num=1,
|
|
81
|
+
page_size=100,
|
|
82
|
+
include_external=False,
|
|
83
|
+
).get("page")
|
|
84
|
+
items = page.get("list") if isinstance(page, dict) and isinstance(page.get("list"), list) else []
|
|
85
|
+
options: list[SelectionOption[int]] = []
|
|
86
|
+
for item in items:
|
|
87
|
+
if not isinstance(item, dict):
|
|
88
|
+
continue
|
|
89
|
+
ws_id = int(item.get("wsId") or 0)
|
|
90
|
+
if ws_id <= 0:
|
|
91
|
+
continue
|
|
92
|
+
workspace_name = str(item.get("workspaceName") or item.get("wsName") or f"Workspace {ws_id}")
|
|
93
|
+
remark = str(item.get("remark") or "").strip()
|
|
94
|
+
label = workspace_name
|
|
95
|
+
if remark:
|
|
96
|
+
label = f"{workspace_name} - {remark}"
|
|
97
|
+
hint = f"ws_id={ws_id}"
|
|
98
|
+
if current_ws_id == ws_id:
|
|
99
|
+
hint += " · 当前"
|
|
100
|
+
options.append(SelectionOption(value=ws_id, label=label, hint=hint))
|
|
101
|
+
return options
|
|
102
|
+
|
|
103
|
+
return resolve_interactive_selection(
|
|
104
|
+
args,
|
|
105
|
+
title="选择工作区",
|
|
106
|
+
unavailable_message="workspace select requires --ws-id, or an interactive terminal to choose a workspace",
|
|
107
|
+
empty_message="workspace select could not open a selector because no visible workspaces were returned.",
|
|
108
|
+
load_options=load_options,
|
|
109
|
+
)
|
|
@@ -8,6 +8,7 @@ from ..tools.ai_builder_tools import AiBuilderTools
|
|
|
8
8
|
from ..tools.app_tools import AppTools
|
|
9
9
|
from ..tools.auth_tools import AuthTools
|
|
10
10
|
from ..tools.code_block_tools import CodeBlockTools
|
|
11
|
+
from ..tools.export_tools import ExportTools
|
|
11
12
|
from ..tools.feedback_tools import FeedbackTools
|
|
12
13
|
from ..tools.file_tools import FileTools
|
|
13
14
|
from ..tools.import_tools import ImportTools
|
|
@@ -29,6 +30,7 @@ class CliContext:
|
|
|
29
30
|
record: RecordTools
|
|
30
31
|
code_block: CodeBlockTools
|
|
31
32
|
imports: ImportTools
|
|
33
|
+
exports: ExportTools
|
|
32
34
|
task: TaskContextTools
|
|
33
35
|
files: FileTools
|
|
34
36
|
builder_feedback: FeedbackTools
|
|
@@ -52,6 +54,7 @@ def build_cli_context() -> CliContext:
|
|
|
52
54
|
record=RecordTools(sessions, backend),
|
|
53
55
|
code_block=CodeBlockTools(sessions, backend),
|
|
54
56
|
imports=ImportTools(sessions, backend),
|
|
57
|
+
exports=ExportTools(sessions, backend),
|
|
55
58
|
task=TaskContextTools(sessions, backend),
|
|
56
59
|
files=FileTools(sessions, backend),
|
|
57
60
|
builder_feedback=FeedbackTools(backend, mcp_side="App Builder MCP"),
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import textwrap
|
|
4
5
|
from typing import Any, TextIO
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def emit_text_result(result: dict[str, Any], *, hint: str, stream: TextIO) -> None:
|
|
8
|
-
|
|
9
|
-
text
|
|
9
|
+
text = _format_cancelled_result(result)
|
|
10
|
+
if text is None:
|
|
11
|
+
formatter = _FORMATTERS.get(hint, _format_generic)
|
|
12
|
+
text = formatter(result)
|
|
10
13
|
stream.write(text)
|
|
11
14
|
if not text.endswith("\n"):
|
|
12
15
|
stream.write("\n")
|
|
13
16
|
|
|
14
17
|
|
|
18
|
+
def _format_cancelled_result(result: dict[str, Any]) -> str | None:
|
|
19
|
+
if str(result.get("status") or "").lower() != "cancelled":
|
|
20
|
+
return None
|
|
21
|
+
return str(result.get("message") or "已取消") + "\n"
|
|
22
|
+
|
|
23
|
+
|
|
15
24
|
def _format_generic(result: dict[str, Any]) -> str:
|
|
16
25
|
lines: list[str] = []
|
|
17
26
|
title = _first_present(result, "status", "message")
|
|
@@ -106,6 +115,21 @@ def _format_workspace_get(result: dict[str, Any]) -> str:
|
|
|
106
115
|
return "\n".join(lines) + "\n"
|
|
107
116
|
|
|
108
117
|
|
|
118
|
+
def _format_workspace_select(result: dict[str, Any]) -> str:
|
|
119
|
+
status = str(result.get("status") or "").lower()
|
|
120
|
+
if status == "cancelled":
|
|
121
|
+
return str(result.get("message") or "已取消") + "\n"
|
|
122
|
+
|
|
123
|
+
workspace = result.get("workspace") if isinstance(result.get("workspace"), dict) else {}
|
|
124
|
+
workspace_name = workspace.get("workspaceName") or workspace.get("wsName") or result.get("selected", {}).get("workspace_name") if isinstance(result.get("selected"), dict) else None
|
|
125
|
+
lines = [
|
|
126
|
+
f"已切换到: {workspace_name or '-'} ({workspace.get('wsId') or result.get('ws_id') or '-'})",
|
|
127
|
+
f"QF Version: {result.get('qf_version') or workspace.get('systemVersion') or '-'}",
|
|
128
|
+
]
|
|
129
|
+
_append_warnings(lines, result.get("warnings"))
|
|
130
|
+
return "\n".join(lines) + "\n"
|
|
131
|
+
|
|
132
|
+
|
|
109
133
|
def _format_app_items(result: dict[str, Any]) -> str:
|
|
110
134
|
items = result.get("items")
|
|
111
135
|
if not isinstance(items, list):
|
|
@@ -214,15 +238,22 @@ def _format_task_get(result: dict[str, Any]) -> str:
|
|
|
214
238
|
f"App: {task.get('app_name') or '-'}",
|
|
215
239
|
f"Initiator: {initiator_label}",
|
|
216
240
|
f"Apply Status: {record_summary.get('apply_status')}",
|
|
241
|
+
f"Apply Number: {record_summary.get('custom_apply_num') or record_summary.get('apply_num') or '-'}",
|
|
242
|
+
f"Apply Time: {record_summary.get('apply_time') or '-'}",
|
|
217
243
|
f"Available Actions: {', '.join(str(item) for item in available_actions) or '-'}",
|
|
218
244
|
f"Editable Fields: {len(editable_fields)}",
|
|
219
245
|
]
|
|
220
246
|
)
|
|
247
|
+
all_fields = record_summary.get("all_fields") if isinstance(record_summary.get("all_fields"), dict) else {}
|
|
221
248
|
core_fields = record_summary.get("core_fields") if isinstance(record_summary.get("core_fields"), dict) else {}
|
|
222
|
-
if
|
|
249
|
+
if all_fields:
|
|
250
|
+
lines.append("Fields:")
|
|
251
|
+
for key, value in all_fields.items():
|
|
252
|
+
lines.extend(_format_field_line(key, value))
|
|
253
|
+
elif core_fields:
|
|
223
254
|
lines.append("Core Fields:")
|
|
224
255
|
for key, value in list(core_fields.items())[:12]:
|
|
225
|
-
lines.
|
|
256
|
+
lines.extend(_format_field_line(key, value))
|
|
226
257
|
if editable_fields:
|
|
227
258
|
lines.append("Editable Fields:")
|
|
228
259
|
for item in editable_fields[:10]:
|
|
@@ -273,6 +304,30 @@ def _format_task_action(result: dict[str, Any]) -> str:
|
|
|
273
304
|
return _task_action_success_label(action) + "\n"
|
|
274
305
|
|
|
275
306
|
|
|
307
|
+
def _format_task_workbench(result: dict[str, Any]) -> str:
|
|
308
|
+
message = str(result.get("message") or "").strip()
|
|
309
|
+
if message:
|
|
310
|
+
return message + "\n"
|
|
311
|
+
return ""
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _format_field_line(key: Any, value: Any) -> list[str]:
|
|
315
|
+
if isinstance(value, list):
|
|
316
|
+
text = " / ".join(str(item) for item in value if item not in (None, ""))
|
|
317
|
+
else:
|
|
318
|
+
text = str(value if value not in (None, "") else "-")
|
|
319
|
+
wrapped = textwrap.wrap(
|
|
320
|
+
text,
|
|
321
|
+
width=120,
|
|
322
|
+
initial_indent=f"- {key}: ",
|
|
323
|
+
subsequent_indent=" ",
|
|
324
|
+
replace_whitespace=False,
|
|
325
|
+
drop_whitespace=False,
|
|
326
|
+
break_long_words=True,
|
|
327
|
+
)
|
|
328
|
+
return wrapped or [f"- {key}: -"]
|
|
329
|
+
|
|
330
|
+
|
|
276
331
|
def _format_task_associated_report_detail(result: dict[str, Any]) -> str:
|
|
277
332
|
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
278
333
|
selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
|
|
@@ -352,11 +407,85 @@ def _format_import_status(result: dict[str, Any]) -> str:
|
|
|
352
407
|
f"Failed Rows: {result.get('failed') or 0}",
|
|
353
408
|
f"Progress: {result.get('progress') or '-'}",
|
|
354
409
|
]
|
|
410
|
+
if result.get("process_status") not in (None, ""):
|
|
411
|
+
lines.append(f"Process Status: {result.get('process_status')}")
|
|
412
|
+
error_file_urls = result.get("error_file_urls") if isinstance(result.get("error_file_urls"), list) else []
|
|
413
|
+
if error_file_urls:
|
|
414
|
+
lines.append("Error Files:")
|
|
415
|
+
for url in error_file_urls:
|
|
416
|
+
lines.append(f"- {url}")
|
|
417
|
+
_append_warnings(lines, result.get("warnings"))
|
|
418
|
+
_append_verification(lines, result.get("verification"))
|
|
419
|
+
return "\n".join(lines) + "\n"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _format_export_common(result: dict[str, Any], *, title: str | None = None) -> str:
|
|
423
|
+
lines: list[str] = []
|
|
424
|
+
if title:
|
|
425
|
+
lines.append(title)
|
|
426
|
+
lines.extend(
|
|
427
|
+
[
|
|
428
|
+
f"Status: {result.get('status') or '-'}",
|
|
429
|
+
f"Export Handle: {result.get('export_handle') or '-'}",
|
|
430
|
+
f"App Key: {result.get('app_key') or '-'}",
|
|
431
|
+
f"View ID: {result.get('view_id') or '-'}",
|
|
432
|
+
f"Process Status: {result.get('process_status') or '-'}",
|
|
433
|
+
f"Rows: {result.get('num') if result.get('num') is not None else '-'}",
|
|
434
|
+
]
|
|
435
|
+
)
|
|
436
|
+
row_scope = result.get("row_scope")
|
|
437
|
+
if row_scope not in (None, ""):
|
|
438
|
+
lines.append(f"Row Scope: {row_scope}")
|
|
439
|
+
selected_record_count = result.get("selected_record_count")
|
|
440
|
+
if selected_record_count not in (None, ""):
|
|
441
|
+
lines.append(f"Selected Rows: {selected_record_count}")
|
|
442
|
+
field_scope = result.get("field_scope")
|
|
443
|
+
if field_scope not in (None, ""):
|
|
444
|
+
lines.append(f"Field Scope: {field_scope}")
|
|
445
|
+
selected_field_count = result.get("selected_field_count")
|
|
446
|
+
if selected_field_count not in (None, ""):
|
|
447
|
+
lines.append(f"Selected Fields: {selected_field_count}")
|
|
448
|
+
include_workflow_log = result.get("include_workflow_log")
|
|
449
|
+
if include_workflow_log not in (None, ""):
|
|
450
|
+
lines.append(f"Include Workflow Log: {include_workflow_log}")
|
|
451
|
+
file_names = result.get("file_names") if isinstance(result.get("file_names"), list) else []
|
|
452
|
+
file_urls = result.get("file_urls") if isinstance(result.get("file_urls"), list) else []
|
|
453
|
+
if file_names or file_urls:
|
|
454
|
+
lines.append("Remote Files:")
|
|
455
|
+
max_items = max(len(file_names), len(file_urls))
|
|
456
|
+
for index in range(max_items):
|
|
457
|
+
name = file_names[index] if index < len(file_names) else "-"
|
|
458
|
+
url = file_urls[index] if index < len(file_urls) else "-"
|
|
459
|
+
lines.append(f"- {name}: {url}")
|
|
460
|
+
downloaded_files = result.get("downloaded_files") if isinstance(result.get("downloaded_files"), list) else []
|
|
461
|
+
if downloaded_files:
|
|
462
|
+
lines.append("Downloaded Files:")
|
|
463
|
+
for item in downloaded_files:
|
|
464
|
+
if isinstance(item, dict):
|
|
465
|
+
lines.append(f"- {item.get('file_name') or '-'}: {item.get('path') or '-'}")
|
|
466
|
+
else:
|
|
467
|
+
lines.append(f"- {item}")
|
|
355
468
|
_append_warnings(lines, result.get("warnings"))
|
|
356
469
|
_append_verification(lines, result.get("verification"))
|
|
357
470
|
return "\n".join(lines) + "\n"
|
|
358
471
|
|
|
359
472
|
|
|
473
|
+
def _format_export_start(result: dict[str, Any]) -> str:
|
|
474
|
+
return _format_export_common(result, title="Export Accepted")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _format_export_status(result: dict[str, Any]) -> str:
|
|
478
|
+
return _format_export_common(result, title="Export Status")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _format_export_get(result: dict[str, Any]) -> str:
|
|
482
|
+
return _format_export_common(result, title="Export Result")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _format_export_direct(result: dict[str, Any]) -> str:
|
|
486
|
+
return _format_export_common(result, title="Export Direct")
|
|
487
|
+
|
|
488
|
+
|
|
360
489
|
def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
361
490
|
lines = []
|
|
362
491
|
if "status" in result:
|
|
@@ -559,15 +688,21 @@ _FORMATTERS = {
|
|
|
559
688
|
"auth_whoami": _format_whoami,
|
|
560
689
|
"workspace_list": _format_workspace_list,
|
|
561
690
|
"workspace_get": _format_workspace_get,
|
|
691
|
+
"workspace_select": _format_workspace_select,
|
|
562
692
|
"app_list": _format_app_items,
|
|
563
693
|
"app_search": _format_app_items,
|
|
564
694
|
"app_get": _format_app_get,
|
|
565
695
|
"record_list": _format_record_list,
|
|
566
696
|
"task_list": _format_task_list,
|
|
697
|
+
"task_workbench": _format_task_workbench,
|
|
567
698
|
"task_get": _format_task_get,
|
|
568
699
|
"task_action_execute": _format_task_action,
|
|
569
700
|
"task_associated_report_detail_get": _format_task_associated_report_detail,
|
|
570
701
|
"import_verify": _format_import_verify,
|
|
571
702
|
"import_status": _format_import_status,
|
|
703
|
+
"export_start": _format_export_start,
|
|
704
|
+
"export_status": _format_export_status,
|
|
705
|
+
"export_get": _format_export_get,
|
|
706
|
+
"export_direct": _format_export_direct,
|
|
572
707
|
"builder_summary": _format_builder_summary,
|
|
573
708
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable, Generic, TypeVar
|
|
5
|
+
|
|
6
|
+
from .terminal_ui import SelectionOption, select_option
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class InteractiveSelectionResult(Generic[T]):
|
|
14
|
+
status: str
|
|
15
|
+
value: T | None = None
|
|
16
|
+
message: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cancelled_result(message: str = "已取消") -> dict[str, str]:
|
|
20
|
+
return {"status": "cancelled", "message": message}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_interactive_selection(
|
|
24
|
+
args: object,
|
|
25
|
+
*,
|
|
26
|
+
title: str,
|
|
27
|
+
unavailable_message: str,
|
|
28
|
+
empty_message: str,
|
|
29
|
+
load_options: Callable[[], list[SelectionOption[T]]],
|
|
30
|
+
) -> InteractiveSelectionResult[T]:
|
|
31
|
+
input_stream = getattr(args, "_stdin", None)
|
|
32
|
+
output_stream = getattr(args, "_stderr_stream", None)
|
|
33
|
+
if input_stream is None or output_stream is None:
|
|
34
|
+
return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
|
|
35
|
+
if not bool(getattr(input_stream, "isatty", lambda: False)()):
|
|
36
|
+
return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
|
|
37
|
+
|
|
38
|
+
options = list(load_options())
|
|
39
|
+
if not options:
|
|
40
|
+
return InteractiveSelectionResult(status="empty", message=empty_message)
|
|
41
|
+
|
|
42
|
+
value = select_option(
|
|
43
|
+
title=title,
|
|
44
|
+
options=options,
|
|
45
|
+
input_stream=input_stream,
|
|
46
|
+
output_stream=output_stream,
|
|
47
|
+
)
|
|
48
|
+
if value is None:
|
|
49
|
+
return InteractiveSelectionResult(status="cancelled", message="已取消")
|
|
50
|
+
return InteractiveSelectionResult(status="selected", value=value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_interactive_text_input(
|
|
54
|
+
args: object,
|
|
55
|
+
*,
|
|
56
|
+
prompt: str,
|
|
57
|
+
unavailable_message: str,
|
|
58
|
+
) -> InteractiveSelectionResult[str]:
|
|
59
|
+
input_stream = getattr(args, "_stdin", None)
|
|
60
|
+
output_stream = getattr(args, "_stderr_stream", None)
|
|
61
|
+
if input_stream is None or output_stream is None:
|
|
62
|
+
return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
|
|
63
|
+
if not bool(getattr(input_stream, "isatty", lambda: False)()):
|
|
64
|
+
return InteractiveSelectionResult(status="unavailable", message=unavailable_message)
|
|
65
|
+
|
|
66
|
+
output_stream.write(prompt)
|
|
67
|
+
output_stream.flush()
|
|
68
|
+
line = input_stream.readline()
|
|
69
|
+
text = str(line or "").strip()
|
|
70
|
+
if not text:
|
|
71
|
+
return InteractiveSelectionResult(status="cancelled", message="已取消")
|
|
72
|
+
return InteractiveSelectionResult(status="selected", value=text)
|
|
@@ -44,6 +44,9 @@ def run(
|
|
|
44
44
|
args = parser.parse_args(normalized_argv)
|
|
45
45
|
except SystemExit as exc:
|
|
46
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)
|
|
47
50
|
handler = getattr(args, "handler", None)
|
|
48
51
|
if handler is None:
|
|
49
52
|
parser.print_help(out)
|
|
@@ -53,6 +56,8 @@ def run(
|
|
|
53
56
|
if not bool(args.json):
|
|
54
57
|
_emit_cli_effective_context_notice(args, context, stream=err)
|
|
55
58
|
result = handler(args, context)
|
|
59
|
+
except SystemExit as exc:
|
|
60
|
+
return int(exc.code or 0)
|
|
56
61
|
except RuntimeError as exc:
|
|
57
62
|
payload = trim_error_response(_parse_error_payload(exc))
|
|
58
63
|
return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import shutil
|
|
6
|
+
import textwrap
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Generic, Sequence, TextIO, TypeVar
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import termios
|
|
12
|
+
import tty
|
|
13
|
+
except ImportError: # pragma: no cover - non-POSIX fallback
|
|
14
|
+
termios = None
|
|
15
|
+
tty = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
RAW_TTY_NEWLINE = "\r\n"
|
|
20
|
+
# Give terminal escape sequences a slightly roomier window so arrow keys
|
|
21
|
+
# still parse correctly when an outer Node/npm launcher adds a bit of PTY lag.
|
|
22
|
+
ESCAPE_SEQUENCE_TIMEOUT_SECONDS = 0.2
|
|
23
|
+
ESCAPE_SEQUENCE_FOLLOWUP_TIMEOUT_SECONDS = 0.02
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class SelectionOption(Generic[T]):
|
|
28
|
+
value: T
|
|
29
|
+
label: str
|
|
30
|
+
hint: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def select_option(
|
|
34
|
+
*,
|
|
35
|
+
title: str,
|
|
36
|
+
options: Sequence[SelectionOption[T]],
|
|
37
|
+
input_stream: TextIO,
|
|
38
|
+
output_stream: TextIO,
|
|
39
|
+
page_size: int = 8,
|
|
40
|
+
) -> T | None:
|
|
41
|
+
if not options:
|
|
42
|
+
return None
|
|
43
|
+
if len(options) == 1:
|
|
44
|
+
return options[0].value
|
|
45
|
+
if not _supports_raw_selection(input_stream=input_stream, output_stream=output_stream):
|
|
46
|
+
return _select_option_via_prompt(title=title, options=options, input_stream=input_stream, output_stream=output_stream)
|
|
47
|
+
return _select_option_via_raw_terminal(
|
|
48
|
+
title=title,
|
|
49
|
+
options=options,
|
|
50
|
+
input_stream=input_stream,
|
|
51
|
+
output_stream=output_stream,
|
|
52
|
+
page_size=max(3, page_size),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _supports_raw_selection(*, input_stream: TextIO, output_stream: TextIO) -> bool:
|
|
57
|
+
if termios is None or tty is None:
|
|
58
|
+
return False
|
|
59
|
+
if not bool(getattr(input_stream, "isatty", lambda: False)()) or not bool(getattr(output_stream, "isatty", lambda: False)()):
|
|
60
|
+
return False
|
|
61
|
+
return hasattr(input_stream, "fileno")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _select_option_via_prompt(
|
|
65
|
+
*,
|
|
66
|
+
title: str,
|
|
67
|
+
options: Sequence[SelectionOption[T]],
|
|
68
|
+
input_stream: TextIO,
|
|
69
|
+
output_stream: TextIO,
|
|
70
|
+
) -> T | None:
|
|
71
|
+
output_stream.write(title + "\n")
|
|
72
|
+
for index, option in enumerate(options, start=1):
|
|
73
|
+
suffix = f" ({option.hint})" if option.hint else ""
|
|
74
|
+
output_stream.write(f"{index}. {option.label}{suffix}\n")
|
|
75
|
+
output_stream.write("请输入编号并回车,留空取消: ")
|
|
76
|
+
output_stream.flush()
|
|
77
|
+
line = input_stream.readline()
|
|
78
|
+
selected = str(line or "").strip()
|
|
79
|
+
if not selected:
|
|
80
|
+
return None
|
|
81
|
+
if not selected.isdigit():
|
|
82
|
+
return None
|
|
83
|
+
index = int(selected) - 1
|
|
84
|
+
if index < 0 or index >= len(options):
|
|
85
|
+
return None
|
|
86
|
+
return options[index].value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _select_option_via_raw_terminal(
|
|
90
|
+
*,
|
|
91
|
+
title: str,
|
|
92
|
+
options: Sequence[SelectionOption[T]],
|
|
93
|
+
input_stream: TextIO,
|
|
94
|
+
output_stream: TextIO,
|
|
95
|
+
page_size: int,
|
|
96
|
+
) -> T | None:
|
|
97
|
+
fd = input_stream.fileno()
|
|
98
|
+
original_mode = termios.tcgetattr(fd)
|
|
99
|
+
selected_index = 0
|
|
100
|
+
output_stream.write("\x1b[?1049h\x1b[?25l")
|
|
101
|
+
output_stream.flush()
|
|
102
|
+
try:
|
|
103
|
+
tty.setraw(fd)
|
|
104
|
+
while True:
|
|
105
|
+
_render_options(
|
|
106
|
+
title=title,
|
|
107
|
+
options=options,
|
|
108
|
+
selected_index=selected_index,
|
|
109
|
+
output_stream=output_stream,
|
|
110
|
+
page_size=page_size,
|
|
111
|
+
)
|
|
112
|
+
key = _read_key(input_stream)
|
|
113
|
+
if key in ("\r", "\n"):
|
|
114
|
+
return options[selected_index].value
|
|
115
|
+
if key in ("\x03", "\x1b", "q", "Q"):
|
|
116
|
+
return None
|
|
117
|
+
if key in ("\x1b[A", "k", "K"):
|
|
118
|
+
selected_index = (selected_index - 1) % len(options)
|
|
119
|
+
continue
|
|
120
|
+
if key in ("\x1b[B", "j", "J"):
|
|
121
|
+
selected_index = (selected_index + 1) % len(options)
|
|
122
|
+
finally:
|
|
123
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, original_mode)
|
|
124
|
+
output_stream.write("\x1b[?25h\x1b[?1049l")
|
|
125
|
+
output_stream.flush()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _render_options(
|
|
129
|
+
*,
|
|
130
|
+
title: str,
|
|
131
|
+
options: Sequence[SelectionOption[object]],
|
|
132
|
+
selected_index: int,
|
|
133
|
+
output_stream: TextIO,
|
|
134
|
+
page_size: int,
|
|
135
|
+
) -> None:
|
|
136
|
+
terminal_width = shutil.get_terminal_size((100, 20)).columns
|
|
137
|
+
total = len(options)
|
|
138
|
+
page_start = max(0, min(selected_index - page_size // 2, max(total - page_size, 0)))
|
|
139
|
+
visible = options[page_start: page_start + page_size]
|
|
140
|
+
lines = _render_multiline_text(title, width=terminal_width)
|
|
141
|
+
lines.extend(
|
|
142
|
+
[
|
|
143
|
+
"↑/↓ 或 j/k 选择,Enter 确认,q / Esc 取消",
|
|
144
|
+
"",
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
for offset, option in enumerate(visible, start=page_start):
|
|
148
|
+
marker = ">" if offset == selected_index else " "
|
|
149
|
+
suffix = f" [{option.hint}]" if option.hint else ""
|
|
150
|
+
lines.append(_truncate_line(f"{marker} {option.label}{suffix}", width=terminal_width))
|
|
151
|
+
if total > page_size:
|
|
152
|
+
lines.append("")
|
|
153
|
+
lines.append(f"{selected_index + 1}/{total}")
|
|
154
|
+
output_stream.write("\x1b[2J\x1b[H")
|
|
155
|
+
output_stream.write(RAW_TTY_NEWLINE.join(lines))
|
|
156
|
+
output_stream.flush()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _truncate_line(text: str, *, width: int) -> str:
|
|
160
|
+
if width <= 0 or len(text) <= width:
|
|
161
|
+
return text
|
|
162
|
+
if width <= 1:
|
|
163
|
+
return text[:width]
|
|
164
|
+
return text[: width - 1] + "…"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _render_multiline_text(text: str, *, width: int) -> list[str]:
|
|
168
|
+
parts = text.splitlines() or [text]
|
|
169
|
+
rendered: list[str] = []
|
|
170
|
+
wrap_width = max(1, width)
|
|
171
|
+
for part in parts:
|
|
172
|
+
if not part:
|
|
173
|
+
rendered.append("")
|
|
174
|
+
continue
|
|
175
|
+
initial_indent = ""
|
|
176
|
+
subsequent_indent = ""
|
|
177
|
+
stripped = part.lstrip()
|
|
178
|
+
if stripped.startswith("- "):
|
|
179
|
+
leading_spaces = len(part) - len(stripped)
|
|
180
|
+
initial_indent = part[:leading_spaces] + "- "
|
|
181
|
+
subsequent_indent = part[:leading_spaces] + " "
|
|
182
|
+
content = stripped[2:]
|
|
183
|
+
else:
|
|
184
|
+
content = part
|
|
185
|
+
wrapped = textwrap.wrap(
|
|
186
|
+
content,
|
|
187
|
+
width=wrap_width,
|
|
188
|
+
initial_indent=initial_indent,
|
|
189
|
+
subsequent_indent=subsequent_indent,
|
|
190
|
+
replace_whitespace=False,
|
|
191
|
+
drop_whitespace=False,
|
|
192
|
+
break_long_words=True,
|
|
193
|
+
)
|
|
194
|
+
rendered.extend(wrapped or [""])
|
|
195
|
+
return rendered
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _read_key(input_stream: TextIO) -> str:
|
|
199
|
+
fd = input_stream.fileno()
|
|
200
|
+
first_bytes = os.read(fd, 1)
|
|
201
|
+
if not first_bytes:
|
|
202
|
+
return ""
|
|
203
|
+
first = first_bytes.decode("utf-8", errors="ignore")
|
|
204
|
+
if first != "\x1b":
|
|
205
|
+
return first
|
|
206
|
+
chunks = [first]
|
|
207
|
+
if not select.select([fd], [], [], ESCAPE_SEQUENCE_TIMEOUT_SECONDS)[0]:
|
|
208
|
+
return first
|
|
209
|
+
while True:
|
|
210
|
+
chunk = os.read(fd, 1).decode("utf-8", errors="ignore")
|
|
211
|
+
if not chunk:
|
|
212
|
+
break
|
|
213
|
+
chunks.append(chunk)
|
|
214
|
+
if len(chunks) >= 3:
|
|
215
|
+
break
|
|
216
|
+
if not select.select([fd], [], [], ESCAPE_SEQUENCE_FOLLOWUP_TIMEOUT_SECONDS)[0]:
|
|
217
|
+
break
|
|
218
|
+
return "".join(chunks)
|
|
@@ -54,8 +54,8 @@ class QingflowApiError(Exception):
|
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
@classmethod
|
|
57
|
-
def config_error(cls, message: str) -> "QingflowApiError":
|
|
58
|
-
return cls(category="config", message=message)
|
|
57
|
+
def config_error(cls, message: str, *, details: JSONObject | None = None) -> "QingflowApiError":
|
|
58
|
+
return cls(category="config", message=message, details=details)
|
|
59
59
|
|
|
60
60
|
@classmethod
|
|
61
61
|
def not_supported(cls, message: str) -> "QingflowApiError":
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .import_store import _JsonEntryStore, _store_dir
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExportJobStore(_JsonEntryStore):
|
|
10
|
+
def __init__(self, base_dir: Path | None = None, *, ttl_seconds: int = 24 * 3600) -> None:
|
|
11
|
+
super().__init__(
|
|
12
|
+
base_dir=base_dir or _store_dir("QINGFLOW_MCP_EXPORT_JOB_HOME", "export-jobs"),
|
|
13
|
+
ttl=timedelta(seconds=ttl_seconds),
|
|
14
|
+
)
|