@josephyan/qingflow-app-user-mcp 0.2.0-beta.1002 → 0.2.0-beta.1004

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 CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.1002
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.1004
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.1002 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.1004 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.1002",
3
+ "version": "0.2.0-beta.1004",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b1002"
7
+ version = "0.2.0b1004"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b1002"
8
+ _FALLBACK_VERSION = "0.2.0b1004"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -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:
@@ -19,7 +22,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
19
22
  search.set_defaults(handler=_handle_search, format_hint="app_search")
20
23
 
21
24
  get = app_subparsers.add_parser("get", help="读取应用可访问视图与导入能力")
22
- get.add_argument("--app-key", required=True)
25
+ get.add_argument("--app-key", help="不传时在交互终端中选择应用")
23
26
  get.set_defaults(handler=_handle_get, format_hint="app_get")
24
27
 
25
28
 
@@ -37,4 +40,47 @@ def _handle_search(args: argparse.Namespace, context: CliContext) -> dict:
37
40
 
38
41
 
39
42
  def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
43
+ if not (args.app_key or "").strip():
44
+ selection = _choose_app_interactively(args, context)
45
+ if selection.status == "unavailable":
46
+ raise_config_error(
47
+ "app get requires --app-key, or an interactive terminal to choose an app",
48
+ fix_hint="Run `app list` to inspect visible apps, or retry with `--app-key APP_KEY`.",
49
+ )
50
+ if selection.status == "empty":
51
+ raise_config_error(
52
+ selection.message or "app get could not open a selector because no visible apps were returned.",
53
+ fix_hint="Run `app list` to confirm visible apps, or retry with `--app-key APP_KEY`.",
54
+ )
55
+ if selection.status == "cancelled":
56
+ return cancelled_result(selection.message or "已取消")
57
+ args.app_key = str(selection.value or "")
40
58
  return context.app.app_get(profile=args.profile, app_key=args.app_key)
59
+
60
+
61
+ def _choose_app_interactively(args: argparse.Namespace, context: CliContext):
62
+ def load_options() -> list[SelectionOption[str]]:
63
+ result = context.app.app_list(profile=args.profile)
64
+ items = result.get("items") if isinstance(result, dict) and isinstance(result.get("items"), list) else []
65
+ options: list[SelectionOption[str]] = []
66
+ for item in items:
67
+ if not isinstance(item, dict):
68
+ continue
69
+ app_key = str(item.get("app_key") or "").strip()
70
+ if not app_key:
71
+ continue
72
+ app_name = str(item.get("app_name") or app_key).strip() or app_key
73
+ package_name = str(item.get("package_name") or "").strip()
74
+ hint = f"app_key={app_key}"
75
+ if package_name:
76
+ hint += f" · package={package_name}"
77
+ options.append(SelectionOption(value=app_key, label=app_name, hint=hint))
78
+ return options
79
+
80
+ return resolve_interactive_selection(
81
+ args,
82
+ title="选择应用",
83
+ unavailable_message="app get requires --app-key, or an interactive terminal to choose an app",
84
+ empty_message="app get could not open a selector because no visible apps were returned.",
85
+ load_options=load_options,
86
+ )
@@ -1,9 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ from typing import Any
4
5
 
5
6
  from ..context import CliContext
6
- from .common import load_object_arg
7
+ from ..interaction import cancelled_result, resolve_interactive_selection, resolve_interactive_text_input
8
+ from ..terminal_ui import SelectionOption
9
+ from .common import load_object_arg, raise_config_error
10
+
11
+
12
+ TASK_ACTION_LABELS = {
13
+ "approve": "通过",
14
+ "reject": "驳回",
15
+ "rollback": "退回",
16
+ "transfer": "转交",
17
+ "save_only": "仅保存",
18
+ "urge": "催办",
19
+ }
7
20
 
8
21
 
9
22
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
@@ -37,7 +50,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
37
50
  action.add_argument("--app-key")
