@qingflow-tech/qingflow-app-user-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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/skills/qingflow-app-user/SKILL.md +2 -1
- package/skills/qingflow-app-user/references/data-gotchas.md +5 -2
- package/skills/qingflow-app-user/references/public-surface-sync.md +5 -3
- package/skills/qingflow-app-user/references/record-patterns.md +9 -0
- package/skills/qingflow-record-analysis/SKILL.md +103 -166
- package/skills/qingflow-record-analysis/agents/openai.yaml +2 -2
- package/skills/qingflow-record-analysis/references/analysis-gotchas.md +56 -110
- package/skills/qingflow-record-analysis/references/analysis-patterns.md +106 -119
- package/skills/qingflow-record-analysis/references/business-context.md +74 -0
- package/skills/qingflow-record-analysis/references/confidence-reporting.md +49 -72
- package/skills/qingflow-record-analysis/references/data-access-playbook.md +106 -0
- package/skills/qingflow-record-analysis/references/pandas-recipes.md +172 -0
- package/skills/qingflow-record-analysis/references/report-format.md +76 -0
- package/skills/qingflow-record-insert/SKILL.md +2 -2
- package/skills/qingflow-record-update/SKILL.md +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +164 -1
- 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 +44 -5
- 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 +240 -5
- 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 +7 -1
- package/src/qingflow_mcp/response_trim.py +188 -10
- package/src/qingflow_mcp/server.py +37 -9
- package/src/qingflow_mcp/server_app_builder.py +4 -0
- package/src/qingflow_mcp/server_app_user.py +115 -10
- 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 +12793 -8612
- package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
- package/skills/qingflow-record-analysis/references/dsl-templates.md +0 -93
|
@@ -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):
|
|
@@ -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 = [
|
|
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
|
|
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.
|
|
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)
|