@josephyan/qingflow-cli 1.1.2 → 1.1.4

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 CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-cli@1.1.2
6
+ npm install @josephyan/qingflow-cli@1.1.4
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-cli@1.1.2 qingflow
12
+ npx -y -p @josephyan/qingflow-cli@1.1.4 qingflow
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-cli",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "1.1.2"
7
+ version = "1.1.4"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -428,6 +428,7 @@ qingflow builder button apply \
428
428
  默认使用 `qingflow builder associated-resource apply` 管理应用级关联资源池与视图展示配置。
429
429
 
430
430
  - 权限分层:`upsert_resources` / `patch_resources` / 删除 / 排序应用级关联资源池走 **EditAppAuth**;`view_configs` 修改某个视图里的关联资源展示配置,还需要视图配置侧的 **ViewManagementAuth**(未开启高级应用权限时回落到 **DataManageAuth**)。
431
+ - 多应用批量配置时可用 `--apps-file`,文件为 JSON 数组,每项写 `{ "app_key": "...", "upsert_resources": [...], "patch_resources": [...], "remove_associated_item_ids": [...], "reorder_associated_item_ids": [...], "view_configs": [...] }`;不要和单应用的 `--app-key` / `--*-file` 混用。
431
432
 
432
433
  ```bash
433
434
  qingflow builder associated-resource apply \
@@ -436,6 +437,11 @@ qingflow builder associated-resource apply \
436
437
  --view-configs-file tmp/associated_view_configs.json
437
438
  ```
438
439
 
440
+ ```bash
441
+ qingflow builder associated-resource apply \
442
+ --apps-file tmp/associated_resource_apps.json
443
+ ```
444
+
439
445
  - Shell 退出码为 0、重定向成功或 `echo OK` 只表示命令执行完,不表示业务成功。必须读取输出 JSON,检查顶层 `status`、`error_code`、`warnings`、`blocking_issues`;`status: failed`、`partial_success`、`ASSOCIATED_RESOURCES_APPLY_BLOCKED` 都不能当作完成。
440
446
  - 关联资源要生效必须同时处理两层:应用级资源池(`upsert_resources` / `patch_resources`)和视图详情展示绑定(`view_configs`)。只传 `--upsert-resources-file` 往往只是在资源池准备资源,不会让按钮或视图详情里看到关联资源。
441
447
  - `builder views apply` 新建视图会默认打开详情页关联查看(展示全部应用级关联资源);若只需要默认展示全部资源,创建视图时不用额外写 `view_configs`。需要指定部分资源、关闭展示或配置匹配筛选时,仍必须使用本工具的 `view_configs` / `match_mappings`。
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from enum import Enum
4
5
  from typing import Any
5
6
 
@@ -1762,6 +1763,70 @@ class ChartFilterRulePatch(StrictModel):
1762
1763
  return self
1763
1764
 
1764
1765
 
1766
+ _CHART_METRIC_RE = re.compile(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)\s*$")
1767
+
1768
+
1769
+ def _normalize_chart_string_list(value: Any) -> list[str]:
1770
+ if value is None:
1771
+ return []
1772
+ if isinstance(value, (list, tuple)):
1773
+ return [str(item).strip() for item in value if item is not None and str(item).strip()]
1774
+ raw = str(value).strip()
1775
+ return [raw] if raw else []
1776
+
1777
+
1778
+ def _parse_chart_metric_item(value: Any) -> tuple[list[str], str | None]:
1779
+ if value is None:
1780
+ return [], None
1781
+ if isinstance(value, dict):
1782
+ op = str(value.get("op") or value.get("agg") or value.get("aggregate") or value.get("aggregation") or "").strip().lower()
1783
+ field = value.get("field", value.get("field_name", value.get("name", value.get("selector"))))
1784
+ selectors = _normalize_chart_string_list(field)
1785
+ return selectors, op or ("count" if not selectors else None)
1786
+ raw = str(value).strip()
1787
+ if not raw:
1788
+ return [], None
1789
+ matched = _CHART_METRIC_RE.match(raw)
1790
+ if matched:
1791
+ op = matched.group(1).strip().lower()
1792
+ field = matched.group(2).strip()
1793
+ if field in {"*", ""}:
1794
+ return [], op
1795
+ return [field], op
1796
+ if raw.lower() in {"count", "count(*)"}:
1797
+ return [], "count"
1798
+ return [raw], None
1799
+
1800
+
1801
+ def _apply_semantic_chart_metric_aliases(payload: dict[str, Any]) -> None:
1802
+ raw_metrics: list[Any] = []
1803
+ if "metric" in payload:
1804
+ raw_metrics.append(payload.pop("metric"))
1805
+ if "metrics" in payload:
1806
+ metrics = payload.pop("metrics")
1807
+ raw_metrics.extend(metrics if isinstance(metrics, list) else [metrics])
1808
+ if not raw_metrics:
1809
+ return
1810
+ selectors: list[str] = []
1811
+ aggregates: list[str] = []
1812
+ for metric in raw_metrics:
1813
+ metric_selectors, aggregate = _parse_chart_metric_item(metric)
1814
+ selectors.extend(metric_selectors)
1815
+ if aggregate:
1816
+ aggregates.append(aggregate)
1817
+ if selectors and "indicator_field_ids" not in payload:
1818
+ payload["indicator_field_ids"] = selectors
1819
+ if aggregates:
1820
+ normalized_aggregate = aggregates[0]
1821
+ config = payload.get("config")
1822
+ if not isinstance(config, dict):
1823
+ config = {}
1824
+ else:
1825
+ config = dict(config)
1826
+ config.setdefault("aggregate", normalized_aggregate)
1827
+ payload["config"] = config
1828
+
1829
+
1765
1830
  class ChartUpsertPatch(StrictModel):
