@josephyan/qingflow-cli 0.2.0-beta.55

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 (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from ..context import CliContext
6
+ from .common import load_object_arg
7
+
8
+
9
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
10
+ parser = subparsers.add_parser("task", help="待办与流程上下文")
11
+ task_subparsers = parser.add_subparsers(dest="task_command", required=True)
12
+
13
+ list_parser = task_subparsers.add_parser("list", help="列出待办")
14
+ list_parser.add_argument("--task-box", default="todo")
15
+ list_parser.add_argument("--flow-status", default="all")
16
+ list_parser.add_argument("--app-key")
17
+ list_parser.add_argument("--workflow-node-id", type=int)
18
+ list_parser.add_argument("--query")
19
+ list_parser.add_argument("--page", type=int, default=1)
20
+ list_parser.add_argument("--page-size", type=int, default=20)
21
+ list_parser.set_defaults(handler=_handle_list, format_hint="task_list")
22
+
23
+ get = task_subparsers.add_parser("get", help="读取待办详情")
24
+ get.add_argument("--app-key", required=True)
25
+ get.add_argument("--record-id", required=True, type=int)
26
+ get.add_argument("--workflow-node-id", required=True, type=int)
27
+ get.add_argument("--include-candidates", action=argparse.BooleanOptionalAction, default=True)
28
+ get.add_argument("--include-associated-reports", action=argparse.BooleanOptionalAction, default=True)
29
+ get.set_defaults(handler=_handle_get, format_hint="")
30
+
31
+ action = task_subparsers.add_parser("action", help="执行待办动作")
32
+ action.add_argument("--app-key", required=True)
33
+ action.add_argument("--record-id", required=True, type=int)
34
+ action.add_argument("--workflow-node-id", required=True, type=int)
35
+ action.add_argument("--action", required=True)
36
+ action.add_argument("--payload-file")
37
+ action.set_defaults(handler=_handle_action, format_hint="")
38
+
39
+ log = task_subparsers.add_parser("log", help="读取流程日志")
40
+ log.add_argument("--app-key", required=True)
41
+ log.add_argument("--record-id", required=True, type=int)
42
+ log.add_argument("--workflow-node-id", required=True, type=int)
43
+ log.set_defaults(handler=_handle_log, format_hint="")
44
+
45
+
46
+ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
47
+ return context.task.task_list(
48
+ profile=args.profile,
49
+ task_box=args.task_box,
50
+ flow_status=args.flow_status,
51
+ app_key=args.app_key,
52
+ workflow_node_id=args.workflow_node_id,
53
+ query=args.query,
54
+ page=args.page,
55
+ page_size=args.page_size,
56
+ )
57
+
58
+
59
+ def _handle_get(args: argparse.Namespace, context: CliContext) -> dict:
60
+ return context.task.task_get(
61
+ profile=args.profile,
62
+ app_key=args.app_key,
63
+ record_id=args.record_id,
64
+ workflow_node_id=args.workflow_node_id,
65
+ include_candidates=bool(args.include_candidates),
66
+ include_associated_reports=bool(args.include_associated_reports),
67
+ )
68
+
69
+
70
+ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
71
+ return context.task.task_action_execute(
72
+ profile=args.profile,
73
+ app_key=args.app_key,
74
+ record_id=args.record_id,
75
+ workflow_node_id=args.workflow_node_id,
76
+ action=args.action,
77
+ payload=load_object_arg(args.payload_file, option_name="--payload-file") or {},
78
+ )
79
+
80
+
81
+ def _handle_log(args: argparse.Namespace, context: CliContext) -> dict:
82
+ return context.task.task_workflow_log_get(
83
+ profile=args.profile,
84
+ app_key=args.app_key,
85
+ record_id=args.record_id,
86
+ workflow_node_id=args.workflow_node_id,
87
+ )
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+ from ..context import CliContext
6
+
7
+
8
+ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
9
+ parser = subparsers.add_parser("workspace", help="工作区")
10
+ workspace_subparsers = parser.add_subparsers(dest="workspace_command", required=True)
11
+
12
+ list_parser = workspace_subparsers.add_parser("list", help="列出工作区")
13
+ list_parser.add_argument("--page", type=int, default=1)
14
+ list_parser.add_argument("--page-size", type=int, default=20)
15
+ list_parser.add_argument("--include-external", action="store_true")
16
+ list_parser.set_defaults(handler=_handle_list, format_hint="workspace_list")
17
+
18
+ select = workspace_subparsers.add_parser("select", help="切换工作区")
19
+ select.add_argument("--ws-id", type=int, required=True)
20
+ select.set_defaults(handler=_handle_select, format_hint="")
21
+
22
+
23
+ def _handle_list(args: argparse.Namespace, context: CliContext) -> dict:
24
+ return context.workspace.workspace_list(
25
+ profile=args.profile,
26
+ page_num=args.page,
27
+ page_size=args.page_size,
28
+ include_external=bool(args.include_external),
29
+ )
30
+
31
+
32
+ def _handle_select(args: argparse.Namespace, context: CliContext) -> dict:
33
+ return context.workspace.workspace_select(profile=args.profile, ws_id=args.ws_id)
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from ..backend_client import BackendClient
6
+ from ..session_store import SessionStore
7
+ from ..tools.ai_builder_tools import AiBuilderTools
8
+ from ..tools.app_tools import AppTools
9
+ from ..tools.auth_tools import AuthTools
10
+ from ..tools.code_block_tools import CodeBlockTools
11
+ from ..tools.import_tools import ImportTools
12
+ from ..tools.record_tools import RecordTools
13
+ from ..tools.task_context_tools import TaskContextTools
14
+ from ..tools.workspace_tools import WorkspaceTools
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class CliContext:
19
+ sessions: SessionStore
20
+ backend: BackendClient
21
+ auth: AuthTools
22
+ workspace: WorkspaceTools
23
+ app: AppTools
24
+ record: RecordTools
25
+ code_block: CodeBlockTools
26
+ imports: ImportTools
27
+ task: TaskContextTools
28
+ builder: AiBuilderTools
29
+
30
+ def close(self) -> None:
31
+ self.backend.close()
32
+
33
+
34
+ def build_cli_context() -> CliContext:
35
+ sessions = SessionStore()
36
+ backend = BackendClient()
37
+ return CliContext(
38
+ sessions=sessions,
39
+ backend=backend,
40
+ auth=AuthTools(sessions, backend),
41
+ workspace=WorkspaceTools(sessions, backend),
42
+ app=AppTools(sessions, backend),
43
+ record=RecordTools(sessions, backend),
44
+ code_block=CodeBlockTools(sessions, backend),
45
+ imports=ImportTools(sessions, backend),
46
+ task=TaskContextTools(sessions, backend),
47
+ builder=AiBuilderTools(sessions, backend),
48
+ )
@@ -0,0 +1,269 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, TextIO
5
+
6
+
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)
10
+ stream.write(text)
11
+ if not text.endswith("\n"):
12
+ stream.write("\n")
13
+
14
+
15
+ def _format_generic(result: dict[str, Any]) -> str:
16
+ lines: list[str] = []
17
+ title = _first_present(result, "status", "message")
18
+ if title:
19
+ lines.append(str(title))
20
+ data = result.get("data")
21
+ if isinstance(data, dict):
22
+ scalar_lines = _dict_scalar_lines(data)
23
+ if scalar_lines:
24
+ lines.extend(scalar_lines)
25
+ elif result:
26
+ scalar_lines = _dict_scalar_lines(result)
27
+ if scalar_lines:
28
+ lines.extend(scalar_lines)
29
+ if not lines:
30
+ lines.append(json.dumps(result, ensure_ascii=False, indent=2))
31
+ _append_warnings(lines, result.get("warnings"))
32
+ _append_verification(lines, result.get("verification"))
33
+ return "\n".join(lines) + "\n"
34
+
35
+
36
+ def _format_whoami(result: dict[str, Any]) -> str:
37
+ lines = [
38
+ f"Profile: {result.get('profile')}",
39
+ f"User: {result.get('nick_name') or '-'} ({result.get('email') or '-'})",
40
+ f"UID: {result.get('uid')}",
41
+ f"Workspace: {result.get('selected_ws_name') or '-'} ({result.get('selected_ws_id') or '-'})",
42
+ f"Base URL: {result.get('base_url')}",
43
+ ]
44
+ return "\n".join(lines) + "\n"
45
+
46
+
47
+ def _format_workspace_list(result: dict[str, Any]) -> str:
48
+ page = result.get("page") if isinstance(result.get("page"), dict) else {}
49
+ items = page.get("list") if isinstance(page.get("list"), list) else []
50
+ rows = []
51
+ for item in items:
52
+ if not isinstance(item, dict):
53
+ continue
54
+ rows.append(
55
+ [
56
+ str(item.get("wsId") or ""),
57
+ str(item.get("workspaceName") or item.get("wsName") or ""),
58
+ str(item.get("remark") or ""),
59
+ ]
60
+ )
61
+ return _render_titled_table("Workspaces", ["ws_id", "name", "remark"], rows)
62
+
63
+
64
+ def _format_app_items(result: dict[str, Any]) -> str:
65
+ items = result.get("items")
66
+ if not isinstance(items, list):
67
+ items = result.get("apps")
68
+ rows = []
69
+ for item in items or []:
70
+ if not isinstance(item, dict):
71
+ continue
72
+ rows.append(
73
+ [
74
+ str(item.get("app_key") or item.get("appKey") or ""),
75
+ str(item.get("app_name") or item.get("name") or item.get("title") or ""),
76
+ str(item.get("package_name") or item.get("tag_name") or ""),
77
+ ]
78
+ )
79
+ return _render_titled_table("Apps", ["app_key", "app_name", "package"], rows)
80
+
81
+
82
+ def _format_app_get(result: dict[str, Any]) -> str:
83
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
84
+ lines = [
85
+ f"App: {data.get('app_name') or '-'}",
86
+ f"App Key: {data.get('app_key') or '-'}",
87
+ f"Can Create: {data.get('can_create')}",
88
+ ]
89
+ import_capability = data.get("import_capability")
90
+ if isinstance(import_capability, dict):
91
+ lines.append(
92
+ "Import Capability: "
93
+ f"{import_capability.get('auth_source') or 'unknown'} / "
94
+ f"can_import={import_capability.get('can_import')}"
95
+ )
96
+ views = data.get("accessible_views") if isinstance(data.get("accessible_views"), list) else []
97
+ lines.append(f"Accessible Views: {len(views)}")
98
+ for item in views[:10]:
99
+ if isinstance(item, dict):
100
+ lines.append(f"- {item.get('view_id')}: {item.get('name')}")
101
+ _append_warnings(lines, result.get("warnings"))
102
+ return "\n".join(lines) + "\n"
103
+
104
+
105
+ def _format_record_list(result: dict[str, Any]) -> str:
106
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
107
+ items = data.get("items") if isinstance(data.get("items"), list) else []
108
+ lines = [f"Returned Records: {len(items)}"]
109
+ for item in items[:10]:
110
+ if isinstance(item, dict):
111
+ lines.append(json.dumps(item, ensure_ascii=False))
112
+ if len(items) > 10:
113
+ lines.append(f"... {len(items) - 10} more")
114
+ _append_warnings(lines, result.get("warnings"))
115
+ _append_verification(lines, result.get("verification"))
116
+ return "\n".join(lines) + "\n"
117
+
118
+
119
+ def _format_task_list(result: dict[str, Any]) -> str:
120
+ data = result.get("data") if isinstance(result.get("data"), dict) else {}
121
+ items = data.get("items") if isinstance(data.get("items"), list) else []
122
+ rows = []
123
+ for item in items:
124
+ if not isinstance(item, dict):
125
+ continue
126
+ rows.append(
127
+ [
128
+ str(item.get("app_key") or ""),
129
+ str(item.get("record_id") or ""),
130
+ str(item.get("workflow_node_id") or ""),
131
+ str(item.get("title") or item.get("task_name") or ""),
132
+ ]
133
+ )
134
+ output = _render_titled_table("Tasks", ["app_key", "record_id", "node_id", "title"], rows)
135
+ lines = output.rstrip("\n").split("\n")
136
+ _append_warnings(lines, result.get("warnings"))
137
+ return "\n".join(lines) + "\n"
138
+
139
+
140
+ def _format_import_verify(result: dict[str, Any]) -> str:
141
+ lines = [
142
+ f"App Key: {result.get('app_key') or '-'}",
143
+ f"File: {result.get('file_name') or result.get('file_path') or '-'}",
144
+ f"Can Import: {result.get('can_import')}",
145
+ f"Apply Rows: {result.get('apply_rows')}",
146
+ f"Verification ID: {result.get('verification_id') or '-'}",
147
+ ]
148
+ issues = result.get("issues") if isinstance(result.get("issues"), list) else []
149
+ if issues:
150
+ lines.append("Issues:")
151
+ for issue in issues:
152
+ if isinstance(issue, dict):
153
+ lines.append(f"- {issue.get('code') or 'ISSUE'}: {issue.get('message') or issue}")
154
+ else:
155
+ lines.append(f"- {issue}")
156
+ _append_warnings(lines, result.get("warnings"))
157
+ _append_verification(lines, result.get("verification"))
158
+ return "\n".join(lines) + "\n"
159
+
160
+
161
+ def _format_import_status(result: dict[str, Any]) -> str:
162
+ lines = [
163
+ f"Status: {result.get('status') or '-'}",
164
+ f"Import ID: {result.get('import_id') or '-'}",
165
+ f"Process ID: {result.get('process_id_str') or '-'}",
166
+ f"Success Rows: {result.get('success_rows') or 0}",
167
+ f"Failed Rows: {result.get('failed_rows') or 0}",
168
+ f"Progress: {result.get('progress') or '-'}",
169
+ ]
170
+ _append_warnings(lines, result.get("warnings"))
171
+ _append_verification(lines, result.get("verification"))
172
+ return "\n".join(lines) + "\n"
173
+
174
+
175
+ def _format_builder_summary(result: dict[str, Any]) -> str:
176
+ lines = []
177
+ if "status" in result:
178
+ lines.append(f"Status: {result.get('status')}")
179
+ if "app_key" in result:
180
+ lines.append(f"App Key: {result.get('app_key')}")
181
+ if "dash_key" in result:
182
+ lines.append(f"Dash Key: {result.get('dash_key')}")
183
+ if "verified" in result:
184
+ lines.append(f"Verified: {result.get('verified')}")
185
+ data = result.get("data")
186
+ if isinstance(data, dict):
187
+ scalar_lines = _dict_scalar_lines(data)
188
+ lines.extend(scalar_lines[:8])
189
+ _append_warnings(lines, result.get("warnings"))
190
+ _append_verification(lines, result.get("verification"))
191
+ if not lines:
192
+ return _format_generic(result)
193
+ return "\n".join(lines) + "\n"
194
+
195
+
196
+ def emit_json_result(result: dict[str, Any], *, stream: TextIO) -> None:
197
+ json.dump(result, stream, ensure_ascii=False, indent=2)
198
+ stream.write("\n")
199
+
200
+
201
+ def _render_titled_table(title: str, headers: list[str], rows: list[list[str]]) -> str:
202
+ lines = [title]
203
+ if not rows:
204
+ lines.append("(empty)")
205
+ return "\n".join(lines) + "\n"
206
+ widths = [len(header) for header in headers]
207
+ for row in rows:
208
+ for index, cell in enumerate(row):
209
+ widths[index] = max(widths[index], len(cell))
210
+ header_line = " ".join(header.ljust(widths[index]) for index, header in enumerate(headers))
211
+ lines.append(header_line)
212
+ lines.append(" ".join("-" * width for width in widths))
213
+ for row in rows:
214
+ lines.append(" ".join(cell.ljust(widths[index]) for index, cell in enumerate(row)))
215
+ return "\n".join(lines) + "\n"
216
+
217
+
218
+ def _dict_scalar_lines(payload: dict[str, Any]) -> list[str]:
219
+ lines: list[str] = []
220
+ for key, value in payload.items():
221
+ if isinstance(value, (str, int, float, bool)) or value is None:
222
+ lines.append(f"{key}: {value}")
223
+ return lines
224
+
225
+
226
+ def _append_warnings(lines: list[str], warnings: Any) -> None:
227
+ if not isinstance(warnings, list) or not warnings:
228
+ return
229
+ lines.append("Warnings:")
230
+ for warning in warnings:
231
+ if isinstance(warning, dict):
232
+ code = warning.get("code")
233
+ message = warning.get("message")
234
+ if code or message:
235
+ lines.append(f"- {code or 'WARNING'}: {message or ''}".rstrip())
236
+ else:
237
+ lines.append(f"- {json.dumps(warning, ensure_ascii=False)}")
238
+ else:
239
+ lines.append(f"- {warning}")
240
+
241
+
242
+ def _append_verification(lines: list[str], verification: Any) -> None:
243
+ if not isinstance(verification, dict) or not verification:
244
+ return
245
+ lines.append("Verification:")
246
+ for key, value in verification.items():
247
+ if isinstance(value, (str, int, float, bool)) or value is None:
248
+ lines.append(f"- {key}: {value}")
249
+
250
+
251
+ def _first_present(payload: dict[str, Any], *keys: str) -> Any:
252
+ for key in keys:
253
+ if key in payload and payload.get(key) is not None:
254
+ return payload.get(key)
255
+ return None
256
+
257
+
258
+ _FORMATTERS = {
259
+ "auth_whoami": _format_whoami,
260
+ "workspace_list": _format_workspace_list,
261
+ "app_list": _format_app_items,
262
+ "app_search": _format_app_items,
263
+ "app_get": _format_app_get,
264
+ "record_list": _format_record_list,
265
+ "task_list": _format_task_list,
266
+ "import_verify": _format_import_verify,
267
+ "import_status": _format_import_status,
268
+ "builder_summary": _format_builder_summary,
269
+ }
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..errors import QingflowApiError
9
+
10
+
11
+ def load_json_value(path: str, *, option_name: str) -> Any:
12
+ if not path:
13
+ raise QingflowApiError.config_error(f"{option_name} is required")
14
+ if path == "-":
15
+ raw = sys.stdin.read()
16
+ else:
17
+ try:
18
+ raw = Path(path).expanduser().read_text(encoding="utf-8")
19
+ except OSError as exc:
20
+ raise QingflowApiError.config_error(f"failed to read {option_name} from '{path}': {exc}") from exc
21
+ try:
22
+ return json.loads(raw)
23
+ except json.JSONDecodeError as exc:
24
+ raise QingflowApiError.config_error(f"{option_name} must contain valid JSON: {exc}") from exc
25
+
26
+
27
+ def load_json_object(path: str, *, option_name: str) -> dict[str, Any]:
28
+ payload = load_json_value(path, option_name=option_name)
29
+ if not isinstance(payload, dict):
30
+ raise QingflowApiError.config_error(f"{option_name} must be a JSON object")
31
+ return payload
32
+
33
+
34
+ def load_json_list(path: str, *, option_name: str) -> list[Any]:
35
+ payload = load_json_value(path, option_name=option_name)
36
+ if not isinstance(payload, list):
37
+ raise QingflowApiError.config_error(f"{option_name} must be a JSON array")
38
+ return payload
39
+
40
+
41
+ def load_optional_json_object(path: str | None, *, option_name: str) -> dict[str, Any] | None:
42
+ if not path:
43
+ return None
44
+ return load_json_object(path, option_name=option_name)
45
+
46
+
47
+ def load_optional_json_list(path: str | None, *, option_name: str) -> list[Any]:
48
+ if not path:
49
+ return []
50
+ return load_json_list(path, option_name=option_name)
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from typing import Any, Callable, TextIO
7
+
8
+ from ..errors import QingflowApiError
9
+ from .context import CliContext, build_cli_context
10
+ from .formatters import emit_json_result, emit_text_result
11
+ from .commands import register_all_commands
12
+
13
+
14
+ Handler = Callable[[argparse.Namespace, CliContext], dict[str, Any]]
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(prog="qingflow", description="Qingflow CLI")
19
+ parser.add_argument("--profile", default="default", help="会话 profile,默认 default")
20
+ parser.add_argument("--json", action="store_true", help="输出原始 JSON")
21
+ subparsers = parser.add_subparsers(dest="command", required=True)
22
+ register_all_commands(subparsers)
23
+ return parser
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> None:
27
+ raise SystemExit(run(argv))
28
+
29
+
30
+ def run(
31
+ argv: list[str] | None = None,
32
+ *,
33
+ context_factory: Callable[[], CliContext] = build_cli_context,
34
+ stdout: TextIO | None = None,
35
+ stderr: TextIO | None = None,
36
+ ) -> int:
37
+ out = stdout or sys.stdout
38
+ err = stderr or sys.stderr
39
+ parser = build_parser()
40
+ normalized_argv = _normalize_global_args(list(argv) if argv is not None else sys.argv[1:])
41
+ try:
42
+ args = parser.parse_args(normalized_argv)
43
+ except SystemExit as exc:
44
+ return int(exc.code or 0)
45
+ handler = getattr(args, "handler", None)
46
+ if handler is None:
47
+ parser.print_help(out)
48
+ return 2
49
+ context = context_factory()
50
+ try:
51
+ result = handler(args, context)
52
+ except RuntimeError as exc:
53
+ payload = _parse_error_payload(exc)
54
+ return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
55
+ except QingflowApiError as exc:
56
+ payload = exc.to_dict()
57
+ return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
58
+ finally:
59
+ context.close()
60
+
61
+ exit_code = _result_exit_code(result)
62
+ stream = out if bool(args.json) or exit_code == 0 else err
63
+ if bool(args.json):
64
+ emit_json_result(result, stream=stream)
65
+ else:
66
+ emit_text_result(result, hint=getattr(args, "format_hint", ""), stream=stream)
67
+ return exit_code
68
+
69
+
70
+ def _normalize_global_args(argv: list[str]) -> list[str]:
71
+ global_args: list[str] = []
72
+ remaining: list[str] = []
73
+ index = 0
74
+ while index < len(argv):
75
+ token = argv[index]
76
+ if token == "--json":
77
+ global_args.append(token)
78
+ index += 1
79
+ continue
80
+ if token == "--profile":
81
+ global_args.append(token)
82
+ if index + 1 >= len(argv):
83
+ global_args.append("")
84
+ break
85
+ global_args.append(argv[index + 1])
86
+ index += 2
87
+ continue
88
+ if token.startswith("--profile="):
89
+ global_args.append(token)
90
+ index += 1
91
+ continue
92
+ remaining.append(token)
93
+ index += 1
94
+ return global_args + remaining
95
+
96
+
97
+ def _parse_error_payload(exc: RuntimeError) -> dict[str, Any]:
98
+ raw = str(exc)
99
+ try:
100
+ payload = json.loads(raw)
101
+ except json.JSONDecodeError:
102
+ return {"category": "runtime", "message": raw}
103
+ return payload if isinstance(payload, dict) else {"category": "runtime", "message": raw}
104
+
105
+
106
+ def _emit_error(payload: dict[str, Any], *, json_mode: bool, stdout: TextIO, stderr: TextIO) -> int:
107
+ exit_code = _error_exit_code(payload)
108
+ if json_mode:
109
+ emit_json_result(payload, stream=stdout)
110
+ return exit_code
111
+ lines = [
112
+ f"Category: {payload.get('category') or 'error'}",
113
+ f"Message: {payload.get('message') or 'Unknown error'}",
114
+ ]
115
+ if payload.get("backend_code") is not None:
116
+ lines.append(f"Backend Code: {payload.get('backend_code')}")
117
+ if payload.get("request_id"):
118
+ lines.append(f"Request ID: {payload.get('request_id')}")
119
+ details = payload.get("details")
120
+ if isinstance(details, dict):
121
+ for key, value in details.items():
122
+ if isinstance(value, (str, int, float, bool)) or value is None:
123
+ lines.append(f"{key}: {value}")
124
+ stderr.write("\n".join(lines) + "\n")
125
+ return exit_code
126
+
127
+
128
+ def _error_exit_code(payload: dict[str, Any]) -> int:
129
+ category = str(payload.get("category") or "").lower()
130
+ if category in {"auth", "workspace"}:
131
+ return 3
132
+ return 4
133
+
134
+
135
+ def _result_exit_code(result: dict[str, Any]) -> int:
136
+ if not isinstance(result, dict):
137
+ return 0
138
+ if result.get("ok") is False:
139
+ return 4
140
+ status = str(result.get("status") or "").lower()
141
+ if status in {"failed", "blocked"}:
142
+ return 4
143
+ return 0
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()