@qingflow-tech/qingflow-cli 1.1.49 → 1.1.50-beta.1

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 (64) hide show
  1. package/README.md +4 -4
  2. package/npm/lib/runtime.mjs +29 -13
  3. package/package.json +1 -1
  4. package/pyproject.toml +1 -1
  5. package/skills/qingflow-cli/SKILL.md +92 -410
  6. package/skills/qingflow-cli/manifest.yaml +1 -1
  7. package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
  8. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  16. package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +13 -5
  17. package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +5 -3
  18. package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +1 -1
  19. package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +1 -1
  20. package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1665 -1738
  21. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  22. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
  23. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  26. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  27. package/skills/qingflow-cli/reference/data/README.md +32 -0
  28. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
  29. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  30. package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
  31. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  32. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  33. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  34. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  35. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
  36. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  37. package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +48 -46
  38. package/skills/qingflow-common/SKILL.md +10 -17
  39. package/src/qingflow_mcp/backend_client.py +142 -12
  40. package/src/qingflow_mcp/builder_facade/flow_validation.py +569 -0
  41. package/src/qingflow_mcp/builder_facade/service.py +749 -6
  42. package/src/qingflow_mcp/builder_facade/workflow_spec.py +154 -2
  43. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  44. package/src/qingflow_mcp/cli/commands/auth.py +1 -1
  45. package/src/qingflow_mcp/cli/commands/builder.py +57 -41
  46. package/src/qingflow_mcp/cli/commands/data.py +134 -0
  47. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  48. package/src/qingflow_mcp/cli/context.py +4 -1
  49. package/src/qingflow_mcp/cli/formatters.py +159 -4
  50. package/src/qingflow_mcp/cli/main.py +4 -1
  51. package/src/qingflow_mcp/config.py +60 -0
  52. package/src/qingflow_mcp/public_surface.py +9 -0
  53. package/src/qingflow_mcp/resource_uri.py +241 -0
  54. package/src/qingflow_mcp/response_trim.py +53 -5
  55. package/src/qingflow_mcp/server.py +2 -0
  56. package/src/qingflow_mcp/server_app_builder.py +57 -2
  57. package/src/qingflow_mcp/server_app_user.py +10 -3
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +374 -41
  59. package/src/qingflow_mcp/tools/app_tools.py +52 -13
  60. package/src/qingflow_mcp/tools/csv_export.py +168 -0
  61. package/src/qingflow_mcp/tools/data_tools.py +284 -0
  62. package/src/qingflow_mcp/tools/login_link_tools.py +34 -6
  63. package/src/qingflow_mcp/tools/record_tools.py +11 -46
  64. package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
@@ -8,7 +8,10 @@ from typing import Any, TextIO
8
8
  def emit_text_result(result: dict[str, Any], *, hint: str, stream: TextIO) -> None:
9
9
  text = _format_cancelled_result(result)
10
10
  if text is None:
11
- if _is_failed_result(result) and hint != "task_action_execute":
11
+ if hint in _DATA_FORMAT_HINTS:
12
+ # 数据面失败也必须保留 field_errors 与列表型 hints,不能先走通用失败裁剪。
13
+ text = _format_data_result(result)
14
+ elif _is_failed_result(result) and hint != "task_action_execute":
12
15
  text = _format_failed_result(result)
13
16
  else:
14
17
  formatter = _FORMATTERS.get(hint, _format_generic)
@@ -90,6 +93,92 @@ def _format_generic(result: dict[str, Any]) -> str:
90
93
  return "\n".join(lines) + "\n"
91
94
 
92
95
 
