@qingflow-tech/qingflow-app-builder-mcp 1.0.3 → 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.
Files changed (36) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/pyproject.toml +2 -1
  4. package/src/qingflow_mcp/__init__.py +1 -1
  5. package/src/qingflow_mcp/backend_client.py +164 -1
  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 +44 -5
  14. package/src/qingflow_mcp/cli/commands/task.py +644 -22
  15. package/src/qingflow_mcp/cli/commands/workspace.py +64 -2
  16. package/src/qingflow_mcp/cli/context.py +3 -0
  17. package/src/qingflow_mcp/cli/formatters.py +240 -5
  18. package/src/qingflow_mcp/cli/interaction.py +72 -0
  19. package/src/qingflow_mcp/cli/main.py +5 -0
  20. package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
  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 +7 -1
  24. package/src/qingflow_mcp/response_trim.py +188 -10
  25. package/src/qingflow_mcp/server.py +37 -9
  26. package/src/qingflow_mcp/server_app_builder.py +4 -0
  27. package/src/qingflow_mcp/server_app_user.py +115 -10
  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 +12793 -8612
  35. package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
  36. 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, required=True)
24
- select_parser.set_defaults(handler=_handle_select, format_hint="workspace_get")
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
- 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):
@@ -159,7 +183,17 @@ def _format_app_get(result: dict[str, Any]) -> str:
159
183
  def _format_record_list(result: dict[str, Any]) -> str:
160
184
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
161
185
  items = data.get("items") if isinstance(data.get("items"), list) else []
162
- lines = [f"Returned Records: {len(items)}"]
186
+ lines: list[str] = []
187
+ lookup = result.get("lookup") if isinstance(result.get("lookup"), dict) else {}
188
+ if lookup:
189
+ lines.append("Lookup:")
190
+ lines.append(f"- query: {lookup.get('query')}")
191
+ lines.append(f"- confidence: {lookup.get('confidence')}")
192
+ lines.append(f"- next_action: {lookup.get('next_action')}")
193
+ lines.append(f"- total_count: {lookup.get('total_count')}")
194
+ lines.append(f"- returned_count: {lookup.get('returned_count')}")
195
+ lines.append(f"- truncated: {lookup.get('truncated')}")
196
+ lines.append(f"Returned Records: {len(items)}")
163
197
  for item in items[:10]:
164
198
  if isinstance(item, dict):
165
199
  lines.append(json.dumps(item, ensure_ascii=False))
@@ -170,6 +204,94 @@ def _format_record_list(result: dict[str, Any]) -> str:
170
204
  return "\n".join(lines) + "\n"
171
205
 
172
206
 
