@qingflow-tech/qingflow-cli 1.1.47-beta.5 → 1.1.48-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.47-beta.5
10
+ npm install @qingflow-tech/qingflow-cli@1.1.48-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.47-beta.5 qingflow
24
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.48-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.47-beta.5 qingflow-skills install --agent codex --scope user --copy
65
+ npx -y -p @qingflow-tech/qingflow-cli@1.1.48-beta.1 qingflow-skills install --agent codex --scope user --copy
66
66
  ```
67
67
 
68
68
  `qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
@@ -569,6 +569,34 @@ function findOfflineProjectWheel(findLinksDir) {
569
569
  return path.join(findLinksDir, wheels[wheels.length - 1]);
570
570
  }
571
571
 
572
+ function hasEnv(env, name) {
573
+ return Object.prototype.hasOwnProperty.call(env, name);
574
+ }
575
+
576
+ export function buildPipInstallArgs(env = process.env) {
577
+ const standardNoIndexConfigured = hasEnv(env, "PIP_NO_INDEX");
578
+ const standardFindLinksConfigured = hasEnv(env, "PIP_FIND_LINKS");
579
+
580
+ const noIndex = (standardNoIndexConfigured ? env.PIP_NO_INDEX : env.QINGFLOW_MCP_PIP_NO_INDEX) === "1";
581
+ const findLinks = String(
582
+ standardFindLinksConfigured ? env.PIP_FIND_LINKS ?? "" : env.QINGFLOW_MCP_PIP_FIND_LINKS ?? "",
583
+ ).trim();
584
+
585
+ const pipArgs = ["install", "--disable-pip-version-check"];
586
+ if (noIndex) {
587
+ pipArgs.push("--no-index");
588
+ }
589
+ if (findLinks) {
590
+ pipArgs.push("--find-links", findLinks);
591
+ }
592
+ if (noIndex && findLinks) {
593
+ pipArgs.push(findOfflineProjectWheel(findLinks));
594
+ } else {
595
+ pipArgs.push(".");
596
+ }
597
+ return pipArgs;
598
+ }
599
+
572
600
  export function findPython() {
573
601
  const preferred = process.env.QINGFLOW_MCP_PYTHON?.trim();
574
602
  const candidates = preferred
@@ -629,19 +657,7 @@ export function ensurePythonEnv(packageRoot, { force = false, commandName = "qin
629
657
  }
630
658
 
631
659
  const pip = getVenvPip(packageRoot);
632
- const pipArgs = ["install", "--disable-pip-version-check"];
633
- const offlineFindLinks = process.env.QINGFLOW_MCP_PIP_FIND_LINKS?.trim();
634
- if (process.env.QINGFLOW_MCP_PIP_NO_INDEX === "1") {
635
- pipArgs.push("--no-index");
636
- }
637
- if (offlineFindLinks) {
638
- pipArgs.push("--find-links", offlineFindLinks);
639
- }
640
- if (process.env.QINGFLOW_MCP_PIP_NO_INDEX === "1" && offlineFindLinks) {
641
- pipArgs.push(findOfflineProjectWheel(offlineFindLinks));
642
- } else {
643
- pipArgs.push(".");
644
- }
660
+ const pipArgs = buildPipInstallArgs();
645
661
  runChecked(pip, pipArgs, { cwd: packageRoot });
646
662
 
647
663
  const probe = probePythonRuntime(packageRoot, commandName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qingflow-tech/qingflow-cli",
3
- "version": "1.1.47-beta.5",
3
+ "version": "1.1.48-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.47b5"
7
+ version = "1.1.48b1"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,20 +1,41 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import textwrap
4
5
 
5
6
  from ..context import CliContext
6
7
  from .common import load_list_arg, require_list_arg, require_object_arg, raise_config_error
7
8
 
8
9
 
9
10
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
10
- parser = subparsers.add_parser("data", help="视图无关的应用数据面")
11
+ parser = subparsers.add_parser(
12
+ "data",
13
+ help="视图无关的应用数据面",
14
+ formatter_class=argparse.RawDescriptionHelpFormatter,
15
+ epilog=textwrap.dedent("""
16
+ JSON 文件约定:
17
+ columns.json [101, 102]
18
+ where.json [{"field_id": 101, "op": "eq", "value": "A"}]
19
+ sort.json [{"field_id": 101, "direction": "asc"}]
20
+ items.json [{"fields": {"101": "A"}}]
21
+ fields.json {"101": "B"}
22
+ query/get/insert/update 默认返回 field_capabilities;可用 --no-schema 关闭。
23
+ """).strip(),
24
+ )
11
25
  commands = parser.add_subparsers(dest="data_command", required=True, metavar="{query,get,insert,update,delete}")
12
26
 
13
- query = commands.add_parser("query", help="分页查询应用数据")
27
+ query = commands.add_parser(
28
+ "query", help="分页查询应用数据", formatter_class=argparse.RawDescriptionHelpFormatter,
29
+ epilog='最小示例:qingflow data query --app-key APP_1 --where-file where.json\n'
30
+ 'where.json: [{"field_id": 101, "op": "eq", "value": "A"}]',
31
+ )
14
32
  query.add_argument("--app-key", required=True)
15
- query.add_argument("--columns-file")
16
- query.add_argument("--where-file")
17
- query.add_argument("--sort-file")
33
+ query.add_argument("--columns-file", help='JSON 数组,例如 [101, 102]')
34
+ query.add_argument(
35
+ "--where-file",
36
+ help='JSON 数组;项为 {"field_id":101,"op":"eq","value":"A"},in/not_in 的 value 必须为数组',
37
+ )
38
+ query.add_argument("--sort-file", help='JSON 数组,例如 [{"field_id":101,"direction":"asc"}]')
18
39
  query.add_argument("--search", default="")
19
40
  query.add_argument("--page", type=int, default=1)
20
41
  query.add_argument("--page-size", type=int, default=100)
@@ -29,16 +50,22 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
29
50
  get.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
30
51
  get.set_defaults(handler=_handle_get, format_hint="data_get")
31
52
 
32
- insert = commands.add_parser("insert", help="新增应用数据")
53
+ insert = commands.add_parser(
54
+ "insert", help="新增应用数据", formatter_class=argparse.RawDescriptionHelpFormatter,
55
+ epilog='最小示例:items.json = [{"fields": {"101": "A"}}]',
56
+ )
33
57
  insert.add_argument("--app-key", required=True)
34
- insert.add_argument("--items-file", required=True)
58
+ insert.add_argument("--items-file", required=True, help='JSON 数组,项为 {"fields":{"字段ID":值}}')
35
59
  insert.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
36
60
  insert.set_defaults(handler=_handle_insert, format_hint="data_insert")
37
61
 
38
- update = commands.add_parser("update", help="更新应用数据")
62
+ update = commands.add_parser(
63
+ "update", help="更新应用数据", formatter_class=argparse.RawDescriptionHelpFormatter,
64
+ epilog='最小示例:fields.json = {"101": "B"}',
65
+ )
39
66
  update.add_argument("--app-key", required=True)
40
67
  update.add_argument("--record-id", required=True)
41
- update.add_argument("--fields-file", required=True)
68
+ update.add_argument("--fields-file", required=True, help='JSON 对象,键为字符串形式 field_id')
42
69
  update.add_argument("--no-schema", action="store_false", dest="include_schema", default=True)
43
70
  update.set_defaults(handler=_handle_update, format_hint="data_update")
44
71
 
@@ -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 '-'})",
@@ -1668,4 +1757,11 @@ _FORMATTERS = {
1668
1757
  "builder_summary": _format_builder_summary,
1669
1758
  "file_upload_local": _format_file_upload_local,
1670
1759
  "feedback_submit": _format_feedback_submit,
1760
+ "data_query": _format_data_result,
1761
+ "data_get": _format_data_result,
1762
+ "data_insert": _format_data_result,
1763
+ "data_update": _format_data_result,
1764
+ "data_delete": _format_data_result,
1671
1765
  }
1766
+
1767
+ _DATA_FORMAT_HINTS = {"data_query", "data_get", "data_insert", "data_update", "data_delete"}
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from datetime import datetime
4
4
  from pathlib import Path
5
- from typing import Any
5
+ from typing import Any, Literal, TypedDict
6
6
 
7
7
  from mcp.server.fastmcp import FastMCP
8
8
 
@@ -19,6 +19,32 @@ DATA_CSV_SHARD_ROWS = 20_000
19
19
  DATA_UNBOUNDED_ROW_THRESHOLD = 50_000
20
20
  DATA_CSV_TIME_BUDGET_SECONDS = 55.0
21
21
 
22
+ DataFilterOperator = Literal[
23
+ "eq", "ne", "contains", "not_contains", "gt", "gte", "lt", "lte", "in", "not_in"
24
+ ]
25
+ DataSortDirection = Literal["asc", "desc"]
26
+
27
+
28
+ class DataWhereItem(TypedDict):
29
+ """数据面筛选项;in/not_in 使用数组,其余操作符使用单值。"""
30
+
31
+ field_id: int
32
+ op: DataFilterOperator
33
+ value: JSONValue
34
+
35
+
36
+ class DataSortItem(TypedDict):
37
+ """数据面排序项。"""
38
+
39
+ field_id: int
40
+ direction: DataSortDirection
41
+
42
+
43
+ class DataInsertItem(TypedDict):
44
+ """数据面新增项,fields 的键为字符串形式 field_id。"""
45
+
46
+ fields: dict[str, JSONValue]
47
+
22
48
 
23
49
  class DataTools(ToolBase):
24
50
  """视图无关的应用数据面工具。"""
@@ -29,8 +55,8 @@ class DataTools(ToolBase):
29
55
  profile: str = DEFAULT_PROFILE,
30
56
  app_key: str = "",
31
57
  columns: list[int] | None = None,
32
- where: list[dict] | None = None,
33
- sort: list[dict] | None = None,
58
+ where: list[DataWhereItem] | None = None,
59
+ sort: list[DataSortItem] | None = None,
34
60
  search: str = "",
35
61
  page: int = 1,
36
62
  page_size: int = 100,
@@ -38,6 +64,7 @@ class DataTools(ToolBase):
38
64
  all_pages: bool = False,
39
65
  output: str = "json",
40
66
  ) -> JSONObject:
