@qingflow-tech/qingflow-cli 1.1.44-beta.2 → 1.1.45-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.
package/README.md CHANGED
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
7
7
  ## 安装
8
8
 
9
9
  ```bash
10
- npm install @qingflow-tech/qingflow-cli@1.1.44-beta.2
10
+ npm install @qingflow-tech/qingflow-cli@1.1.45-beta.1
11
11
  ```
12
12
 
13
13
  推荐从官网复制这一条一键安装 Qingflow CLI:
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
21
21
  ## 运行
22
22
 
23
23
  ```bash
24
- npx -y -p @qingflow-tech/qingflow-cli@1.1.44-beta.2 qingflow
24
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.45-beta.1 qingflow
25
25
  ```
26
26
 
27
27
  全局安装后可以直接运行:
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
62
62
  如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
63
63
 
64
64
  ```bash
65
- npx -y -p @qingflow-tech/qingflow-cli@1.1.44-beta.2 qingflow-skills install --agent codex --scope user --copy
65
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.45-beta.1 qingflow-skills install --agent codex --scope user --copy
66
66
  ```
67
67
 
68
68
  `qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qingflow-tech/qingflow-cli",
3
- "version": "1.1.44-beta.2",
3
+ "version": "1.1.45-beta.1",
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.44b2"
7
+ version = "1.1.45b1"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -24,6 +24,8 @@ _INTEGRATION_OUTPUT_TARGET_FIELD_TYPES = {
24
24
  }
25
25
  _Q_LINKER_TEMPLATE_RE = re.compile(r"\{\{(?P<key>[^{}\s]+)\}\}")
26
26
  _FORMULA_IDENTIFIER_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
27
+ #: 公式字段引用正则(#queId_<id>$,支持表格嵌套 <id>.<id>)
28
+ _FORMULA_FIELD_REF_RE = re.compile(r"#queId_([^$]+)\$")
27
29
 
28
30
 
29
31
  class AppFormValidationError(ValueError):
@@ -460,3 +462,82 @@ def validate_declaration(
460
462
  issues = [{"pointer": pointer, "message": message} for pointer, message in sorted(found)]
461
463
  raise AppFormValidationError(issues, schema_version=pinned, api_version=api_version)
462
464
  return {"valid": True, "schemaVersion": pinned, "apiVersion": api_version, "fieldTypes": list(types)}
465
+
466
+
467
+ def validate_formula_expression(
468
+ formula: str,
469
+ fields: list[dict[str, Any]] | None = None,
470
+ ) -> list[dict[str, str]]:
471
+ """校验单条公式表达式:函数名合法 + 字段引用存在性。不依赖整份 AppForm,不调后端。
472
+
473
+ Args:
474
+ formula: 公式文本,如 ``CONCAT(#queId_101$, ":", #queId_102$)``。
475
+ fields: 字段清单 ``[{queId, name}]``(兼容 ``que_id``/``id``);提供时校验字段引用存在性,
476
+ 不提供时跳过字段引用校验。
477
+ Returns:
478
+ 错误列表 ``[{pointer, message}]``,空列表表示本地语法通过。
479
+ """
480
+ errors: list[dict[str, str]] = []
481
+ if not isinstance(formula, str) or not formula.strip():
482
+ errors.append({"pointer": "formula", "message": "formula is empty"})
483
+ return errors
484
+
485
+ # 函数名合法性(复用 AppForm formula Schema 的函数目录)
486
+ supported = _supported_formula_functions("v1alpha1")
487
+ unknown = sorted(
488
+ name for name in _formula_function_calls(formula) if name.upper() not in supported
489
+ )
490
+ for name in unknown:
491
+ errors.append({"pointer": "formula", "message": f"unsupported formula function '{name}'"})
492
+
493
+ # 字段引用存在性(#queId_<id>$,支持表格嵌套 <id>.<id>,按叶子 id 与原始 id 双重匹配)
494
+ if fields:
495
+ known_ids = {
496
+ str(identifier)
497
+ for field in fields
498
+ if isinstance(field, dict)
499
+ for identifier in (
500
+ field.get("queId"),
501
+ field.get("que_id"),
502
+ field.get("id"),
503
+ )
504
+ if identifier is not None and str(identifier) != ""
505
+ }
506
+ for match in _FORMULA_FIELD_REF_RE.finditer(formula):
507
+ raw_id = match.group(1)
508
+ leaf_id = raw_id.split(".")[-1]
509
+ if leaf_id not in known_ids and raw_id not in known_ids:
510
+ errors.append(
511
+ {
512
+ "pointer": "formula",
513
+ "message": f"field reference '{match.group(0)}' refers to unknown queId {raw_id}",
514
+ }
515
+ )
516
+ return errors
517
+
518
+
519
+ def validate_code_block_static(code: str) -> list[dict[str, str]]:
520
+ """校验代码块静态结构:qf_output 赋值 + 基本语法。不调后端。
521
+
522
+ Args:
523
+ code: 代码文本。
524
+ Returns:
525
+ 错误列表 ``[{pointer, message}]``,空列表表示本地静态校验通过。
526
+ """
527
+ errors: list[dict[str, str]] = []
528
+ if not isinstance(code, str) or not code.strip():
529
+ errors.append({"pointer": "code", "message": "code is empty"})
530
+ return errors
531
+
532
+ # qf_output 必须直接赋值(不能用 const/let 声明);仅当代码涉及 outputs 绑定时才提示
533
+ has_qf_output = bool(re.search(r"(^|[^A-Za-z0-9_$])qf_output\s*=(?!=)", code))
534
+ has_outputs_binding = "outputs" in code
535
+ if not has_qf_output and has_outputs_binding:
536
+ errors.append(
537
+ {
538
+ "pointer": "code",
539
+ "message": "code declares outputs but does not assign qf_output; "
540
+ "use 'qf_output = {...}' (not const/let)",
541
+ }
542
+ )
543
+ return errors
@@ -2,9 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  from copy import deepcopy
5
+ from pathlib import Path
5
6
 
6
7
  from ..context import CliContext
7
- from ...app_form_validator import AppFormValidationError, validate_declaration
8
+ from ...app_form_validator import (
9
+ AppFormValidationError,
10
+ validate_code_block_static,
11
+ validate_declaration,
12
+ validate_formula_expression,
13
+ )
8
14
  from ..json_io import load_json_value
9
15
  from .common import load_list_arg, load_object_arg, parse_bool_text, raise_config_error, require_list_arg
10
16
 
@@ -123,6 +129,41 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
123
129
  app_form_validate.add_argument("--schema-version", help="固定使用的 AppForm Schema 版本")
124
130
  app_form_validate.set_defaults(handler=_handle_app_form_validate, format_hint="builder_summary", force_json_output=True)
125
131
 
132
+ app_form_validate_formula = app_form_subparsers.add_parser(
133
+ "validate-formula", help="校验公式表达式(本地语法 + 后端求值)"
134
+ )
135
+ app_form_validate_formula.add_argument("--app-key", required=True, help="目标应用 key")
136
+ app_form_validate_formula.add_argument("--formula", required=True, help="公式文本")
137
+ app_form_validate_formula.add_argument(
138
+ "--test-case",
139
+ required=True,
140
+ help="测试用例 JSON 文件路径,- 表示 stdin;格式 {queId: value}",
141
+ )
142
+ app_form_validate_formula.add_argument(
143
+ "--fields",
144
+ required=False,
145
+ help="字段清单 JSON 文件路径(用于本地字段引用校验);缺省时从后端拉取",
146
+ )
147
+ app_form_validate_formula.set_defaults(
148
+ handler=_handle_app_form_validate_formula, format_hint="builder_summary", force_json_output=True
149
+ )
150
+
151
+ app_form_validate_code_block = app_form_subparsers.add_parser(
152
+ "validate-code-block", help="校验代码块(本地静态 + 后端真跑)"
153
+ )
154
+ app_form_validate_code_block.add_argument("--app-key", required=True, help="目标应用 key")
155
+ app_form_validate_code_block.add_argument(
156
+ "--code", required=True, help="代码文本(或 @文件路径读取)"
157
+ )
158
+ app_form_validate_code_block.add_argument(
159
+ "--test-case",
160
+ required=True,
161
+ help="测试用例 JSON 文件路径,- 表示 stdin;格式 [{keyQueId, ordinal?, values}]",
162
+ )
163
+ app_form_validate_code_block.set_defaults(
164
+ handler=_handle_app_form_validate_code_block, format_hint="builder_summary", force_json_output=True
165
+ )
166
+
126
167
  app = builder_subparsers.add_parser("app", help="应用")
127
168
  app_subparsers = app.add_subparsers(dest="builder_app_command", required=True)
128
169
 
@@ -390,6 +431,168 @@ def _handle_app_form_validate(args: argparse.Namespace, _context: CliContext) ->
390
431
  return {"category": "validation", "status": "success", "ok": True, **result, "message": "AppForm declaration is valid"}
391
432
 
392
433
 
434
+ def _handle_app_form_validate_formula(args: argparse.Namespace, context: CliContext) -> dict:
435
+ formula = args.formula
436
+ test_case = load_json_value(
437
+ args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
438
+ )
439
+ if not isinstance(test_case, dict):
440
+ raise_config_error(
441
+ "--test-case must be a JSON object {queId: value}", error_code="INVALID_TEST_CASE"
442
+ )
443
+
444
+ # ① 本地语法校验(函数名 + 字段引用存在性)
445
+ fields = _load_fields_for_validation(args, context)
446
+ syntax_errors = validate_formula_expression(formula, fields)
447
+
448
+ # ② 后端求值校验(语法有错时不浪费后端调用)
449
+ evaluation = None
450
+ evaluation_error = None
451
+ if not syntax_errors:
452
+ result = context.builder.app_form_evaluate_formula(
453
+ profile=args.profile, app_key=args.app_key, formula=formula, test_case=test_case
454
+ )
455
+ evaluation = result.get("evaluation")
456
+ evaluation_error = result.get("evaluationError")
457
+
458
+ ok = bool(
459
+ not syntax_errors
460
+ and isinstance(evaluation, dict)
461
+ and evaluation.get("valid") is True
462
+ )
463
+ return {
464
+ "category": "validation",
465
+ "status": "success" if ok else "failed",
466
+ "ok": ok,
467
+ "syntaxErrors": syntax_errors,
468
+ "evaluation": evaluation,
469
+ "evaluationError": evaluation_error,
470
+ "message": "formula is valid" if ok else "formula validation failed",
471
+ }
472
+
473
+
474
+ def _handle_app_form_validate_code_block(args: argparse.Namespace, context: CliContext) -> dict:
475
+ code = _read_code_arg(args.code)
476
+ test_case = load_json_value(
477
+ args.test_case, option_name="--test-case", max_bytes=1 * 1024 * 1024, max_depth=32
478
+ )
479
+ if not isinstance(test_case, list):
480
+ raise_config_error(
481
+ "--test-case must be a JSON array [{keyQueId, ordinal?, values}]",
482
+ error_code="INVALID_TEST_CASE",
483
+ )
484
+
485
+ # ① 本地静态校验(qf_output 赋值 + 基本语法)
486
+ static_errors = validate_code_block_static(code)
487
+
488
+ # ② 后端真跑校验(静态有错时不浪费后端调用)
489
+ key_que_values = _normalize_code_block_test_case(test_case)
490
+ run_result = None
491
+ run_error = None
492
+ if not static_errors:
493
+ result = context.builder.app_form_run_code_block(
494
+ profile=args.profile, app_key=args.app_key, code=code, key_que_values=key_que_values
495
+ )
496
+ run_result = result.get("runResult")
497
+ run_error = result.get("runError")
498
+
499
+ ok = not static_errors and run_result is not None
500
+ return {
501
+ "category": "validation",
502
+ "status": "success" if ok else "failed",
503
+ "ok": ok,
504
+ "staticErrors": static_errors,
505
+ "runResult": run_result,
506
+ "runError": run_error,
507
+ "message": "code block is valid" if ok else "code block validation failed",
508
+ }
509
+
510
+
511
+ def _load_fields_for_validation(args: argparse.Namespace, context: CliContext) -> list[dict] | None:
512
+ """加载字段清单:优先 --fields 参数,缺省时从后端拉取应用字段。
513
+
514
+ 后端拉取失败时返回 None(跳过字段引用校验,不阻断流程)。
515
+ """
516
+ fields_path = getattr(args, "fields", None)
517
+ if fields_path:
518
+ fields = load_json_value(
519
+ fields_path, option_name="--fields", max_bytes=2 * 1024 * 1024, max_depth=64
520
+ )
521
+ if not isinstance(fields, list):
522
+ raise_config_error(
523
+ "--fields must be a JSON array of field objects", error_code="INVALID_FIELDS"
524
+ )
525
+ return fields
526
+ fetched = context.builder.app_get_fields(profile=args.profile, app_key=args.app_key)
527
+ if not isinstance(fetched, dict) or fetched.get("status") != "success":
528
+ return None
529
+ raw_fields = fetched.get("fields")
530
+ if not isinstance(raw_fields, list):
531
+ return None
532
+ return [
533
+ {"queId": field.get("que_id"), "name": field.get("name")}
534
+ for field in raw_fields
535
+ if isinstance(field, dict)
536
+ ]
537
+
538
+
539
+ def _read_code_arg(code_arg: str) -> str:
540
+ """读取 --code 参数:@ 前缀读文件内容,否则当文本。"""
541
+ if not isinstance(code_arg, str) or not code_arg:
542
+ raise_config_error("--code is required", error_code="INVALID_CODE")
543
+ if code_arg.startswith("@"):
544
+ path = code_arg[1:]
545
+ try:
546
+ return Path(path).expanduser().read_text(encoding="utf-8")
547
+ except OSError as exc:
548
+ raise_config_error(
549
+ f"failed to read --code from '{path}': {exc}", error_code="INVALID_CODE"
550
+ )
551
+ return code_arg
552
+
553
+
554
+ def _normalize_code_block_test_case(test_case: list) -> list[dict]:
555
+ """将 --test-case 规范化为后端 OpenMatchQueValue 结构 [{keyQueId, ordinal?, values}]。"""
556
+ normalized: list[dict] = []
557
+ for item in test_case:
558
+ if not isinstance(item, dict):
559
+ raise_config_error(
560
+ "--test-case items must be JSON objects {keyQueId, ordinal?, values}",
561
+ error_code="INVALID_TEST_CASE",
562
+ )
563
+ raw_que_id = item.get("keyQueId", item.get("key_que_id"))
564
+ if raw_que_id is None:
565
+ raise_config_error(
566
+ "--test-case items require keyQueId", error_code="INVALID_TEST_CASE"
567
+ )
568
+ try:
569
+ key_que_id = int(raw_que_id)
570
+ except (TypeError, ValueError):
571
+ raise_config_error(
572
+ f"--test-case keyQueId must be an integer, got {raw_que_id!r}",
573
+ error_code="INVALID_TEST_CASE",
574
+ )
575
+ entry: dict = {"keyQueId": key_que_id}
576
+ if item.get("ordinal") is not None:
577
+ try:
578
+ entry["ordinal"] = int(item.get("ordinal"))
579
+ except (TypeError, ValueError):
580
+ raise_config_error(
581
+ f"--test-case ordinal must be an integer, got {item.get('ordinal')!r}",
582
+ error_code="INVALID_TEST_CASE",
583
+ )
584
+ values = item.get("values", [])
585
+ if not isinstance(values, list):
586
+ raise_config_error(
587
+ "--test-case item values must be a JSON array", error_code="INVALID_TEST_CASE"
588
+ )
589
+ entry["values"] = [str(value) for value in values]
590
+ if item.get("tableValues") is not None:
591
+ entry["tableValues"] = item.get("tableValues")
592
+ normalized.append(entry)
593
+ return normalized
594
+
595
+
393
596
  def _handle_app_delete(args: argparse.Namespace, context: CliContext) -> dict:
394
597
  return context.builder.app_delete(profile=args.profile, app_key=args.app_key)
395
598
 
@@ -144,6 +144,8 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
144
144
  PublicToolSpec(BUILDER_DOMAIN, "app_resolve", ("app_resolve",), ("builder", "app", "resolve"), has_contract=True, cli_show_effective_context=True),
145
145
  PublicToolSpec(BUILDER_DOMAIN, "app_form_schema", ("app_form_schema",), ("builder", "app-form", "schema")),
146
146
  PublicToolSpec(BUILDER_DOMAIN, "app_form_validate", (), ("builder", "app-form", "validate"), mcp_public=False),
147
+ PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_formula", (), ("builder", "app-form", "validate-formula"), mcp_public=False),
148
+ PublicToolSpec(BUILDER_DOMAIN, "app_form_validate_code_block", (), ("builder", "app-form", "validate-code-block"), mcp_public=False),
147
149
  PublicToolSpec(BUILDER_DOMAIN, "app_form_get", ("app_form_get",), ("builder", "app-form", "get"), cli_show_effective_context=True),
148
150
  PublicToolSpec(BUILDER_DOMAIN, "app_form_apply", ("app_form_apply",), ("builder", "app-form", "apply"), cli_show_effective_context=True, cli_context_write=True),
149
151
  PublicToolSpec(BUILDER_DOMAIN, "app_delete", ("app_delete",), ("builder", "app", "delete"), cli_show_effective_context=True, cli_context_write=True),
@@ -51,6 +51,10 @@ COMMON_ERROR_DROP_TOP = {
51
51
 
52
52
  SUCCESS_POLICY_BY_TOOL: dict[str, TransformFn] = {}
53
53
 
54
+ #: 这两个校验命令的产出契约明确包含 ok 字段(供阶段 2 SKILL.md 引用),
55
+ #: 需在 success 裁剪时保留 ok(默认 COMMON_SUCCESS_DROP_TOP 会丢弃 ok)。
56
+ _KEEP_OK_TOOLS = {"builder:app_form_validate_formula", "builder:app_form_validate_code_block"}
57
+
54
58
 
55
59
  def trim_public_response(tool_name: str | None, payload: dict[str, Any]) -> dict[str, Any]:
56
60
  if not isinstance(payload, dict):
@@ -85,6 +89,8 @@ def trim_success_response(tool_name: str | None, payload: dict[str, Any]) -> dic
85
89
  drop_keys = COMMON_SUCCESS_DROP_TOP - {"output_profile"}
86
90
  if tool_name in {"user:record_insert", "user:record_update", "user:record_delete"} and payload.get("ok") is False:
87
91
  drop_keys = drop_keys - {"ok"}
92
+ if tool_name in _KEEP_OK_TOOLS:
93
+ drop_keys = drop_keys - {"ok"}
88
94
  if tool_name == "builder:app_form_apply" and payload.get("mustReadCurrentStateBeforeRepair") is True:
89
95
  drop_keys = drop_keys - {"suggested_next_call"}
90
96
  _drop_top_keys(trimmed, drop_keys)
@@ -852,6 +852,64 @@ class AiBuilderTools(ToolBase):
852
852
  """Reconcile one complete declarative AppForm target."""
853
853
  return self._app_form.apply(profile=profile, spec=spec)
854
854
 
855
+ def app_form_evaluate_formula(
856
+ self,
857
+ *,
858
+ profile: str,
859
+ app_key: str,
860
+ formula: str,
861
+ test_case: dict[int, Any],
862
+ ) -> JSONObject:
863
+ """调用后端 POST /ai/coding/formula/validate 对公式求值。
864
+
865
+ 本地语法校验由 CLI handler 负责;本方法只做后端求值,捕获后端错误为 evaluationError。
866
+ 鉴权/会话错误(未登录/未选工作区)按既有约定向上抛出 QingflowApiError。
867
+ """
868
+ _session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
869
+ evaluation_result: Any = None
870
+ evaluation_error: str | None = None
871
+ try:
872
+ evaluation_result = self.backend.request(
873
+ "POST",
874
+ context,
875
+ "/ai/coding/formula/validate",
876
+ json_body={"appKey": app_key, "formula": formula, "testCase": test_case},
877
+ )
878
+ except QingflowApiError as exc:
879
+ evaluation_error = exc.message
880
+ return {"evaluation": evaluation_result, "evaluationError": evaluation_error}
881
+
882
+ def app_form_run_code_block(
883
+ self,
884
+ *,
885
+ profile: str,
886
+ app_key: str,
887
+ code: str,
888
+ key_que_values: list[JSONObject],
889
+ ) -> JSONObject:
890
+ """调用后端 POST /data/{appKey}/codeBlock/detection 真跑代码块。
891
+
892
+ 本地静态校验由 CLI handler 负责;本方法只做后端真跑,捕获后端错误为 runError。
893
+ ``key_que_values`` 需符合后端 OpenMatchQueValue 结构 ``{keyQueId, ordinal?, values}``。
894
+
895
+ 传入的 ``code`` 是原始 JS(未经前端字段公式加密),必须声明 ``unEncrypted=True``;
896
+ 否则后端 ``DataSvcImpl.codeBlockTest`` 会走 ``FormulaDecoder.extractFormula``
897
+ 对原始 JS 解密并抛 ``QFRuntimeException``,导致后端真跑对所有真实代码必然失败。
898
+ """
899
+ _session_profile, _backend_session, context = self._require_context(profile, require_workspace=True)
900
+ run_result: Any = None
901
+ run_error: str | None = None
902
+ try:
903
+ run_result = self.backend.request(
904
+ "POST",
905
+ context,
906
+ f"/data/{app_key}/codeBlock/detection",
907
+ json_body={"code": code, "keyQueValues": key_que_values, "unEncrypted": True},
908
+ )
909
+ except QingflowApiError as exc:
910
+ run_error = exc.message
911
+ return {"runResult": run_result, "runError": run_error}
912
+
855
913
  def app_delete(self, *, profile: str, app_key: str) -> JSONObject:
856
914
  """Delete an application explicitly."""
857
915
  return self._app_form.delete(profile=profile, app_key=app_key)