@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.
- package/README.md +31 -0
- package/docs/local-agent-install.md +309 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow.mjs +5 -0
- package/npm/lib/runtime.mjs +346 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow +15 -0
- package/src/qingflow_mcp/__init__.py +37 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +649 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +1846 -0
- package/src/qingflow_mcp/builder_facade/service.py +16502 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +112 -0
- package/src/qingflow_mcp/cli/commands/builder.py +539 -0
- package/src/qingflow_mcp/cli/commands/chart.py +18 -0
- package/src/qingflow_mcp/cli/commands/common.py +62 -0
- package/src/qingflow_mcp/cli/commands/imports.py +96 -0
- package/src/qingflow_mcp/cli/commands/portal.py +25 -0
- package/src/qingflow_mcp/cli/commands/record.py +331 -0
- package/src/qingflow_mcp/cli/commands/repo.py +80 -0
- package/src/qingflow_mcp/cli/commands/task.py +141 -0
- package/src/qingflow_mcp/cli/commands/view.py +18 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
- package/src/qingflow_mcp/cli/context.py +60 -0
- package/src/qingflow_mcp/cli/formatters.py +573 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +186 -0
- package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
- package/src/qingflow_mcp/config.py +407 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/id_utils.py +49 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/public_surface.py +243 -0
- package/src/qingflow_mcp/repository_store.py +71 -0
- package/src/qingflow_mcp/response_trim.py +841 -0
- package/src/qingflow_mcp/server.py +216 -0
- package/src/qingflow_mcp/server_app_builder.py +543 -0
- package/src/qingflow_mcp/server_app_user.py +386 -0
- package/src/qingflow_mcp/session_store.py +369 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2398 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +855 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
- package/src/qingflow_mcp/tools/app_tools.py +926 -0
- package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
- package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
- package/src/qingflow_mcp/tools/base.py +281 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
- package/src/qingflow_mcp/tools/directory_tools.py +675 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
- package/src/qingflow_mcp/tools/file_tools.py +409 -0
- package/src/qingflow_mcp/tools/import_tools.py +2223 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
- package/src/qingflow_mcp/tools/package_tools.py +326 -0
- package/src/qingflow_mcp/tools/portal_tools.py +158 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
- package/src/qingflow_mcp/tools/record_tools.py +14291 -0
- package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
- package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
- package/src/qingflow_mcp/tools/role_tools.py +112 -0
- package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
- package/src/qingflow_mcp/tools/task_tools.py +889 -0
- package/src/qingflow_mcp/tools/view_tools.py +335 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
- 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
|