@josephyan/qingflow-cli 0.2.0-beta.999 → 1.0.6
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 +49 -50
- 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 +2 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +55 -9
- 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 +515 -45
- package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
-
import json
|
|
5
4
|
|
|
6
5
|
from ..context import CliContext
|
|
7
|
-
from ..
|
|
6
|
+
from ..interaction import cancelled_result, resolve_interactive_selection
|
|
7
|
+
from ..terminal_ui import SelectionOption
|
|
8
|
+
from .common import raise_config_error
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
@@ -23,7 +24,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
23
24
|
|
|
24
25
|
select_parser = workspace_subparsers.add_parser("select", help="切换当前工作区")
|
|
25
26
|
select_parser.add_argument("--ws-id", type=int, default=0, help="不传时在交互终端中选择工作区")
|
|
26
|
-
select_parser.set_defaults(handler=_handle_select, format_hint="
|
|
27
|
+
select_parser.set_defaults(handler=_handle_select, format_hint="workspace_select")
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
|
|
@@ -44,40 +45,27 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
44
45
|
|
|
45
46
|
def _handle_select(args: argparse.Namespace, context: CliContext) -> dict:
|
|
46
47
|
if int(args.ws_id or 0) <= 0:
|
|
47
|
-
|
|
48
|
-
if
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"category": "config",
|
|
53
|
-
"message": "workspace select requires --ws-id, or an interactive terminal to choose a workspace",
|
|
54
|
-
},
|
|
55
|
-
ensure_ascii=False,
|
|
56
|
-
)
|
|
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.",
|
|
57
53
|
)
|
|
58
|
-
|
|
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)
|
|
59
62
|
return context.workspace.workspace_select(
|
|
60
63
|
profile=args.profile,
|
|
61
64
|
ws_id=int(args.ws_id),
|
|
62
65
|
)
|
|
63
66
|
|
|
64
67
|
|
|
65
|
-
def _choose_workspace_interactively(args: argparse.Namespace, context: CliContext)
|
|
66
|
-
input_stream = getattr(args, "_stdin", None)
|
|
67
|
-
output_stream = getattr(args, "_stderr_stream", None)
|
|
68
|
-
if input_stream is None or output_stream is None:
|
|
69
|
-
return None
|
|
70
|
-
if not bool(getattr(input_stream, "isatty", lambda: False)()):
|
|
71
|
-
return None
|
|
72
|
-
page = context.workspace.workspace_list(
|
|
73
|
-
profile=args.profile,
|
|
74
|
-
page_num=1,
|
|
75
|
-
page_size=100,
|
|
76
|
-
include_external=False,
|
|
77
|
-
).get("page")
|
|
78
|
-
items = page.get("list") if isinstance(page, dict) and isinstance(page.get("list"), list) else []
|
|
79
|
-
if not items:
|
|
80
|
-
return None
|
|
68
|
+
def _choose_workspace_interactively(args: argparse.Namespace, context: CliContext):
|
|
81
69
|
current_ws_id = None
|
|
82
70
|
sessions = getattr(context, "sessions", None)
|
|
83
71
|
if sessions is not None and hasattr(sessions, "get_profile"):
|
|
@@ -86,25 +74,36 @@ def _choose_workspace_interactively(args: argparse.Namespace, context: CliContex
|
|
|
86
74
|
except Exception:
|
|
87
75
|
session_profile = None
|
|
88
76
|
current_ws_id = getattr(session_profile, "selected_ws_id", None) if session_profile is not None else None
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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,
|
|
106
105
|
title="选择工作区",
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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,
|
|
110
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)
|
|
@@ -56,6 +56,8 @@ def run(
|
|
|
56
56
|
if not bool(args.json):
|
|
57
57
|
_emit_cli_effective_context_notice(args, context, stream=err)
|
|
58
58
|
result = handler(args, context)
|
|
59
|
+
except SystemExit as exc:
|
|
60
|
+
return int(exc.code or 0)
|
|
59
61
|
except RuntimeError as exc:
|
|
60
62
|
payload = trim_error_response(_parse_error_payload(exc))
|
|
61
63
|
return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import os
|
|
4
4
|
import select
|
|
5
5
|
import shutil
|
|
6
|
+
import textwrap
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Generic, Sequence, TextIO, TypeVar
|
|
8
9
|
|
|
@@ -15,6 +16,11 @@ except ImportError: # pragma: no cover - non-POSIX fallback
|
|
|
15
16
|
|
|
16
17
|
|
|
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
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
@dataclass(slots=True)
|
|
@@ -131,11 +137,13 @@ def _render_options(
|
|
|
131
137
|
total = len(options)
|
|
132
138
|
page_start = max(0, min(selected_index - page_size // 2, max(total - page_size, 0)))
|
|
133
139
|
visible = options[page_start: page_start + page_size]
|
|
134
|
-
lines =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
140
|
+
lines = _render_multiline_text(title, width=terminal_width)
|
|
141
|
+
lines.extend(
|
|
142
|
+
[
|
|
143
|
+
"↑/↓ 或 j/k 选择,Enter 确认,q / Esc 取消",
|
|
144
|
+
"",
|
|
145
|
+
]
|
|
146
|
+
)
|
|
139
147
|
for offset, option in enumerate(visible, start=page_start):
|
|
140
148
|
marker = ">" if offset == selected_index else " "
|
|
141
149
|
suffix = f" [{option.hint}]" if option.hint else ""
|
|
@@ -144,7 +152,7 @@ def _render_options(
|
|
|
144
152
|
lines.append("")
|
|
145
153
|
lines.append(f"{selected_index + 1}/{total}")
|
|
146
154
|
output_stream.write("\x1b[2J\x1b[H")
|
|
147
|
-
output_stream.write(
|
|
155
|
+
output_stream.write(RAW_TTY_NEWLINE.join(lines))
|
|
148
156
|
output_stream.flush()
|
|
149
157
|
|
|
150
158
|
|
|
@@ -156,17 +164,55 @@ def _truncate_line(text: str, *, width: int) -> str:
|
|
|
156
164
|
return text[: width - 1] + "…"
|
|
157
165
|
|
|
158
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
|
+
|
|
159
198
|
def _read_key(input_stream: TextIO) -> str:
|
|
160
|
-
|
|
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")
|
|
161
204
|
if first != "\x1b":
|
|
162
205
|
return first
|
|
163
206
|
chunks = [first]
|
|
164
|
-
|
|
165
|
-
|
|
207
|
+
if not select.select([fd], [], [], ESCAPE_SEQUENCE_TIMEOUT_SECONDS)[0]:
|
|
208
|
+
return first
|
|
209
|
+
while True:
|
|
166
210
|
chunk = os.read(fd, 1).decode("utf-8", errors="ignore")
|
|
167
211
|
if not chunk:
|
|
168
212
|
break
|
|
169
213
|
chunks.append(chunk)
|
|
170
214
|
if len(chunks) >= 3:
|
|
171
215
|
break
|
|
216
|
+
if not select.select([fd], [], [], ESCAPE_SEQUENCE_FOLLOWUP_TIMEOUT_SECONDS)[0]:
|
|
217
|
+
break
|
|
172
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
|
+
)
|
|
@@ -82,6 +82,7 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
82
82
|
PublicToolSpec(USER_DOMAIN, "record_department_candidates", ("record_department_candidates",), cli_public=False),
|
|
83
83
|
PublicToolSpec(USER_DOMAIN, "record_analyze", ("record_analyze",), ("record", "analyze")),
|
|
84
84
|
PublicToolSpec(USER_DOMAIN, "record_list", ("record_list",), ("record", "list"), cli_show_effective_context=True),
|
|
85
|
+
PublicToolSpec(USER_DOMAIN, "record_access", ("record_access",), ("record", "access"), cli_show_effective_context=True),
|
|
85
86
|
PublicToolSpec(USER_DOMAIN, "record_get", ("record_get_public",), ("record", "get"), cli_show_effective_context=True),
|
|
86
87
|
PublicToolSpec(USER_DOMAIN, "record_insert", ("record_insert_public",), ("record", "insert"), cli_show_effective_context=True, cli_context_write=True),
|
|
87
88
|
PublicToolSpec(USER_DOMAIN, "record_update", ("record_update_public",), ("record", "update"), cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -91,6 +92,10 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
91
92
|
PublicToolSpec(USER_DOMAIN, "record_import_repair_local", ("record_import_repair_local",), ("import", "repair")),
|
|
92
93
|
PublicToolSpec(USER_DOMAIN, "record_import_start", ("record_import_start",), ("import", "start")),
|
|
93
94
|
PublicToolSpec(USER_DOMAIN, "record_import_status_get", ("record_import_status_get",), ("import", "status")),
|
|
95
|
+
PublicToolSpec(USER_DOMAIN, "record_export_start", ("record_export_start",), ("export", "start"), cli_show_effective_context=True),
|
|
96
|
+
PublicToolSpec(USER_DOMAIN, "record_export_status_get", ("record_export_status_get",), ("export", "status"), cli_show_effective_context=True),
|
|
97
|
+
PublicToolSpec(USER_DOMAIN, "record_export_get", ("record_export_get",), ("export", "get"), cli_show_effective_context=True),
|
|
98
|
+
PublicToolSpec(USER_DOMAIN, "record_export_direct", ("record_export_direct",), ("export", "direct"), cli_show_effective_context=True),
|
|
94
99
|
PublicToolSpec(USER_DOMAIN, "record_code_block_run", ("record_code_block_run",), ("record", "code-block-run"), cli_show_effective_context=True, cli_context_write=True),
|
|
95
100
|
PublicToolSpec(USER_DOMAIN, "task_list", ("task_list",), ("task", "list"), cli_show_effective_context=True),
|
|
96
101
|
PublicToolSpec(USER_DOMAIN, "task_get", ("task_get",), ("task", "get"), cli_show_effective_context=True),
|
|
@@ -130,6 +135,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
130
135
|
PublicToolSpec(BUILDER_DOMAIN, "role_create", ("role_create",), ("builder", "role", "create"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
131
136
|
PublicToolSpec(BUILDER_DOMAIN, "app_release_edit_lock_if_mine", ("app_release_edit_lock_if_mine",), ("builder", "app", "release-edit-lock-if-mine"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
132
137
|
PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
|
|
138
|
+
PublicToolSpec(BUILDER_DOMAIN, "button_style_catalog_get", ("button_style_catalog_get",), ("builder", "button", "catalog"), has_contract=True, cli_show_effective_context=True),
|
|
133
139
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_button_list", ("app_custom_button_list",), ("builder", "button", "list"), has_contract=True, cli_show_effective_context=True),
|
|
134
140
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_button_get", ("app_custom_button_get",), ("builder", "button", "get"), has_contract=True, cli_show_effective_context=True),
|
|
135
141
|
PublicToolSpec(BUILDER_DOMAIN, "app_custom_button_create", ("app_custom_button_create",), ("builder", "button", "create"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|