207
+ def _format_record_access(result: dict[str, Any]) -> str:
208
+ status = result.get("status") or "-"
209
+ lines = [
210
+ f"Status: {status}",
211
+ f"Rows: {result.get('row_count')}",
212
+ f"Complete: {result.get('complete')}",
213
+ f"Safe for final conclusion: {result.get('safe_for_final_conclusion')}",
214
+ ]
215
+ if result.get("local_dir"):
216
+ lines.append(f"Local dir: {result.get('local_dir')}")
217
+ files = result.get("files") if isinstance(result.get("files"), list) else []
218
+ if files:
219
+ lines.append("Files:")
220
+ for item in files:
221
+ if isinstance(item, dict):
222
+ lines.append(f"- part {item.get('part')}: {item.get('local_path')} ({item.get('row_count')} rows)")
223
+ scope = result.get("scope") if isinstance(result.get("scope"), dict) else {}
224
+ if status == "needs_scope" and scope:
225
+ lines.append("Scope required:")
226
+ lines.append(f"- reported_total: {scope.get('reported_total')}")
227
+ lines.append(f"- estimated_pages: {scope.get('estimated_pages')}")
228
+ suggested = scope.get("suggested_time_fields") if isinstance(scope.get("suggested_time_fields"), list) else []
229
+ if suggested:
230
+ names = ", ".join(str(item.get("title") or item.get("field_id")) for item in suggested if isinstance(item, dict))
231
+ lines.append(f"- suggested_time_fields: {names}")
232
+ _append_warnings(lines, result.get("warnings"))
233
+ _append_verification(lines, result.get("verification"))
234
+ return "\n".join(lines) + "\n"
235
+
236
+
237
+ def _format_record_get(result: dict[str, Any]) -> str:
238
+ record = result.get("record") if isinstance(result.get("record"), dict) else {}
239
+ app = result.get("app") if isinstance(result.get("app"), dict) else {}
240
+ view = result.get("view") if isinstance(result.get("view"), dict) else {}
241
+ fields = result.get("fields") if isinstance(result.get("fields"), list) else []
242
+ data_logs = result.get("data_logs") if isinstance(result.get("data_logs"), dict) else {}
243
+ workflow_logs = result.get("workflow_logs") if isinstance(result.get("workflow_logs"), dict) else {}
244
+ media_assets = result.get("media_assets") if isinstance(result.get("media_assets"), dict) else {}
245
+ media_items = media_assets.get("items") if isinstance(media_assets.get("items"), list) else []
246
+ downloaded_media = [item for item in media_items if isinstance(item, dict) and item.get("access_status") == "downloaded"]
247
+ failed_media = [item for item in media_items if isinstance(item, dict) and item.get("access_status") != "downloaded"]
248
+ file_assets = result.get("file_assets") if isinstance(result.get("file_assets"), dict) else {}
249
+ file_items = file_assets.get("items") if isinstance(file_assets.get("items"), list) else []
250
+ downloaded_files = [item for item in file_items if isinstance(item, dict) and item.get("access_status") == "downloaded"]
251
+ failed_files = [item for item in file_items if isinstance(item, dict) and item.get("access_status") != "downloaded"]
252
+ extracted_files = [
253
+ item
254
+ for item in downloaded_files
255
+ if isinstance(item.get("extraction"), dict) and item["extraction"].get("status") == "ok"
256
+ ]
257
+ associated_resources = result.get("associated_resources") if isinstance(result.get("associated_resources"), list) else []
258
+ unavailable_context = result.get("unavailable_context") if isinstance(result.get("unavailable_context"), list) else []
259
+ lines = [
260
+ f"Status: {result.get('status') or '-'}",
261
+ f"App: {app.get('app_name') or app.get('app_key') or '-'} ({app.get('app_key') or '-'})",
262
+ f"View: {view.get('name') or view.get('view_id') or '-'}",
263
+ f"Record: {record.get('title') or '-'} ({record.get('record_id') or '-'})",
264
+ f"Fields: {len(fields)}",
265
+ f"Data logs: {data_logs.get('status') or '-'} / loaded={data_logs.get('items_loaded')}",
266
+ f"Workflow logs: {workflow_logs.get('status') or '-'} / loaded={workflow_logs.get('items_loaded')}",
267
+ f"Media assets: {media_assets.get('status') or '-'} / downloaded={len(downloaded_media)} / failed={len(failed_media)}",
268
+ f"File assets: {file_assets.get('status') or '-'} / downloaded={len(downloaded_files)} / extracted={len(extracted_files)} / failed={len(failed_files)}",
269
+ f"Associated resources: {len(associated_resources)}",
270
+ f"Unavailable contexts: {len(unavailable_context)}",
271
+ ]
272
+ if media_assets.get("local_dir"):
273
+ lines.append(f"Media dir: {media_assets.get('local_dir')}")
274
+ if file_assets.get("local_dir"):
275
+ lines.append(f"File dir: {file_assets.get('local_dir')}")
276
+ if failed_media:
277
+ failure_counts: dict[str, int] = {}
278
+ for item in failed_media:
279
+ key = f"{item.get('access_status') or 'unknown'} via {item.get('download_strategy') or 'unknown'}"
280
+ failure_counts[key] = failure_counts.get(key, 0) + 1
281
+ lines.append("Media failures: " + ", ".join(f"{key}={count}" for key, count in sorted(failure_counts.items())))
282
+ if failed_files:
283
+ failure_counts = {}
284
+ for item in failed_files:
285
+ key = f"{item.get('access_status') or 'unknown'} via {item.get('download_strategy') or 'unknown'}"
286
+ failure_counts[key] = failure_counts.get(key, 0) + 1
287
+ lines.append("File failures: " + ", ".join(f"{key}={count}" for key, count in sorted(failure_counts.items())))
288
+ summary = result.get("summary") if isinstance(result.get("summary"), dict) else {}
289
+ if summary.get("text"):
290
+ lines.append(f"Summary: {summary.get('text')}")
291
+ _append_warnings(lines, result.get("warnings"))
292
+ return "\n".join(lines) + "\n"
293
+
294
+
173
295
  def _format_task_list(result: dict[str, Any]) -> str:
