@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.
Files changed (36) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/qingflow_mcp/__init__.py +1 -1
  5. package/src/qingflow_mcp/backend_client.py +109 -0
  6. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  7. package/src/qingflow_mcp/builder_facade/models.py +44 -5
  8. package/src/qingflow_mcp/builder_facade/service.py +21 -8
  9. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  10. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  11. package/src/qingflow_mcp/cli/commands/builder.py +7 -0
  12. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  13. package/src/qingflow_mcp/cli/commands/record.py +20 -0
  14. package/src/qingflow_mcp/cli/commands/task.py +644 -22
  15. package/src/qingflow_mcp/cli/commands/workspace.py +49 -50
  16. package/src/qingflow_mcp/cli/context.py +3 -0
  17. package/src/qingflow_mcp/cli/formatters.py +139 -4
  18. package/src/qingflow_mcp/cli/interaction.py +72 -0
  19. package/src/qingflow_mcp/cli/main.py +2 -0
  20. package/src/qingflow_mcp/cli/terminal_ui.py +55 -9
  21. package/src/qingflow_mcp/errors.py +2 -2
  22. package/src/qingflow_mcp/export_store.py +14 -0
  23. package/src/qingflow_mcp/public_surface.py +6 -0
  24. package/src/qingflow_mcp/response_trim.py +40 -1
  25. package/src/qingflow_mcp/server.py +22 -0
  26. package/src/qingflow_mcp/server_app_builder.py +4 -0
  27. package/src/qingflow_mcp/server_app_user.py +104 -8
  28. package/src/qingflow_mcp/session_store.py +57 -6
  29. package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
  30. package/src/qingflow_mcp/tools/auth_tools.py +26 -0
  31. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  32. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  33. package/src/qingflow_mcp/tools/import_tools.py +42 -2
  34. package/src/qingflow_mcp/tools/record_tools.py +515 -45
  35. package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
  36. 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 ..terminal_ui import SelectionOption, select_option
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="workspace_get")
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
- selected_ws_id = _choose_workspace_interactively(args, context)
48
- if selected_ws_id is None:
49
- raise RuntimeError(
50
- json.dumps(
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
- args.ws_id = selected_ws_id
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) -> int | None:
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
- options: list[SelectionOption[int]] = []
90
- for item in items:
91
- if not isinstance(item, dict):
92
- continue
93
- ws_id = int(item.get("wsId") or 0)
94
- if ws_id <= 0:
95
- continue
96
- workspace_name = str(item.get("workspaceName") or item.get("wsName") or f"Workspace {ws_id}")
97
- remark = str(item.get("remark") or "").strip()
98
- label = workspace_name
99
- if remark:
100
- label = f"{workspace_name} - {remark}"
101
- hint = f"ws_id={ws_id}"
102
- if current_ws_id == ws_id:
103
- hint += " · 当前"
104
- options.append(SelectionOption(value=ws_id, label=label, hint=hint))
105
- return select_option(
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
- options=options,
108
- input_stream=input_stream,
109
- output_stream=output_stream,
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
- formatter = _FORMATTERS.get(hint, _format_generic)
9
- text = formatter(result)
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 core_fields:
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.append(f"- {key}: {value}")
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
- title,
136
- "↑/↓ 或 j/k 选择,Enter 确认,q / Esc 取消",
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("\n".join(lines))
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
- first = input_stream.read(1)
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
- fd = input_stream.fileno()
165
- while select.select([fd], [], [], 0.02)[0]:
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),