38
51
  action.add_argument("--record-id")
39
52
  action.add_argument("--workflow-node-id", type=int)
40
- action.add_argument("--action", required=True)
53
+ action.add_argument("--action", help="不传时在交互终端中选择当前待办可执行动作")
41
54
  action.add_argument("--payload-file")
42
55
  action.add_argument("--fields-file")
43
56
  action.set_defaults(
@@ -51,7 +64,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
51
64
  report.add_argument("--app-key")
52
65
  report.add_argument("--record-id")
53
66
  report.add_argument("--workflow-node-id", type=int)
54
- report.add_argument("--report-id", required=True, type=int)
67
+ report.add_argument("--report-id", type=int, help="不传时在交互终端中选择关联报表")
55
68
  report.add_argument("--page", type=int, default=1)
56
69
  report.add_argument("--page-size", type=int, default=20)
57
70
  report.set_defaults(handler=_handle_report, format_hint="task_associated_report_detail_get")
@@ -78,10 +91,9 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
78
91
 
79
92
 
80
93
  def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
81
- if not args.task_id and not (args.app_key and args.record_id and args.workflow_node_id):
82
- raise RuntimeError(
83
- '{"category":"config","message":"task get requires --task-id, or --app-key together with --record-id and --workflow-node-id"}'
84
- )
94
+ selection_result = _resolve_task_locator_or_select(args, context, tool_name="task get")
95
+ if isinstance(selection_result, dict):
96
+ return selection_result
85
97
  return context.task.task_get(
86
98
  profile=args.profile,
87
99
  task_id=args.task_id,
@@ -94,10 +106,21 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
94
106
 
95
107
 
96
108
  def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
97
- if not args.task_id and not (args.app_key and args.record_id and args.workflow_node_id):
98
- raise RuntimeError(
99
- '{"category":"config","message":"task action requires --task-id, or --app-key together with --record-id and --workflow-node-id"}'
100
- )
109
+ selection_result = _resolve_task_locator_or_select(args, context, tool_name="task action")
110
+ if isinstance(selection_result, dict):
111
+ return selection_result
112
+ payload = load_object_arg(args.payload_file, option_name="--payload-file") or {}
113
+ fields = load_object_arg(args.fields_file, option_name="--fields-file") or {}
114
+ task_context: dict[str, Any] | None = None
115
+ if not str(args.action or "").strip():
116
+ task_context = _load_task_action_context(args, context)
117
+ action_selection = _resolve_task_action_or_select(args, task_context, fields=fields)
118
+ if isinstance(action_selection, dict):
119
+ return action_selection
120
+ payload_selection = _resolve_action_payload_or_select(args, task_context, payload=payload)
121
+ if _is_cancelled_result(payload_selection):
122
+ return payload_selection
123
+ payload = payload_selection
101
124
  return context.task.task_action_execute(
102
125
  profile=args.profile,
103
126
  task_id=args.task_id,
@@ -105,16 +128,15 @@ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
105
128
  record_id=args.record_id or "",
106
129
  workflow_node_id=int(args.workflow_node_id or 0),
107
130
  action=args.action,
108
- payload=load_object_arg(args.payload_file, option_name="--payload-file") or {},
109
- fields=load_object_arg(args.fields_file, option_name="--fields-file") or {},
131
+ payload=payload,
132
+ fields=fields,
110
133
  )
111
134
 
112
135
 
113
136
  def _handle_log(args: argparse.Namespace, context: CliContext) -> dict:
114
- if not args.task_id and not (args.app_key and args.record_id and args.workflow_node_id):
115
- raise RuntimeError(
116
- '{"category":"config","message":"task log requires --task-id, or --app-key together with --record-id and --workflow-node-id"}'
117
- )
137
+ selection_result = _resolve_task_locator_or_select(args, context, tool_name="task log")
138
+ if isinstance(selection_result, dict):
139
+ return selection_result
118
140
  return context.task.task_workflow_log_get(
119
141
  profile=args.profile,
120
142
  task_id=args.task_id,
@@ -125,10 +147,12 @@ def _handle_log(args: argparse.Namespace, context: CliContext) -> dict:
125
147
 
126
148
 
127
149
  def _handle_report(args: argparse.Namespace, context: CliContext) -> dict:
128
- if not args.task_id and not (args.app_key and args.record_id and args.workflow_node_id):
129
- raise RuntimeError(
130
- '{"category":"config","message":"task report requires --task-id, or --app-key together with --record-id and --workflow-node-id"}'
131
- )
150
+ selection_result = _resolve_task_locator_or_select(args, context, tool_name="task report")
151
+ if isinstance(selection_result, dict):
152
+ return selection_result
153
+ report_selection = _resolve_report_id_or_select(args, context)
154
+ if isinstance(report_selection, dict):
155
+ return report_selection
132
156
  return context.task.task_associated_report_detail_get(
133
157
  profile=args.profile,
134
158
  task_id=args.task_id,
@@ -139,3 +163,393 @@ def _handle_report(args: argparse.Namespace, context: CliContext) -> dict:
139
163
  page=int(args.page),
140
164
  page_size=int(args.page_size),
141
165
  )
166
+
167
+
168
+ def _resolve_task_locator_or_select(args: argparse.Namespace, context: CliContext, *, tool_name: str) -> dict | None:
169
+ if (args.task_id or "").strip():
170
+ return None
171
+ has_app_key = bool((args.app_key or "").strip())
172
+ has_record_id = bool((args.record_id or "").strip())
173
+ has_workflow_node_id = int(args.workflow_node_id or 0) > 0
174
+ if has_app_key and has_record_id and has_workflow_node_id:
175
+ return None
176
+ if has_app_key or has_record_id or has_workflow_node_id:
177
+ raise_config_error(
178
+ f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id",
179
+ fix_hint="Either pass `--task-id TASK_ID`, or provide the full locator triple `--app-key --record-id --workflow-node-id`.",
180
+ )
181
+
182
+ selection = _choose_todo_task_interactively(args, context, tool_name=tool_name)
183
+ if selection.status == "unavailable":
184
+ raise_config_error(
185
+ f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id",
186
+ fix_hint=(
187
+ "Retry in an interactive terminal to choose from current todo tasks, "
188
+ "or pass `--task-id TASK_ID` explicitly."
189
+ ),
190
+ )
191
+ if selection.status == "empty":
192
+ raise_config_error(
193
+ selection.message or f"{tool_name} could not open a selector because no current todo tasks are available.",
194
+ fix_hint="Run `task list` to confirm current todo tasks, or retry later with `--task-id TASK_ID`.",
195
+ )
196
+ if selection.status == "cancelled":
197
+ return cancelled_result(selection.message or "已取消")
198
+
199
+ args.task_id = str(selection.value or "")
200
+ return None
201
+
202
+
203
+ def _choose_todo_task_interactively(args: argparse.Namespace, context: CliContext, *, tool_name: str):
204
+ def load_options() -> list[SelectionOption[str]]:
205
+ result = context.task.task_list(
206
+ profile=args.profile,
207
+ task_box="todo",
208
+ flow_status="all",
209
+ app_key=None,
210
+ workflow_node_id=None,
211
+ query=None,
212
+ page=1,
213
+ page_size=100,
214
+ )
215
+ data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else {}
216
+ items = data.get("items") if isinstance(data.get("items"), list) else []
217
+ options: list[SelectionOption[str]] = []
218
+ for item in items:
219
+ if not isinstance(item, dict):
220
+ continue
221
+ task_id = str(item.get("task_id") or "").strip()
222
+ if not task_id:
223
+ continue
224
+ node_name = str(item.get("workflow_node_name") or "未命名节点").strip() or "未命名节点"
225
+ app_name = str(item.get("app_name") or item.get("app_key") or "未知应用").strip() or "未知应用"
226
+ label = f"{node_name} / {app_name}"
227
+ hint_parts = [f"task_id={task_id}"]
228
+ apply_time = str(item.get("apply_time") or "").strip()
229
+ if apply_time:
230
+ hint_parts.append(apply_time)
231
+ summary_fields = item.get("summary_fields") if isinstance(item.get("summary_fields"), list) else []
232
+ preview_parts: list[str] = []
233
+ for summary in summary_fields[:2]:
234
+ if not isinstance(summary, dict):
235
+ continue
236
+ title = str(summary.get("title") or "").strip()
237
+ answer = str(summary.get("answer") or "").strip()
238
+ if title and answer:
239
+ preview_parts.append(f"{title}: {answer}")
240
+ if preview_parts:
241
+ hint_parts.append("; ".join(preview_parts))
242
+ options.append(SelectionOption(value=task_id, label=label, hint=" · ".join(hint_parts)))
243
+ return options
244
+
245
+ return resolve_interactive_selection(
246
+ args,
247
+ title="选择待办",
248
+ unavailable_message=(
249
+ f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id"
250
+ ),
251
+ empty_message=f"{tool_name} could not open a selector because no current todo tasks are available.",
252
+ load_options=load_options,
253
+ )
254
+
255
+
256
+ def _resolve_report_id_or_select(args: argparse.Namespace, context: CliContext) -> dict | None:
257
+ if int(args.report_id or 0) > 0:
258
+ return None
259
+
260
+ selection = _choose_associated_report_interactively(args, context)
261
+ if selection.status == "unavailable":
262
+ raise_config_error(
263
+ "task report requires --report-id, or an interactive terminal to choose a visible associated report",
264
+ fix_hint="Pass `--report-id REPORT_ID`, or run in an interactive terminal and choose from visible associated reports.",
265
+ )
266
+ if selection.status == "empty":
267
+ raise_config_error(
268
+ selection.message or "task report could not open a selector because the selected task has no visible associated reports.",
269
+ fix_hint="Run `task get --task-id TASK_ID` to inspect `extras.associated_reports`, or choose another task.",
270
+ )
271
+ if selection.status == "cancelled":
272
+ return cancelled_result(selection.message or "已取消")
273
+
274
+ args.report_id = int(selection.value or 0)
275
+ return None
276
+
277
+
278
+ def _load_task_action_context(args: argparse.Namespace, context: CliContext) -> dict[str, Any]:
279
+ return context.task.task_get(
280
+ profile=args.profile,
281
+ task_id=args.task_id,
282
+ app_key=args.app_key or "",
283
+ record_id=args.record_id or "",
284
+ workflow_node_id=int(args.workflow_node_id or 0),
285
+ include_candidates=True,
286
+ include_associated_reports=False,
287
+ )
288
+
289
+
290
+ def _resolve_task_action_or_select(
291
+ args: argparse.Namespace,
292
+ task_context: dict[str, Any],
293
+ *,
294
+ fields: dict[str, Any],
295
+ ) -> dict | None:
296
+ selection = _choose_task_action_interactively(args, task_context, fields=fields)
297
+ if selection.status == "unavailable":
298
+ raise_config_error(
299
+ "task action requires --action, or an interactive terminal to choose the current task action",
300
+ fix_hint="Pass `--action ACTION`, or run `qingflow task action` in an interactive terminal and choose from available actions.",
301
+ )
302
+ if selection.status == "empty":
303
+ raise_config_error(
304
+ selection.message or "task action could not open an action selector because no interactive actions are available.",
305
+ fix_hint="Run `task get --task-id TASK_ID` to inspect available_actions, or pass a supported `--action` explicitly.",
306
+ )
307
+ if selection.status == "cancelled":
308
+ return cancelled_result(selection.message or "已取消")
309
+
310
+ args.action = str(selection.value or "").strip()
311
+ return None
312
+
313
+
314
+ def _choose_task_action_interactively(
315
+ args: argparse.Namespace,
316
+ task_context: dict[str, Any],
317
+ *,
318
+ fields: dict[str, Any],
319
+ ):
320
+ data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
321
+ available_actions = [str(item).strip() for item in (data.get("available_actions") or []) if str(item).strip()]
322
+ extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
323
+ action_metadata = data.get("action_metadata") if isinstance(data.get("action_metadata"), dict) else {}
324
+ feedback_required = {
325
+ str(item).strip()
326
+ for item in (action_metadata.get("feedback_required_for") or [])
327
+ if str(item).strip()
328
+ }
329
+ rollback_count = _count_candidate_items(extras.get("rollback_candidates"))
330
+ transfer_count = _count_candidate_items(extras.get("transfer_candidates"))
331
+
332
+ def load_options() -> list[SelectionOption[str]]:
333
+ options: list[SelectionOption[str]] = []
334
+ for action in available_actions:
335
+ if action == "save_only" and not fields:
336
+ continue
337
+ label = TASK_ACTION_LABELS.get(action, action)
338
+ hint_parts = [action]
339
+ if action in feedback_required:
340
+ hint_parts.append("需要理由")
341
+ if action == "rollback":
342
+ hint_parts.append(f"可退回节点 {rollback_count}")
343
+ if action == "transfer":
344
+ hint_parts.append(f"可转交成员 {transfer_count}")
345
+ if action == "save_only":
346
+ hint_parts.append("仅保存字段,不推进流程")
347
+ options.append(SelectionOption(value=action, label=label, hint=" · ".join(hint_parts)))
348
+ return options
349
+
350
+ return resolve_interactive_selection(
351
+ args,
352
+ title="选择操作",
353
+ unavailable_message="task action requires --action, or an interactive terminal to choose the current task action",
354
+ empty_message="task action could not open an action selector because no interactive actions are available.",
355
+ load_options=load_options,
356
+ )
357
+
358
+
359
+ def _resolve_action_payload_or_select(
360
+ args: argparse.Namespace,
361
+ task_context: dict[str, Any],
362
+ *,
363
+ payload: dict[str, Any],
364
+ ) -> dict[str, Any] | dict[str, str]:
365
+ action = str(args.action or "").strip().lower()
366
+ if action == "reject" and not _extract_audit_feedback(payload):
367
+ feedback = resolve_interactive_text_input(
368
+ args,
369
+ prompt="请输入驳回原因并回车,留空取消: ",
370
+ unavailable_message="task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
371
+ )
372
+ if feedback.status == "unavailable":
373
+ raise_config_error(
374
+ "task reject requires payload.audit_feedback, or an interactive terminal to enter a reject reason",
375
+ fix_hint="Pass `--payload-file` with `{\"audit_feedback\": \"...\"}`, or retry in an interactive terminal.",
376
+ )
377
+ if feedback.status == "cancelled":
378
+ return cancelled_result(feedback.message or "已取消")
379
+ payload["audit_feedback"] = str(feedback.value or "").strip()
380
+ return payload
381
+
382
+ if action == "rollback" and _extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId")) <= 0:
383
+ selection = _choose_rollback_candidate_interactively(args, task_context)
384
+ if selection.status == "unavailable":
385
+ raise_config_error(
386
+ "task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
387
+ fix_hint="Pass `--payload-file` with `{\"target_workflow_node_id\": NODE_ID}`, or retry in an interactive terminal.",
388
+ )
389
+ if selection.status == "empty":
390
+ raise_config_error(
391
+ selection.message or "task rollback could not open a selector because no rollback candidates are visible.",
392
+ fix_hint="Run `task get --task-id TASK_ID` to inspect rollback candidates, or choose another action.",
393
+ )
394
+ if selection.status == "cancelled":
395
+ return cancelled_result(selection.message or "已取消")
396
+ payload["target_workflow_node_id"] = int(selection.value or 0)
397
+ return payload
398
+
399
+ if action == "transfer" and _extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId")) <= 0:
400
+ selection = _choose_transfer_candidate_interactively(args, task_context)
401
+ if selection.status == "unavailable":
402
+ raise_config_error(
403
+ "task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
404
+ fix_hint="Pass `--payload-file` with `{\"target_member_id\": UID}`, or retry in an interactive terminal.",
405
+ )
406
+ if selection.status == "empty":
407
+ raise_config_error(
408
+ selection.message or "task transfer could not open a selector because no transfer candidates are visible.",
409
+ fix_hint="Run `task get --task-id TASK_ID` to inspect transfer candidates, or choose another action.",
410
+ )
411
+ if selection.status == "cancelled":
412
+ return cancelled_result(selection.message or "已取消")
413
+ payload["target_member_id"] = int(selection.value or 0)
414
+ return payload
415
+
416
+ return payload
417
+
418
+
419
+ def _choose_rollback_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
420
+ extras = _task_context_extras(task_context)
421
+
422
+ def load_options() -> list[SelectionOption[int]]:
423
+ rollback_candidates = extras.get("rollback_candidates") if isinstance(extras.get("rollback_candidates"), dict) else {}
424
+ items = rollback_candidates.get("items") if isinstance(rollback_candidates.get("items"), list) else []
425
+ options: list[SelectionOption[int]] = []
426
+ for item in items:
427
+ if not isinstance(item, dict):
428
+ continue
429
+ workflow_node_id = int(item.get("workflow_node_id") or 0)
430
+ if workflow_node_id <= 0:
431
+ continue
432
+ workflow_node_name = str(item.get("workflow_node_name") or f"节点 {workflow_node_id}").strip() or f"节点 {workflow_node_id}"
433
+ options.append(
434
+ SelectionOption(
435
+ value=workflow_node_id,
436
+ label=workflow_node_name,
437
+ hint=f"workflow_node_id={workflow_node_id}",
438
+ )
439
+ )
440
+ return options
441
+
442
+ return resolve_interactive_selection(
443
+ args,
444
+ title="选择退回节点",
445
+ unavailable_message="task rollback requires target_workflow_node_id, or an interactive terminal to choose a rollback node",
446
+ empty_message="task rollback could not open a selector because no rollback candidates are visible.",
447
+ load_options=load_options,
448
+ )
449
+
450
+
451
+ def _choose_transfer_candidate_interactively(args: argparse.Namespace, task_context: dict[str, Any]):
452
+ extras = _task_context_extras(task_context)
453
+
454
+ def load_options() -> list[SelectionOption[int]]:
455
+ transfer_candidates = extras.get("transfer_candidates") if isinstance(extras.get("transfer_candidates"), dict) else {}
456
+ items = transfer_candidates.get("items") if isinstance(transfer_candidates.get("items"), list) else []
457
+ options: list[SelectionOption[int]] = []
458
+ for item in items:
459
+ if not isinstance(item, dict):
460
+ continue
461
+ uid = int(item.get("uid") or 0)
462
+ if uid <= 0:
463
+ continue
464
+ name = str(item.get("name") or f"成员 {uid}").strip() or f"成员 {uid}"
465
+ hint_parts = [f"uid={uid}"]
466
+ department_name = str(item.get("department_name") or "").strip()
467
+ email = str(item.get("email") or "").strip()
468
+ if department_name:
469
+ hint_parts.append(department_name)
470
+ if email:
471
+ hint_parts.append(email)
472
+ options.append(SelectionOption(value=uid, label=name, hint=" · ".join(hint_parts)))
473
+ return options
474
+
475
+ return resolve_interactive_selection(
476
+ args,
477
+ title="选择转交成员",
478
+ unavailable_message="task transfer requires target_member_id, or an interactive terminal to choose a transfer member",
479
+ empty_message="task transfer could not open a selector because no transfer candidates are visible.",
480
+ load_options=load_options,
481
+ )
482
+
483
+
484
+ def _task_context_extras(task_context: dict[str, Any]) -> dict[str, Any]:
485
+ data = task_context.get("data") if isinstance(task_context.get("data"), dict) else {}
486
+ return data.get("extras") if isinstance(data.get("extras"), dict) else {}
487
+
488
+
489
+ def _count_candidate_items(value: Any) -> int:
490
+ if not isinstance(value, dict):
491
+ return 0
492
+ items = value.get("items")
493
+ return len(items) if isinstance(items, list) else 0
494
+
495
+
496
+ def _extract_audit_feedback(payload: dict[str, Any]) -> str | None:
497
+ for key in ("audit_feedback", "auditFeedback"):
498
+ value = payload.get(key)
499
+ if isinstance(value, str) and value.strip():
500
+ return value.strip()
501
+ return None
502
+
503
+
504
+ def _extract_positive_int(payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
505
+ for candidate in (key, *aliases):
506
+ value = payload.get(candidate)
507
+ if isinstance(value, int) and value > 0:
508
+ return value
509
+ return 0
510
+
511
+
512
+ def _is_cancelled_result(value: Any) -> bool:
513
+ return isinstance(value, dict) and str(value.get("status") or "").strip().lower() == "cancelled"
514
+
515
+
516
+ def _choose_associated_report_interactively(args: argparse.Namespace, context: CliContext):
517
+ def load_options() -> list[SelectionOption[int]]:
518
+ result = context.task.task_get(
519
+ profile=args.profile,
520
+ task_id=args.task_id,
521
+ app_key=args.app_key or "",
522
+ record_id=args.record_id or "",
523
+ workflow_node_id=int(args.workflow_node_id or 0),
524
+ include_candidates=False,
525
+ include_associated_reports=True,
526
+ )
527
+ data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else {}
528
+ extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
529
+ associated_reports = extras.get("associated_reports") if isinstance(extras.get("associated_reports"), dict) else {}
530
+ items = associated_reports.get("items") if isinstance(associated_reports.get("items"), list) else []
531
+ options: list[SelectionOption[int]] = []
532
+ for item in items:
533
+ if not isinstance(item, dict):
534
+ continue
535
+ report_id = int(item.get("report_id") or 0)
536
+ if report_id <= 0:
537
+ continue
538
+ chart_name = str(item.get("chart_name") or f"报表 {report_id}").strip() or f"报表 {report_id}"
539
+ graph_type = str(item.get("graph_type") or "chart").strip() or "chart"
540
+ options.append(
541
+ SelectionOption(
542
+ value=report_id,
543
+ label=chart_name,
544
+ hint=f"report_id={report_id} · {graph_type}",
545
+ )
546
+ )
547
+ return options
548
+
549
+ return resolve_interactive_selection(
550
+ args,
551
+ title="选择关联报表",
552
+ unavailable_message="task report requires --report-id, or an interactive terminal to choose a visible associated report",
553
+ empty_message="task report could not open a selector because the selected task has no visible associated reports.",
554
+ load_options=load_options,
555
+ )
@@ -1,12 +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
8
-
9
- _INTERACTIVE_SELECTION_UNAVAILABLE = object()
6
+ from ..interaction import cancelled_result, resolve_interactive_selection
7
+ from ..terminal_ui import SelectionOption
8
+ from .common import raise_config_error
10
9
 
11
10
 
12
11
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
@@ -46,42 +45,27 @@ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
46
45
 
47
46
  def _handle_select(args: argparse.Namespace, context: CliContext) -> dict:
48
47
  if int(args.ws_id or 0) <= 0:
49
- selected_ws_id = _choose_workspace_interactively(args, context)
50
- if selected_ws_id is _INTERACTIVE_SELECTION_UNAVAILABLE:
51
- raise RuntimeError(
52
- json.dumps(
53
- {
54
- "category": "config",
55
- "message": "workspace select requires --ws-id, or an interactive terminal to choose a workspace",
56
- },
57
- ensure_ascii=False,
58
- )
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`.",
59
58
  )
60
- if selected_ws_id is None:
61
- return {"status": "cancelled", "message": "已取消"}
62
- args.ws_id = selected_ws_id
59
+ if selection.status == "cancelled":
60
+ return cancelled_result(selection.message or "已取消")
61
+ args.ws_id = int(selection.value or 0)
63
62
  return context.workspace.workspace_select(
64
63
  profile=args.profile,
65
64
  ws_id=int(args.ws_id),
66
65
  )
67
66
 
68
67
 
69
- def _choose_workspace_interactively(args: argparse.Namespace, context: CliContext) -> int | None | object:
70
- input_stream = getattr(args, "_stdin", None)
71
- output_stream = getattr(args, "_stderr_stream", None)
72
- if input_stream is None or output_stream is None:
73
- return _INTERACTIVE_SELECTION_UNAVAILABLE
74
- if not bool(getattr(input_stream, "isatty", lambda: False)()):
75
- return _INTERACTIVE_SELECTION_UNAVAILABLE
76
- page = context.workspace.workspace_list(
77
- profile=args.profile,
78
- page_num=1,
79
- page_size=100,
80
- include_external=False,
81
- ).get("page")
82
- items = page.get("list") if isinstance(page, dict) and isinstance(page.get("list"), list) else []
83
- if not items:
84
- return _INTERACTIVE_SELECTION_UNAVAILABLE
68
+ def _choose_workspace_interactively(args: argparse.Namespace, context: CliContext):
85
69
  current_ws_id = None
86
70
  sessions = getattr(context, "sessions", None)
87
71
  if sessions is not None and hasattr(sessions, "get_profile"):
@@ -90,25 +74,36 @@ def _choose_workspace_interactively(args: argparse.Namespace, context: CliContex
90
74
  except Exception:
91
75
  session_profile = None
92
76
  current_ws_id = getattr(session_profile, "selected_ws_id", None) if session_profile is not None else None
93
- options: list[SelectionOption[int]] = []
94
- for item in items:
95
- if not isinstance(item, dict):
96
- continue
97
- ws_id = int(item.get("wsId") or 0)
98
- if ws_id <= 0:
99
- continue
100
- workspace_name = str(item.get("workspaceName") or item.get("wsName") or f"Workspace {ws_id}")
101
- remark = str(item.get("remark") or "").strip()
102
- label = workspace_name
103
- if remark:
104
- label = f"{workspace_name} - {remark}"
105
- hint = f"ws_id={ws_id}"
106
- if current_ws_id == ws_id:
107
- hint += " · 当前"
108
- options.append(SelectionOption(value=ws_id, label=label, hint=hint))
109
- 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,
110
105
  title="选择工作区",
111
- options=options,
112
- input_stream=input_stream,
113
- 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,
114
109
  )
@@ -5,13 +5,21 @@ from typing import Any, TextIO
5
5
 
6
6
 
7
7
  def emit_text_result(result: dict[str, Any], *, hint: str, stream: TextIO) -> None:
8
- formatter = _FORMATTERS.get(hint, _format_generic)
9
- text = formatter(result)
8
+ text = _format_cancelled_result(result)
9
+ if text is None:
10
+ formatter = _FORMATTERS.get(hint, _format_generic)
11
+ text = formatter(result)
10
12
  stream.write(text)
11
13
  if not text.endswith("\n"):
12
14
  stream.write("\n")
13
15
 
14
16
 
17
+ def _format_cancelled_result(result: dict[str, Any]) -> str | None:
18
+ if str(result.get("status") or "").lower() != "cancelled":
19
+ return None
20
+ return str(result.get("message") or "已取消") + "\n"
21
+
22
+
15
23
  def _format_generic(result: dict[str, Any]) -> str:
16
24
  lines: list[str] = []
17
25
  title = _first_present(result, "status", "message")
@@ -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)