1766
1831
  chart_id: str | None = None
1767
1832
  name: str
@@ -1790,6 +1855,15 @@ class ChartUpsertPatch(StrictModel):
1790
1855
  payload["indicator_field_ids"] = payload.pop("indicator_fields")
1791
1856
  if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
1792
1857
  payload["indicator_field_ids"] = payload.pop("metric_field_ids")
1858
+ if "group_by" in payload and "dimension_field_ids" not in payload:
1859
+ payload["dimension_field_ids"] = _normalize_chart_string_list(payload.pop("group_by"))
1860
+ if "groupBy" in payload and "dimension_field_ids" not in payload:
1861
+ payload["dimension_field_ids"] = _normalize_chart_string_list(payload.pop("groupBy"))
1862
+ if "where" in payload and "filters" not in payload:
1863
+ payload["filters"] = payload.pop("where")
1864
+ else:
1865
+ payload.pop("where", None)
1866
+ _apply_semantic_chart_metric_aliases(payload)
1793
1867
  raw_type = payload.get("chart_type")
1794
1868
  if isinstance(raw_type, str):
1795
1869
  normalized = raw_type.strip().lower()
@@ -1980,6 +2054,7 @@ class PortalViewRefPatch(StrictModel):
1980
2054
  class PortalSectionPatch(StrictModel):
1981
2055
  title: str
1982
2056
  source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
2057
+ role: str | None = None
1983
2058
  position: PortalComponentPositionPatch | None = None
1984
2059
  dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
1985
2060
  config: dict[str, Any] = Field(default_factory=dict)
@@ -2010,6 +2085,13 @@ class PortalSectionPatch(StrictModel):
2010
2085
  supported = {"chart", "view", "grid", "filter", "text", "link"}
2011
2086
  if self.source_type not in supported:
2012
2087
  raise ValueError(f"unsupported portal source_type '{self.source_type}'")
2088
+ if self.role is not None:
2089
+ normalized_role = str(self.role or "").strip().lower()
2090
+ if normalized_role not in {"metric"}:
2091
+ raise ValueError(f"unsupported portal section role '{self.role}'")
2092
+ self.role = normalized_role
2093
+ if self.source_type != "chart":
2094
+ raise ValueError("portal section role is only supported for chart sections")
2013
2095
  if self.source_type == "chart" and self.chart_ref is None:
2014
2096
  raise ValueError("chart section requires chart_ref")
2015
2097
  if self.source_type == "view" and self.view_ref is None:
@@ -1184,6 +1184,24 @@ class AiBuilderFacade:
1184
1184
  suggested_next_call=None,
1185
1185
  )
1186
1186
 
1187
+ if not normalized_items and not current_resources and not current_group_specs:
1188
+ return {
1189
+ "status": "success",
1190
+ "error_code": None,
1191
+ "recoverable": False,
1192
+ "message": "package items already empty; skipped package sort",
1193
+ "normalized_args": normalized_args,
1194
+ "missing_fields": [],
1195
+ "allowed_values": {},
1196
+ "details": {"group_operations": [], "sort_skipped": True},
1197
+ "request_id": None,
1198
+ "suggested_next_call": None,
1199
+ "noop": True,
1200
+ "warnings": [],
1201
+ "verification": {"layout_verified": True, "sort_skipped": True},
1202
+ "verified": True,
1203
+ }
1204
+
1187
1205
  duplicate_resources = _find_duplicate_package_resources(normalized_items)
1188
1206
  if duplicate_resources:
1189
1207
  return _failed(
@@ -5344,7 +5362,7 @@ class AiBuilderFacade:
5344
5362
  for app_key in app_keys:
5345
5363
  try:
5346
5364
  result = single_reader(profile=profile, app_key=app_key)
5347
- status = result.get("status", "")
5365
+ status = str(result.get("status") or "").strip().lower()
5348
5366
  if status in {"success", "partial_success"}:
5349
5367
  app_results.append(_batch_read_app_item(app_key=app_key, result=result, data_key=data_key))
5350
5368
  else:
@@ -5429,7 +5447,7 @@ class AiBuilderFacade:
5429
5447
  details={"app_key": app_key},
5430
5448
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, "app_key": app_key}},
5431
5449
  )
