@josephyan/qingflow-app-user-mcp 0.2.0-beta.1002 → 0.2.0-beta.1003
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/cli/commands/app.py +47 -1
- package/src/qingflow_mcp/cli/commands/task.py +168 -14
- package/src/qingflow_mcp/cli/commands/workspace.py +48 -53
- package/src/qingflow_mcp/cli/formatters.py +10 -2
- package/src/qingflow_mcp/cli/interaction.py +50 -0
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.
|
|
6
|
+
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.1003
|
|
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.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.1003 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -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",
|
|
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
|
+
)
|
|
@@ -3,7 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
import argparse
|
|
4
4
|
|
|
5
5
|
from ..context import CliContext
|
|
6
|
-
from
|
|
6
|
+
from ..interaction import cancelled_result, resolve_interactive_selection
|
|
7
|
+
from ..terminal_ui import SelectionOption
|
|
8
|
+
from .common import load_object_arg, raise_config_error
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
@@ -51,7 +53,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
51
53
|
report.add_argument("--app-key")
|
|
52
54
|
report.add_argument("--record-id")
|
|
53
55
|
report.add_argument("--workflow-node-id", type=int)
|
|
54
|
-
report.add_argument("--report-id",
|
|
56
|
+
report.add_argument("--report-id", type=int, help="不传时在交互终端中选择关联报表")
|
|
55
57
|
report.add_argument("--page", type=int, default=1)
|
|
56
58
|
report.add_argument("--page-size", type=int, default=20)
|
|
57
59
|
report.set_defaults(handler=_handle_report, format_hint="task_associated_report_detail_get")
|
|
@@ -78,10 +80,9 @@ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
78
80
|
|
|
79
81
|
|
|
80
82
|
def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
83
|
+
selection_result = _resolve_task_locator_or_select(args, context, tool_name="task get")
|
|
84
|
+
if isinstance(selection_result, dict):
|
|
85
|
+
return selection_result
|
|
85
86
|
return context.task.task_get(
|
|
86
87
|
profile=args.profile,
|
|
87
88
|
task_id=args.task_id,
|
|
@@ -111,10 +112,9 @@ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
111
112
|
|
|
112
113
|
|
|
113
114
|
def _handle_log(args: argparse.Namespace, context: CliContext) -> dict:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
)
|
|
115
|
+
selection_result = _resolve_task_locator_or_select(args, context, tool_name="task log")
|
|
116
|
+
if isinstance(selection_result, dict):
|
|
117
|
+
return selection_result
|
|
118
118
|
return context.task.task_workflow_log_get(
|
|
119
119
|
profile=args.profile,
|
|
120
120
|
task_id=args.task_id,
|
|
@@ -125,10 +125,12 @@ def _handle_log(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
125
125
|
|
|
126
126
|
|
|
127
127
|
def _handle_report(args: argparse.Namespace, context: CliContext) -> dict:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
selection_result = _resolve_task_locator_or_select(args, context, tool_name="task report")
|
|
129
|
+
if isinstance(selection_result, dict):
|
|
130
|
+
return selection_result
|
|
131
|
+
report_selection = _resolve_report_id_or_select(args, context)
|
|
132
|
+
if isinstance(report_selection, dict):
|
|
133
|
+
return report_selection
|
|
132
134
|
return context.task.task_associated_report_detail_get(
|
|
133
135
|
profile=args.profile,
|
|
134
136
|
task_id=args.task_id,
|
|
@@ -139,3 +141,155 @@ def _handle_report(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
139
141
|
page=int(args.page),
|
|
140
142
|
page_size=int(args.page_size),
|
|
141
143
|
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_task_locator_or_select(args: argparse.Namespace, context: CliContext, *, tool_name: str) -> dict | None:
|
|
147
|
+
if (args.task_id or "").strip():
|
|
148
|
+
return None
|
|
149
|
+
has_app_key = bool((args.app_key or "").strip())
|
|
150
|
+
has_record_id = bool((args.record_id or "").strip())
|
|
151
|
+
has_workflow_node_id = int(args.workflow_node_id or 0) > 0
|
|
152
|
+
if has_app_key and has_record_id and has_workflow_node_id:
|
|
153
|
+
return None
|
|
154
|
+
if has_app_key or has_record_id or has_workflow_node_id:
|
|
155
|
+
raise_config_error(
|
|
156
|
+
f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id",
|
|
157
|
+
fix_hint="Either pass `--task-id TASK_ID`, or provide the full locator triple `--app-key --record-id --workflow-node-id`.",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
selection = _choose_todo_task_interactively(args, context, tool_name=tool_name)
|
|
161
|
+
if selection.status == "unavailable":
|
|
162
|
+
raise_config_error(
|
|
163
|
+
f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id",
|
|
164
|
+
fix_hint=(
|
|
165
|
+
"Retry in an interactive terminal to choose from current todo tasks, "
|
|
166
|
+
"or pass `--task-id TASK_ID` explicitly."
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
if selection.status == "empty":
|
|
170
|
+
raise_config_error(
|
|
171
|
+
selection.message or f"{tool_name} could not open a selector because no current todo tasks are available.",
|
|
172
|
+
fix_hint="Run `task list` to confirm current todo tasks, or retry later with `--task-id TASK_ID`.",
|
|
173
|
+
)
|
|
174
|
+
if selection.status == "cancelled":
|
|
175
|
+
return cancelled_result(selection.message or "已取消")
|
|
176
|
+
|
|
177
|
+
args.task_id = str(selection.value or "")
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _choose_todo_task_interactively(args: argparse.Namespace, context: CliContext, *, tool_name: str):
|
|
182
|
+
def load_options() -> list[SelectionOption[str]]:
|
|
183
|
+
result = context.task.task_list(
|
|
184
|
+
profile=args.profile,
|
|
185
|
+
task_box="todo",
|
|
186
|
+
flow_status="all",
|
|
187
|
+
app_key=None,
|
|
188
|
+
workflow_node_id=None,
|
|
189
|
+
query=None,
|
|
190
|
+
page=1,
|
|
191
|
+
page_size=100,
|
|
192
|
+
)
|
|
193
|
+
data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else {}
|
|
194
|
+
items = data.get("items") if isinstance(data.get("items"), list) else []
|
|
195
|
+
options: list[SelectionOption[str]] = []
|
|
196
|
+
for item in items:
|
|
197
|
+
if not isinstance(item, dict):
|
|
198
|
+
continue
|
|
199
|
+
task_id = str(item.get("task_id") or "").strip()
|
|
200
|
+
if not task_id:
|
|
201
|
+
continue
|
|
202
|
+
node_name = str(item.get("workflow_node_name") or "未命名节点").strip() or "未命名节点"
|
|
203
|
+
app_name = str(item.get("app_name") or item.get("app_key") or "未知应用").strip() or "未知应用"
|
|
204
|
+
label = f"{node_name} / {app_name}"
|
|
205
|
+
hint_parts = [f"task_id={task_id}"]
|
|
206
|
+
apply_time = str(item.get("apply_time") or "").strip()
|
|
207
|
+
if apply_time:
|
|
208
|
+
hint_parts.append(apply_time)
|
|
209
|
+
summary_fields = item.get("summary_fields") if isinstance(item.get("summary_fields"), list) else []
|
|
210
|
+
preview_parts: list[str] = []
|
|
211
|
+
for summary in summary_fields[:2]:
|
|
212
|
+
if not isinstance(summary, dict):
|
|
213
|
+
continue
|
|
214
|
+
title = str(summary.get("title") or "").strip()
|
|
215
|
+
answer = str(summary.get("answer") or "").strip()
|
|
216
|
+
if title and answer:
|
|
217
|
+
preview_parts.append(f"{title}: {answer}")
|
|
218
|
+
if preview_parts:
|
|
219
|
+
hint_parts.append("; ".join(preview_parts))
|
|
220
|
+
options.append(SelectionOption(value=task_id, label=label, hint=" · ".join(hint_parts)))
|
|
221
|
+
return options
|
|
222
|
+
|
|
223
|
+
return resolve_interactive_selection(
|
|
224
|
+
args,
|
|
225
|
+
title="选择待办",
|
|
226
|
+
unavailable_message=(
|
|
227
|
+
f"{tool_name} requires --task-id, or --app-key together with --record-id and --workflow-node-id"
|
|
228
|
+
),
|
|
229
|
+
empty_message=f"{tool_name} could not open a selector because no current todo tasks are available.",
|
|
230
|
+
load_options=load_options,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _resolve_report_id_or_select(args: argparse.Namespace, context: CliContext) -> dict | None:
|
|
235
|
+
if int(args.report_id or 0) > 0:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
selection = _choose_associated_report_interactively(args, context)
|
|
239
|
+
if selection.status == "unavailable":
|
|
240
|
+
raise_config_error(
|
|
241
|
+
"task report requires --report-id, or an interactive terminal to choose a visible associated report",
|
|
242
|
+
fix_hint="Pass `--report-id REPORT_ID`, or run in an interactive terminal and choose from visible associated reports.",
|
|
243
|
+
)
|
|
244
|
+
if selection.status == "empty":
|
|
245
|
+
raise_config_error(
|
|
246
|
+
selection.message or "task report could not open a selector because the selected task has no visible associated reports.",
|
|
247
|
+
fix_hint="Run `task get --task-id TASK_ID` to inspect `extras.associated_reports`, or choose another task.",
|
|
248
|
+
)
|
|
249
|
+
if selection.status == "cancelled":
|
|
250
|
+
return cancelled_result(selection.message or "已取消")
|
|
251
|
+
|
|
252
|
+
args.report_id = int(selection.value or 0)
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _choose_associated_report_interactively(args: argparse.Namespace, context: CliContext):
|
|
257
|
+
def load_options() -> list[SelectionOption[int]]:
|
|
258
|
+
result = context.task.task_get(
|
|
259
|
+
profile=args.profile,
|
|
260
|
+
task_id=args.task_id,
|
|
261
|
+
app_key=args.app_key or "",
|
|
262
|
+
record_id=args.record_id or "",
|
|
263
|
+
workflow_node_id=int(args.workflow_node_id or 0),
|
|
264
|
+
include_candidates=False,
|
|
265
|
+
include_associated_reports=True,
|
|
266
|
+
)
|
|
267
|
+
data = result.get("data") if isinstance(result, dict) and isinstance(result.get("data"), dict) else {}
|
|
268
|
+
extras = data.get("extras") if isinstance(data.get("extras"), dict) else {}
|
|
269
|
+
associated_reports = extras.get("associated_reports") if isinstance(extras.get("associated_reports"), dict) else {}
|
|
270
|
+
items = associated_reports.get("items") if isinstance(associated_reports.get("items"), list) else []
|
|
271
|
+
options: list[SelectionOption[int]] = []
|
|
272
|
+
for item in items:
|
|
273
|
+
if not isinstance(item, dict):
|
|
274
|
+
continue
|
|
275
|
+
report_id = int(item.get("report_id") or 0)
|
|
276
|
+
if report_id <= 0:
|
|
277
|
+
continue
|
|
278
|
+
chart_name = str(item.get("chart_name") or f"报表 {report_id}").strip() or f"报表 {report_id}"
|
|
279
|
+
graph_type = str(item.get("graph_type") or "chart").strip() or "chart"
|
|
280
|
+
options.append(
|
|
281
|
+
SelectionOption(
|
|
282
|
+
value=report_id,
|
|
283
|
+
label=chart_name,
|
|
284
|
+
hint=f"report_id={report_id} · {graph_type}",
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
return options
|
|
288
|
+
|
|
289
|
+
return resolve_interactive_selection(
|
|
290
|
+
args,
|
|
291
|
+
title="选择关联报表",
|
|
292
|
+
unavailable_message="task report requires --report-id, or an interactive terminal to choose a visible associated report",
|
|
293
|
+
empty_message="task report could not open a selector because the selected task has no visible associated reports.",
|
|
294
|
+
load_options=load_options,
|
|
295
|
+
)
|
|
@@ -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 ..
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
50
|
-
if
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
61
|
-
return
|
|
62
|
-
args.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)
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
9
|
-
text
|
|
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,50 @@
|
|
|
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)
|