174
296
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
175
297
  items = data.get("items") if isinstance(data.get("items"), list) else []
@@ -214,15 +336,22 @@ def _format_task_get(result: dict[str, Any]) -> str:
214
336
  f"App: {task.get('app_name') or '-'}",
215
337
  f"Initiator: {initiator_label}",
216
338
  f"Apply Status: {record_summary.get('apply_status')}",
339
+ f"Apply Number: {record_summary.get('custom_apply_num') or record_summary.get('apply_num') or '-'}",
340
+ f"Apply Time: {record_summary.get('apply_time') or '-'}",
217
341
  f"Available Actions: {', '.join(str(item) for item in available_actions) or '-'}",
218
342
  f"Editable Fields: {len(editable_fields)}",
219
343
  ]
220
344
  )
345
+ all_fields = record_summary.get("all_fields") if isinstance(record_summary.get("all_fields"), dict) else {}
221
346
  core_fields = record_summary.get("core_fields") if isinstance(record_summary.get("core_fields"), dict) else {}
222
- if core_fields:
347
+ if all_fields:
348
+ lines.append("Fields:")
349
+ for key, value in all_fields.items():
350
+ lines.extend(_format_field_line(key, value))
351
+ elif core_fields:
223
352
  lines.append("Core Fields:")
224
353
  for key, value in list(core_fields.items())[:12]:
225
- lines.append(f"- {key}: {value}")
354
+ lines.extend(_format_field_line(key, value))
226
355
  if editable_fields:
227
356
  lines.append("Editable Fields:")
228
357
  for item in editable_fields[:10]:
@@ -273,6 +402,30 @@ def _format_task_action(result: dict[str, Any]) -> str:
273
402
  return _task_action_success_label(action) + "\n"
274
403
 
275
404
 
405
+ def _format_task_workbench(result: dict[str, Any]) -> str:
406
+ message = str(result.get("message") or "").strip()
407
+ if message:
408
+ return message + "\n"
409
+ return ""
410
+
411
+
412
+ def _format_field_line(key: Any, value: Any) -> list[str]:
413
+ if isinstance(value, list):
414
+ text = " / ".join(str(item) for item in value if item not in (None, ""))
415
+ else:
416
+ text = str(value if value not in (None, "") else "-")
417
+ wrapped = textwrap.wrap(
418
+ text,
419
+ width=120,
420
+ initial_indent=f"- {key}: ",
421
+ subsequent_indent=" ",
422
+ replace_whitespace=False,
423
+ drop_whitespace=False,
424
+ break_long_words=True,
425
+ )
426
+ return wrapped or [f"- {key}: -"]
427
+
428
+
276
429
  def _format_task_associated_report_detail(result: dict[str, Any]) -> str:
277
430
  data = result.get("data") if isinstance(result.get("data"), dict) else {}
278
431
  selection = data.get("selection") if isinstance(data.get("selection"), dict) else {}
@@ -352,11 +505,85 @@ def _format_import_status(result: dict[str, Any]) -> str:
352
505
  f"Failed Rows: {result.get('failed') or 0}",
353
506
  f"Progress: {result.get('progress') or '-'}",
354
507
  ]