5432
- if current.get("status") != "success":
5450
+ if str(current.get("status") or "").strip().lower() != "success":
5433
5451
  return _failed(
5434
5452
  "FLOW_READ_FAILED",
5435
5453
  current.get("message") or "failed to read current flow",
@@ -5473,6 +5491,16 @@ class AiBuilderFacade:
5473
5491
  normalized_args={"app_key": app_key, "not_found_ids": not_found},
5474
5492
  suggested_next_call={"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}},
5475
5493
  )
5494
+ edge_container = spec.get("edges") if isinstance(spec.get("edges"), dict) else {}
5495
+ edges = edge_container.get("edges") if isinstance(edge_container, dict) else None
5496
+ transitions = spec.get("transitions") if isinstance(spec.get("transitions"), list) else None
5497
+ if not edges and not transitions:
5498
+ return _failed(
5499
+ "FLOW_PATCH_EMPTY_GRAPH_UNSUPPORTED",
5500
+ "current flow spec has no edges; patch_nodes cannot safely save this backend shape. Use full app_flow_apply to create a workflow with edges first.",
5501
+ normalized_args={"app_key": app_key, "patch_nodes": patch_nodes},
5502
+ suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, "app_key": app_key}},
5503
+ )
5476
5504
  return self.flow_apply(
5477
5505
  profile=profile,
5478
5506
  app_key=app_key,
@@ -5486,6 +5514,9 @@ class AiBuilderFacade:
5486
5514
  result = []
5487
5515
  for c in components:
5488
5516
  item = {k: v for k, v in c.items() if k != "order"}
5517
+ position = item.get("position")
5518
+ if isinstance(position, dict):
5519
+ item["position"] = _portal_component_position_for_request(position)
5489
5520
  result.append(item)
5490
5521
  return result
5491
5522
 
@@ -13830,6 +13861,73 @@ def _two_gauge_metric_fields(metrics: list[dict[str, Any]], *, requested_metric_
13830
13861
  return [deepcopy(metrics[0]), deepcopy(metrics[1])]
13831
13862
 
13832
13863
 
13864
+ def _qingbi_filter_judge_type(operator: str) -> str:
13865
+ return {
13866
+ ViewFilterOperator.eq.value: "equal",
13867
+ ViewFilterOperator.neq.value: "notEqual",
13868
+ ViewFilterOperator.gte.value: "greaterOrEqual",
13869
+ ViewFilterOperator.lte.value: "lessOrEqual",
13870
+ ViewFilterOperator.in_.value: "anyMatch",
13871
+ ViewFilterOperator.contains.value: "contains",
13872
+ ViewFilterOperator.is_empty.value: "isNull",
13873
+ ViewFilterOperator.not_empty.value: "isNotNull",
13874
+ }.get(operator, "equal")
13875
+
13876
+
13877
+ def _qingbi_option_label_lookup(*, form_field: dict[str, Any], qingbi_field: dict[str, Any]) -> dict[str, str]:
13878
+ labels: dict[str, str] = {}
13879
+
13880
+ def add_option(raw_option: Any) -> None:
13881
+ if isinstance(raw_option, dict):
13882
+ label = raw_option.get("value")
13883
+ if label is None:
13884
+ label = raw_option.get("label") or raw_option.get("name") or raw_option.get("title") or raw_option.get("optValue")
13885
+ label_text = str(label).strip() if label is not None else ""
13886
+ for key in ("id", "optionId", "option_id", "optId", "value", "label", "name", "title", "optValue"):
13887
+ raw_key = raw_option.get(key)
13888
+ if raw_key is not None and str(raw_key).strip() and label_text:
13889
+ labels[str(raw_key).strip()] = label_text
13890
+ elif raw_option is not None:
13891
+ label_text = str(raw_option).strip()
13892
+ if label_text:
13893
+ labels[label_text] = label_text
13894
+
13895
+ for option in form_field.get("option_details") or []:
13896
+ add_option(option)
13897
+ for option in form_field.get("options") or []:
13898
+ add_option(option)
13899
+ for option in qingbi_field.get("options") or qingbi_field.get("option_details") or []:
13900
+ add_option(option)
13901
+ return labels
13902
+
13903
+
13904
+ def _qingbi_filter_text_values(values: list[Any], *, form_field: dict[str, Any], qingbi_field: dict[str, Any]) -> list[str]:
13905
+ labels = _qingbi_option_label_lookup(form_field=form_field, qingbi_field=qingbi_field)
13906
+ normalized: list[str] = []
13907
+ for value in values:
13908
+ if isinstance(value, dict):
13909
+ raw_label = value.get("value") or value.get("label") or value.get("name") or value.get("title") or value.get("optValue")
13910
+ if raw_label is not None and str(raw_label).strip():
13911
+ normalized.append(str(raw_label).strip())
13912
+ continue
13913
+ raw_id = value.get("id") or value.get("optionId") or value.get("option_id") or value.get("optId")
13914
+ raw_text = str(raw_id).strip() if raw_id is not None else ""
13915
+ else:
13916
+ raw_text = str(value).strip()
13917
+ if not raw_text:
13918
+ continue
13919
+ normalized.append(labels.get(raw_text, raw_text))
13920
+ return normalized
13921
+
13922
+
13923
+ def _qingbi_filter_judge_value(values: list[str]) -> Any:
13924
+ if not values:
13925
+ return ""
13926
+ if len(values) == 1:
13927
+ return values[0]
13928
+ return values
13929
+
13930
+
13833
13931
  def _build_public_chart_filter_matrix(
13834
13932
  rules: list[Any],
13835
13933
  *,
@@ -13842,16 +13940,6 @@ def _build_public_chart_filter_matrix(
13842
13940
  if not rules:
13843
13941
  return []
13844
13942
  group: list[dict[str, Any]] = []
13845
- judge_map = {
13846
- ViewFilterOperator.eq.value: 0,
13847
- ViewFilterOperator.neq.value: 1,
13848
- ViewFilterOperator.gte.value: 5,
13849
- ViewFilterOperator.lte.value: 7,
13850
- ViewFilterOperator.in_.value: 9,
13851
- ViewFilterOperator.contains.value: 19,
13852
- ViewFilterOperator.is_empty.value: 15,
13853
- ViewFilterOperator.not_empty.value: 16,
13854
- }
13855
13943
  for rule in rules:
13856
13944
  qingbi_field = _resolve_qingbi_chart_field(
13857
13945
  getattr(rule, "field_name", None),
@@ -13863,13 +13951,15 @@ def _build_public_chart_filter_matrix(
13863
13951
  form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
13864
13952
  operator = str(getattr(rule, "operator", ViewFilterOperator.eq.value).value if hasattr(getattr(rule, "operator", None), "value") else getattr(rule, "operator", ViewFilterOperator.eq.value))
13865
13953
  values = list(getattr(rule, "values", []) or [])
13954
+ text_values = _qingbi_filter_text_values(values, form_field=form_field, qingbi_field=qingbi_field)
13866
13955
  group.append(
13867
13956
  {
13868
13957
  "fieldId": field_id,
13869
13958
  "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
13870
13959
  "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
13871
- "judgeType": judge_map.get(operator, 0),
13872
- "judgeValues": values,
13960
+ "judgeType": _qingbi_filter_judge_type(operator),
13961
+ "judgeValue": _qingbi_filter_judge_value(text_values),
13962
+ "judgeValues": text_values,
13873
13963
  "matchType": 1,
13874
13964
  }
13875
13965
  )
@@ -14180,6 +14270,27 @@ def _portal_position_payload(position: Any, *, inferred_mobile_y: int = 0) -> di
14180
14270
  }
14181
14271
 
14182
14272
 
14273
+ def _portal_component_position_for_request(position: dict[str, Any]) -> dict[str, Any]:
14274
+ """Normalize portal_get component positions into PortalSectionPatch input shape."""
14275
+ payload = deepcopy(position)
14276
+ if isinstance(payload.get("pc"), dict) or isinstance(payload.get("mobile"), dict):
14277
+ return payload
14278
+ pc_keys = {"x", "y", "cols", "rows"}
14279
+ if any(key in payload for key in pc_keys):
14280
+ pc = {
14281
+ "x": int(payload.get("x") or 0),
14282
+ "y": int(payload.get("y") or 0),
14283
+ "cols": int(payload.get("cols") or payload.get("w") or 12),
14284
+ "rows": int(payload.get("rows") or payload.get("h") or 8),
14285
+ }
14286
+ normalized: dict[str, Any] = {"pc": pc}
14287
+ mobile = payload.get("mobile")
14288
+ if isinstance(mobile, dict):
14289
+ normalized["mobile"] = mobile
14290
+ return normalized
14291
+ return payload
14292
+
14293
+
14183
14294
  def _portal_component_position_public(
14184
14295
  source_type: Any,
14185
14296
  *,
@@ -14261,15 +14372,26 @@ def _portal_layout_diagnostics(sections: list[PortalSectionPatch], components: l
14261
14372
  pc_positions.append(pc)
14262
14373
  section = sections[index] if index < len(sections) else None
14263
14374
  source_type = str(getattr(section, "source_type", "") or "").lower() if section is not None else ""
14375
+ role = str(getattr(section, "role", "") or "").lower() if section is not None else ""
14264
14376
  title = str(getattr(section, "title", "") or "").strip() if section is not None else None
14265
14377
  cols = int(pc.get("cols") or 0)
14266
14378
  rows = int(pc.get("rows") or 0)
14267
- if source_type == "chart" and (cols < 8 or rows < 5):
14379
+ if source_type == "chart" and role == "metric" and (cols < 6 or rows < 5):
14380
+ warnings.append(_warning(
14381
+ "PORTAL_CHART_CARD_TOO_SMALL",
14382
+ "metric portal card is too small; use at least pc.cols >= 6 and pc.rows >= 5",
14383
+ section_index=index,
14384
+ title=title,
14385
+ role=role,
14386
+ pc=deepcopy(pc),
14387
+ ))
14388
+ elif source_type == "chart" and role != "metric" and (cols < 8 or rows < 7):
14268
14389
  warnings.append(_warning(
14269
14390
  "PORTAL_CHART_CARD_TOO_SMALL",
14270
- "chart portal card is too small; use at least pc.cols >= 8 and pc.rows >= 5, preferably rows >= 6",
14391
+ "chart portal card is too small; use at least pc.cols >= 8 and pc.rows >= 7",
14271
14392
  section_index=index,
14272
14393
  title=title,
14394
+ role=role or None,
14273
14395
  pc=deepcopy(pc),
14274
14396
  ))
14275
14397
  if section is not None and section.position is not None and not bool(getattr(section.position, "mobile_provided", False)):
@@ -21059,6 +21181,9 @@ def _normalize_portal_components(components: Any) -> list[dict[str, Any]]:
21059
21181
  "chart_key": chart_key,
21060
21182
  "chart_name": str(config.get("chartComponentTitle") or title or "").strip() or None,
21061
21183
  }
21184
+ backend_chart_type = _normalize_backend_chart_type(config.get("biChartType") or config.get("chartType"))
21185
+ if backend_chart_type in {"indicator", PublicChartType.target.value}:
21186
+ summary["role"] = "metric"
21062
21187
  section_config = _portal_section_config_for_roundtrip(config, source_type="chart")
21063
21188
  if section_config:
21064
21189
  summary["config"] = section_config
@@ -21794,10 +21919,11 @@ _BATCH_READ_ENVELOPE_KEYS = {
21794
21919
 
21795
21920
 
21796
21921
  def _batch_read_app_item(*, app_key: str, result: dict[str, Any], data_key: str) -> dict[str, Any]:
21922
+ status = str(result.get("status") or "").strip()
21797
21923
  item: dict[str, Any] = {
21798
21924
  "app_key": app_key,
21799
21925
  "status": result.get("status"),
21800
- "verified": bool(result.get("verified", result.get("status") == "success")),
21926
+ "verified": bool(result.get("verified", status.lower() == "success")),
21801
21927
  }
21802
21928
  for key, value in result.items():
21803
21929
  if key in _BATCH_READ_ENVELOPE_KEYS or key in {"status", "app_key", "verified"}:
@@ -4,6 +4,7 @@ import argparse
4
4
  from typing import Any
5
5
 
6
6
  from ..context import CliContext
7
+ from ..json_io import load_json_value
7
8
  from .common import load_list_arg, load_object_arg, raise_config_error, require_list_arg
8
9
 
9
10
 
@@ -15,6 +16,55 @@ def _parse_app_keys_arg(raw: Any) -> list[str] | None:
15
16
  return keys if keys else None
16
17
 
17
18
 
19
+ def _load_schema_apps_payload(path: str | None, *, cli_package_id: int | None) -> tuple[list[Any], int | None]:
20
+ """Load schema --apps-file.
21
+
22
+ Public docs recommend {"package_id": 123, "apps": [...]}; older payloads used
23
+ a raw apps array, and some agents accidentally wrapped the object in a
24
+ singleton array. Keep all three shapes on the same multi-app path.
25
+ """
26
+ if not path:
27
+ return [], cli_package_id
28
+ payload = load_json_value(path, option_name="--apps-file")
29
+ package_id = cli_package_id
30
+ if isinstance(payload, dict):
31
+ if package_id is None:
32
+ raw_package_id = payload.get("package_id")
33
+ if isinstance(raw_package_id, int):
34
+ package_id = raw_package_id
35
+ elif raw_package_id is not None and str(raw_package_id).strip().isdigit():
36
+ package_id = int(str(raw_package_id).strip())
37
+ apps = payload.get("apps")
38
+ elif isinstance(payload, list):
39
+ if len(payload) == 1 and isinstance(payload[0], dict) and "apps" in payload[0]:
40
+ wrapper = payload[0]
41
+ if package_id is None:
42
+ raw_package_id = wrapper.get("package_id")
43
+ if isinstance(raw_package_id, int):
44
+ package_id = raw_package_id
45
+ elif raw_package_id is not None and str(raw_package_id).strip().isdigit():
46
+ package_id = int(str(raw_package_id).strip())
47
+ apps = wrapper.get("apps")
48
+ elif any(isinstance(item, dict) and "apps" in item for item in payload):
49
+ raise_config_error(
50
+ "APPS_FILE_SHAPE_INVALID: --apps-file may be {package_id, apps}, a raw apps array, or a singleton wrapper array only.",
51
+ fix_hint='Use {"package_id": 123, "apps": [{"client_key": "main", "app_name": "主表", "icon": "database", "color": "blue", "add_fields": []}]}',
52
+ )
53
+ else:
54
+ apps = payload
55
+ else:
56
+ raise_config_error(
57
+ "APPS_FILE_SHAPE_INVALID: --apps-file must be {package_id, apps} or a JSON array of app items.",
58
+ fix_hint='Use {"package_id": 123, "apps": [{"client_key": "main", "app_name": "主表", "icon": "database", "color": "blue", "add_fields": []}]}',
59
+ )
60
+ if not isinstance(apps, list):
61
+ raise_config_error(
62
+ "APPS_FILE_SHAPE_INVALID: --apps-file apps must be a JSON array.",
63
+ fix_hint='Use {"package_id": 123, "apps": [{"client_key": "main", "app_name": "主表", "icon": "database", "color": "blue", "add_fields": []}]}',
64
+ )
65
+ return apps, package_id
66
+
67
+
18
68
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
19
69
  parser = subparsers.add_parser("builder", aliases=["build"], help="稳定 builder 命令")
20
70
  builder_subparsers = parser.add_subparsers(dest="builder_command", required=True)
@@ -166,12 +216,13 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
166
216
  associated_resource_get.set_defaults(handler=_handle_associated_resource_get, format_hint="builder_summary")
167
217
 
168
218
  associated_resource_apply = associated_resource_subparsers.add_parser("apply", help="声明式管理应用关联资源池和视图展示配置")
169
- associated_resource_apply.add_argument("--app-key", required=True)
219
+ associated_resource_apply.add_argument("--app-key", default="")
170
220
  associated_resource_apply.add_argument("--upsert-resources-file")
171
221
  associated_resource_apply.add_argument("--patch-resources-file")
172
222
  associated_resource_apply.add_argument("--remove-associated-item-ids-file")
173
223
  associated_resource_apply.add_argument("--reorder-associated-item-ids-file")
174
224
  associated_resource_apply.add_argument("--view-configs-file")
225
+ associated_resource_apply.add_argument("--apps-file", help="多应用批量关联资源 JSON 数组,每项含 app_key + upsert_resources/patch_resources/remove_associated_item_ids/reorder_associated_item_ids/view_configs")
175
226
  associated_resource_apply.set_defaults(handler=_handle_associated_resource_apply, format_hint="builder_summary", force_json_output=True)
176
227
 
177
228
  portal = builder_subparsers.add_parser("portal", help="门户")
@@ -214,7 +265,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
214
265
  schema_apply_apply.add_argument("--visibility-file")
215
266
  schema_apply_apply.add_argument("--create-if-missing", action="store_true")
216
267
  schema_apply_apply.add_argument("--publish", action=argparse.BooleanOptionalAction, default=True)
217
- schema_apply_apply.add_argument("--apps-file", help="多应用 schema JSON 数组;每项可带 client_key/app_name/add_fields,支持 relation target_app_ref")
268
+ schema_apply_apply.add_argument("--apps-file", help="多应用 schema JSON;推荐对象 {package_id, apps},兼容 raw apps 数组;支持 relation target_app_ref")
218
269
  schema_apply_apply.add_argument("--add-fields-file", help="字段 JSON 数组;字段可用 as_data_title/as_data_cover 标记数据标题/封面")
219
270
  schema_apply_apply.add_argument("--update-fields-file", help="字段更新 JSON 数组;set 内可用 as_data_title/as_data_cover 标记数据标题/封面")
220
271
  schema_apply_apply.add_argument("--remove-fields-file")
@@ -456,6 +507,36 @@ def _handle_button_apply(args: argparse.Namespace, context: CliContext) -> dict:
456
507
 
457
508
 
458
509
  def _handle_associated_resource_apply(args: argparse.Namespace, context: CliContext) -> dict:
510
+ apps = load_list_arg(getattr(args, "apps_file", None), option_name="--apps-file")
511
+ if apps:
512
+ single_app_args = [
513
+ a for a in [
514
+ "--app-key" if args.app_key else None,
515
+ "--upsert-resources-file" if getattr(args, "upsert_resources_file", None) else None,
516
+ "--patch-resources-file" if getattr(args, "patch_resources_file", None) else None,
517
+ "--remove-associated-item-ids-file" if getattr(args, "remove_associated_item_ids_file", None) else None,
518
+ "--reorder-associated-item-ids-file" if getattr(args, "reorder_associated_item_ids_file", None) else None,
519
+ "--view-configs-file" if getattr(args, "view_configs_file", None) else None,
520
+ ] if a
521
+ ]
522
+ if single_app_args:
523
+ raise_config_error(
524
+ f"associated-resource apply --apps-file cannot be combined with {', '.join(single_app_args)}.",
525
+ fix_hint="Use --apps-file for batch mode (each item contains app_key + per-app params), or remove --apps-file for single-app mode.",
526
+ )
527
+ if apps:
528
+ return context.builder.app_associated_resources_apply(
529
+ profile=args.profile,
530
+ app_key="",
531
+ upsert_resources=[],
532
+ patch_resources=[],
533
+ remove_associated_item_ids=[],
534
+ reorder_associated_item_ids=[],
535
+ view_configs=[],
536
+ apps=apps,
537
+ )
538
+ if not args.app_key:
539
+ raise_config_error("associated-resource apply requires --app-key or --apps-file", fix_hint="Pass --app-key APP_KEY for single-app mode, or --apps-file for batch mode.")
459
540
  return context.builder.app_associated_resources_apply(
460
541
  profile=args.profile,
461
542
  app_key=args.app_key,
@@ -524,6 +605,7 @@ def _handle_app_get(args: argparse.Namespace, context: CliContext) -> dict:
524
605
  return context.builder.app_get_associated_resources(profile=args.profile, app_key=app_key)
525
606
  if app_keys:
526
607
  batch_handlers = {
608
+ "summary": context.builder.app_get,
527
609
  "fields": context.builder.app_get_fields,
528
610
  "layout": context.builder.app_get_layout,
529
611
  "views": context.builder.app_get_views,
@@ -531,7 +613,7 @@ def _handle_app_get(args: argparse.Namespace, context: CliContext) -> dict:
531
613
  "charts": context.builder.app_get_charts,
532
614
  }
533
615
  if section not in batch_handlers:
534
- raise_config_error(f"app get --app-keys does not support section '{section}'", fix_hint="Batch reads support: fields, layout, views, flow, charts, buttons, associated-resources")
616
+ raise_config_error(f"app get --app-keys does not support section '{section}'", fix_hint="Batch reads support: summary, fields, layout, views, flow, charts, buttons, associated-resources")
535
617
  return batch_handlers[section](profile=args.profile, app_keys=app_keys)
536
618
  handlers = {
537
619
  "summary": context.builder.app_get,
@@ -570,26 +652,26 @@ def _handle_chart_get(args: argparse.Namespace, context: CliContext) -> dict:
570
652
 
571
653
 
572
654
  def _handle_schema_apply(args: argparse.Namespace, context: CliContext) -> dict:
573
- apps = load_list_arg(args.apps_file, option_name="--apps-file")
655
+ apps, apps_package_id = _load_schema_apps_payload(args.apps_file, cli_package_id=args.package_id)
574
656
  if args.apps_file:
575
657
  if not apps:
576
658
  raise_config_error(
577
659
  "schema apply multi-app mode requires a non-empty --apps-file.",
578
- fix_hint="Pass a JSON array with at least one app item.",
660
+ fix_hint='Pass {"package_id": 123, "apps": [{"client_key": "main", "app_name": "主表", "icon": "database", "color": "blue", "add_fields": []}]}',
579
661
  )
580
662
  if args.app_key or args.app_name or args.app_title or args.add_fields_file or args.update_fields_file or args.remove_fields_file:
581
663
  raise_config_error(
582
664
  "schema apply multi-app mode accepts --package-id/--create-if-missing plus --apps-file only.",
583
665
  fix_hint="Use `--apps-file` for batch mode, or remove `--apps-file` and use the single-app arguments.",
584
666
  )
585
- if args.package_id is None:
667
+ if apps_package_id is None:
586
668
  raise_config_error(
587
669
  "schema apply multi-app mode requires --package-id.",
588
- fix_hint="Pass `--package-id` and app names inside --apps-file.",
670
+ fix_hint="Pass root `--package-id`, or include package_id in the --apps-file object.",
589
671
  )
590
672
  return context.builder.app_schema_apply(
591
673
  profile=args.profile,
592
- package_id=args.package_id,
674
+ package_id=apps_package_id,
593
675
  visibility=load_object_arg(args.visibility_file, option_name="--visibility-file"),
594
676
  create_if_missing=bool(args.create_if_missing),
595
677
  publish=bool(args.publish),
@@ -284,6 +284,7 @@ class AiBuilderTools(ToolBase):
284
284
  remove_associated_item_ids: list[int] | None = None,
285
285
  reorder_associated_item_ids: list[int] | None = None,
286
286
  view_configs: list[JSONObject] | None = None,
287
+ apps: list[JSONObject] | None = None,
287
288
  ) -> JSONObject:
288
289
  return self.app_associated_resources_apply(
289
290
  profile=profile,
@@ -293,10 +294,13 @@ class AiBuilderTools(ToolBase):
293
294
  remove_associated_item_ids=remove_associated_item_ids or [],
294
295
  reorder_associated_item_ids=reorder_associated_item_ids or [],
295
296
  view_configs=view_configs or [],
297
+ apps=apps,
296
298
  )
297
299
 
298
300
  @mcp.tool()
299
- def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
301
+ def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "", app_keys: list[str] | None = None) -> JSONObject:
302
+ if app_keys:
303
+ return self._facade._batch_read_app_keys(profile=profile, app_keys=app_keys, single_reader=self._facade.app_get, data_key="summary", tool_name="app_get")
300
304
  return self.app_get(profile=profile, app_key=app_key)
301
305
 
302
306
  @mcp.tool()
@@ -1120,8 +1124,24 @@ class AiBuilderTools(ToolBase):
1120
1124
  reorder_associated_item_ids: list[int],
1121
1125
  view_configs: list[JSONObject],
1122
1126
  patch_resources: list[JSONObject] | None = None,
1127
+ apps: list[JSONObject] | None = None,
1123
1128
  ) -> JSONObject:
1124
1129
  """执行应用关联资源 apply 逻辑。"""
1130
+ if apps:
1131
+ return self._facade._batch_write_apps(
1132
+ profile=profile,
1133
+ apps=apps,
1134
+ single_writer=lambda profile, app_key, **kw: self.app_associated_resources_apply(
1135
+ profile=profile,
1136
+ app_key=app_key,
1137
+ upsert_resources=kw.get("upsert_resources", []),
1138
+ patch_resources=kw.get("patch_resources", []),
1139
+ remove_associated_item_ids=kw.get("remove_associated_item_ids", []),
1140
+ reorder_associated_item_ids=kw.get("reorder_associated_item_ids", []),
1141
+ view_configs=kw.get("view_configs", []),
1142
+ ),
1143
+ tool_name="app_associated_resources_apply",
1144
+ )
1125
1145
  raw_request = {
1126
1146
  "app_key": app_key,
1127
1147
  "upsert_resources": upsert_resources,
@@ -1284,8 +1304,12 @@ class AiBuilderTools(ToolBase):
1284
1304
  ))
1285
1305
 
1286
1306
  @tool_cn_name("应用详情查询")
1287
- def app_get(self, *, profile: str, app_key: str) -> JSONObject:
1307
+ def app_get(self, *, profile: str, app_key: str = "", app_keys: list[str] | None = None) -> JSONObject:
1288
1308
  """执行应用相关逻辑。"""
1309
+ if app_keys:
1310
+ return _publicize_package_fields(
1311
+ self._facade._batch_read_app_keys(profile=profile, app_keys=app_keys, single_reader=self._facade.app_get, data_key="summary", tool_name="app_get")
1312
+ )
1289
1313
  normalized_args = {"app_key": app_key}
1290
1314
  return _publicize_package_fields(_safe_tool_call(
1291
1315
  lambda: self._facade.app_get(profile=profile, app_key=app_key),
@@ -4458,6 +4482,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4458
4482
  "app_associated_resources_apply": {
4459
4483
  "allowed_keys": [
4460
4484
  "app_key",
4485
+ "apps",
4461
4486
  "upsert_resources",
4462
4487
  "patch_resources",
4463
4488
  "remove_associated_item_ids",
@@ -4527,6 +4552,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4527
4552
  "if an associated resource delete returns readback_status=unavailable or still_exists, treat the result as readback pending and do not blindly repeat the delete",
4528
4553
  "this tool publishes after at least one write succeeds; there is no draft-only mode",
4529
4554
  "visible=false hides the associated-resource area without clearing previous selected ids; visible=true with limit_type=all shows the whole app-level pool",
4555
+ "accepts apps[] for multi-app batch; each item is {app_key, upsert_resources?, patch_resources?, remove_associated_item_ids?, reorder_associated_item_ids?, view_configs?}",
4530
4556
  ],
4531
4557
  "minimal_example": {
4532
4558
  "profile": "default",
@@ -4557,6 +4583,22 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
4557
4583
  }
4558
4584
  ],
4559
4585
  },
4586
+ "batch_example": {
4587
+ "profile": "default",
4588
+ "apps": [
4589
+ {
4590
+ "app_key": "APP_1",
4591
+ "upsert_resources": [
4592
+ {"client_key": "orders_view", "graph_type": "view", "target_app_key": "ORDER_APP", "view_key": "ORDER_VIEW"}
4593
+ ],
4594
+ "view_configs": [{"view_key": "MAIN_VIEW", "limit_type": "select", "associated_item_refs": ["orders_view"]}],
4595
+ },
4596
+ {
4597
+ "app_key": "APP_2",
4598
+ "view_configs": [{"view_key": "MAIN_VIEW", "visible": True, "limit_type": "all"}],
4599
+ },
4600
+ ],
4601
+ },
4560
4602
  },
4561
4603
  "app_schema_plan": {
4562
4604
  "allowed_keys": ["app_key", "package_id", "app_name", "icon", "color", "visibility", "create_if_missing", "add_fields", "update_fields", "remove_fields"],
@@ -5287,7 +5329,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5287
5329
  },
5288
5330
  },
5289
5331
  "app_get": {
5290
- "allowed_keys": ["app_key"],
5332
+ "allowed_keys": ["app_key", "app_keys"],
5291
5333
  "aliases": {},
5292
5334
  "allowed_values": {},
5293
5335
  "execution_notes": [
@@ -5299,6 +5341,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5299
5341
  "returns normalized app visibility when backend auth is readable",
5300
5342
  "custom_buttons[].button_id is the id required by app_custom_buttons_apply view_configs[].buttons[].button_ref",
5301
5343
  "associated_resources[].associated_item_id is the internal id; app_associated_resources_apply.view_configs/remove/reorder may also pass an existing resource's chart_id/chart_key/view_key",
5344
+ "accepts app_keys[] for batch summary read; batch returns {status, apps[], errors[]} where each apps[] item preserves the single app_get summary fields",
5302
5345
  ],
5303
5346
  "minimal_example": {
5304
5347
  "profile": "default",
@@ -5529,7 +5572,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5529
5572
  "viewRef": "view_ref",
5530
5573
  "dashStyleConfigBO": "dash_style_config",
5531
5574
  },
5532
- "section_allowed_keys": ["title", "source_type", "position", "dash_style_config", "config", "chart_ref", "view_ref", "text", "url"],
5575
+ "section_allowed_keys": ["title", "source_type", "role", "position", "dash_style_config", "config", "chart_ref", "view_ref", "text", "url"],
5533
5576
  "section_aliases": {
5534
5577
  "sourceType": "source_type",
5535
5578
  "chartRef": "chart_ref",
@@ -5538,6 +5581,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5538
5581
  },
5539
5582
  "allowed_values": {
5540
5583
  "section.source_type": ["chart", "view", "grid", "filter", "text", "link"],
5584
+ "section.role": ["metric"],
5541
5585
  "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
5542
5586
  "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
5543
5587
  "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
@@ -5552,7 +5596,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5552
5596
  "call workspace_icon_catalog_get or `qingflow --json builder icon catalog` for supported icon/color candidates",
5553
5597
  "portal_apply uses replace semantics for sections",
5554
5598
  "when editing an existing portal, sections may be omitted to update only base info such as visibility, icon, or package",
5555
- "use patch_sections[] for targeted section updates without replacing all sections; each item needs one selector (chart_ref with chart_id/chart_key/chart_name, view_ref with view_key/view_name, or order as 0-based index) plus set/unset",
5599
+ "use patch_sections[] for section-level patch updates without replacing all sections; each item needs one selector (chart_ref with chart_id/chart_key/chart_name, view_ref with view_key/view_name, or order as 0-based index) plus set/unset",
5556
5600
  "when sections[] is supplied without patch_sections[], it uses replace semantics for all sections",
5557
5601
  "remove a section by omitting it from the sections list (replace mode) or by unset in patch_sections (patch mode)",
5558
5602
  "package_id is required when creating a new portal",
@@ -5560,6 +5604,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5560
5604
  "chart_ref resolves by chart_id/chart_key first, then exact unique chart_name",
5561
5605
  "view_ref resolves by view_key first, then exact unique view_name",
5562
5606
  "pc layout uses a 24-column grid; mobile layout uses a 6-column grid",
5607
+ "set section.role=metric only for target/indicator chart cards; metric cards use pc.rows >= 5 and pc.cols >= 6, while ordinary BI charts use pc.rows >= 7 and pc.cols >= 8",
5563
5608
  "if unsure about layout, omit position or use layout_preset=auto/dashboard_2col/dashboard_3col",
5564
5609
  "two-column pc layout should use x=0/12 with cols=12; three-column pc layout should use x=0/8/16 with cols=8",
5565
5610
  "x=0/6 with cols=6 only occupies the left half of the pc portal and triggers PORTAL_LAYOUT_HALF_WIDTH",