67
+ """按字段 ID 查询应用数据;先读取 field_capabilities 再构造 where 和 sort。"""
41
68
  return self.data_query(
42
69
  profile=profile, app_key=app_key, columns=columns or [], where=where or [], sort=sort or [],
43
70
  search=search, page=page, page_size=page_size, include_schema=include_schema,
@@ -52,9 +79,10 @@ class DataTools(ToolBase):
52
79
 
53
80
  @mcp.tool()
54
81
  def data_insert(
55
- profile: str = DEFAULT_PROFILE, app_key: str = "", items: list[dict] | None = None,
82
+ profile: str = DEFAULT_PROFILE, app_key: str = "", items: list[DataInsertItem] | None = None,
56
83
  include_schema: bool = True,
57
84
  ) -> JSONObject:
85
+ """新增应用数据;每项形如 {"fields": {"字段ID": 值}}。"""
58
86
  return self.data_insert(profile=profile, app_key=app_key, items=items or [], include_schema=include_schema)
59
87
 
60
88
  @mcp.tool()
@@ -74,7 +102,8 @@ class DataTools(ToolBase):
74
102
  return self.data_delete(profile=profile, app_key=app_key, record_ids=record_ids or [])
75
103
 
76
104
  def data_query(
77
- self, *, profile: str, app_key: str, columns: list[int], where: list[dict], sort: list[dict],
105
+ self, *, profile: str, app_key: str, columns: list[int], where: list[DataWhereItem],
106
+ sort: list[DataSortItem],
78
107
  search: str = "", page: int = 1, page_size: int = 100, include_schema: bool = True,
79
108
  all_pages: bool = False, output: str = "json",
80
109
  ) -> JSONObject:
@@ -113,7 +142,9 @@ class DataTools(ToolBase):
113
142
  tool_name="数据面单条读取",
114
143
  )
115
144
 
116
- def data_insert(self, *, profile: str, app_key: str, items: list[dict], include_schema: bool = True) -> JSONObject:
145
+ def data_insert(
146
+ self, *, profile: str, app_key: str, items: list[DataInsertItem], include_schema: bool = True
147
+ ) -> JSONObject:
117
148
  self._require_app_key(app_key)
118
149
  return self._write(profile, app_key, "POST", self._base_path(app_key), {"items": items, "include_schema": include_schema}, "数据面新增")
119
150