@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.
|
|
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.
|
|
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.
|
|
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` 才会替换。
|
package/npm/lib/runtime.mjs
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
@@ -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(
|
|
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(
|
|
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(
|
|
17
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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[
|
|
33
|
-
sort: list[
|
|
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[
|
|
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[
|
|
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(
|
|
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
|
|