96
+ def _format_data_result(result: dict[str, Any]) -> str:
97
+ lines = [f"Status: {result.get('status') or ('success' if result.get('ok') is not False else 'failed')}"]
98
+ if result.get("format") == "csv":
99
+ files = result.get("files") if isinstance(result.get("files"), list) else []
100
+ lines.extend(
101
+ [
102
+ f"Local dir: {result.get('local_dir') or '-'}",
103
+ f"Files: {len(files)}",
104
+ ]
105
+ )
106
+ for item in files:
107
+ if isinstance(item, dict):
108
+ lines.append(
109
+ f"- part {item.get('part')}: {item.get('local_path')} ({item.get('row_count')} rows)"
110
+ )
111
+ lines.extend(
112
+ [
113
+ f"Rows: {result.get('row_count')}",
114
+ f"Complete: {result.get('complete')}",
115
+ f"Truncated: {result.get('truncated')}",
116
+ f"Safe for final conclusion: {result.get('safe_for_final_conclusion')}",
117
+ f"Stopped reason: {result.get('stopped_reason') or '-'}",
118
+ ]
119
+ )
120
+ access = result.get("access") if isinstance(result.get("access"), dict) else {}
121
+ if access:
122
+ sources = access.get("scope_sources") if isinstance(access.get("scope_sources"), list) else []
123
+ lines.append(
124
+ f"Access: level={access.get('level') or '-'} / can_write={access.get('can_write')}"
125
+ f" / scope_sources={','.join(str(item) for item in sources) or '-'}"
126
+ )
127
+
128
+ records = result.get("records") if isinstance(result.get("records"), dict) else {}
129
+ if records:
130
+ items = records.get("items") if isinstance(records.get("items"), list) else []
131
+ lines.append(
132
+ "Pagination: "
133
+ f"page={records.get('page')} / requested_page_size={records.get('requested_page_size')} / "
134
+ f"page_size={records.get('page_size')} / total={records.get('total')} / "
135
+ f"has_more={records.get('has_more')} / returned={len(items)}"
136
+ )
137
+ record = result.get("record") if isinstance(result.get("record"), dict) else {}
138
+ if record:
139
+ lines.append(f"Record ID: {record.get('record_id') or '-'}")
140
+ items = result.get("items") if isinstance(result.get("items"), list) else []
141
+ if items:
142
+ lines.append(f"Items: {len(items)}")
143
+
144
+ for key, label in (
145
+ ("error_code", "Error Code"),
146
+ ("message", "Message"),
147
+ ("write_executed", "Write Executed"),
148
+ ("retry_safe", "Retry Safe"),
149
+ ):
150
+ if key in result:
151
+ lines.append(f"{label}: {result.get(key)}")
152
+
153
+ field_errors = result.get("field_errors") if isinstance(result.get("field_errors"), list) else []
154
+ if field_errors:
155
+ lines.append("Field Errors:")
156
+ _append_data_field_errors(lines, field_errors)
157
+ for index, item in enumerate(items):
158
+ if not isinstance(item, dict) or not isinstance(item.get("field_errors"), list):
159
+ continue
160
+ lines.append(f"Item {index} Field Errors:")
161
+ _append_data_field_errors(lines, item["field_errors"])
162
+
163
+ hints = result.get("hints") if isinstance(result.get("hints"), list) else []
164
+ lines.append(f"Hints: {len(hints)}")
165
+ for hint in hints:
166
+ if isinstance(hint, dict):
167
+ lines.append(f"- {hint.get('code') or '-'}: {hint.get('message') or '-'}")
168
+ else:
169
+ lines.append(f"- {hint}")
170
+ return "\n".join(lines) + "\n"
171
+
172
+
173
+ def _append_data_field_errors(lines: list[str], field_errors: list[Any]) -> None:
174
+ for error in field_errors:
175
+ if isinstance(error, dict):
176
+ lines.append(
177
+ f"- {error.get('path') or '-'}: {error.get('message') or '-'}"
178
+ f" / fix_hint={error.get('fix_hint') or '-'}"
179
+ )
180
+
181
+
93
182
  def _format_whoami(result: dict[str, Any]) -> str:
94
183
  lines = [
95
184
  f"User: {result.get('nick_name') or '-'} ({result.get('email') or '-'})",
@@ -195,9 +284,10 @@ def _format_app_items(result: dict[str, Any]) -> str:
195
284
  str(item.get("app_key") or item.get("appKey") or ""),
196
285
  str(item.get("app_name") or item.get("name") or item.get("title") or ""),
197
286
  str(item.get("package_name") or item.get("tag_name") or ""),
287
+ str(item.get("uri") or ""),
198
288
  ]
199
289
  )
200
- return _render_titled_table("Apps", ["app_key", "app_name", "package"], rows)
290
+ return _render_titled_table("Apps", ["app_key", "app_name", "package", "uri"], rows)
201
291
 
202
292
 
