@qingflow-tech/qingflow-cli 1.1.47-beta.1 → 1.1.47-beta.3
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 +3 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +21 -3
- package/skills/qingflow-common/SKILL.md +13 -4
- package/src/qingflow_mcp/builder_facade/service.py +119 -1
- package/src/qingflow_mcp/cli/commands/auth.py +1 -1
- package/src/qingflow_mcp/cli/context.py +1 -1
- package/src/qingflow_mcp/cli/formatters.py +62 -3
- package/src/qingflow_mcp/public_surface.py +1 -0
- package/src/qingflow_mcp/resource_uri.py +184 -0
- package/src/qingflow_mcp/response_trim.py +3 -3
- package/src/qingflow_mcp/server_app_builder.py +16 -0
- package/src/qingflow_mcp/server_app_user.py +6 -3
- package/src/qingflow_mcp/tools/ai_builder_tools.py +208 -39
- package/src/qingflow_mcp/tools/app_tools.py +52 -13
- package/src/qingflow_mcp/tools/login_link_tools.py +31 -3
- package/src/qingflow_mcp/tools/resource_read_tools.py +13 -0
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.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.47-beta.3
|
|
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.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.47-beta.3 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.
|
|
65
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.47-beta.3 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.47-beta.
|
|
3
|
+
"version": "1.1.47-beta.3",
|
|
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
|
@@ -124,6 +124,24 @@ qingflow auth login \
|
|
|
124
124
|
qingflow --json auth whoami
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
+
### 交付网页资源链接
|
|
128
|
+
|
|
129
|
+
创建、修改或安装轻流资源后,最终回复主动提供主要结果的可点击页面入口,无需等待用户再次索要。采用“完成结果 + 查看入口”,文案使用“在轻流中查看应用/应用包/视图”,不默认称为“继续编辑”。
|
|
130
|
+
|
|
131
|
+
默认只交付用户请求的主要结果:安装方案默认提供应用包入口;直接创建或修改应用、视图时提供对应入口;用户明确要求多个主要对象时分别提供。使用业务结果中的稳定 `uri`,按 URI 去重,不为全部子资源预签票据。主要对象没有可靠 URI 时说明限制,不猜地址,也不替换为不相关页面。
|
|
132
|
+
|
|
133
|
+
部分成功时仅交付已确认成功且可定位的主要结果,保留失败或未验证说明。纯数据问答、中间读取、失败资源和删除操作不自动签发;用户明确要求不提供链接时遵从。用户明确要求查看或打开页面时也使用已确认的路径。
|
|
134
|
+
|
|
135
|
+
目标浏览器登录态未知时,临近交付取该 URI 的 pathname 执行:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
qingflow auth login-link --redirect-path "/resources/ws/<WS_ID>/app/<APP_KEY>"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
对每个实际展示的入口分别签发,使用本次返回的完整 URL,不额外追问登录态,不复用已消费链接。只有已有用户确认同一浏览器、网页域名、账号和工作区已登录、没有失效信号的事实,才直接交付稳定 `uri`。CLI 已登录、自动化浏览器已登录或曾经成功签发票据都不能证明目标浏览器已登录。
|
|
142
|
+
|
|
143
|
+
用户未要求打开时不要提前打开或消费链接;签发失败时说明免登录链接未生成,并提供标注“需登录”的稳定 `uri` 作为备用入口,不重做业务操作。机器结果继续保留稳定 URI,临时登录链接仅用于面向用户交付。
|
|
144
|
+
|
|
127
145
|
常用参数:`--credential-stdin`、`--password-stdin`、`--persist` / `--forget-persisted`;详见 `qingflow auth --help`。
|
|
128
146
|
|
|
129
147
|
---
|
|
@@ -449,17 +467,17 @@ qingflow auth logout --forget-persisted
|
|
|
449
467
|
|
|
450
468
|
### 临时网页登录链接
|
|
451
469
|
|
|
452
|
-
|
|
470
|
+
按上文“交付网页资源链接”规则,在创建、修改或安装完成后主动为主要结果提供查看入口;用户明确要求进入页面时也适用,纯数据问答不自动调用:
|
|
453
471
|
|
|
454
472
|
```bash
|
|
455
473
|
qingflow --json auth login-link
|
|
456
|
-
qingflow --profile default --json auth login-link --redirect-path "/
|
|
474
|
+
qingflow --profile default --json auth login-link --redirect-path "/resources/ws/<WS_ID>/app/<APP_KEY>"
|
|
457
475
|
```
|
|
458
476
|
|
|
459
477
|
- 默认使用当前会话和已选工作区;缺失时按实际错误恢复,不额外预查或重新登录。
|
|
460
478
|
- `--redirect-path` 可选,仅接受已确认的站内业务路径,可包含 query/hash;不接受外域或认证入口。示例 ID 仅作格式展示,不得照抄或编造实际目标;省略时进入工作区首页。
|
|
461
479
|
- 将返回的 `url` 作为可点击链接交付,不修改或拼造。链接短时有效且仅可使用一次,失效后重新获取,不写入公共文档或日志。
|
|
462
|
-
-
|
|
480
|
+
- 命令不会自动打开浏览器;用户未要求打开时不提前打开或消费链接,明确要求打开时才调用浏览器。
|
|
463
481
|
|
|
464
482
|
**高发必败**:旧应用搜索入口 · **`record list`/`browse`/`access` 缺 `--view-id`** · **`task report` 缺 `--report-id`** · **`record update` 批量与单条参数混用**。
|
|
465
483
|
|
|
@@ -45,7 +45,12 @@ Each package includes this common Skill. The CLI package installs its CLI guidan
|
|
|
45
45
|
|
|
46
46
|
## 从 AI 对话进入轻流页面
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
创建、修改或安装轻流资源后,最终回复主动提供主要结果的可点击页面入口,无需等待用户再次索要。采用“完成结果 + 查看入口”,文案使用“在轻流中查看应用/应用包/视图”,不默认称为“继续编辑”。用户明确要求查看或打开页面时也适用以下规则。
|
|
49
|
+
|
|
50
|
+
- 默认只交付用户请求的主要结果:安装方案默认提供应用包入口;直接创建或修改应用、视图时提供对应入口;用户明确要求多个主要对象时分别提供。使用业务结果中的稳定 `uri`,按 URI 去重,不为全部子资源预签票据。
|
|
51
|
+
- 主要对象没有可靠 URI 时说明限制,不猜地址,也不替换为不相关页面。部分成功时仅交付已确认成功且可定位的主要结果,保留失败或未验证说明,不把链接当作整个任务成功的证明。
|
|
52
|
+
- 纯数据问答、中间读取、失败资源和删除操作不自动签发;用户明确要求不提供链接时遵从。
|
|
53
|
+
- MCP 调用 `get_login_link`;CLI 调用 `qingflow --json auth login-link --redirect-path <资源 pathname>`。
|
|
49
54
|
|
|
50
55
|
- 目标路径使用用户提供或业务工具确认的站内路径,不根据应用、记录标识猜测地址。
|
|
51
56
|
- 用户明确指定页面但路径未知时继续定位或说明限制,不猜测,也不静默替换成首页。搜索未命中时继续搜索 `login` 或查看服务工具列表,不擅自打开固定正式站点;失败时如实说明。
|
|
@@ -53,10 +58,14 @@ Each package includes this common Skill. The CLI package installs its CLI guidan
|
|
|
53
58
|
- 默认使用当前会话及已选工作区;需要指定会话时使用 MCP 的 `profile` 或 CLI 的 `--profile`。
|
|
54
59
|
- `redirect_path` 可选,默认空:省略时进入工作区首页;传入时仅支持以单个 `/` 开头的站内业务路径,可带 query/hash。不得传完整 URL、外部地址、认证入口或多层编码路径。
|
|
55
60
|
- 示例:`get_login_link({})`;指定目标示例:`get_login_link({"redirect_path":"/index/app/123?view=abc"})`。示例标识仅作格式展示,实际目标必须来自用户提供或已经确认的页面信息,不得编造应用、记录标识。
|
|
56
|
-
-
|
|
61
|
+
- 成功响应仅有 `url` 和毫秒整数 `expires_at`。临近交付时再签发,使用本次返回的完整链接作为主要点击入口,不展示工作区数字 ID 或原始 JSON。
|
|
57
62
|
- CLI 示例:`qingflow --json auth login-link`;指定目标使用 `qingflow --profile default --json auth login-link --redirect-path "/index/app/123?view=abc"`,同样不得编造实际路径。
|
|
58
|
-
-
|
|
59
|
-
-
|
|
63
|
+
- 目标浏览器状态未知时,默认按每个待交付资源分别签发新链接,不额外追问登录态。只有已有用户确认目标是同一浏览器、网页域名、账号和工作区且已登录、没有失效信号的事实,才直接交付稳定 `uri`。调用过票据工具、自动化浏览器已登录或之前签发过票据都不是该事实;已消费链接不可复用。
|
|
64
|
+
- 链接约 5 分钟内只能兑换一次,不作为长期书签;过期或使用后重新调用,不修改票据或拼造链接;只交付给请求用户,不写入公共文档或日志。
|
|
65
|
+
- 用户未要求打开时仅交付,不提前消费;要求“打开”时才使用可用浏览器打开。浏览器不可用时提供可点击链接,不假称已经打开。
|
|
66
|
+
- 签发失败时说明免登录链接未生成,并提供标注“需登录”的稳定 `uri` 作为备用入口,不重做业务操作。机器结果继续保留稳定 URI,临时登录链接仅用于面向用户交付。
|
|
67
|
+
- 必要的 MFA、密码安全验证仍执行,完成后先切换当前 MCP 工作区,再进入目标页面;业务权限仍由目标页面校验,不因链接而扩大。
|
|
68
|
+
- 工具拒绝非法跳转参数;浏览器收到被篡改的非法目标时忽略目标并进入首页。网页域名由 MCP 配置 `QINGFLOW_MCP_WEB_BASE_URL` 决定,不从目标路径选择其他环境。
|
|
60
69
|
|
|
61
70
|
## Route Next
|
|
62
71
|
|
|
@@ -19,6 +19,7 @@ from ..backend_client import BackendRequestContext
|
|
|
19
19
|
from ..errors import QingflowApiError, backend_code_int, backend_code_value_int, is_auth_like_error
|
|
20
20
|
from ..json_types import JSONObject
|
|
21
21
|
from ..list_type_labels import RECORD_LIST_TYPE_LABELS, SYSTEM_VIEW_ID_TO_LIST_TYPE
|
|
22
|
+
from ..resource_uri import validate_positive_resource_id, validate_resource_identity
|
|
22
23
|
from ..solution.build_assembly_store import BuildAssemblyStore, default_artifacts, default_manifest
|
|
23
24
|
from ..solution.compiler.chart_compiler import qingbi_workspace_visible_auth
|
|
24
25
|
from ..solution.compiler.form_compiler import build_question, default_form_payload, default_member_auth
|
|
@@ -1491,6 +1492,74 @@ class AiBuilderFacade:
|
|
|
1491
1492
|
details={"solution_key": requested_solution_key},
|
|
1492
1493
|
suggested_next_call={"tool_name": "solution_install", "arguments": {"profile": profile, **normalized_args}},
|
|
1493
1494
|
)
|
|
1495
|
+
raw_result = created.get("result") if isinstance(created.get("result"), dict) else {}
|
|
1496
|
+
raw_tag_ids = raw_result.get("tagIds")
|
|
1497
|
+
raw_app_keys = raw_result.get("appKeys")
|
|
1498
|
+
tag_ids, tag_ids_complete = _unique_resource_tag_ids(raw_tag_ids)
|
|
1499
|
+
app_keys, app_keys_complete = _unique_resource_app_keys(raw_app_keys)
|
|
1500
|
+
resource_list_complete = tag_ids_complete and app_keys_complete
|
|
1501
|
+
resources: list[JSONObject] = []
|
|
1502
|
+
warnings: list[JSONObject] = []
|
|
1503
|
+
|
|
1504
|
+
for tag_id in tag_ids:
|
|
1505
|
+
name = None
|
|
1506
|
+
try:
|
|
1507
|
+
detail = self.packages.package_get(profile=profile, tag_id=tag_id, include_raw=False)
|
|
1508
|
+
package = detail.get("result") if isinstance(detail.get("result"), dict) else {}
|
|
1509
|
+
name = str(package.get("tagName") or package.get("tag_name") or "").strip() or None
|
|
1510
|
+
except (QingflowApiError, RuntimeError):
|
|
1511
|
+
pass
|
|
1512
|
+
if not name:
|
|
1513
|
+
warnings.append(_warning("RESOURCE_NAME_UNAVAILABLE", "installed package name could not be read", package_id=tag_id))
|
|
1514
|
+
resources.append(
|
|
1515
|
+
_compact_dict(
|
|
1516
|
+
{
|
|
1517
|
+
"resource_type": "package",
|
|
1518
|
+
"operation": "installed",
|
|
1519
|
+
"status": "success",
|
|
1520
|
+
"id": tag_id,
|
|
1521
|
+
"key": str(tag_id),
|
|
1522
|
+
"name": name,
|
|
1523
|
+
"ids": {"package_id": tag_id},
|
|
1524
|
+
}
|
|
1525
|
+
)
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
for app_key in app_keys:
|
|
1529
|
+
name = None
|
|
1530
|
+
parent = None
|
|
1531
|
+
try:
|
|
1532
|
+
detail = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=False)
|
|
1533
|
+
app = detail.get("result") if isinstance(detail.get("result"), dict) else {}
|
|
1534
|
+
name = str(app.get("formTitle") or app.get("appName") or "").strip() or None
|
|
1535
|
+
matching_tag_ids = [tag_id for tag_id in _unique_positive_ints(app.get("tagIds")) if tag_id in tag_ids]
|
|
1536
|
+
if len(matching_tag_ids) == 1:
|
|
1537
|
+
parent = {"resource_type": "package", "id": matching_tag_ids[0], "key": str(matching_tag_ids[0])}
|
|
1538
|
+
except (QingflowApiError, RuntimeError):
|
|
1539
|
+
pass
|
|
1540
|
+
if not name:
|
|
1541
|
+
warnings.append(_warning("RESOURCE_NAME_UNAVAILABLE", "installed application name could not be read", app_key=app_key))
|
|
1542
|
+
resources.append(
|
|
1543
|
+
_compact_dict(
|
|
1544
|
+
{
|
|
1545
|
+
"resource_type": "app",
|
|
1546
|
+
"operation": "installed",
|
|
1547
|
+
"status": "success",
|
|
1548
|
+
"key": app_key,
|
|
1549
|
+
"name": name,
|
|
1550
|
+
"ids": {"app_key": app_key},
|
|
1551
|
+
"parent": parent,
|
|
1552
|
+
}
|
|
1553
|
+
)
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
if not resource_list_complete:
|
|
1557
|
+
warnings.append(
|
|
1558
|
+
_warning(
|
|
1559
|
+
"RESOURCE_LIST_INCOMPLETE",
|
|
1560
|
+
"solution installation succeeded, but its returned package/application identity list was incomplete",
|
|
1561
|
+
)
|
|
1562
|
+
)
|
|
1494
1563
|
return {
|
|
1495
1564
|
"status": "success",
|
|
1496
1565
|
"error_code": None,
|
|
@@ -1503,9 +1572,15 @@ class AiBuilderFacade:
|
|
|
1503
1572
|
"request_id": created.get("request_id"),
|
|
1504
1573
|
"suggested_next_call": None,
|
|
1505
1574
|
"noop": False,
|
|
1506
|
-
"
|
|
1575
|
+
"warnings": warnings,
|
|
1576
|
+
"verified": False,
|
|
1577
|
+
"verification": {
|
|
1578
|
+
"resource_list_complete": resource_list_complete,
|
|
1579
|
+
"business_verified": False,
|
|
1580
|
+
},
|
|
1507
1581
|
"write_executed": True,
|
|
1508
1582
|
"safe_to_retry": False,
|
|
1583
|
+
"resources": resources,
|
|
1509
1584
|
}
|
|
1510
1585
|
|
|
1511
1586
|
def package_list(self, *, profile: str, trial_status: str = "all", query: str = "") -> JSONObject:
|
|
@@ -17148,6 +17223,49 @@ def _coerce_positive_int(value: Any) -> int | None:
|
|
|
17148
17223
|
return None
|
|
17149
17224
|
|
|
17150
17225
|
|
|
17226
|
+
def _unique_positive_ints(value: Any) -> list[int]:
|
|
17227
|
+
if not isinstance(value, list):
|
|
17228
|
+
return []
|
|
17229
|
+
result: list[int] = []
|
|
17230
|
+
for item in value:
|
|
17231
|
+
number = _coerce_positive_int(item)
|
|
17232
|
+
if number is not None and number not in result:
|
|
17233
|
+
result.append(number)
|
|
17234
|
+
return result
|
|
17235
|
+
|
|
17236
|
+
|
|
17237
|
+
def _unique_resource_tag_ids(value: Any) -> tuple[list[int], bool]:
|
|
17238
|
+
if not isinstance(value, list):
|
|
17239
|
+
return [], False
|
|
17240
|
+
result: list[int] = []
|
|
17241
|
+
complete = True
|
|
17242
|
+
for item in value:
|
|
17243
|
+
try:
|
|
17244
|
+
tag_id = validate_positive_resource_id(item, "package_id")
|
|
17245
|
+
except (ValueError, UnicodeError):
|
|
17246
|
+
complete = False
|
|
17247
|
+
continue
|
|
17248
|
+
if tag_id not in result:
|
|
17249
|
+
result.append(tag_id)
|
|
17250
|
+
return result, complete
|
|
17251
|
+
|
|
17252
|
+
|
|
17253
|
+
def _unique_resource_app_keys(value: Any) -> tuple[list[str], bool]:
|
|
17254
|
+
if not isinstance(value, list):
|
|
17255
|
+
return [], False
|
|
17256
|
+
result: list[str] = []
|
|
17257
|
+
complete = True
|
|
17258
|
+
for item in value:
|
|
17259
|
+
try:
|
|
17260
|
+
app_key = validate_resource_identity(item, "app_key")
|
|
17261
|
+
except (ValueError, UnicodeError):
|
|
17262
|
+
complete = False
|
|
17263
|
+
continue
|
|
17264
|
+
if app_key not in result:
|
|
17265
|
+
result.append(app_key)
|
|
17266
|
+
return result, complete
|
|
17267
|
+
|
|
17268
|
+
|
|
17151
17269
|
def _coerce_nonnegative_int(value: Any) -> int | None:
|
|
17152
17270
|
if isinstance(value, bool) or value is None:
|
|
17153
17271
|
return None
|
|
@@ -41,7 +41,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
41
41
|
whoami.set_defaults(handler=_handle_whoami, format_hint="auth_whoami")
|
|
42
42
|
|
|
43
43
|
login_link = auth_subparsers.add_parser("login-link", help="生成当前工作区的临时一次性登录链接(不自动打开)")
|
|
44
|
-
login_link.add_argument("--redirect-path", default="", help="
|
|
44
|
+
login_link.add_argument("--redirect-path", default="", help="登录后跳转的站内路径;资源路径必须属于当前工作区,其他业务路径可包含查询参数和锚点")
|
|
45
45
|
login_link.set_defaults(handler=_handle_login_link, format_hint="login_link")
|
|
46
46
|
|
|
47
47
|
logout = auth_subparsers.add_parser("logout", help="退出登录")
|
|
@@ -10,12 +10,12 @@ from ..session_store import SessionStore
|
|
|
10
10
|
from ..tools.ai_builder_tools import AiBuilderTools
|
|
11
11
|
from ..tools.app_tools import AppTools
|
|
12
12
|
from ..tools.auth_tools import AuthTools
|
|
13
|
-
from ..tools.login_link_tools import LoginLinkTools
|
|
14
13
|
from ..tools.code_block_tools import CodeBlockTools
|
|
15
14
|
from ..tools.export_tools import ExportTools
|
|
16
15
|
from ..tools.feedback_tools import FeedbackTools
|
|
17
16
|
from ..tools.file_tools import FileTools
|
|
18
17
|
from ..tools.import_tools import ImportTools
|
|
18
|
+
from ..tools.login_link_tools import LoginLinkTools
|
|
19
19
|
from ..tools.record_tools import RecordTools
|
|
20
20
|
from ..tools.resource_read_tools import ResourceReadTools
|
|
21
21
|
from ..tools.task_context_tools import TaskContextTools
|
|
@@ -191,9 +191,10 @@ def _format_app_items(result: dict[str, Any]) -> str:
|
|
|
191
191
|
str(item.get("app_key") or item.get("appKey") or ""),
|
|
192
192
|
str(item.get("app_name") or item.get("name") or item.get("title") or ""),
|
|
193
193
|
str(item.get("package_name") or item.get("tag_name") or ""),
|
|
194
|
+
str(item.get("uri") or ""),
|
|
194
195
|
]
|
|
195
196
|
)
|
|
196
|
-
return _render_titled_table("Apps", ["app_key", "app_name", "package"], rows)
|
|
197
|
+
return _render_titled_table("Apps", ["app_key", "app_name", "package", "uri"], rows)
|
|
197
198
|
|
|
198
199
|
|
|
199
200
|
def _format_app_get(result: dict[str, Any]) -> str:
|
|
@@ -201,6 +202,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
201
202
|
lines = [
|
|
202
203
|
f"App: {data.get('app_name') or '-'}",
|
|
203
204
|
f"App Key: {data.get('app_key') or '-'}",
|
|
205
|
+
f"URI: {data.get('uri') or '-'}",
|
|
204
206
|
f"Can Create: {data.get('can_create')}",
|
|
205
207
|
]
|
|
206
208
|
import_capability = data.get("import_capability")
|
|
@@ -224,7 +226,7 @@ def _format_app_get(result: dict[str, Any]) -> str:
|
|
|
224
226
|
lines.append(f"Accessible Views: {len(views)}")
|
|
225
227
|
for item in views[:10]:
|
|
226
228
|
if isinstance(item, dict):
|
|
227
|
-
lines.append(f"- {item.get('view_id')}: {item.get('name')}")
|
|
229
|
+
lines.append(f"- {item.get('view_id')}: {item.get('name')} / {item.get('uri') or '-'}")
|
|
228
230
|
_append_warnings(lines, result.get("warnings"))
|
|
229
231
|
return "\n".join(lines) + "\n"
|
|
230
232
|
|
|
@@ -296,6 +298,7 @@ def _format_view_get(result: dict[str, Any]) -> str:
|
|
|
296
298
|
f"View Key: {data.get('view_key') or '-'}",
|
|
297
299
|
f"App Key: {data.get('app_key') or '-'}",
|
|
298
300
|
f"View Type: {data.get('view_type') or '-'}",
|
|
301
|
+
f"URI: {data.get('uri') or '-'}",
|
|
299
302
|
f"Analysis Supported: {data.get('analysis_supported')}",
|
|
300
303
|
]
|
|
301
304
|
if export_capability:
|
|
@@ -1184,6 +1187,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1184
1187
|
("view_key", "View Key"),
|
|
1185
1188
|
("chart_id", "Chart ID"),
|
|
1186
1189
|
("package_id", "Package ID"),
|
|
1190
|
+
("uri", "URI"),
|
|
1187
1191
|
("verified", "Verified"),
|
|
1188
1192
|
("write_executed", "Write Executed"),
|
|
1189
1193
|
("delete_executed", "Delete Executed"),
|
|
@@ -1216,6 +1220,7 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1216
1220
|
"associations",
|
|
1217
1221
|
"package_ids",
|
|
1218
1222
|
"app_keys",
|
|
1223
|
+
"resources",
|
|
1219
1224
|
):
|
|
1220
1225
|
value = result.get(key)
|
|
1221
1226
|
if not isinstance(value, list) and isinstance(data, dict):
|
|
@@ -1226,6 +1231,15 @@ def _format_builder_summary(result: dict[str, Any]) -> str:
|
|
|
1226
1231
|
lines.append("- " + _builder_item_summary(item))
|
|
1227
1232
|
if len(value) > 8:
|
|
1228
1233
|
lines.append(f"... {len(value) - 8} more")
|
|
1234
|
+
if key == "apps":
|
|
1235
|
+
uri_lines = [
|
|
1236
|
+
line
|
|
1237
|
+
for item in value
|
|
1238
|
+
for line in _builder_batch_item_uri_lines(item)
|
|
1239
|
+
]
|
|
1240
|
+
if uri_lines:
|
|
1241
|
+
lines.append("Resource URIs:")
|
|
1242
|
+
lines.extend(f"- {line}" for line in uri_lines)
|
|
1229
1243
|
components = result.get("components") if isinstance(result.get("components"), list) else []
|
|
1230
1244
|
if components:
|
|
1231
1245
|
lines.append("Component Refs:")
|
|
@@ -1294,6 +1308,7 @@ def _builder_item_summary(item: Any) -> str:
|
|
|
1294
1308
|
"uid",
|
|
1295
1309
|
"userId",
|
|
1296
1310
|
"email",
|
|
1311
|
+
"uri",
|
|
1297
1312
|
)
|
|
1298
1313
|
parts = [
|
|
1299
1314
|
f"{key}={item.get(key)}"
|
|
@@ -1305,6 +1320,50 @@ def _builder_item_summary(item: Any) -> str:
|
|
|
1305
1320
|
return " / ".join(parts) if parts else json.dumps(item, ensure_ascii=False)
|
|
1306
1321
|
|
|
1307
1322
|
|
|
1323
|
+
def _builder_batch_item_uri_lines(item: Any) -> list[str]:
|
|
1324
|
+
if not isinstance(item, dict):
|
|
1325
|
+
return []
|
|
1326
|
+
item_status = str(item.get("status") or "").lower()
|
|
1327
|
+
if item_status not in {"success", "partial_success"}:
|
|
1328
|
+
return []
|
|
1329
|
+
data = item.get("data") if isinstance(item.get("data"), dict) else item.get("result")
|
|
1330
|
+
if not isinstance(data, dict) or not _builder_uri_is_confirmed(data, fallback_status=item_status):
|
|
1331
|
+
return []
|
|
1332
|
+
lines: list[str] = []
|
|
1333
|
+
if data.get("uri"):
|
|
1334
|
+
lines.append(f"URI: {data['uri']}")
|
|
1335
|
+
views = data.get("views") if isinstance(data.get("views"), list) else []
|
|
1336
|
+
for view in views:
|
|
1337
|
+
if (
|
|
1338
|
+
isinstance(view, dict)
|
|
1339
|
+
and view.get("uri")
|
|
1340
|
+
and _builder_uri_is_confirmed(view, fallback_status=item_status)
|
|
1341
|
+
):
|
|
1342
|
+
lines.append(f"View URI: {view['uri']}")
|
|
1343
|
+
return lines
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _builder_uri_is_confirmed(value: dict[str, Any], *, fallback_status: str) -> bool:
|
|
1347
|
+
status = str(value.get("status") or fallback_status).lower()
|
|
1348
|
+
operation = str(value.get("operation") or "").lower()
|
|
1349
|
+
return (
|
|
1350
|
+
status in {"success", "partial_success", "unchanged", "verified"}
|
|
1351
|
+
and operation
|
|
1352
|
+
not in {
|
|
1353
|
+
"failed",
|
|
1354
|
+
"pending",
|
|
1355
|
+
"planned",
|
|
1356
|
+
"readback_pending",
|
|
1357
|
+
"pending_readback",
|
|
1358
|
+
"unknown",
|
|
1359
|
+
"remove",
|
|
1360
|
+
"removed",
|
|
1361
|
+
"delete",
|
|
1362
|
+
"deleted",
|
|
1363
|
+
}
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
|
|
1308
1367
|
def _format_file_upload_local(result: dict[str, Any]) -> str:
|
|
1309
1368
|
lines = [
|
|
1310
1369
|
f"Upload Kind: {result.get('upload_kind') or result.get('requested_upload_kind') or '-'}",
|
|
@@ -1567,7 +1626,7 @@ def _task_action_debug_lines(result: dict[str, Any]) -> list[str]:
|
|
|
1567
1626
|
|
|
1568
1627
|
|
|
1569
1628
|
def _format_login_link(result: dict[str, Any]) -> str:
|
|
1570
|
-
return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n
|
|
1629
|
+
return f"登录链接:{result['url']}\n过期时间(Unix 毫秒):{result['expires_at']}\n链接只能使用一次,请勿泄露;仅在用户要求打开时才打开。\n"
|
|
1571
1630
|
|
|
1572
1631
|
|
|
1573
1632
|
_FORMATTERS = {
|
|
@@ -128,6 +128,7 @@ BUILDER_PUBLIC_TOOL_SPECS: tuple[PublicToolSpec, ...] = (
|
|
|
128
128
|
PublicToolSpec(BUILDER_DOMAIN, "auth_use_credential", ("auth_use_credential",), ("builder", "auth", "use-credential"), cli_public=False),
|
|
129
129
|
PublicToolSpec(BUILDER_DOMAIN, "auth_whoami", ("auth_whoami",), ("builder", "auth", "whoami"), cli_public=False),
|
|
130
130
|
PublicToolSpec(BUILDER_DOMAIN, "auth_logout", ("auth_logout",), ("builder", "auth", "logout"), cli_public=False),
|
|
131
|
+
PublicToolSpec(BUILDER_DOMAIN, "get_login_link", ("get_login_link",), mcp_public=True, cli_public=False),
|
|
131
132
|
PublicToolSpec(BUILDER_DOMAIN, "workspace_list", ("workspace_list",), ("builder", "workspace", "list"), cli_public=False),
|
|
132
133
|
PublicToolSpec(BUILDER_DOMAIN, "workspace_get", ("workspace_get",), ("builder", "workspace", "get"), cli_public=False),
|
|
133
134
|
PublicToolSpec(BUILDER_DOMAIN, "file_upload_local", ("file_upload_local",), ("builder", "file", "upload-local"), has_contract=True, cli_show_effective_context=True, cli_context_write=True),
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
from .config import get_web_base_url
|
|
9
|
+
from .errors import QingflowApiError
|
|
10
|
+
from .json_types import JSONObject
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SYSTEM_VIEW_NAMES = frozenset({"all", "initiated", "todo", "done", "cc"})
|
|
14
|
+
_ENCODED_OCTET = re.compile(r"%[0-9A-Fa-f]{2}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ResourcePath:
|
|
19
|
+
resource_type: str
|
|
20
|
+
ws_id: int
|
|
21
|
+
package_id: int | None = None
|
|
22
|
+
app_key: str | None = None
|
|
23
|
+
view_kind: str | None = None
|
|
24
|
+
view_key: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_resource_identity(value: object, field: str = "resource_id") -> str:
|
|
28
|
+
"""按资源 URI 单路径段合同校验并返回规范身份,不读取网页域名配置。"""
|
|
29
|
+
return _validate_identity(value, field)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_positive_resource_id(value: object, field: str = "resource_id") -> int:
|
|
33
|
+
"""按资源 URI 数字路径段合同校验正整数,不接受布尔值或浮点截断。"""
|
|
34
|
+
return int(_positive_int_segment(value, field))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_resource_uri(
|
|
38
|
+
*,
|
|
39
|
+
ws_id: object,
|
|
40
|
+
resource_type: str,
|
|
41
|
+
package_id: object = None,
|
|
42
|
+
app_key: object = None,
|
|
43
|
+
view_kind: str | None = None,
|
|
44
|
+
view_key: object = None,
|
|
45
|
+
web_origin: str | None = None,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""按固定公共合同构造资源 URI,不接受通用目标路由。"""
|
|
48
|
+
workspace = _positive_int_segment(ws_id, "ws_id")
|
|
49
|
+
if resource_type == "package":
|
|
50
|
+
path = f"/resources/ws/{workspace}/package/{_positive_int_segment(package_id, 'package_id')}"
|
|
51
|
+
elif resource_type == "app":
|
|
52
|
+
path = f"/resources/ws/{workspace}/app/{_identity_segment(app_key, 'app_key')}"
|
|
53
|
+
elif resource_type == "view":
|
|
54
|
+
app_segment = _identity_segment(app_key, "app_key")
|
|
55
|
+
if view_kind == "custom":
|
|
56
|
+
view_segment = _identity_segment(view_key, "view_key")
|
|
57
|
+
elif view_kind == "system":
|
|
58
|
+
name = str(view_key or "").strip()
|
|
59
|
+
if name not in SYSTEM_VIEW_NAMES:
|
|
60
|
+
raise ValueError("system view name is not supported")
|
|
61
|
+
view_segment = name
|
|
62
|
+
else:
|
|
63
|
+
raise ValueError("view_kind must be custom or system")
|
|
64
|
+
path = f"/resources/ws/{workspace}/app/{app_segment}/view/{view_kind}/{view_segment}"
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError("resource_type is not supported")
|
|
67
|
+
return f"{_normalize_web_origin(web_origin) if web_origin is not None else get_web_base_url()}{path}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_resource_path(value: str) -> ResourcePath:
|
|
71
|
+
"""严格解析资源 pathname,供免登录签发前校验工作区。"""
|
|
72
|
+
try:
|
|
73
|
+
parsed = urlsplit(value)
|
|
74
|
+
if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment or parsed.path != value:
|
|
75
|
+
raise ValueError
|
|
76
|
+
parts = parsed.path.split("/")
|
|
77
|
+
if len(parts) < 6 or parts[:3] != ["", "resources", "ws"]:
|
|
78
|
+
raise ValueError
|
|
79
|
+
ws_id = int(_decode_segment(parts[3], "ws_id"))
|
|
80
|
+
if ws_id <= 0:
|
|
81
|
+
raise ValueError
|
|
82
|
+
if len(parts) == 6 and parts[4] == "package":
|
|
83
|
+
package_id = int(_decode_segment(parts[5], "package_id"))
|
|
84
|
+
if package_id <= 0:
|
|
85
|
+
raise ValueError
|
|
86
|
+
return ResourcePath("package", ws_id, package_id=package_id)
|
|
87
|
+
if len(parts) == 6 and parts[4] == "app":
|
|
88
|
+
return ResourcePath("app", ws_id, app_key=_decode_segment(parts[5], "app_key"))
|
|
89
|
+
if len(parts) == 9 and parts[4] == "app" and parts[6] == "view":
|
|
90
|
+
app_key = _decode_segment(parts[5], "app_key")
|
|
91
|
+
view_kind = parts[7]
|
|
92
|
+
view_key = _decode_segment(parts[8], "view_key")
|
|
93
|
+
if view_kind == "system" and view_key not in SYSTEM_VIEW_NAMES:
|
|
94
|
+
raise ValueError
|
|
95
|
+
if view_kind not in {"custom", "system"}:
|
|
96
|
+
raise ValueError
|
|
97
|
+
return ResourcePath("view", ws_id, app_key=app_key, view_kind=view_kind, view_key=view_key)
|
|
98
|
+
except (TypeError, ValueError, UnicodeError):
|
|
99
|
+
pass
|
|
100
|
+
raise ValueError("resource path is invalid")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def try_build_resource_uri(**kwargs: Any) -> tuple[str | None, JSONObject | None]:
|
|
104
|
+
try:
|
|
105
|
+
return build_resource_uri(**kwargs), None
|
|
106
|
+
except (QingflowApiError, ValueError, UnicodeError) as error:
|
|
107
|
+
return None, {
|
|
108
|
+
"code": "RESOURCE_URI_UNAVAILABLE",
|
|
109
|
+
"message": f"resource URI was omitted because its identity or web origin is invalid: {error}",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _positive_int_segment(value: object, field: str) -> str:
|
|
114
|
+
if isinstance(value, bool):
|
|
115
|
+
raise ValueError(f"{field} must be a positive integer")
|
|
116
|
+
try:
|
|
117
|
+
parsed = int(value)
|
|
118
|
+
except (TypeError, ValueError):
|
|
119
|
+
raise ValueError(f"{field} must be a positive integer") from None
|
|
120
|
+
if parsed <= 0 or str(value).strip() != str(parsed):
|
|
121
|
+
raise ValueError(f"{field} must be a positive integer")
|
|
122
|
+
return str(parsed)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _normalize_web_origin(value: str) -> str:
|
|
126
|
+
try:
|
|
127
|
+
parsed = urlsplit(value.strip())
|
|
128
|
+
if (
|
|
129
|
+
parsed.scheme not in {"http", "https"}
|
|
130
|
+
or not parsed.hostname
|
|
131
|
+
or parsed.username is not None
|
|
132
|
+
or parsed.password is not None
|
|
133
|
+
or parsed.path not in {"", "/"}
|
|
134
|
+
or parsed.query
|
|
135
|
+
or parsed.fragment
|
|
136
|
+
or any(character.isspace() or ord(character) < 32 for character in value)
|
|
137
|
+
):
|
|
138
|
+
raise ValueError
|
|
139
|
+
_ = parsed.port
|
|
140
|
+
except (AttributeError, ValueError):
|
|
141
|
+
raise ValueError("web_origin must be an absolute HTTP(S) origin") from None
|
|
142
|
+
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _identity_segment(value: object, field: str) -> str:
|
|
146
|
+
raw = _validate_identity(value, field)
|
|
147
|
+
return quote(raw, safe="")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _decode_segment(value: str, field: str) -> str:
|
|
151
|
+
decoded = _validate_identity(unquote(value, errors="strict"), field)
|
|
152
|
+
if quote(decoded, safe="") != value:
|
|
153
|
+
raise ValueError(f"{field} must use canonical path encoding")
|
|
154
|
+
return decoded
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validate_identity(value: object, field: str) -> str:
|
|
158
|
+
source = str(value or "")
|
|
159
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in source):
|
|
160
|
+
raise ValueError(f"{field} must be one safe path segment")
|
|
161
|
+
raw = source.strip()
|
|
162
|
+
if not raw:
|
|
163
|
+
raise ValueError(f"{field} is required")
|
|
164
|
+
current = raw
|
|
165
|
+
for _ in range(8):
|
|
166
|
+
if (
|
|
167
|
+
current in {".", ".."}
|
|
168
|
+
or any(character in current for character in "/\\?#")
|
|
169
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in current)
|
|
170
|
+
):
|
|
171
|
+
raise ValueError(f"{field} must be one safe path segment")
|
|
172
|
+
decoded = unquote(current, errors="strict")
|
|
173
|
+
if decoded == current:
|
|
174
|
+
return raw
|
|
175
|
+
current = decoded
|
|
176
|
+
if (
|
|
177
|
+
current in {".", ".."}
|
|
178
|
+
or any(character in current for character in "/\\?#")
|
|
179
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in current)
|
|
180
|
+
):
|
|
181
|
+
raise ValueError(f"{field} must be one safe path segment")
|
|
182
|
+
if _ENCODED_OCTET.search(current):
|
|
183
|
+
raise ValueError(f"{field} has excessive encoding")
|
|
184
|
+
return raw
|