508
+ if result.get("process_status") not in (None, ""):
509
+ lines.append(f"Process Status: {result.get('process_status')}")
510
+ error_file_urls = result.get("error_file_urls") if isinstance(result.get("error_file_urls"), list) else []
511
+ if error_file_urls:
512
+ lines.append("Error Files:")
513
+ for url in error_file_urls:
514
+ lines.append(f"- {url}")
515
+ _append_warnings(lines, result.get("warnings"))
516
+ _append_verification(lines, result.get("verification"))
517
+ return "\n".join(lines) + "\n"
518
+
519
+
520
+ def _format_export_common(result: dict[str, Any], *, title: str | None = None) -> str:
521
+ lines: list[str] = []
522
+ if title:
523
+ lines.append(title)
524
+ lines.extend(
525
+ [
526
+ f"Status: {result.get('status') or '-'}",
527
+ f"Export Handle: {result.get('export_handle') or '-'}",
528
+ f"App Key: {result.get('app_key') or '-'}",
529
+ f"View ID: {result.get('view_id') or '-'}",
530
+ f"Process Status: {result.get('process_status') or '-'}",
531
+ f"Rows: {result.get('num') if result.get('num') is not None else '-'}",
532
+ ]
533
+ )
534
+ row_scope = result.get("row_scope")
535
+ if row_scope not in (None, ""):
536
+ lines.append(f"Row Scope: {row_scope}")
537
+ selected_record_count = result.get("selected_record_count")
538
+ if selected_record_count not in (None, ""):
539
+ lines.append(f"Selected Rows: {selected_record_count}")
540
+ field_scope = result.get("field_scope")
541
+ if field_scope not in (None, ""):
542
+ lines.append(f"Field Scope: {field_scope}")
543
+ selected_field_count = result.get("selected_field_count")
544
+ if selected_field_count not in (None, ""):
545
+ lines.append(f"Selected Fields: {selected_field_count}")
546
+ include_workflow_log = result.get("include_workflow_log")
547
+ if include_workflow_log not in (None, ""):
548
+ lines.append(f"Include Workflow Log: {include_workflow_log}")
549
+ file_names = result.get("file_names") if isinstance(result.get("file_names"), list) else []
550
+ file_urls = result.get("file_urls") if isinstance(result.get("file_urls"), list) else []
551
+ if file_names or file_urls:
552
+ lines.append("Remote Files:")
553
+ max_items = max(len(file_names), len(file_urls))
554
+ for index in range(max_items):
555
+ name = file_names[index] if index < len(file_names) else "-"
556
+ url = file_urls[index] if index < len(file_urls) else "-"
557
+ lines.append(f"- {name}: {url}")
558
+ downloaded_files = result.get("downloaded_files") if isinstance(result.get("downloaded_files"), list) else []
559
+ if downloaded_files:
560
+ lines.append("Downloaded Files:")
561
+ for item in downloaded_files:
562
+ if isinstance(item, dict):
563
+ lines.append(f"- {item.get('file_name') or '-'}: {item.get('path') or '-'}")
564
+ else:
565
+ lines.append(f"- {item}")
355
566
  _append_warnings(lines, result.get("warnings"))
356
567
  _append_verification(lines, result.get("verification"))
357
568
  return "\n".join(lines) + "\n"
358
569
 
359
570
 
571
+ def _format_export_start(result: dict[str, Any]) -> str:
572
+ return _format_export_common(result, title="Export Accepted")
573
+
574
+
575
+ def _format_export_status(result: dict[str, Any]) -> str:
576
+ return _format_export_common(result, title="Export Status")
577
+
578
+
579
+ def _format_export_get(result: dict[str, Any]) -> str:
580
+ return _format_export_common(result, title="Export Result")
581
+
582
+
583
+ def _format_export_direct(result: dict[str, Any]) -> str:
584
+ return _format_export_common(result, title="Export Direct")
585
+
586
+
360
587
  def _format_builder_summary(result: dict[str, Any]) -> str:
361
588
  lines = []
362
589
  if "status" in result:
@@ -559,15 +786,23 @@ _FORMATTERS = {
559
786
  "auth_whoami": _format_whoami,
560
787
  "workspace_list": _format_workspace_list,
561
788
  "workspace_get": _format_workspace_get,
789
+ "workspace_select": _format_workspace_select,
562
790
  "app_list": _format_app_items,
563
791
  "app_search": _format_app_items,
564
792
  "app_get": _format_app_get,
565
793
  "record_list": _format_record_list,
794
+ "record_access": _format_record_access,
795
+ "record_get": _format_record_get,
566
796
  "task_list": _format_task_list,
797
+ "task_workbench": _format_task_workbench,
567
798
  "task_get": _format_task_get,
568
799
  "task_action_execute": _format_task_action,
569
800
  "task_associated_report_detail_get": _format_task_associated_report_detail,
570
801
  "import_verify": _format_import_verify,
571
802
  "import_status": _format_import_status,
803
+ "export_start": _format_export_start,
804
+ "export_status": _format_export_status,
805
+ "export_get": _format_export_get,
806
+ "export_direct": _format_export_direct,
572
807
  "builder_summary": _format_builder_summary,
573
808
  }
@@ -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)