203
293
  def _format_app_get(result: dict[str, Any]) -> str:
@@ -205,6 +295,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
205
295
  lines = [
206
296
  f"App: {data.get('app_name') or '-'}",
207
297
  f"App Key: {data.get('app_key') or '-'}",
298
+ f"URI: {data.get('uri') or '-'}",
208
299
  f"Can Create: {data.get('can_create')}",
209
300
  ]
210
301
  import_capability = data.get("import_capability")
@@ -228,7 +319,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
228
319
  lines.append(f"Accessible Views: {len(views)}")
229
320
  for item in views[:10]:
230
321
  if isinstance(item, dict):
231
- lines.append(f"- {item.get('view_id')}: {item.get('name')}")
322
+ lines.append(f"- {item.get('view_id')}: {item.get('name')} / {item.get('uri') or '-'}")
232
323
  _append_warnings(lines, result.get("warnings"))
233
324
  return "\n".join(lines) + "\n"
234
325
 
@@ -300,6 +391,7 @@ def _format_view_get(result: dict[str, Any]) -> str:
300
391
  f"View Key: {data.get('view_key') or '-'}",
301
392
  f"App Key: {data.get('app_key') or '-'}",
302
393
  f"View Type: {data.get('view_type') or '-'}",
394
+ f"URI: {data.get('uri') or '-'}",
303
395
  f"Analysis Supported: {data.get('analysis_supported')}",
304
396
  ]
305
397
  if export_capability:
@@ -1184,6 +1276,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
1184
1276
  ("view_key", "View Key"),
1185
1277
  ("chart_id", "Chart ID"),
1186
1278
  ("package_id", "Package ID"),
1279
+ ("uri", "URI"),
1187
1280
  ("verified", "Verified"),
1188
1281
  ("write_executed", "Write Executed"),
1189
1282
  ("delete_executed", "Delete Executed"),
@@ -1216,6 +1309,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
1216
1309
  "associations",
1217
1310
  "package_ids",
1218
1311
  "app_keys",
1312
+ "resources",
1219
1313
  ):
1220
1314
  value = result.get(key)
1221
1315
  if not isinstance(value, list) and isinstance(data, dict):
@@ -1227,6 +1321,15 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
1227
1321
  lines.append("- " + _builder_item_summary(item))
1228
1322
  if len(value) > len(visible_items):
1229
1323
  lines.append(f"... {len(value) - len(visible_items)} more")
1324
+ if key == "apps":
1325
+ uri_lines = [
1326
+ line
1327
+ for item in value
1328
+ for line in _builder_batch_item_uri_lines(item)
1329
+ ]
1330
+ if uri_lines:
1331
+ lines.append("Resource URIs:")
1332
+ lines.extend(f"- {line}" for line in uri_lines)
1230
1333
  components = result.get("components") if isinstance(result.get("components"), list) else []
1231
1334
  if components:
1232
1335
  lines.append("Component Refs:")
@@ -1295,6 +1398,7 @@ def _builder_item_summary(item: Any) -> str:
1295
1398
  "uid",
1296
1399
  "userId",
1297
1400
  "email",
1401
+ "uri",
1298
1402
  )
