@josephyan/qingflow-cli 1.1.3 → 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.3
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.3 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.3",
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.3"
7
+ version = "1.1.4"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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,
@@ -13833,6 +13861,73 @@ def _two_gauge_metric_fields(metrics: list[dict[str, Any]], *, requested_metric_
13833
13861
  return [deepcopy(metrics[0]), deepcopy(metrics[1])]
13834
13862
 
13835
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
+
13836
13931
  def _build_public_chart_filter_matrix(
13837
13932
  rules: list[Any],
13838
13933
  *,
@@ -13845,16 +13940,6 @@ def _build_public_chart_filter_matrix(
13845
13940
  if not rules:
13846
13941
  return []
13847
13942
  group: list[dict[str, Any]] = []
13848
- judge_map = {
13849
- ViewFilterOperator.eq.value: 0,
13850
- ViewFilterOperator.neq.value: 1,
13851
- ViewFilterOperator.gte.value: 5,
13852
- ViewFilterOperator.lte.value: 7,
13853
- ViewFilterOperator.in_.value: 9,
13854
- ViewFilterOperator.contains.value: 19,
13855
- ViewFilterOperator.is_empty.value: 15,
13856
- ViewFilterOperator.not_empty.value: 16,
13857
- }
13858
13943
  for rule in rules:
13859
13944
  qingbi_field = _resolve_qingbi_chart_field(
13860
13945
  getattr(rule, "field_name", None),
@@ -13866,13 +13951,15 @@ def _build_public_chart_filter_matrix(
13866
13951
  form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
13867
13952
  operator = str(getattr(rule, "operator", ViewFilterOperator.eq.value).value if hasattr(getattr(rule, "operator", None), "value") else getattr(rule, "operator", ViewFilterOperator.eq.value))
13868
13953
  values = list(getattr(rule, "values", []) or [])
13954
+ text_values = _qingbi_filter_text_values(values, form_field=form_field, qingbi_field=qingbi_field)
13869
13955
  group.append(
13870
13956
  {
13871
13957
  "fieldId": field_id,
13872
13958
  "fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
13873
13959
  "fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
13874
- "judgeType": judge_map.get(operator, 0),
13875
- "judgeValues": values,
13960
+ "judgeType": _qingbi_filter_judge_type(operator),
13961
+ "judgeValue": _qingbi_filter_judge_value(text_values),
13962
+ "judgeValues": text_values,
13876
13963
  "matchType": 1,
13877
13964
  }
13878
13965
  )
@@ -14285,15 +14372,26 @@ def _portal_layout_diagnostics(sections: list[PortalSectionPatch], components: l
14285
14372
  pc_positions.append(pc)
14286
14373
  section = sections[index] if index < len(sections) else None
14287
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 ""
14288
14376
  title = str(getattr(section, "title", "") or "").strip() if section is not None else None
14289
14377
  cols = int(pc.get("cols") or 0)
14290
14378
  rows = int(pc.get("rows") or 0)
14291
- 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):
14292
14389
  warnings.append(_warning(
14293
14390
  "PORTAL_CHART_CARD_TOO_SMALL",
14294
- "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",
14295
14392
  section_index=index,
14296
14393
  title=title,
14394
+ role=role or None,
14297
14395
  pc=deepcopy(pc),
14298
14396
  ))
14299
14397
  if section is not None and section.position is not None and not bool(getattr(section.position, "mobile_provided", False)):
@@ -21083,6 +21181,9 @@ def _normalize_portal_components(components: Any) -> list[dict[str, Any]]:
21083
21181
  "chart_key": chart_key,
21084
21182
  "chart_name": str(config.get("chartComponentTitle") or title or "").strip() or None,
21085
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"
21086
21187
  section_config = _portal_section_config_for_roundtrip(config, source_type="chart")
21087
21188
  if section_config:
21088
21189
  summary["config"] = section_config
@@ -21818,10 +21919,11 @@ _BATCH_READ_ENVELOPE_KEYS = {
21818
21919
 
21819
21920
 
21820
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()
21821
21923
  item: dict[str, Any] = {
21822
21924
  "app_key": app_key,
21823
21925
  "status": result.get("status"),
21824
- "verified": bool(result.get("verified", result.get("status") == "success")),
21926
+ "verified": bool(result.get("verified", status.lower() == "success")),
21825
21927
  }
21826
21928
  for key, value in result.items():
21827
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)
@@ -215,7 +265,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
215
265
  schema_apply_apply.add_argument("--visibility-file")
216
266
  schema_apply_apply.add_argument("--create-if-missing", action="store_true")
217
267
  schema_apply_apply.add_argument("--publish", action=argparse.BooleanOptionalAction, default=True)
218
- 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")
219
269
  schema_apply_apply.add_argument("--add-fields-file", help="字段 JSON 数组;字段可用 as_data_title/as_data_cover 标记数据标题/封面")
220
270
  schema_apply_apply.add_argument("--update-fields-file", help="字段更新 JSON 数组;set 内可用 as_data_title/as_data_cover 标记数据标题/封面")
221
271
  schema_apply_apply.add_argument("--remove-fields-file")
@@ -555,6 +605,7 @@ def _handle_app_get(args: argparse.Namespace, context: CliContext) -> dict:
555
605
  return context.builder.app_get_associated_resources(profile=args.profile, app_key=app_key)
556
606
  if app_keys:
557
607
  batch_handlers = {
608
+ "summary": context.builder.app_get,
558
609
  "fields": context.builder.app_get_fields,
559
610
  "layout": context.builder.app_get_layout,
560
611
  "views": context.builder.app_get_views,
@@ -562,7 +613,7 @@ def _handle_app_get(args: argparse.Namespace, context: CliContext) -> dict:
562
613
  "charts": context.builder.app_get_charts,
563
614
  }
564
615
  if section not in batch_handlers:
565
- 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")
566
617
  return batch_handlers[section](profile=args.profile, app_keys=app_keys)
567
618
  handlers = {
568
619
  "summary": context.builder.app_get,
@@ -601,26 +652,26 @@ def _handle_chart_get(args: argparse.Namespace, context: CliContext) -> dict:
601
652
 
602
653
 
603
654
  def _handle_schema_apply(args: argparse.Namespace, context: CliContext) -> dict:
604
- 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)
605
656
  if args.apps_file:
606
657
  if not apps:
607
658
  raise_config_error(
608
659
  "schema apply multi-app mode requires a non-empty --apps-file.",
609
- 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": []}]}',
610
661
  )
611
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:
612
663
  raise_config_error(
613
664
  "schema apply multi-app mode accepts --package-id/--create-if-missing plus --apps-file only.",
614
665
  fix_hint="Use `--apps-file` for batch mode, or remove `--apps-file` and use the single-app arguments.",
615
666
  )
616
- if args.package_id is None:
667
+ if apps_package_id is None:
617
668
  raise_config_error(
618
669
  "schema apply multi-app mode requires --package-id.",
619
- 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.",
620
671
  )
621
672
  return context.builder.app_schema_apply(
622
673
  profile=args.profile,
623
- package_id=args.package_id,
674
+ package_id=apps_package_id,
624
675
  visibility=load_object_arg(args.visibility_file, option_name="--visibility-file"),
625
676
  create_if_missing=bool(args.create_if_missing),
626
677
  publish=bool(args.publish),
@@ -298,7 +298,9 @@ class AiBuilderTools(ToolBase):
298
298
  )
299
299
 
300
300
  @mcp.tool()
301
- 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")
302
304
  return self.app_get(profile=profile, app_key=app_key)
303
305
 
304
306
  @mcp.tool()
@@ -1302,8 +1304,12 @@ class AiBuilderTools(ToolBase):
1302
1304
  ))
1303
1305
 
1304
1306
  @tool_cn_name("应用详情查询")
1305
- 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:
1306
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
+ )
1307
1313
  normalized_args = {"app_key": app_key}
1308
1314
  return _publicize_package_fields(_safe_tool_call(
1309
1315
  lambda: self._facade.app_get(profile=profile, app_key=app_key),
@@ -5323,7 +5329,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5323
5329
  },
5324
5330
  },
5325
5331
  "app_get": {
5326
- "allowed_keys": ["app_key"],
5332
+ "allowed_keys": ["app_key", "app_keys"],
5327
5333
  "aliases": {},
5328
5334
  "allowed_values": {},
5329
5335
  "execution_notes": [
@@ -5335,6 +5341,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5335
5341
  "returns normalized app visibility when backend auth is readable",
5336
5342
  "custom_buttons[].button_id is the id required by app_custom_buttons_apply view_configs[].buttons[].button_ref",
5337
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",
5338
5345
  ],
5339
5346
  "minimal_example": {
5340
5347
  "profile": "default",
@@ -5565,7 +5572,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5565
5572
  "viewRef": "view_ref",
5566
5573
  "dashStyleConfigBO": "dash_style_config",
5567
5574
  },
5568
- "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"],
5569
5576
  "section_aliases": {
5570
5577
  "sourceType": "source_type",
5571
5578
  "chartRef": "chart_ref",
@@ -5574,6 +5581,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5574
5581
  },
5575
5582
  "allowed_values": {
5576
5583
  "section.source_type": ["chart", "view", "grid", "filter", "text", "link"],
5584
+ "section.role": ["metric"],
5577
5585
  "workspace_icon.icon_names": list(WORKSPACE_ICON_NAMES),
5578
5586
  "workspace_icon.icon_colors": list(WORKSPACE_ICON_COLORS),
5579
5587
  "workspace_icon.generic_icon_names": list(GENERIC_WORKSPACE_ICON_NAMES),
@@ -5596,6 +5604,7 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
5596
5604
  "chart_ref resolves by chart_id/chart_key first, then exact unique chart_name",
5597
5605
  "view_ref resolves by view_key first, then exact unique view_name",
5598
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",
5599
5608
  "if unsure about layout, omit position or use layout_preset=auto/dashboard_2col/dashboard_3col",
5600
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",
5601
5610
  "x=0/6 with cols=6 only occupies the left half of the pc portal and triggers PORTAL_LAYOUT_HALF_WIDTH",