@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -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 +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from contextvars import ContextVar
5
+ from typing import Any, Callable, TypeVar
6
+
7
+ from ..backend_client import BackendRequestContext, BackendClient
8
+ from ..config import (
9
+ get_credit_meter_enabled,
10
+ get_credit_usage_base_url,
11
+ get_credit_usage_path,
12
+ )
13
+ from ..errors import QingflowApiError, raise_tool_error
14
+ from ..json_types import JSONObject
15
+ from ..session_store import BackendSession, SessionProfile, SessionStore
16
+
17
+
18
+ T = TypeVar("T")
19
+ _RUN_DEPTH: ContextVar[int] = ContextVar("qingflow_mcp_tool_run_depth", default=0)
20
+
21
+
22
+ def tool_cn_name(name: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
23
+ """为 tools 方法声明中文展示名(用于计费 extraInfo 记录)。"""
24
+ normalized = str(name).strip()
25
+
26
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
27
+ setattr(func, "__tool_cn_name__", normalized)
28
+ return func
29
+
30
+ return decorator
31
+
32
+
33
+ class ToolBase:
34
+ def __init__(self, sessions: SessionStore, backend: BackendClient) -> None:
35
+ self.sessions = sessions
36
+ self.backend = backend
37
+
38
+ def _require_context(self, profile: str, *, require_workspace: bool) -> tuple[SessionProfile, BackendSession, BackendRequestContext]:
39
+ session_profile = self.sessions.get_profile(profile)
40
+ if session_profile is None:
41
+ raise QingflowApiError.auth_required(profile)
42
+ backend_session = self.sessions.get_backend_session(profile)
43
+ if backend_session is None:
44
+ raise QingflowApiError.auth_required(profile)
45
+ if require_workspace and session_profile.selected_ws_id is None:
46
+ raise QingflowApiError.workspace_not_selected(profile)
47
+ context = BackendRequestContext(
48
+ base_url=backend_session.base_url,
49
+ token=backend_session.token,
50
+ # Keep the selected workspace in context whenever we know it.
51
+ # Some top-level tools (for example workspace browsing) do not
52
+ # require a workspace to execute, but credit metering still needs
53
+ # a stable wsId when the profile already has one selected.
54
+ ws_id=session_profile.selected_ws_id,
55
+ qf_version=backend_session.qf_version,
56
+ qf_version_source=backend_session.qf_version_source,
57
+ )
58
+ return session_profile, backend_session, context
59
+
60
+ def _run(self, profile: str, func: Callable[[SessionProfile, BackendRequestContext], T], *, require_workspace: bool = True) -> T:
61
+ depth = _RUN_DEPTH.get()
62
+ depth_token = _RUN_DEPTH.set(depth + 1)
63
+ is_top_level_call = depth == 0
64
+ session_profile: SessionProfile | None = None
65
+ backend_session: BackendSession | None = None
66
+ tool_name = self._resolve_calling_tool_name()
67
+ try:
68
+ session_profile, backend_session, context = self._require_context(profile, require_workspace=require_workspace)
69
+ if is_top_level_call:
70
+ self._charge_tool_call(
71
+ profile=profile,
72
+ tool_name=tool_name,
73
+ session_profile=session_profile,
74
+ context=context,
75
+ )
76
+ return func(session_profile, context)
77
+ except QingflowApiError as error:
78
+ if (
79
+ error.looks_like_invalid_token()
80
+ and session_profile is not None
81
+ and backend_session is not None
82
+ and self._refresh_session_from_credential(
83
+ profile,
84
+ session_profile=session_profile,
85
+ backend_session=backend_session,
86
+ )
87
+ ):
88
+ try:
89
+ refreshed_profile, _, refreshed_context = self._require_context(profile, require_workspace=require_workspace)
90
+ return func(refreshed_profile, refreshed_context)
91
+ except QingflowApiError as refreshed_error:
92
+ self._handle_error(profile, refreshed_error)
93
+ self._handle_error(profile, error)
94
+ finally:
95
+ _RUN_DEPTH.reset(depth_token)
96
+ raise AssertionError("unreachable")
97
+
98
+ def _handle_error(self, profile: str, error: QingflowApiError) -> None:
99
+ if error.looks_like_invalid_token():
100
+ self.sessions.invalidate(profile)
101
+ error = QingflowApiError(
102
+ category="auth",
103
+ message=f"Qingflow session for profile '{profile}' has expired. Run auth login or auth_use_credential again.",
104
+ backend_code=error.backend_code,
105
+ request_id=error.request_id,
106
+ http_status=error.http_status,
107
+ )
108
+ raise_tool_error(error)
109
+
110
+ def _refresh_session_from_credential(
111
+ self,
112
+ profile: str,
113
+ *,
114
+ session_profile: SessionProfile,
115
+ backend_session: BackendSession,
116
+ ) -> bool:
117
+ credential = (backend_session.credential or "").strip()
118
+ if not credential:
119
+ return False
120
+ try:
121
+ response = self.backend.public_request_with_meta(
122
+ "POST",
123
+ session_profile.base_url,
124
+ "/mcp/auth/context",
125
+ json_body={"credential": credential},
126
+ qf_version=backend_session.qf_version,
127
+ )
128
+ payload = self._unwrap_context_payload(response.data)
129
+ token = self._normalize_text(payload.get("token"))
130
+ ws_id = self._coerce_positive_int(payload.get("wsId"))
131
+ uid = self._coerce_positive_int(payload.get("uid")) or session_profile.uid
132
+ if not token or ws_id is None:
133
+ return False
134
+ backend_qf_version = self._normalize_text(payload.get("qfVersion")) or response.qf_response_version
135
+ qf_version = backend_qf_version if backend_qf_version is not None else backend_session.qf_version
136
+ qf_version_source = (
137
+ "backend_response"
138
+ if backend_qf_version is not None
139
+ else backend_session.qf_version_source
140
+ )
141
+ base_url = self._normalize_text(payload.get("baseUrl")) or session_profile.base_url
142
+ ws_name = self._normalize_text(payload.get("wsName")) or session_profile.selected_ws_name
143
+ self.sessions.save_session(
144
+ profile=profile,
145
+ base_url=base_url,
146
+ qf_version=qf_version,
147
+ qf_version_source=qf_version_source,
148
+ token=token,
149
+ login_token=None,
150
+ credential=credential,
151
+ uid=uid,
152
+ email=self._normalize_text(payload.get("email")) or session_profile.email,
153
+ nick_name=(
154
+ self._normalize_text(payload.get("nickName"))
155
+ or self._normalize_text(payload.get("displayName"))
156
+ or self._normalize_text(payload.get("name"))
157
+ or session_profile.nick_name
158
+ ),
159
+ persist=session_profile.persisted,
160
+ )
161
+ self.sessions.select_workspace(profile, ws_id=ws_id, ws_name=ws_name)
162
+ return True
163
+ except QingflowApiError:
164
+ return False
165
+
166
+ def _unwrap_context_payload(self, payload: Any) -> dict[str, Any]:
167
+ if not isinstance(payload, dict):
168
+ return {}
169
+ nested = payload.get("data")
170
+ if isinstance(nested, dict):
171
+ return nested
172
+ nested = payload.get("result")
173
+ if isinstance(nested, dict):
174
+ return nested
175
+ return payload
176
+
177
+ def _normalize_text(self, value: Any) -> str | None:
178
+ if value is None:
179
+ return None
180
+ text = str(value).strip()
181
+ return text or None
182
+
183
+ def _coerce_positive_int(self, value: Any) -> int | None:
184
+ if isinstance(value, bool) or value is None:
185
+ return None
186
+ try:
187
+ parsed = int(value)
188
+ except (TypeError, ValueError):
189
+ return None
190
+ return parsed if parsed > 0 else None
191
+
192
+ def _resolve_calling_tool_name(self) -> str:
193
+ frame = inspect.currentframe()
194
+ if frame is None:
195
+ return "unknown_tool"
196
+ caller = frame.f_back.f_back if frame.f_back is not None else None
197
+ try:
198
+ if caller is None:
199
+ return "unknown_tool"
200
+ name = caller.f_code.co_name
201
+ if not name:
202
+ return "unknown_tool"
203
+ method = getattr(self, name, None)
204
+ cn_name = getattr(method, "__tool_cn_name__", None)
205
+ if isinstance(cn_name, str) and cn_name.strip():
206
+ return cn_name.strip()
207
+ return name
208
+ finally:
209
+ del frame
210
+
211
+ def _charge_tool_call(
212
+ self,
213
+ *,
214
+ profile: str,
215
+ tool_name: str,
216
+ session_profile: SessionProfile,
217
+ context: BackendRequestContext,
218
+ ) -> None:
219
+ if not get_credit_meter_enabled():
220
+ return
221
+ if context.ws_id is None:
222
+ raise_tool_error(QingflowApiError(category="payment", message="credit meter requires wsId in current session context"))
223
+
224
+ self._record_credit_usage(
225
+ tool_name=tool_name,
226
+ context=context,
227
+ )
228
+
229
+ def _record_credit_usage(
230
+ self,
231
+ *,
232
+ tool_name: str,
233
+ context: BackendRequestContext,
234
+ ) -> None:
235
+ usage_context = BackendRequestContext(
236
+ base_url=get_credit_usage_base_url() or context.base_url,
237
+ token=context.token,
238
+ ws_id=context.ws_id,
239
+ qf_request_id=context.qf_request_id,
240
+ qf_version=context.qf_version,
241
+ qf_version_source=context.qf_version_source,
242
+ )
243
+ self.backend.request(
244
+ "POST",
245
+ usage_context,
246
+ get_credit_usage_path(),
247
+ json_body={
248
+ "skuType": "MCP",
249
+ "skuName": "MCP",
250
+ "modelName": "MCP",
251
+ "scene": "MCP",
252
+ "aiBiz": "MCP",
253
+ "extraInfo": tool_name,
254
+ },
255
+ )
256
+
257
+ def _require_dict(self, payload: JSONObject | None, field_name: str = "payload") -> JSONObject:
258
+ if not isinstance(payload, dict) or not payload:
259
+ raise_tool_error(QingflowApiError.config_error(f"{field_name} must be a non-empty object"))
260
+ return payload
261
+
262
+ def _high_risk_tool_description(self, *, operation: str, target: str) -> str:
263
+ return (
264
+ f"High-risk {operation} operation for {target}. Read the current state first, "
265
+ "confirm the exact target IDs and intended diff with a human, and avoid running "
266
+ "against production without explicit approval."
267
+ )
268
+
269
+ def _attach_human_review_notice(self, response: JSONObject, *, operation: str, target: str) -> JSONObject:
270
+ payload = dict(response)
271
+ payload["requires_human_review"] = True
272
+ payload["risk_notice"] = {
273
+ "operation": operation,
274
+ "target": target,
275
+ "severity": "high",
276
+ "guidance": (
277
+ "Read the current state first, confirm the exact target IDs and intended diff with a human, "
278
+ "and require explicit approval before running against production."
279
+ ),
280
+ }
281
+ return payload