1299
1403
  parts = [
1300
1404
  f"{key}={item.get(key)}"
@@ -1306,6 +1410,50 @@ def _builder_item_summary(item: Any) -> str:
1306
1410
  return " / ".join(parts) if parts else json.dumps(item, ensure_ascii=False)
1307
1411
 
1308
1412
 
1413
+ def _builder_batch_item_uri_lines(item: Any) -> list[str]:
1414
+ if not isinstance(item, dict):
1415
+ return []
1416
+ item_status = str(item.get("status") or "").lower()
1417
+ if item_status not in {"success", "partial_success"}:
1418
+ return []
1419
+ data = item.get("data") if isinstance(item.get("data"), dict) else item.get("result")
1420
+ if not isinstance(data, dict) or not _builder_uri_is_confirmed(data, fallback_status=item_status):
1421
+ return []
1422
+ lines: list[str] = []
1423
+ if data.get("uri"):
1424
+ lines.append(f"URI: {data['uri']}")
1425
+ views = data.get("views") if isinstance(data.get("views"), list) else []
1426
+ for view in views:
1427
+ if (
1428
+ isinstance(view, dict)
1429
+ and view.get("uri")
1430
+ and _builder_uri_is_confirmed(view, fallback_status=item_status)
1431
+ ):
1432
+ lines.append(f"View URI: {view['uri']}")
1433
+ return lines
1434
+
1435
+
1436
+ def _builder_uri_is_confirmed(value: dict[str, Any], *, fallback_status: str) -> bool:
1437
+ status = str(value.get("status") or fallback_status).lower()
1438
+ operation = str(value.get("operation") or "").lower()
1439
+ return (
1440
+ status in {"success", "partial_success", "unchanged", "verified"}
1441
+ and operation
1442
+ not in {
1443
+ "failed",
1444
+ "pending",
1445
+ "planned",
1446
+ "readback_pending",
1447
+ "pending_readback",
1448
+ "unknown",
1449
+ "remove",
1450
+ "removed",
1451
+ "delete",
1452
+ "deleted",
1453
+ }
1454
+ )
1455
+
1456
+
1309
1457
  def _format_file_upload_local(result: dict[str, Any]) -> str:
1310
1458
  lines = [
1311
1459
  f"Upload Kind: {result.get('upload_kind') or result.get('requested_upload_kind') or '-'}",
@@ -1568,7 +1716,7 @@ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
1568
1716
 
1569
1717
 
1570
1718
  def _format_login_link(result: dict[str, Any]) -> str:
1571
- return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n链接仅可使用一次,请勿泄露。\n"
1719
+ return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n链接只能使用一次,请勿泄露;仅在用户要求打开时才打开。\n"
1572
1720
 
1573
1721
 
1574
1722
  _FORMATTERS = {
@@ -1610,4 +1758,11 @@ _FORMATTERS = {
1610
1758
  "builder_summary": _format_builder_summary,
1611
1759
  "file_upload_local": _format_file_upload_local,
1612
1760
  "feedback_submit": _format_feedback_submit,
1761
+ "data_query": _format_data_result,
1762
+ "data_get": _format_data_result,
1763
+ "data_insert": _format_data_result,
1764
+ "data_update": _format_data_result,
1765
+ "data_delete": _format_data_result,
1613
1766
  }
1767
+
1768
+ _DATA_FORMAT_HINTS = {"data_query", "data_get", "data_insert", "data_update", "data_delete"}
@@ -2,11 +2,13 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import os
5
6
  import sys
6
7
  from typing import Any, Callable, TextIO
7
8
 
8
9
  from ..errors import QingflowApiError, backend_code_value_int, message_looks_like_invalid_token
9
10
  from ..public_surface import cli_public_tool_spec_from_namespace
11
+ from ..resource_uri import cli_resource_base_url
10
12
  from ..response_trim import resolve_cli_tool_name, trim_error_response, trim_public_response
11
13
  from ..tools.ai_builder_tools import _attach_builder_apply_envelope
12
14
  from ..version import get_cli_version, get_cli_version_info
@@ -94,7 +96,8 @@ def run(
94
96
  context = context_factory()
95
97
  if not bool(args.json):
96
98
  _emit_cli_effective_context_notice(args, context, stream=err)
97
- result = handler(args, context)
99
+ with cli_resource_base_url(os.getenv("QINGFLOW_BASE_URL") or ""):
100
+ result = handler(args, context)
98
101
  except SystemExit as exc:
99
102
  return int(exc.code or 0)
100
103
  except RuntimeError as exc:
@@ -285,6 +285,66 @@ def get_credit_meter_enabled() -> bool:
285
285
  return normalized in {"1", "true", "yes", "on"}
286
286
 
287
287
 
288
+ # BEGIN temporary flow edit maintenance guard: remove this block when flow editing is restored.
289
+ FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD_DEFAULT = 5
290
+
291
+
292
+ def get_flow_edit_maintenance_threshold() -> int | None:
293
+ """读取流程编辑维护闸门的当前节点数阈值。
294
+
295
+ 配置键 flow_edit.maintenance_node_threshold / 环境变量
296
+ QINGFLOW_MCP_FLOW_EDIT_MAINTENANCE_THRESHOLD,默认 5;
297
+ 取值 off|disabled|none(大小写不敏感)返回 None 表示闸门关闭
298
+ (评测批量等场景的逃生门);解析失败或负数回退默认值 5。
299
+ """
300
+ value = get_config_value(
301
+ "flow_edit.maintenance_node_threshold",
302
+ env_var="QINGFLOW_MCP_FLOW_EDIT_MAINTENANCE_THRESHOLD",
303
+ default=FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD_DEFAULT,
304
+ )
305
+ normalized = str(value if value is not None else "").strip().lower()
306
+ if normalized in {"off", "disabled", "none"}:
307
+ return None
308
+ try:
309
+ threshold = int(normalized)
310
+ except ValueError:
311
+ return FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD_DEFAULT
312
+ return threshold if threshold >= 0 else FLOW_EDIT_MAINTENANCE_NODE_THRESHOLD_DEFAULT
313
+ # END temporary flow edit maintenance guard.
314
+
315
+
316
+ DEFAULT_HTTP_RETRY_ATTEMPTS = 3
317
+ DEFAULT_HTTP_RETRY_BASE_SECONDS = 3.0
318
+
319
+
320
+ def get_http_retry_attempts() -> int:
321
+ """传输层单请求最大尝试次数(含首次),≤1 视为不重试。"""
322
+ value = get_config_value(
323
+ "http.retry_attempts",
324
+ env_var="QINGFLOW_MCP_HTTP_RETRY_ATTEMPTS",
325
+ default=DEFAULT_HTTP_RETRY_ATTEMPTS,
326
+ )
327
+ try:
328
+ parsed = int(value)
329
+ except (ValueError, TypeError):
330
+ return DEFAULT_HTTP_RETRY_ATTEMPTS
331
+ return max(parsed, 1)
332
+
333
+
334
+ def get_http_retry_base_seconds() -> float:
335
+ """传输层重试线性退避基数:第 n 次失败后等待 base × n 秒。"""
336
+ value = get_config_value(
337
+ "http.retry_base_seconds",
338
+ env_var="QINGFLOW_MCP_HTTP_RETRY_BASE_SECONDS",
339
+ default=DEFAULT_HTTP_RETRY_BASE_SECONDS,
340
+ )
341
+ try:
342
+ parsed = float(value)
343
+ except (ValueError, TypeError):
344
+ return DEFAULT_HTTP_RETRY_BASE_SECONDS
345
+ return parsed if parsed >= 0 else DEFAULT_HTTP_RETRY_BASE_SECONDS
346
+
347
+
288
348
  def get_credit_usage_base_url() -> str | None:
289
349
  value = get_config_value(
290
350
  "credit_meter.apaas.base_url",
@@ -40,6 +40,11 @@ USER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
40
40
  PublicToolSpec(USER_DOMAIN, "workspace_select", ("workspace_select",), ("workspace", "select")),
41
41
  PublicToolSpec(USER_DOMAIN, "app_list", ("app_list",), ("app", "list"), cli_show_effective_context=True),
42
42
  PublicToolSpec(USER_DOMAIN, "app_get", ("app_get",), ("app", "get"), cli_show_effective_context=True),
43
+ PublicToolSpec(USER_DOMAIN, "data_query", ("data_query",), ("data", "query"), cli_show_effective_context=True),
44
+ PublicToolSpec(USER_DOMAIN, "data_get", ("data_get",), ("data", "get"), cli_show_effective_context=True),
45
+ PublicToolSpec(USER_DOMAIN, "data_insert", ("data_insert",), ("data", "insert"), cli_show_effective_context=True, cli_context_write=True),
46
+ PublicToolSpec(USER_DOMAIN, "data_update", ("data_update",), ("data", "update"), cli_show_effective_context=True, cli_context_write=True),
47
+ PublicToolSpec(USER_DOMAIN, "data_delete", ("data_delete",), ("data", "delete"), cli_show_effective_context=True, cli_context_write=True),
43
48
  PublicToolSpec(USER_DOMAIN, "portal_list", ("portal_list",), ("portal", "list"), cli_show_effective_context=True),
44
49
  PublicToolSpec(USER_DOMAIN, "portal_get", ("portal_get",), ("portal", "get"), cli_show_effective_context=True),
45
50
  PublicToolSpec(USER_DOMAIN, "view_get", ("view_get",), ("view", "get"), cli_show_effective_context=True),
@@ -128,6 +133,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
128
133
  PublicToolSpec(BUILDER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("builder", "auth", "use-credential"), cli_public=False),
129
134
  PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), ("builder", "auth", "whoami"), cli_public=False),
130
135
  PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), ("builder", "auth", "logout"), cli_public=False),
136
+ PublicToolSpec(BUILDER_DOMAIN, "get_login_link", ("get_login_link",), mcp_public=True, cli_public=False),
131
137
  PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), ("builder", "workspace", "list"), cli_public=False),
132
138
  PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), ("builder", "workspace", "get"), cli_public=False),
133
139
  PublicToolSpec(BUILDER_DOMAIN, "file_upload_local", ("file_upload_local",), ("builder", "file", "upload-local"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
@@ -168,6 +174,9 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
168
174
  PublicToolSpec(BUILDER_DOMAIN, "chart_get", ("chart_get",), ("builder", "chart", "get"), has_contract=True, cli_show_effective_context=True),
169
175
  PublicToolSpec(BUILDER_DOMAIN, "app_flow_get_schema", ("app_flow_get_schema",), ("builder", "flow", "schema"), has_contract=True, cli_show_effective_context=True),
170
176
  PublicToolSpec(BUILDER_DOMAIN, "app_flow_get", ("app_flow_get",), ("builder", "flow", "get"), has_contract=True, cli_show_effective_context=True),
177
+ PublicToolSpec(BUILDER_DOMAIN, "app_flow_validate", ("app_flow_validate",), ("builder", "flow", "validate"), has_contract=True, cli_show_effective_context=True),
178
+ PublicToolSpec(BUILDER_DOMAIN, "app_flow_list_versions", ("app_flow_list_versions",), ("builder", "flow", "versions"), has_contract=True, cli_show_effective_context=True),
179
+ PublicToolSpec(BUILDER_DOMAIN, "app_flow_restore_version", ("app_flow_restore_version",), ("builder", "flow", "restore"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
171
180
  PublicToolSpec(BUILDER_DOMAIN, "app_flow_apply", ("app_flow_apply",), ("builder", "flow", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
172
181
  PublicToolSpec(BUILDER_DOMAIN, "app_views_apply", ("app_views_apply",), ("builder", "views", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
173
182
  PublicToolSpec(BUILDER_DOMAIN, "app_charts_apply", ("app_charts_apply",), ("builder", "charts", "apply"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
10
+
11
+ from .config import DEFAULT_WEB_BASE_URL, get_web_base_url
12
+ from .errors import QingflowApiError
13
+ from .json_types import JSONObject
14
+
15
+
16
+ SYSTEM_VIEW_NAMES = frozenset({"all", "initiated", "todo", "done", "cc"})
17
+ _ENCODED_OCTET = re.compile(r"%[0-9A-Fa-f]{2}")
18
+ _CLI_RESOURCE_BASE_URL: ContextVar[str | None] = ContextVar(
19
+ "qingflow_cli_resource_base_url",
20
+ default=None,
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ResourcePath:
26
+ resource_type: str
27
+ ws_id: int
28
+ package_id: int | None = None
29
+ app_key: str | None = None
30
+ view_kind: str | None = None
31
+ view_key: str | None = None
32
+
33
+
34
+ @contextmanager
35
+ def cli_resource_base_url(value: str) -> Iterator[None]:
36
+ """让一次 CLI 调用的资源 URI 使用 QINGFLOW_BASE_URL 对应的网页环境。"""
37
+ token = _CLI_RESOURCE_BASE_URL.set(value)
38
+ try:
39
+ yield
40
+ finally:
41
+ _CLI_RESOURCE_BASE_URL.reset(token)
42
+
43
+
44
+ def validate_resource_identity(value: object, field: str = "resource_id") -> str:
45
+ """按资源 URI 单路径段合同校验并返回规范身份,不读取网页域名配置。"""
46
+ return _validate_identity(value, field)
47
+
48
+
49
+ def validate_positive_resource_id(value: object, field: str = "resource_id") -> int:
50
+ """按资源 URI 数字路径段合同校验正整数,不接受布尔值或浮点截断。"""
51
+ return int(_positive_int_segment(value, field))
52
+
53
+
54
+ def build_resource_uri(
55
+ *,
56
+ ws_id: object,
57
+ resource_type: str,
58
+ package_id: object = None,
59
+ app_key: object = None,
60
+ view_kind: str | None = None,
61
+ view_key: object = None,
62
+ web_origin: str | None = None,
63
+ ) -> str:
64
+ """按固定公共合同构造资源 URI,不接受通用目标路由。"""
65
+ workspace = _positive_int_segment(ws_id, "ws_id")
66
+ if resource_type == "package":
67
+ path = f"/resources/ws/{workspace}/package/{_positive_int_segment(package_id, 'package_id')}"
68
+ elif resource_type == "app":
69
+ path = f"/resources/ws/{workspace}/app/{_identity_segment(app_key, 'app_key')}"
70
+ elif resource_type == "view":
71
+ app_segment = _identity_segment(app_key, "app_key")
72
+ if view_kind == "custom":
73
+ view_segment = _identity_segment(view_key, "view_key")
74
+ elif view_kind == "system":
75
+ name = str(view_key or "").strip()
76
+ if name not in SYSTEM_VIEW_NAMES:
77
+ raise ValueError("system view name is not supported")
78
+ view_segment = name
79
+ else:
80
+ raise ValueError("view_kind must be custom or system")
81
+ path = f"/resources/ws/{workspace}/app/{app_segment}/view/{view_kind}/{view_segment}"
82
+ else:
83
+ raise ValueError("resource_type is not supported")
84
+ return f"{resolve_resource_web_origin(web_origin)}{path}"
85
+
86
+
87
+ def parse_resource_path(value: str) -> ResourcePath:
88
+ """严格解析资源 pathname,供免登录签发前校验工作区。"""
89
+ try:
90
+ parsed = urlsplit(value)
91
+ if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment or parsed.path != value:
92
+ raise ValueError
93
+ parts = parsed.path.split("/")
94
+ if len(parts) < 6 or parts[:3] != ["", "resources", "ws"]:
95
+ raise ValueError
96
+ ws_id = int(_decode_segment(parts[3], "ws_id"))
97
+ if ws_id <= 0:
98
+ raise ValueError
99
+ if len(parts) == 6 and parts[4] == "package":
100
+ package_id = int(_decode_segment(parts[5], "package_id"))
101
+ if package_id <= 0:
102
+ raise ValueError
103
+ return ResourcePath("package", ws_id, package_id=package_id)
104
+ if len(parts) == 6 and parts[4] == "app":
105
+ return ResourcePath("app", ws_id, app_key=_decode_segment(parts[5], "app_key"))
106
+ if len(parts) == 9 and parts[4] == "app" and parts[6] == "view":
107
+ app_key = _decode_segment(parts[5], "app_key")
108
+ view_kind = parts[7]
109
+ view_key = _decode_segment(parts[8], "view_key")
110
+ if view_kind == "system" and view_key not in SYSTEM_VIEW_NAMES:
111
+ raise ValueError
112
+ if view_kind not in {"custom", "system"}:
113
+ raise ValueError
114
+ return ResourcePath("view", ws_id, app_key=app_key, view_kind=view_kind, view_key=view_key)
115
+ except (TypeError, ValueError, UnicodeError):
116
+ pass
117
+ raise ValueError("resource path is invalid")
118
+
119
+
120
+ def try_build_resource_uri(**kwargs: Any) -> tuple[str | None, JSONObject | None]:
121
+ try:
122
+ return build_resource_uri(**kwargs), None
123
+ except (QingflowApiError, ValueError, UnicodeError) as error:
124
+ return None, {
125
+ "code": "RESOURCE_URI_UNAVAILABLE",
126
+ "message": f"resource URI was omitted because its identity or web origin is invalid: {error}",
127
+ }
128
+
129
+
130
+ def _positive_int_segment(value: object, field: str) -> str:
131
+ if isinstance(value, bool):
132
+ raise ValueError(f"{field} must be a positive integer")
133
+ try:
134
+ parsed = int(value)
135
+ except (TypeError, ValueError):
136
+ raise ValueError(f"{field} must be a positive integer") from None
137
+ if parsed <= 0 or str(value).strip() != str(parsed):
138
+ raise ValueError(f"{field} must be a positive integer")
139
+ return str(parsed)
140
+
141
+
142
+ def _normalize_web_origin(value: str) -> str:
143
+ try:
144
+ parsed = urlsplit(value.strip())
145
+ if (
146
+ parsed.scheme not in {"http", "https"}
147
+ or not parsed.hostname
148
+ or parsed.username is not None
149
+ or parsed.password is not None
150
+ or parsed.path not in {"", "/"}
151
+ or parsed.query
152
+ or parsed.fragment
153
+ or any(character.isspace() or ord(character) < 32 for character in value)
154
+ ):
155
+ raise ValueError
156
+ _ = parsed.port
157
+ except (AttributeError, ValueError):
158
+ raise ValueError("web_origin must be an absolute HTTP(S) origin") from None
159
+ return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
160
+
161
+
162
+ def resolve_resource_web_origin(web_origin: str | None = None) -> str:
163
+ """解析当前调用面的网页根地址;CLI 与 MCP 分别沿用各自的环境配置。"""
164
+ if web_origin is not None:
165
+ return _normalize_web_origin(web_origin)
166
+ cli_base_url = _CLI_RESOURCE_BASE_URL.get()
167
+ if cli_base_url is None:
168
+ return get_web_base_url()
169
+ return _web_origin_from_api_base_url(cli_base_url)
170
+
171
+
172
+ def _web_origin_from_api_base_url(value: str) -> str:
173
+ normalized = value.strip()
174
+ if not normalized:
175
+ return DEFAULT_WEB_BASE_URL
176
+ try:
177
+ parsed = urlsplit(normalized)
178
+ host = parsed.hostname or ""
179
+ valid_host = bool(host) and (":" in host or all(
180
+ re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label)
181
+ for label in host.rstrip(".").split(".")
182
+ ))
183
+ if (
184
+ parsed.scheme not in {"http", "https"}
185
+ or not valid_host
186
+ or host.lower() in {"null", "undefined"}
187
+ or parsed.username is not None
188
+ or parsed.password is not None
189
+ or parsed.query
190
+ or parsed.fragment
191
+ or any(character.isspace() or ord(character) < 32 for character in normalized)
192
+ ):
193
+ raise ValueError
194
+ _ = parsed.port
195
+ return _normalize_web_origin(urlunsplit((parsed.scheme, parsed.netloc, "", "", "")))
196
+ except ValueError:
197
+ raise QingflowApiError.config_error(
198
+ "QINGFLOW_BASE_URL 必须是有效的 HTTP(S) API 地址,不包含查询参数、片段或用户凭证"
199
+ ) from None
200
+
201
+
202
+ def _identity_segment(value: object, field: str) -> str:
203
+ raw = _validate_identity(value, field)
204
+ return quote(raw, safe="")
205
+
206
+
207
+ def _decode_segment(value: str, field: str) -> str:
208
+ decoded = _validate_identity(unquote(value, errors="strict"), field)
209
+ if quote(decoded, safe="") != value:
210
+ raise ValueError(f"{field} must use canonical path encoding")
211
+ return decoded
212
+
213
+
214
+ def _validate_identity(value: object, field: str) -> str:
215
+ source = str(value or "")
216
+ if any(ord(character) < 32 or ord(character) == 127 for character in source):
217
+ raise ValueError(f"{field} must be one safe path segment")
218
+ raw = source.strip()
219
+ if not raw:
220
+ raise ValueError(f"{field} is required")
221
+ current = raw
222
+ for _ in range(8):
223
+ if (
224
+ current in {".", ".."}
225
+ or any(character in current for character in "/\\?#")
226
+ or any(ord(character) < 32 or ord(character) == 127 for character in current)
227
+ ):
228
+ raise ValueError(f"{field} must be one safe path segment")
229
+ decoded = unquote(current, errors="strict")
230
+ if decoded == current:
231
+ return raw
232
+ current = decoded
233
+ if (
234
+ current in {".", ".."}
235
+ or any(character in current for character in "/\\?#")
236
+ or any(ord(character) < 32 or ord(character) == 127 for character in current)
237
+ ):
238
+ raise ValueError(f"{field} must be one safe path segment")
239
+ if _ENCODED_OCTET.search(current):
240
+ raise ValueError(f"{field} has excessive encoding")
241
+ return raw