Merge remote-tracking branch 'origin/main' into codex/fix-pr-592-merge-gates

# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
This commit is contained in:
修雨 committed 2026-07-15 23:10:37 +08:00
commit 6c192c2bf4
57 files changed
+5892 -419

No files matched your search

-3
View File
@@ -298,9 +298,6 @@ jobs:
- name: Policy
run: make policy
- name: Generated Drift
run: ./scripts/policy/check-generated-drift.sh
interface-integrity:
name: Interface Integrity
runs-on: ubuntu-latest
+1
View File
@@ -8,6 +8,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
### Added
- **Sheet imports and Aitable workflow writes** (#624) — adds `dws sheet import` / `sheet import create` for converting local xlsx/xls files into new online sheets, `sheet import get` for polling import tasks, and `dws aitable workflow create/update` for applying validated `workflow-dsl/v1` definitions, with matching reviewed Agent Schema and bundled Skill guidance.
- **Official multi-platform Homebrew channel** — stable `Formula/dingtalk-workspace-cli.rb` and keg-only `Formula/dingtalk-workspace-cli-beta.rb` live in this repository and select signed macOS Intel/Apple Silicon or Linux amd64/arm64 artifacts at install time. Stable and beta releases open isolated Formula update PRs after final artifact signing, so beta never replaces the stable Formula. Agent Skills stay under `pkgshare` without mutating the user's home directory, and both tracks are covered by the six-channel post-release verifier.
## [1.0.52] - 2026-07-14
+12 -8
View File
@@ -1,4 +1,7 @@
GO ?= go
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release publish-homebrew-formula setup-hooks
@@ -10,7 +13,7 @@ help:
@printf " make test - Run the Go test suite\n"
@printf " make lint - Run formatting checks and golangci-lint when available\n"
@printf " make fmt - Format Go source files\n"
@printf " make policy - Run open-source asset and Schema registry checks\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and changed-code coverage\n"
@@ -45,13 +48,14 @@ fmt:
@find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
policy:
@./scripts/policy/check-open-source-assets.sh
@./scripts/policy/check-schema-command-registry.sh
@./scripts/policy/check-command-surface.sh --strict
@./scripts/policy/check-generated-drift.sh
@./scripts/policy/check-schema-catalog.sh
@./scripts/policy/check-schema-binary.sh
@$(MAKE) test-schema-agent-examples
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
@@ -29017,6 +29017,288 @@
"用户要移动字段/调整列顺序时必须走本命令"
]
},
"aitable workflow create": {
"agent_summary": "创建并发布 AI 表格自动化工作流。",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": [
"已有工作流要修改时用 workflow update;仅启停已有工作流用 enable/disable;请求结果不确定时先 list 核对,避免非幂等重复创建"
],
"confirmation": "not_required",
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws aitable workflow create --base-id \u003cBASE_ID\u003e --dsl @workflow.json --locale zh-CN"
],
"field_provenance": {
"agent_summary": {
"value": "创建并发布 AI 表格自动化工作流。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
"candidates": [
{
"value": "创建并发布 AI 表格自动化工作流。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
}
]
},
"avoid_when": {
"value": [
"已有工作流要修改时用 workflow update;仅启停已有工作流用 enable/disable;请求结果不确定时先 list 核对,避免非幂等重复创建"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
"candidates": [
{
"value": [
"已有工作流要修改时用 workflow update;仅启停已有工作流用 enable/disable;请求结果不确定时先 list 核对,避免非幂等重复创建"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
},
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"effect": {
"value": "write",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
"candidates": [
{
"value": "write",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
},
{
"value": "write",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": false
}
]
},
"examples": {
"value": [
"dws aitable workflow create --base-id \u003cBASE_ID\u003e --dsl @workflow.json --locale zh-CN"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
"candidates": [
{
"value": [
"dws aitable workflow create --base-id \u003cBASE_ID\u003e --dsl @workflow.json --locale zh-CN"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
}
]
},
"idempotency": {
"value": "non_idempotent",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
"candidates": [
{
"value": "non_idempotent",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
},
{
"value": "unknown",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
}
]
},
"interface_reason": {
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
"candidates": [
{
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
}
]
},
"risk": {
"value": "medium",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
"candidates": [
{
"value": "medium",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
},
{
"value": "medium",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"use_when": {
"value": [
"用户明确要求在已知 Base 中创建自动化,且已准备完整 workflow-dsl/v1 定义时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
"candidates": [
{
"value": [
"用户明确要求在已知 Base 中创建自动化,且已准备完整 workflow-dsl/v1 定义时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
}
]
}
},
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"risk": "medium",
"source_refs": [
"cobra-help:dws aitable workflow create --help",
"internal/cli/schema_command_registry.json#aitable.workflow_create",
"internal/cli/schema_hints/metadata/aitable.json",
"internal/cli/schema_hints/selection/aitable.json",
"skills/mono/references/products/aitable/aitable-workflow.md"
],
"use_when": [
"用户明确要求在已知 Base 中创建自动化,且已准备完整 workflow-dsl/v1 定义时"
]
},
"aitable workflow disable": {
"agent_summary": "禁用工作流(高危,需确认;status 变 STOP)。",
"agent_summary_source": "dws-agent-selection/aitable",
@@ -30072,6 +30354,288 @@
"use_when": [
"查看有哪些工作流及 status/flowId 时"
]
},
"aitable workflow update": {
"agent_summary": "用完整 DSL 更新并发布已有 AI 表格自动化工作流。",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": [
"新建工作流用 create;只改变运行状态用 enable/disable;不要把局部 patch 或 workflow get 返回的 flowSchema 直接当作 DSL"
],
"confirmation": "not_required",
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws aitable workflow update --base-id \u003cBASE_ID\u003e --workflow-id \u003cFLOW_ID\u003e --dsl @workflow.json --locale zh-CN"
],
"field_provenance": {
"agent_summary": {
"value": "用完整 DSL 更新并发布已有 AI 表格自动化工作流。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
"candidates": [
{
"value": "用完整 DSL 更新并发布已有 AI 表格自动化工作流。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
}
]
},
"avoid_when": {
"value": [
"新建工作流用 create;只改变运行状态用 enable/disable;不要把局部 patch 或 workflow get 返回的 flowSchema 直接当作 DSL"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
"candidates": [
{
"value": [
"新建工作流用 create;只改变运行状态用 enable/disable;不要把局部 patch 或 workflow get 返回的 flowSchema 直接当作 DSL"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
},
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"effect": {
"value": "write",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
"candidates": [
{
"value": "write",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
},
{
"value": "write",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": false
}
]
},
"examples": {
"value": [
"dws aitable workflow update --base-id \u003cBASE_ID\u003e --workflow-id \u003cFLOW_ID\u003e --dsl @workflow.json --locale zh-CN"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
"candidates": [
{
"value": [
"dws aitable workflow update --base-id \u003cBASE_ID\u003e --workflow-id \u003cFLOW_ID\u003e --dsl @workflow.json --locale zh-CN"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
}
]
},
"idempotency": {
"value": "unknown",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
"candidates": [
{
"value": "unknown",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
},
{
"value": "unknown",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
}
]
},
"interface_reason": {
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
"candidates": [
{
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
}
]
},
"risk": {
"value": "medium",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
"candidates": [
{
"value": "medium",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
},
{
"value": "medium",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"use_when": {
"value": [
"用户明确要求修改已知工作流,且已先留底当前详情并准备完整 workflow-dsl/v1 目标定义时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
"candidates": [
{
"value": [
"用户明确要求修改已知工作流,且已先留底当前详情并准备完整 workflow-dsl/v1 目标定义时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
}
]
}
},
"idempotency": "unknown",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"risk": "medium",
"source_refs": [
"cobra-help:dws aitable workflow update --help",
"internal/cli/schema_command_registry.json#aitable.workflow_update",
"internal/cli/schema_hints/metadata/aitable.json",
"internal/cli/schema_hints/selection/aitable.json",
"skills/mono/references/products/aitable/aitable-workflow.md"
],
"use_when": [
"用户明确要求修改已知工作流,且已先留底当前详情并准备完整 workflow-dsl/v1 目标定义时"
]
}
}
}
+24 -18
View File
@@ -1,17 +1,17 @@
{
"version": 1,
"source_hash": "sha256:84ae56ee689d80254b9f590733f7bcaa77f9c0f94ac9e0e5e6762956b1b102a2",
"surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3",
"source_hash": "sha256:53567141622d90e02ce50fe3bc1733357a25b9860a0795eb396d9361f43c41d1",
"surface_hash": "sha256:69e1f9fcd3768d49a2a197c40fb26c0bc07385e868753210761c3ea7f676eadb",
"coverage": {
"surface_products": 22,
"products_with_metadata": 22,
"surface_tools": 564,
"tools_with_metadata": 564,
"tools_with_agent_summary": 564,
"tools_with_use_when": 564,
"tools_with_avoid_when": 564,
"tools_with_examples": 564,
"tools_with_interface_mode": 564,
"surface_tools": 568,
"tools_with_metadata": 568,
"tools_with_agent_summary": 568,
"tools_with_use_when": 568,
"tools_with_avoid_when": 568,
"tools_with_examples": 568,
"tools_with_interface_mode": 568,
"unmatched_skill_tools": 125,
"unreviewed_skill_tools": 7
},
@@ -1371,20 +1371,21 @@
]
},
"sheet": {
"agent_summary": "创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
"agent_summary": "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
"agent_summary_source": "dws-agent-selection/sheet",
"avoid_when": [
"目标是 AI 表格 Base 的结构化记录、钉钉文档正文或本地 Excel 文件时不要使用 sheet"
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
"只读取已上传但未转换的 xlsx/xls 节点时先用 doc download;不要把文件节点直接传给单元格命令"
],
"field_provenance": {
"agent_summary": {
"value": "创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
"value": "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": "创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
"value": "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": true
@@ -1393,7 +1394,8 @@
},
"avoid_when": {
"value": [
"目标是 AI 表格 Base 的结构化记录、钉钉文档正文或本地 Excel 文件时不要使用 sheet"
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
"只读取已上传但未转换的 xlsx/xls 节点时先用 doc download;不要把文件节点直接传给单元格命令"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
@@ -1401,7 +1403,8 @@
"candidates": [
{
"value": [
"目标是 AI 表格 Base 的结构化记录、钉钉文档正文或本地 Excel 文件时不要使用 sheet"
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
"只读取已上传但未转换的 xlsx/xls 节点时先用 doc download;不要把文件节点直接传给单元格命令"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
@@ -1411,7 +1414,8 @@
},
"use_when": {
"value": [
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时"
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
@@ -1419,7 +1423,8 @@
"candidates": [
{
"value": [
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时"
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
@@ -1440,7 +1445,8 @@
"skills/mono/references/products/sheet.md"
],
"use_when": [
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时"
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时"
]
},
"todo": {
@@ -11251,6 +11251,581 @@
"需要关闭指定工作表的网格线显示时"
]
},
"sheet import create": {
"agent_summary": "将本地 xlsx/xls 文件导入为新的钉钉在线电子表格。",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"读取或修改已有在线表格时应使用 sheet list/info/range 等命令",
"查询已提交导入任务的状态时使用 sheet import get,不要重复创建导入任务"
],
"confirmation": "not_required",
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws sheet import create --file ./report.xlsx --folder-token \u003cFOLDER_TOKEN\u003e --format json",
"dws sheet import create --file ./data.xls --workspace \u003cWORKSPACE_ID\u003e --name \"月度报表\" --format json"
],
"field_provenance": {
"agent_summary": {
"value": "将本地 xlsx/xls 文件导入为新的钉钉在线电子表格。",
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
"candidates": [
{
"value": "将本地 xlsx/xls 文件导入为新的钉钉在线电子表格。",
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
}
]
},
"avoid_when": {
"value": [
"读取或修改已有在线表格时应使用 sheet list/info/range 等命令",
"查询已提交导入任务的状态时使用 sheet import get,不要重复创建导入任务"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
"candidates": [
{
"value": [
"读取或修改已有在线表格时应使用 sheet list/info/range 等命令",
"查询已提交导入任务的状态时使用 sheet import get,不要重复创建导入任务"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
},
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"effect": {
"value": "write",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
"candidates": [
{
"value": "write",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
},
{
"value": "write",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": false
}
]
},
"examples": {
"value": [
"dws sheet import create --file ./report.xlsx --folder-token \u003cFOLDER_TOKEN\u003e --format json",
"dws sheet import create --file ./data.xls --workspace \u003cWORKSPACE_ID\u003e --name \"月度报表\" --format json"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
"candidates": [
{
"value": [
"dws sheet import create --file ./report.xlsx --folder-token \u003cFOLDER_TOKEN\u003e --format json",
"dws sheet import create --file ./data.xls --workspace \u003cWORKSPACE_ID\u003e --name \"月度报表\" --format json"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
}
]
},
"idempotency": {
"value": "non_idempotent",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
"candidates": [
{
"value": "non_idempotent",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
},
{
"value": "unknown",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
}
]
},
"interface_reason": {
"value": "该 CLI 导入流程包含本地文件校验、创建导入会话、OSS 上传、确认转换和任务轮询,不能绑定为单一 interface_ref。",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
"candidates": [
{
"value": "该 CLI 导入流程包含本地文件校验、创建导入会话、OSS 上传、确认转换和任务轮询,不能绑定为单一 interface_ref。",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
}
]
},
"risk": {
"value": "medium",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
"candidates": [
{
"value": "medium",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
},
{
"value": "medium",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"use_when": {
"value": [
"用户要把本地 Excel 文件转换为可在线编辑的钉钉表格时"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
"candidates": [
{
"value": [
"用户要把本地 Excel 文件转换为可在线编辑的钉钉表格时"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
}
]
}
},
"idempotency": "non_idempotent",
"interface_mode": "composite",
"interface_reason": "该 CLI 导入流程包含本地文件校验、创建导入会话、OSS 上传、确认转换和任务轮询,不能绑定为单一 interface_ref。",
"reviewed": true,
"risk": "medium",
"source_refs": [
"cobra-help:dws sheet import create --help",
"internal/cli/schema_command_registry.json",
"internal/cli/schema_hints/metadata/sheet.json",
"internal/cli/schema_hints/selection/sheet.json",
"live-mcp:none (Cobra+Skill; composite/unpinned)",
"skills/mono/references/products/sheet/sheet-import.md"
],
"use_when": [
"用户要把本地 Excel 文件转换为可在线编辑的钉钉表格时"
]
},
"sheet import get": {
"agent_summary": "根据 taskId 查询表格导入任务结果。",
"agent_summary_source": "dws-agent-selection/sheet",
"availability": "available",
"avoid_when": [
"发起新的 xlsx/xls 导入应使用 sheet import;不要用本命令代替导入",
"状态仍为 processing 时不要重新提交 import,稍后继续查询同一 taskId"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws sheet import get --task-id \u003cTASK_ID\u003e --format json"
],
"field_provenance": {
"agent_summary": {
"value": "根据 taskId 查询表格导入任务结果。",
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
"candidates": [
{
"value": "根据 taskId 查询表格导入任务结果。",
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
}
]
},
"avoid_when": {
"value": [
"发起新的 xlsx/xls 导入应使用 sheet import;不要用本命令代替导入",
"状态仍为 processing 时不要重新提交 import,稍后继续查询同一 taskId"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
"candidates": [
{
"value": [
"发起新的 xlsx/xls 导入应使用 sheet import;不要用本命令代替导入",
"状态仍为 processing 时不要重新提交 import,稍后继续查询同一 taskId"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
},
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
},
{
"value": "write",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": false
}
]
},
"examples": {
"value": [
"dws sheet import get --task-id \u003cTASK_ID\u003e --format json"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
"candidates": [
{
"value": [
"dws sheet import get --task-id \u003cTASK_ID\u003e --format json"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
},
{
"value": "unknown",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
}
]
},
"interface_reason": {
"value": "该 CLI 续查命令显式复用 doc server 的 query_import_task;当前 Sheet 静态接口快照没有可直接绑定的单一 interface_ref。",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
"candidates": [
{
"value": "该 CLI 续查命令显式复用 doc server 的 query_import_task;当前 Sheet 静态接口快照没有可直接绑定的单一 interface_ref。",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
},
{
"value": "medium",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"use_when": {
"value": [
"已有 sheet import 返回的 taskId,需要在导入超时或中断后续查转换状态时"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
"candidates": [
{
"value": [
"已有 sheet import 返回的 taskId,需要在导入超时或中断后续查转换状态时"
],
"source": "internal/cli/schema_hints/selection/sheet.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "该 CLI 续查命令显式复用 doc server 的 query_import_task;当前 Sheet 静态接口快照没有可直接绑定的单一 interface_ref。",
"reviewed": true,
"risk": "low",
"source_refs": [
"cobra-help:dws sheet import get --help",
"internal/cli/schema_command_registry.json",
"internal/cli/schema_hints/metadata/sheet.json",
"internal/cli/schema_hints/selection/sheet.json",
"live-mcp:none (Cobra+Skill; composite/unpinned)",
"skills/mono/references/products/sheet/sheet-import.md"
],
"use_when": [
"已有 sheet import 返回的 taskId,需要在导入超时或中断后续查转换状态时"
]
},
"sheet info": {
"agent_summary": "获取指定工作表详情(行列数、非空范围、合并区等)。",
"agent_summary_source": "dws-agent-selection/sheet",
+13 -13
View File
@@ -1,11 +1,11 @@
{
"version": 1,
"source_hash": "sha256:84ae56ee689d80254b9f590733f7bcaa77f9c0f94ac9e0e5e6762956b1b102a2",
"surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3",
"source_files": 149,
"source_hash": "sha256:53567141622d90e02ce50fe3bc1733357a25b9860a0795eb396d9361f43c41d1",
"surface_hash": "sha256:69e1f9fcd3768d49a2a197c40fb26c0bc07385e868753210761c3ea7f676eadb",
"source_files": 150,
"hint_files": 46,
"hint_products": 39,
"hint_tools": 1280,
"hint_tools": 1288,
"interface_metadata": {
"source": "mcp-tools-list+cli-registry",
"revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8",
@@ -29,13 +29,13 @@
"coverage": {
"surface_products": 22,
"products_with_metadata": 22,
"surface_tools": 564,
"tools_with_metadata": 564,
"tools_with_agent_summary": 564,
"tools_with_use_when": 564,
"tools_with_avoid_when": 564,
"tools_with_examples": 564,
"tools_with_interface_mode": 564,
"surface_tools": 568,
"tools_with_metadata": 568,
"tools_with_agent_summary": 568,
"tools_with_use_when": 568,
"tools_with_avoid_when": 568,
"tools_with_examples": 568,
"tools_with_interface_mode": 568,
"unmatched_skill_tools": 125,
"unreviewed_skill_tools": 7
},
@@ -3877,7 +3877,7 @@
{
"tool_path": "sheet range",
"source": "skills/mono/references/products/sheet.md",
"line": 65,
"line": 66,
"candidates": [
"sheet range batch-clear",
"sheet range batch-set-style",
@@ -3891,7 +3891,7 @@
{
"tool_path": "sheet filter-view",
"source": "skills/mono/references/products/sheet.md",
"line": 66,
"line": 67,
"candidates": [
"sheet filter-view create",
"sheet filter-view delete",
File diff suppressed because it is too large. Load diff
+16
View File
@@ -465,6 +465,10 @@
"canonical_path": "aitable.view_update_visible_fields",
"cli_path": "aitable view update visible-fields"
},
{
"canonical_path": "aitable.workflow_create",
"cli_path": "aitable workflow create"
},
{
"canonical_path": "aitable.workflow_disable",
"cli_path": "aitable workflow disable"
@@ -480,6 +484,10 @@
{
"canonical_path": "aitable.workflow_list",
"cli_path": "aitable workflow list"
},
{
"canonical_path": "aitable.workflow_update",
"cli_path": "aitable workflow update"
}
]
},
@@ -2110,6 +2118,14 @@
"canonical_path": "sheet.info",
"cli_path": "sheet info"
},
{
"canonical_path": "sheet.import",
"cli_path": "sheet import create"
},
{
"canonical_path": "sheet.import_get",
"cli_path": "sheet import get"
},
{
"canonical_path": "sheet.insert_dimension",
"cli_path": "sheet insert-dimension"
@@ -89,6 +89,7 @@ func init() {
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
registerRequireOneOf("sheet.import", "folder-token", "workspace")
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
}
@@ -959,6 +959,33 @@
"availability": "available",
"reviewed": true
},
"aitable.workflow_create": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"parameters": {
"base-id": {
"property": "baseId",
"required": true
},
"dsl": {
"property": "dsl",
"required": true,
"interface_type": "object"
},
"locale": {
"property": "locale"
}
},
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
"cli_path": "aitable workflow create",
"runtime_gate": "none"
},
"aitable.workflow_disable": {
"effect": "write",
"risk": "high",
@@ -998,6 +1025,37 @@
"interface_mode": "mcp",
"availability": "available",
"reviewed": true
},
"aitable.workflow_update": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"parameters": {
"base-id": {
"property": "baseId",
"required": true
},
"workflow-id": {
"property": "workflowId",
"required": true
},
"dsl": {
"property": "dsl",
"required": true,
"interface_type": "object"
},
"locale": {
"property": "locale"
}
},
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
"cli_path": "aitable workflow update",
"runtime_gate": "none"
}
}
}
@@ -392,6 +392,28 @@
"availability": "available",
"reviewed": true
},
"sheet.import": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "non_idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "该 CLI 导入流程包含本地文件校验、创建导入会话、OSS 上传、确认转换和任务轮询,不能绑定为单一 interface_ref。",
"reviewed": true,
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
},
"sheet.import_get": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "该 CLI 续查命令显式复用 doc server 的 query_import_task;当前 Sheet 静态接口快照没有可直接绑定的单一 interface_ref。",
"reviewed": true,
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
},
"sheet.insert_dimension": {
"interface_mode": "mcp",
"availability": "available",
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 564,
"source_tools": 568,
"matched_tools": 71
},
"tools": {
@@ -2211,6 +2211,25 @@
"dws-schema-live:aitable.update_view"
]
},
"aitable.workflow_create": {
"agent_summary": "创建并发布 AI 表格自动化工作流。",
"use_when": [
"用户明确要求在已知 Base 中创建自动化,且已准备完整 workflow-dsl/v1 定义时"
],
"avoid_when": [
"已有工作流要修改时用 workflow update;仅启停已有工作流用 enable/disable;请求结果不确定时先 list 核对,避免非幂等重复创建"
],
"examples": [
"dws aitable workflow create --base-id <BASE_ID> --dsl @workflow.json --locale zh-CN"
],
"reviewed": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
"source_refs": [
"internal/cli/schema_command_registry.json#aitable.workflow_create",
"cobra-help:dws aitable workflow create --help",
"skills/mono/references/products/aitable/aitable-workflow.md"
]
},
"aitable.workflow_disable": {
"agent_summary": "禁用工作流(高危,需确认;status 变 STOP)。",
"use_when": [
@@ -2290,6 +2309,25 @@
"skills/mono/references/products/aitable.md",
"dws-schema-live:none (helper/composite; Skill+Cobra)"
]
},
"aitable.workflow_update": {
"agent_summary": "用完整 DSL 更新并发布已有 AI 表格自动化工作流。",
"use_when": [
"用户明确要求修改已知工作流,且已先留底当前详情并准备完整 workflow-dsl/v1 目标定义时"
],
"avoid_when": [
"新建工作流用 create;只改变运行状态用 enable/disable;不要把局部 patch 或 workflow get 返回的 flowSchema 直接当作 DSL"
],
"examples": [
"dws aitable workflow update --base-id <BASE_ID> --workflow-id <FLOW_ID> --dsl @workflow.json --locale zh-CN"
],
"reviewed": true,
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
"source_refs": [
"internal/cli/schema_command_registry.json#aitable.workflow_update",
"cobra-help:dws aitable workflow update --help",
"skills/mono/references/products/aitable/aitable-workflow.md"
]
}
},
"products": {
+48 -3
View File
@@ -849,6 +849,49 @@
"dws-schema-live:sheet.get_sheet"
]
},
"sheet.import": {
"agent_summary": "将本地 xlsx/xls 文件导入为新的钉钉在线电子表格。",
"use_when": [
"用户要把本地 Excel 文件转换为可在线编辑的钉钉表格时"
],
"avoid_when": [
"读取或修改已有在线表格时应使用 sheet list/info/range 等命令",
"查询已提交导入任务的状态时使用 sheet import get,不要重复创建导入任务"
],
"examples": [
"dws sheet import create --file ./report.xlsx --folder-token <FOLDER_TOKEN> --format json",
"dws sheet import create --file ./data.xls --workspace <WORKSPACE_ID> --name \"月度报表\" --format json"
],
"reviewed": true,
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
"source_refs": [
"internal/cli/schema_command_registry.json",
"cobra-help:dws sheet import create --help",
"skills/mono/references/products/sheet/sheet-import.md",
"live-mcp:none (Cobra+Skill; composite/unpinned)"
]
},
"sheet.import_get": {
"agent_summary": "根据 taskId 查询表格导入任务结果。",
"use_when": [
"已有 sheet import 返回的 taskId,需要在导入超时或中断后续查转换状态时"
],
"avoid_when": [
"发起新的 xlsx/xls 导入应使用 sheet import;不要用本命令代替导入",
"状态仍为 processing 时不要重新提交 import,稍后继续查询同一 taskId"
],
"examples": [
"dws sheet import get --task-id <TASK_ID> --format json"
],
"reviewed": true,
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
"source_refs": [
"internal/cli/schema_command_registry.json",
"cobra-help:dws sheet import get --help",
"skills/mono/references/products/sheet/sheet-import.md",
"live-mcp:none (Cobra+Skill; composite/unpinned)"
]
},
"sheet.insert_dimension": {
"agent_summary": "在指定位置之前插入空行或空列。",
"use_when": [
@@ -1552,12 +1595,14 @@
},
"products": {
"sheet": {
"agent_summary": "创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
"agent_summary": "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
"use_when": [
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时"
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时"
],
"avoid_when": [
"目标是 AI 表格 Base 的结构化记录、钉钉文档正文或本地 Excel 文件时不要使用 sheet"
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
"只读取已上传但未转换的 xlsx/xls 节点时先用 doc download;不要把文件节点直接传给单元格命令"
],
"reviewed": true,
"review_reason": "人工结合实时 MCP/Skill/Cobra 审阅产品级选择边界。",
+3 -7
View File
@@ -199,17 +199,13 @@ func TestReadFileBoundedEmptyFile(t *testing.T) {
}
}
func TestReadFileBoundedPermissionDenied(t *testing.T) {
func TestReadFileBoundedRejectsDirectory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "noperm.txt")
writeTestFile(t, path, []byte("secret"))
os.Chmod(path, 0o000)
t.Cleanup(func() { os.Chmod(path, 0o644) })
_, _, err := ReadFileArg("@" + path)
_, _, err := ReadFileArg("@" + dir)
if err == nil {
t.Fatal("expected error for permission denied")
t.Fatal("expected error when reading a directory as a file")
}
}
+124 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
@@ -81,6 +82,44 @@ func resolveRecordsFlag(cmd *cobra.Command) (string, error) {
return "", fmt.Errorf("missing required flag(s): --records (example: --records '[{\"cells\":{\"fldTextId\":\"文本内容\"}}]')\n hint: for large payloads or Windows, use --records-file ./path/to/records.json")
}
// resolveWorkflowDSL reads --dsl from inline JSON, @file, or stdin (-), then
// decodes the MCP-facing workflow-dsl/v1 object. Detailed DSL validation stays
// on the workflow service so callers receive its structured issues response.
func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
raw := mustGetFlag(cmd, "dsl")
switch {
case raw == "-":
data, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, fmt.Errorf("--dsl stdin read failed: %w", err)
}
raw = string(data)
case strings.HasPrefix(raw, "@"):
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" {
return nil, fmt.Errorf("--dsl file path must not be empty\n hint: use --dsl @workflow.json")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("--dsl file read failed: %w\n hint: ensure the file exists and contains a workflow-dsl/v1 JSON object", err)
}
raw = string(data)
}
if strings.TrimSpace(raw) == "" {
return nil, fmt.Errorf("--dsl must not be empty\n hint: pass inline JSON, @workflow.json, or - for stdin")
}
var dsl map[string]any
if err := json.Unmarshal([]byte(raw), &dsl); err != nil {
return nil, fmt.Errorf("--dsl JSON parse failed: %w\n hint: --dsl must be a workflow-dsl/v1 JSON object", err)
}
if dsl == nil {
return nil, fmt.Errorf("--dsl must be a JSON object, got null")
}
return dsl, nil
}
// recordQueryFetchAll implements --all auto-pagination for record query.
// - pageLimit controls max pages (default 50, 0 = unlimited)
// - Mid-loop errors break gracefully, outputting already-fetched data
@@ -3204,16 +3243,87 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
workflowCmd := &cobra.Command{
Use: "workflow",
Short: "自动化工作流管理(启停 / 查看 / 列表)",
Short: "自动化工作流管理(创建 / 更新 / 启停 / 查看 / 列表)",
RunE: groupRunE,
}
workflowCreateCmd := &cobra.Command{
Use: "create",
Short: "创建并发布自动化工作流",
Long: `在指定 Base 中创建并发布自动化工作流。
--dsl 必须是完整的 workflow-dsl/v1 JSON 对象;涉及数据表、字段或视图的节点应使用真实的 sheetId / fieldId / viewId。
--dsl 支持内联 JSON、@文件路径,或 - 从 stdin 读取。创建属于非幂等操作,CLI 不会自动重试。
返回 data.valid、flowId、flowSchema、stepNodeIds、referenceMap、issues;即使 status=success,
valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正后再调用。`,
Example: ` dws aitable workflow create --base-id BASE_ID --dsl @workflow.json --locale zh-CN
cat workflow.json | dws aitable workflow create --base-id BASE_ID --dsl -`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "dsl"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
dsl, err := resolveWorkflowDSL(cmd)
if err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID,
"dsl": dsl,
}
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
toolArgs["locale"] = locale
}
// create_workflow is non-idempotent. Bypass the retry wrapper to
// prevent an uncertain first response from creating a duplicate.
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
},
}
workflowUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新并发布已有自动化工作流",
Long: `在指定 Base 中更新并发布已有自动化工作流。
建议先用 workflow get 留底当前详情;--dsl 必须是完整的 workflow-dsl/v1 JSON 对象,而不是局部 patch。
--dsl 支持内联 JSON、@文件路径,或 - 从 stdin 读取。更新会发布传入的目标 DSL;请提供完整、可独立校验的 workflow-dsl/v1 对象。
返回 data.valid、flowId、flowSchema、stepNodeIds、referenceMap、issues;即使 status=success,
valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正后再调用。`,
Example: ` dws aitable workflow update --base-id BASE_ID --workflow-id WORKFLOW_ID --dsl @workflow.json --locale zh-CN
cat workflow.json | dws aitable workflow update --base-id BASE_ID --workflow-id WORKFLOW_ID --dsl -`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "workflow-id", "dsl"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
dsl, err := resolveWorkflowDSL(cmd)
if err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID,
"workflowId": mustGetFlag(cmd, "workflow-id"),
"dsl": dsl,
}
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
toolArgs["locale"] = locale
}
return callAitableTool("update_workflow", toolArgs)
},
}
workflowEnableCmd := &cobra.Command{
Use: "enable",
Short: "启用指定工作流",
Long: `启用指定 Base 中的自动化工作流。启用后工作流将按配置的触发条件自动执行。
返回 {workflowId, enabled} 用于确认操作结果(enabled 为动作确认而非状态查询)。
当前不支持通过 CLI 新建工作流,请在 AI 表格 Web 端配置好后用 workflow list 拿到 workflowId 再启停。`,
可用 workflow create 创建工作流,或用 workflow list 获取已有 workflowId。`,
Example: ` dws aitable workflow enable --base-id BASE_ID --workflow-id WORKFLOW_ID
# 查询 workflowId: dws aitable workflow list --base-id <baseId>`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -4750,6 +4860,13 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
formCmd.AddCommand(formListCmd, formGetCmd, formCreateCmd, formDeleteCmd, formUpdateCmd, formFieldCmd, formShareCmd, formQuestionsCmd)
// workflow
workflowCreateCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
workflowCreateCmd.Flags().String("dsl", "", "workflow-dsl/v1 JSON 对象;支持内联 JSON、@文件路径或 - 从 stdin 读取 (必填)")
workflowCreateCmd.Flags().String("locale", "", "请求语言,例如 zh-CN 或 zh_CN (可选)")
workflowUpdateCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
workflowUpdateCmd.Flags().String("workflow-id", "", "目标工作流 ID (必填)")
workflowUpdateCmd.Flags().String("dsl", "", "workflow-dsl/v1 JSON 对象;支持内联 JSON、@文件路径或 - 从 stdin 读取 (必填)")
workflowUpdateCmd.Flags().String("locale", "", "请求语言,例如 zh-CN 或 zh_CN (可选)")
workflowEnableCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
workflowEnableCmd.Flags().String("workflow-id", "", "目标工作流 ID (必填)")
workflowDisableCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
@@ -4759,7 +4876,11 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
workflowListCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
workflowListCmd.Flags().Int("limit", 0, "分页大小 [1, 100],不传走服务端默认 20")
workflowListCmd.Flags().Int("offset", 0, "分页偏移量,>= 0,不传走服务端默认 0")
workflowCmd.AddCommand(workflowEnableCmd, workflowDisableCmd, workflowGetCmd, workflowListCmd)
workflowCmd.AddCommand(
workflowCreateCmd, workflowUpdateCmd,
workflowEnableCmd, workflowDisableCmd,
workflowGetCmd, workflowListCmd,
)
// dashboard
dashboardGetCmd.Flags().String("base-id", "", "所属 Base ID (必填)")
@@ -0,0 +1,168 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package helpers
import (
"context"
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type aitableWorkflowCall struct {
productID string
toolName string
args map[string]any
}
type aitableWorkflowCaller struct {
calls []aitableWorkflowCall
}
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
return &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
}}}, nil
}
func (*aitableWorkflowCaller) Format() string { return "json" }
func (*aitableWorkflowCaller) DryRun() bool { return false }
func (*aitableWorkflowCaller) Fields() string { return "" }
func (*aitableWorkflowCaller) JQ() string { return "" }
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
t.Helper()
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
caller := &aitableWorkflowCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
cmd := newAitableCommand()
cmd.PersistentFlags().String("format", "json", "output format")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append([]string{"workflow"}, args...))
if stdin != nil {
cmd.SetIn(stdin)
}
return caller, cmd.Execute()
}
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
wantDSL := map[string]any{
"version": "workflow-dsl/v1",
"name": "create test",
}
caller, err := runAitableWorkflowCommand(t, nil,
"create",
"--base-id", "base-create",
"--dsl", `{"version":"workflow-dsl/v1","name":"create test"}`,
"--locale", "zh-CN",
)
if err != nil {
t.Fatalf("workflow create returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.productID != "aitable" || call.toolName != "create_workflow" {
t.Fatalf("tool call = %s/%s, want aitable/create_workflow", call.productID, call.toolName)
}
wantArgs := map[string]any{
"baseId": "base-create",
"dsl": wantDSL,
"locale": "zh-CN",
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
func TestAitableWorkflowUpdateReadsDSLFile(t *testing.T) {
path := t.TempDir() + "/workflow.json"
if err := os.WriteFile(path, []byte(`{"version":"workflow-dsl/v1","name":"updated"}`), 0o600); err != nil {
t.Fatalf("write workflow fixture: %v", err)
}
caller, err := runAitableWorkflowCommand(t, nil,
"update",
"--base-id", "base-update",
"--workflow-id", "flow-existing",
"--dsl", "@"+path,
)
if err != nil {
t.Fatalf("workflow update returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.productID != "aitable" || call.toolName != "update_workflow" {
t.Fatalf("tool call = %s/%s, want aitable/update_workflow", call.productID, call.toolName)
}
wantArgs := map[string]any{
"baseId": "base-update",
"workflowId": "flow-existing",
"dsl": map[string]any{
"version": "workflow-dsl/v1",
"name": "updated",
},
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
func TestAitableWorkflowCreateReadsDSLFromStdin(t *testing.T) {
caller, err := runAitableWorkflowCommand(t,
strings.NewReader(`{"version":"workflow-dsl/v1","name":"stdin"}`),
"create", "--base-id", "base-stdin", "--dsl", "-",
)
if err != nil {
t.Fatalf("workflow create from stdin returned error: %v", err)
}
if got := caller.calls[0].args["dsl"].(map[string]any)["name"]; got != "stdin" {
t.Fatalf("dsl name = %#v, want stdin", got)
}
}
func TestAitableWorkflowWriteRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "create missing dsl", args: []string{"create", "--base-id", "base"}, want: "dsl"},
{name: "update missing workflow", args: []string{"update", "--base-id", "base", "--dsl", `{}`}, want: "workflow-id"},
{name: "malformed json", args: []string{"create", "--base-id", "base", "--dsl", "{not-json"}, want: "JSON parse failed"},
{name: "array", args: []string{"create", "--base-id", "base", "--dsl", `[]`}, want: "JSON parse failed"},
{name: "null", args: []string{"create", "--base-id", "base", "--dsl", `null`}, want: "JSON object"},
{name: "empty file path", args: []string{"create", "--base-id", "base", "--dsl", "@"}, want: "file path"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want substring %q", err, tc.want)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid input reached MCP: %#v", caller.calls)
}
})
}
}
+19 -14
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -27,13 +28,15 @@ func TestAtomicWrite_Basic(t *testing.T) {
t.Fatalf("got %q, want %q", got, data)
}
// Check permissions
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if perm := info.Mode().Perm(); perm != 0600 {
t.Fatalf("permissions = %o, want 0600", perm)
// Windows does not expose POSIX permission bits.
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if perm := info.Mode().Perm(); perm != 0600 {
t.Fatalf("permissions = %o, want 0600", perm)
}
}
}
@@ -149,12 +152,14 @@ func TestAtomicWriteJSON_Basic(t *testing.T) {
t.Fatalf("got %q, want %q", got, data)
}
// Check permissions are 0600
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if perm := info.Mode().Perm(); perm != 0600 {
t.Fatalf("permissions = %o, want 0600", perm)
// Windows does not expose POSIX permission bits.
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if perm := info.Mode().Perm(); perm != 0600 {
t.Fatalf("permissions = %o, want 0600", perm)
}
}
}
+15 -1
View File
@@ -17,6 +17,7 @@ import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -24,7 +25,20 @@ import (
// writeExecStub drops an executable shell stub named name into dir so PATH
// lookups resolve without the real CLI installed.
func writeExecStub(dir, name string) error {
return os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755)
path := filepath.Join(dir, name)
body := []byte("#!/bin/sh\n")
if runtime.GOOS == "windows" {
path += ".exe"
body = nil
}
return os.WriteFile(path, body, 0o755)
}
func requirePOSIXShell(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("test fixture requires a POSIX shell executable")
}
}
// TestConvSessions covers the per-conversation session contract: first message
@@ -26,6 +26,7 @@ import (
func writeShellExecutable(t *testing.T, dir, name, body string) string {
t.Helper()
requirePOSIXShell(t)
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil {
t.Fatalf("write stub %s: %v", name, err)
@@ -37,7 +38,9 @@ func TestForwarderForChannelCodexPrefersAppServer(t *testing.T) {
clearChannelEnv(t)
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
stub := t.TempDir()
writeShellExecutable(t, stub, "codex", "exit 0\n")
if err := writeExecStub(stub, "codex"); err != nil {
t.Fatalf("write codex stub: %v", err)
}
t.Setenv("PATH", stub)
fwd, err := forwarderForChannel("codex", "", connectAgentOptions{Memory: true})
+9 -6
View File
@@ -17,6 +17,7 @@ import (
"bytes"
"encoding/json"
"os"
"runtime"
"strings"
"testing"
"time"
@@ -89,12 +90,14 @@ func TestStageDaemonExecutable(t *testing.T) {
if string(got) != "test-binary" {
t.Fatalf("content = %q, want test-binary", got)
}
info, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != daemonExecutablePerm {
t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm)
if runtime.GOOS != "windows" {
info, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != daemonExecutablePerm {
t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm)
}
}
}
@@ -23,6 +23,7 @@ import (
)
func TestExecForwarderRetriesMissingSessionOnce(t *testing.T) {
requirePOSIXShell(t)
dir := t.TempDir()
bin := filepath.Join(dir, "agent")
logPath := filepath.Join(dir, "calls.log")
@@ -68,6 +69,7 @@ echo fresh-ok
}
func TestExecForwarderStreamRetriesMissingSessionOnce(t *testing.T) {
requirePOSIXShell(t)
dir := t.TempDir()
bin := filepath.Join(dir, "agent-stream")
logPath := filepath.Join(dir, "calls.log")
@@ -113,6 +115,7 @@ printf '%s\n' '{"type":"result","result":"fresh-stream-ok"}'
}
func TestExecForwarderDoesNotRetryNonSessionErrors(t *testing.T) {
requirePOSIXShell(t)
dir := t.TempDir()
bin := filepath.Join(dir, "agent")
logPath := filepath.Join(dir, "calls.log")
+5 -13
View File
@@ -14,13 +14,13 @@
package helpers
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
eventprocess "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
)
// connectLockDir is a var so tests can isolate lock files.
@@ -67,16 +67,8 @@ func sanitizeLockID(s string) string {
}, s)
}
// processAlive reports whether pid refers to a live process (unix signal-0
// probe; a permission error still means "alive").
// processAlive reports whether pid refers to a live process using the shared
// Unix signal-0 / Windows OpenProcess implementation.
func processAlive(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
err = proc.Signal(syscall.Signal(0))
if err == nil {
return true
}
return errors.Is(err, syscall.EPERM)
return eventprocess.Alive(pid)
}
@@ -108,6 +108,7 @@ func TestForwarderProviderBaseURLDropsHaiku(t *testing.T) {
// agent that prints "API Error: 422 ..." to stdout (and exits 0) must NOT have
// that raw error forwarded as the answer; forward returns an actionable hint.
func TestForwardReturnsHintOnAPIError(t *testing.T) {
requirePOSIXShell(t)
stub := t.TempDir()
bin := filepath.Join(stub, "fakeagent")
// Print a provider 422 to stdout and exit 0, exactly like claude does when
@@ -26,6 +26,7 @@ import (
func writeQoderStreamStub(t *testing.T, dir string) string {
t.Helper()
requirePOSIXShell(t)
logPath := filepath.Join(dir, "qoder-starts.log")
script := `#!/usr/bin/env python3
import json
+1
View File
@@ -40,6 +40,7 @@ func TestForwardConnectTurnPreservesAttachmentsForCapableAgent(t *testing.T) {
}
func TestExecForwarderAllowsOnlyAttachmentDirectory(t *testing.T) {
requirePOSIXShell(t)
dir := t.TempDir()
logPath := filepath.Join(dir, "args.log")
stub := filepath.Join(dir, "agent")
+3 -1
View File
@@ -223,7 +223,9 @@ func TestResolveAgentYoloMode(t *testing.T) {
func TestForwarderForChannel(t *testing.T) {
clearChannelEnv(t)
stub := t.TempDir()
writeShellExecutable(t, stub, "codex", "exit 0\n")
if err := writeExecStub(stub, "codex"); err != nil {
t.Fatalf("write codex stub: %v", err)
}
t.Setenv("PATH", stub)
// DWS_AGENT_CMD covers ordinary stream-bridge channels so the test does not
// depend on those binaries being installed. Codex ignores it and stays on
+2 -221
View File
@@ -2543,133 +2543,7 @@ CLI 内部自动完成全部流程:
# 自定义导入后的文档名称
dws doc import --file ./draft.md --name "项目周报"`,
RunE: func(cmd *cobra.Command, args []string) error {
filePath := mustGetFlag(cmd, "file")
if filePath == "" && len(args) > 0 {
filePath = args[0]
}
if filePath == "" {
return fmt.Errorf("flag --file is required (or pass file path as argument)")
}
fi, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fi.IsDir() {
return fmt.Errorf("%s is a directory, not a file", filePath)
}
const maxFileSize = 20 * 1024 * 1024
fileSize := fi.Size()
if fileSize > maxFileSize {
return fmt.Errorf("file size %d bytes exceeds 20MB limit", fileSize)
}
if fileSize == 0 {
return fmt.Errorf("file is empty: %s", filePath)
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
supportedFormats := map[string]bool{
"docx": true, "doc": true, "xlsx": true, "xls": true,
"md": true, "txt": true, "xmind": true, "mark": true,
}
if !supportedFormats[ext] {
return fmt.Errorf("unsupported file format %q, supported: docx, doc, xlsx, xls, md, txt, xmind, mark", ext)
}
fileName := filepath.Base(filePath)
name, _ := cmd.Flags().GetString("name")
if name == "" {
name = strings.TrimSuffix(fileName, filepath.Ext(fileName))
}
folder := flagOrFallback(cmd, "folder", "folder-id")
workspace := flagOrFallback(cmd, "workspace", "workspace-id")
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "导入本地文件为在线文档")
deps.Out.PrintKeyValue("文件", filePath)
deps.Out.PrintKeyValue("名称", name)
deps.Out.PrintKeyValue("格式", ext)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
return nil
}
ctx := context.Background()
deps.Out.PrintInfo("[1/4] 创建导入会话...")
sessionArgs := map[string]any{
"fileName": name,
"suffix": ext,
"fileSize": fileSize,
}
if folder != "" {
sessionArgs["targetFolderId"] = folder
}
if workspace != "" {
sessionArgs["workspaceId"] = workspace
}
sessionText, err := callMCPToolReturnText(ctx, "create_import_session", sessionArgs)
if err != nil {
return fmt.Errorf("创建导入会话失败: %w", err)
}
var sessionResult map[string]any
if err := json.Unmarshal([]byte(sessionText), &sessionResult); err != nil {
return fmt.Errorf("解析导入会话响应失败: %w", err)
}
sessionID, _ := sessionResult["sessionId"].(string)
uploadURL, _ := sessionResult["uploadUrl"].(string)
if sessionID == "" || uploadURL == "" {
deps.Out.PrintRaw(sessionText)
return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl")
}
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
deps.Out.PrintInfo("[2/4] 上传文件...")
if err := httpPutFile(ctx, uploadURL, nil, filePath, fileSize); err != nil {
return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err)
}
deps.Out.PrintInfo(" 文件上传完成")
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
confirmText, err := callMCPToolReturnText(ctx, "confirm_import", map[string]any{
"sessionId": sessionID,
})
if err != nil {
return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err)
}
var confirmResult map[string]any
if err := json.Unmarshal([]byte(confirmText), &confirmResult); err != nil {
return fmt.Errorf("解析确认导入响应失败: %w", err)
}
taskID, _ := confirmResult["taskId"].(string)
if taskID == "" {
deps.Out.PrintRaw(confirmText)
return fmt.Errorf("确认导入成功但未返回 taskId")
}
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
importResult, err := pollDocImportTask(ctx, taskID)
if err != nil {
return err
}
documentURL, _ := importResult["documentUrl"].(string)
documentName, _ := importResult["documentName"].(string)
documentType, _ := importResult["documentType"].(string)
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
deps.Out.PrintJSON(map[string]any{
"success": true,
"taskId": taskID,
"documentUrl": documentURL,
"documentName": documentName,
"documentType": documentType,
})
return nil
return runImportCommand(cmd, args, docImportFlowConfig())
},
}
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
@@ -2694,43 +2568,7 @@ CLI 内部自动完成全部流程:
failed 导入失败`,
Example: ` dws doc import get --task-id <TASK_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
taskID := mustGetFlag(cmd, "task-id")
if taskID == "" {
return fmt.Errorf("flag --task-id is required")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "查询导入任务结果")
deps.Out.PrintKeyValue("任务ID", taskID)
return nil
}
ctx := context.Background()
text, err := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID})
if err != nil {
return err
}
var result map[string]any
if err := json.Unmarshal([]byte(text), &result); err != nil {
deps.Out.PrintRaw(text)
return nil
}
status, _ := result["status"].(string)
message, _ := result["message"].(string)
switch strings.ToLower(status) {
case "completed", "processing":
deps.Out.PrintJSON(result)
return nil
default:
deps.Out.PrintJSON(result)
if message != "" {
return fmt.Errorf("导入任务失败 (status=%s): %s", status, message)
}
return fmt.Errorf("导入任务失败 (status=%s)", status)
}
return runImportGetCommand(cmd, docImportFlowConfig())
},
}
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
@@ -3237,63 +3075,6 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er
return "", fmt.Errorf("导出任务超时:已轮询 %d 次仍在处理中 (jobId=%s),请稍后使用 dws doc export get --job-id %s 手动查询", maxPolls, jobID, jobID)
}
// pollDocImportTask polls the import task status with progressive backoff.
func pollDocImportTask(ctx context.Context, taskID string) (map[string]any, error) {
const maxPolls = 30
pollInterval := func(attempt int) time.Duration {
switch {
case attempt <= 5:
return 2 * time.Second
case attempt <= 10:
return 5 * time.Second
case attempt <= 20:
return 10 * time.Second
default:
return 15 * time.Second
}
}
for attempt := 1; attempt <= maxPolls; attempt++ {
interval := pollInterval(attempt)
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
select {
case <-ctx.Done():
return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, ctx.Err())
case <-time.After(interval):
}
text, queryErr := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID})
if queryErr != nil {
return nil, fmt.Errorf("查询导入任务失败 (taskId=%s): %w", taskID, queryErr)
}
var result map[string]any
if parseErr := json.Unmarshal([]byte(text), &result); parseErr != nil {
return nil, fmt.Errorf("解析查询结果失败 (taskId=%s): %w", taskID, parseErr)
}
status, _ := result["status"].(string)
switch strings.ToLower(status) {
case "completed":
return result, nil
case "processing":
continue
case "failed":
message, _ := result["message"].(string)
if message != "" {
return nil, fmt.Errorf("导入任务失败 (taskId=%s): %s", taskID, message)
}
return nil, fmt.Errorf("导入任务失败 (taskId=%s)", taskID)
default:
continue
}
}
return nil, fmt.Errorf("导入任务超时:已轮询 %d 次仍在处理中 (taskId=%s),请稍后使用 dws doc import get --task-id %s 手动查询", maxPolls, taskID, taskID)
}
// stripDuplicateTitle removes the leading H1 heading from markdown content
// when it matches the document name (set via --name). This prevents the title
// from appearing twice: once as document metadata and once in the body.
+404
View File
@@ -0,0 +1,404 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
)
const importMaxFileSize int64 = 20 * 1024 * 1024
type importPollTimeoutError struct {
taskID string
maxPolls int
}
func (e *importPollTimeoutError) Error() string {
return fmt.Sprintf("导入任务超时:已轮询 %d 次仍在处理中 (taskId=%s)", e.maxPolls, e.taskID)
}
type importPollPolicy struct {
maxPolls int
interval func(attempt int) time.Duration
wait func(context.Context, time.Duration) error
}
type importFlowConfig struct {
operation string
queryOperation string
supportedFormats map[string]bool
supportedFormatsText string
folderFlags []string
workspaceFlags []string
requireTarget bool
serverID string
includeNodeID bool
timeoutAsResult bool
nextCommand string
poll importPollPolicy
}
type preparedImportFile struct {
path string
name string
extension string
size int64
folder string
workspace string
}
func defaultImportPollPolicy() importPollPolicy {
return importPollPolicy{
maxPolls: 30,
interval: func(attempt int) time.Duration {
switch {
case attempt <= 5:
return 2 * time.Second
case attempt <= 10:
return 5 * time.Second
case attempt <= 20:
return 10 * time.Second
default:
return 15 * time.Second
}
},
wait: waitForImportPoll,
}
}
func docImportFlowConfig() importFlowConfig {
return importFlowConfig{
operation: "导入本地文件为在线文档",
queryOperation: "查询导入任务结果",
supportedFormats: map[string]bool{"docx": true, "doc": true, "xlsx": true, "xls": true, "md": true, "txt": true, "xmind": true, "mark": true},
supportedFormatsText: "docx, doc, xlsx, xls, md, txt, xmind, mark",
folderFlags: []string{"folder", "folder-id"},
workspaceFlags: []string{"workspace", "workspace-id"},
nextCommand: "dws doc import get --task-id %s",
poll: defaultImportPollPolicy(),
}
}
func sheetImportFlowConfig() importFlowConfig {
return importFlowConfig{
operation: "导入本地表格文件为在线电子表格",
queryOperation: "查询表格导入任务结果",
supportedFormats: map[string]bool{"xlsx": true, "xls": true},
supportedFormatsText: "xlsx, xls",
folderFlags: []string{"folder-token", "folder"},
workspaceFlags: []string{"workspace"},
requireTarget: true,
serverID: "doc",
includeNodeID: true,
timeoutAsResult: true,
nextCommand: "dws sheet import get --task-id %s",
poll: defaultImportPollPolicy(),
}
}
func waitForImportPoll(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func importFlagValue(cmd *cobra.Command, names ...string) string {
for _, name := range names {
if cmd.Flags().Lookup(name) == nil {
continue
}
if value, _ := cmd.Flags().GetString(name); value != "" {
return value
}
}
return ""
}
func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig) (preparedImportFile, error) {
filePath := mustGetFlag(cmd, "file")
if filePath == "" && len(args) > 0 {
filePath = args[0]
}
if filePath == "" {
return preparedImportFile{}, fmt.Errorf("flag --file is required (or pass file path as argument)")
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return preparedImportFile{}, fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return preparedImportFile{}, fmt.Errorf("%s is a directory, not a file", filePath)
}
if fileInfo.Size() > importMaxFileSize {
return preparedImportFile{}, fmt.Errorf("file size %d bytes exceeds 20MB limit", fileInfo.Size())
}
if fileInfo.Size() == 0 {
return preparedImportFile{}, fmt.Errorf("file is empty: %s", filePath)
}
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
if !cfg.supportedFormats[extension] {
return preparedImportFile{}, fmt.Errorf("unsupported file format %q, supported: %s", extension, cfg.supportedFormatsText)
}
name, _ := cmd.Flags().GetString("name")
if name == "" {
fileName := filepath.Base(filePath)
name = strings.TrimSuffix(fileName, filepath.Ext(fileName))
}
folder := importFlagValue(cmd, cfg.folderFlags...)
workspace := importFlagValue(cmd, cfg.workspaceFlags...)
if cfg.requireTarget && folder == "" && workspace == "" {
return preparedImportFile{}, fmt.Errorf("--folder-token 与 --workspace 至少需要提供一个(导入目标位置)")
}
return preparedImportFile{
path: filePath,
name: name,
extension: extension,
size: fileInfo.Size(),
folder: folder,
workspace: workspace,
}, nil
}
func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args map[string]any) (string, error) {
if cfg.serverID != "" {
return callMCPToolReturnTextOnServer(ctx, cfg.serverID, toolName, args)
}
return callMCPToolReturnText(ctx, toolName, args)
}
func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) error {
file, err := prepareImportFile(cmd, args, cfg)
if err != nil {
return err
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", cfg.operation)
deps.Out.PrintKeyValue("文件", file.path)
deps.Out.PrintKeyValue("名称", file.name)
deps.Out.PrintKeyValue("格式", file.extension)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", file.size))
return nil
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
deps.Out.PrintInfo("[1/4] 创建导入会话...")
sessionArgs := map[string]any{
"fileName": file.name,
"suffix": file.extension,
"fileSize": file.size,
}
if file.folder != "" {
sessionArgs["targetFolderId"] = file.folder
}
if file.workspace != "" {
sessionArgs["workspaceId"] = file.workspace
}
sessionText, err := cfg.callTool(ctx, "create_import_session", sessionArgs)
if err != nil {
return fmt.Errorf("创建导入会话失败: %w", err)
}
var sessionResult map[string]any
if err := json.Unmarshal([]byte(sessionText), &sessionResult); err != nil {
return fmt.Errorf("解析导入会话响应失败: %w", err)
}
sessionID, _ := sessionResult["sessionId"].(string)
uploadURL, _ := sessionResult["uploadUrl"].(string)
if sessionID == "" || uploadURL == "" {
deps.Out.PrintRaw(sessionText)
return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl")
}
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
deps.Out.PrintInfo("[2/4] 上传文件...")
if err := httpPutFile(ctx, uploadURL, nil, file.path, file.size); err != nil {
return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err)
}
deps.Out.PrintInfo(" 文件上传完成")
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
confirmText, err := cfg.callTool(ctx, "confirm_import", map[string]any{"sessionId": sessionID})
if err != nil {
return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err)
}
var confirmResult map[string]any
if err := json.Unmarshal([]byte(confirmText), &confirmResult); err != nil {
return fmt.Errorf("解析确认导入响应失败: %w", err)
}
taskID, _ := confirmResult["taskId"].(string)
if taskID == "" {
deps.Out.PrintRaw(confirmText)
return fmt.Errorf("确认导入成功但未返回 taskId")
}
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
result, err := pollImportTask(ctx, taskID, cfg)
if err != nil {
var timeoutErr *importPollTimeoutError
if !errors.As(err, &timeoutErr) {
return err
}
if cfg.timeoutAsResult {
deps.Out.PrintInfo(timeoutErr.Error())
_ = deps.Out.PrintJSON(map[string]any{
"success": false,
"timed_out": true,
"taskId": taskID,
"status": "processing",
"next_command": fmt.Sprintf(cfg.nextCommand, taskID),
})
return nil
}
return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), fmt.Sprintf(cfg.nextCommand, taskID))
}
documentURL, _ := result["documentUrl"].(string)
documentName, _ := result["documentName"].(string)
documentType, _ := result["documentType"].(string)
finalResult := map[string]any{
"success": true,
"taskId": taskID,
"documentUrl": documentURL,
"documentName": documentName,
"documentType": documentType,
}
if cfg.includeNodeID {
finalResult["nodeId"] = extractNodeIDFromDocURL(documentURL)
}
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
_ = deps.Out.PrintJSON(finalResult)
return nil
}
func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
taskID := mustGetFlag(cmd, "task-id")
if taskID == "" {
return fmt.Errorf("flag --task-id is required")
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", cfg.queryOperation)
deps.Out.PrintKeyValue("任务ID", taskID)
return nil
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
text, err := cfg.callTool(ctx, "query_import_task", map[string]any{"taskId": taskID})
if err != nil {
return err
}
var result map[string]any
if err := json.Unmarshal([]byte(text), &result); err != nil {
deps.Out.PrintRaw(text)
return nil
}
status, _ := result["status"].(string)
message, _ := result["message"].(string)
if strings.EqualFold(status, "completed") {
if cfg.includeNodeID {
documentURL, _ := result["documentUrl"].(string)
result["nodeId"] = extractNodeIDFromDocURL(documentURL)
}
_ = deps.Out.PrintJSON(result)
return nil
}
if strings.EqualFold(status, "processing") {
_ = deps.Out.PrintJSON(result)
return nil
}
_ = deps.Out.PrintJSON(result)
if message != "" {
return fmt.Errorf("导入任务失败 (status=%s): %s", status, message)
}
return fmt.Errorf("导入任务失败 (status=%s)", status)
}
func pollImportTask(ctx context.Context, taskID string, cfg importFlowConfig) (map[string]any, error) {
poll := cfg.poll
if poll.maxPolls <= 0 || poll.interval == nil || poll.wait == nil {
poll = defaultImportPollPolicy()
}
for attempt := 1; attempt <= poll.maxPolls; attempt++ {
interval := poll.interval(attempt)
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval))
if err := poll.wait(ctx, interval); err != nil {
return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, err)
}
text, err := cfg.callTool(ctx, "query_import_task", map[string]any{"taskId": taskID})
if err != nil {
return nil, fmt.Errorf("查询导入任务失败 (taskId=%s): %w", taskID, err)
}
var result map[string]any
if err := json.Unmarshal([]byte(text), &result); err != nil {
return nil, fmt.Errorf("解析查询结果失败 (taskId=%s): %w", taskID, err)
}
status, _ := result["status"].(string)
switch strings.ToLower(status) {
case "completed":
return result, nil
case "processing":
continue
case "failed":
message, _ := result["message"].(string)
if message != "" {
return nil, fmt.Errorf("导入任务失败 (taskId=%s): %s", taskID, message)
}
return nil, fmt.Errorf("导入任务失败 (taskId=%s)", taskID)
}
}
return nil, &importPollTimeoutError{taskID: taskID, maxPolls: poll.maxPolls}
}
func extractNodeIDFromDocURL(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Path == "" {
return ""
}
nodeID := path.Base(strings.TrimRight(parsed.Path, "/"))
if nodeID == "." || nodeID == "/" {
return ""
}
return nodeID
}
+3 -1
View File
@@ -93,6 +93,7 @@ func newSheetCommand() *cobra.Command {
dws sheet chart update 更新浮动图表
dws sheet chart delete 删除浮动图表
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
dws sheet import 导入 xlsx/xls 为在线电子表格
dws sheet template list 获取表格模板列表
dws sheet template search 搜索表格模板
dws sheet template apply 应用表格模板创建新表格文档`,
@@ -110,6 +111,7 @@ func newSheetCommand() *cobra.Command {
floatImageCmds := newFloatImageCmds()
chartCmd := newChartCmd()
exportCmd := newExportCmd()
importCmd := newSheetImportCmd()
templateCmd := newSheetTemplateCmd()
tableCmds := newTableCmds()
pivotTableCmd := newPivotTableCmd()
@@ -134,7 +136,7 @@ func newSheetCommand() *cobra.Command {
standaloneCmds = append(standaloneCmds, mediaCmds...)
standaloneCmds = append(standaloneCmds, floatImageCmds...)
standaloneCmds = append(standaloneCmds, tableCmds...)
standaloneCmds = append(standaloneCmds, exportCmd, batchUpdateCmd)
standaloneCmds = append(standaloneCmds, exportCmd, importCmd, batchUpdateCmd)
// Register cross-product aliases
for _, cmd := range standaloneCmds {
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import "github.com/spf13/cobra"
const sheetImportLong = `将本地表格文件导入为一个新的钉钉在线电子表格,与 dws sheet export(导出)对称。
支持的文件格式 (按扩展名):
xlsx, xls Microsoft Excel 表格
文件大小限制: 20MB
固定导入为电子表格类型;只新建文档,不操作已有表格。
CLI 内部自动完成全部流程:
1. 创建导入会话(获取 OSS 上传凭证)
2. 上传文件到 OSS
3. 确认导入(触发格式转换)
4. 渐进式退避轮询等待完成(最多约 5 分钟)
如果轮询超时仍未完成,会输出 taskId 供后续手动查询:
dws sheet import get --task-id <taskId>`
func newSheetImportCmd() *cobra.Command {
return newSheetImportCmdWithConfig(sheetImportFlowConfig())
}
func newSheetImportCmdWithConfig(cfg importFlowConfig) *cobra.Command {
importCmd := &cobra.Command{
Use: "import",
Short: "导入本地表格文件为在线电子表格 (xlsx / xls)",
Long: sheetImportLong,
Example: ` # 导入 xlsx 为在线电子表格(默认表格名取文件名)
dws sheet import --file ./quote.xlsx --folder-token <FOLDER_TOKEN>
# 指定目标文件夹与导入后表格名称
dws sheet import --file ./report.xls --folder-token <FOLDER_TOKEN> --name "月度报表"
# 导入到指定知识库
dws sheet import --file ./data.xls --workspace <WORKSPACE_ID>`,
RunE: func(cmd *cobra.Command, args []string) error {
return runImportCommand(cmd, args, cfg)
},
}
addSheetImportFlags(importCmd)
// Schema deliberately binds only runnable leaves. Keep the historical
// runnable parent for CLI compatibility, and expose the same action through
// a leaf so agents can discover and invoke the import operation.
importCreateCmd := &cobra.Command{
Use: "create",
Short: "导入本地表格文件为在线电子表格 (xlsx / xls)",
Long: sheetImportLong,
Example: ` dws sheet import create --file ./quote.xlsx --folder-token <FOLDER_TOKEN>
dws sheet import create --file ./data.xls --workspace <WORKSPACE_ID> --name "月度报表"`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runImportCommand(cmd, args, cfg)
},
}
addSheetImportFlags(importCreateCmd)
importGetCmd := &cobra.Command{
Use: "get",
Short: "查询表格导入任务结果(手动兜底)",
Long: `根据 taskId 查询表格导入任务的执行结果。
通常不需要手动调用,dws sheet import 会自动完成轮询。
仅在导入命令超时或中断后,用于手动查询任务状态。
任务状态:
processing 转换中
completed 导入成功,返回 documentUrl
failed 导入失败`,
Example: ` dws sheet import get --task-id <TASK_ID>`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runImportGetCommand(cmd, cfg)
},
}
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
importCmd.AddCommand(importCreateCmd, importGetCmd)
return importCmd
}
func addSheetImportFlags(cmd *cobra.Command) {
cmd.Flags().String("file", "", "本地表格文件路径 (必填,支持 xlsx/xls)")
cmd.Flags().String("folder-token", "", "目标文件夹 ID 或 URL (与 --workspace 至少传一个)")
cmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (与 --folder-token 至少传一个)")
cmd.Flags().StringP("name", "n", "", "导入后表格名称 (可选,默认取文件名)")
cmd.Flags().String("folder", "", "")
_ = cmd.Flags().MarkHidden("folder")
}
+313
View File
@@ -0,0 +1,313 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type sheetImportCall struct {
server string
tool string
args map[string]any
}
type sheetImportCaller struct {
calls []sheetImportCall
responses map[string][]string
dryRun bool
}
func (c *sheetImportCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, sheetImportCall{server: server, tool: tool, args: args})
responses := c.responses[tool]
if len(responses) == 0 {
return nil, fmt.Errorf("unexpected tool call %s/%s", server, tool)
}
c.responses[tool] = responses[1:]
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: responses[0]}}}, nil
}
func (*sheetImportCaller) Format() string { return "json" }
func (c *sheetImportCaller) DryRun() bool { return c.dryRun }
func (*sheetImportCaller) Fields() string { return "" }
func (*sheetImportCaller) JQ() string { return "" }
func fastSheetImportConfig() importFlowConfig {
cfg := sheetImportFlowConfig()
cfg.poll.maxPolls = 3
cfg.poll.interval = func(int) time.Duration { return 0 }
cfg.poll.wait = func(context.Context, time.Duration) error { return nil }
return cfg
}
func executeSheetImportCommand(t *testing.T, caller *sheetImportCaller, cfg importFlowConfig, args ...string) (string, error) {
t.Helper()
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
SetHTTPPutFile(nil)
})
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
deps.Out.errW = &output
os.Args = append([]string{"dws", "sheet"}, args...)
root := newSheetImportCmdWithConfig(cfg)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
err := root.Execute()
return output.String(), err
}
func writeImportFixture(t *testing.T, ext string) string {
t.Helper()
filePath := filepath.Join(t.TempDir(), "sales."+ext)
if err := os.WriteFile(filePath, []byte("test workbook"), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
return filePath
}
func TestSheetImportRejectsInvalidInputBeforeRemoteCall(t *testing.T) {
tests := []struct {
name string
fileExt string
args func(string) []string
wantErr string
}{
{
name: "target is required",
fileExt: "xlsx",
args: func(path string) []string { return []string{"--file", path} },
wantErr: "--folder-token 与 --workspace 至少需要提供一个",
},
{
name: "only excel formats are accepted",
fileExt: "csv",
args: func(path string) []string { return []string{"--file", path, "--workspace", "ws1"} },
wantErr: `unsupported file format "csv", supported: xlsx, xls`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &sheetImportCaller{}
_, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), tt.args(writeImportFixture(t, tt.fileExt))...)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
}
})
}
}
func TestSheetImportRunsSharedDocImportFlow(t *testing.T) {
caller := &sheetImportCaller{responses: map[string][]string{
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`},
"confirm_import": {`{"taskId":"task-1"}`},
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-1?from=test","documentName":"Sales","documentType":"1"}`},
}}
SetHTTPPutFile(func(_ context.Context, uploadURL string, _ map[string]string, _ string, size int64) error {
if uploadURL != "https://upload.example.test/object" || size <= 0 {
return fmt.Errorf("unexpected upload url=%s size=%d", uploadURL, size)
}
return nil
})
output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(),
"--file", writeImportFixture(t, "xlsx"),
"--workspace", "workspace-1",
"--name", "Sales",
)
if err != nil {
t.Fatalf("sheet import returned error: %v", err)
}
wantTools := []string{"create_import_session", "confirm_import", "query_import_task"}
if len(caller.calls) != len(wantTools) {
t.Fatalf("calls = %#v", caller.calls)
}
for i, call := range caller.calls {
if call.server != "doc" || call.tool != wantTools[i] {
t.Fatalf("call[%d] = %#v, want doc/%s", i, call, wantTools[i])
}
}
wantSession := map[string]any{
"fileName": "Sales",
"suffix": "xlsx",
"fileSize": int64(len("test workbook")),
"workspaceId": "workspace-1",
}
if !reflect.DeepEqual(caller.calls[0].args, wantSession) {
t.Fatalf("session args = %#v, want %#v", caller.calls[0].args, wantSession)
}
if !strings.Contains(output, `"nodeId": "node-1"`) || !strings.Contains(output, `"success": true`) {
t.Fatalf("output missing success contract:\n%s", output)
}
}
func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
filePath := writeImportFixture(t, "xlsx")
legacy, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(),
"--file", filePath, "--workspace", "workspace-1", "--name", "Sales")
if err != nil {
t.Fatalf("legacy sheet import dry-run returned error: %v", err)
}
leaf, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(),
"create", "--file", filePath, "--workspace", "workspace-1", "--name", "Sales")
if err != nil {
t.Fatalf("sheet import create dry-run returned error: %v", err)
}
if leaf != legacy {
t.Fatalf("create leaf output differs from legacy entry:\nlegacy:\n%s\nleaf:\n%s", legacy, leaf)
}
root := newSheetImportCmdWithConfig(fastSheetImportConfig())
create, _, err := root.Find([]string{"create"})
if err != nil || create == nil {
t.Fatalf("find create leaf: command=%v err=%v", create, err)
}
if !create.Runnable() || create.HasSubCommands() {
t.Fatalf("create command must be a runnable leaf: runnable=%v hasSubcommands=%v", create.Runnable(), create.HasSubCommands())
}
}
func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) {
cfg := fastSheetImportConfig()
cfg.poll.maxPolls = 2
caller := &sheetImportCaller{responses: map[string][]string{
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`},
"confirm_import": {`{"taskId":"task-1"}`},
"query_import_task": {
`{"status":"processing"}`,
`{"status":"processing"}`,
},
}}
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
output, err := executeSheetImportCommand(t, caller, cfg,
"--file", writeImportFixture(t, "xls"), "--folder-token", "folder-1")
if err != nil {
t.Fatalf("timeout should exit successfully, got %v", err)
}
for _, fragment := range []string{
`"success": false`,
`"timed_out": true`,
`"status": "processing"`,
`"next_command": "dws sheet import get --task-id task-1"`,
} {
if !strings.Contains(output, fragment) {
t.Fatalf("output missing %s:\n%s", fragment, output)
}
}
}
func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) {
caller := &sheetImportCaller{responses: map[string][]string{
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2/","documentType":"1"}`},
}}
output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), "get", "--task-id", "task-2")
if err != nil {
t.Fatalf("sheet import get returned error: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "query_import_task" {
t.Fatalf("calls = %#v", caller.calls)
}
if !reflect.DeepEqual(caller.calls[0].args, map[string]any{"taskId": "task-2"}) {
t.Fatalf("args = %#v", caller.calls[0].args)
}
var result map[string]any
jsonStart := strings.Index(output, "{")
if jsonStart < 0 || json.Unmarshal([]byte(output[jsonStart:]), &result) != nil {
t.Fatalf("invalid JSON output:\n%s", output)
}
if result["nodeId"] != "node-2" {
t.Fatalf("nodeId = %#v, want node-2", result["nodeId"])
}
}
func TestDocImportConfigPreservesExistingContract(t *testing.T) {
cfg := docImportFlowConfig()
for _, ext := range []string{"docx", "doc", "xlsx", "xls", "md", "txt", "xmind", "mark"} {
if !cfg.supportedFormats[ext] {
t.Errorf("doc import no longer supports %s", ext)
}
}
if cfg.requireTarget || cfg.includeNodeID || cfg.timeoutAsResult {
t.Fatalf("doc import compatibility changed: %#v", cfg)
}
}
func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) {
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
caller := &sheetImportCaller{dryRun: true}
InitDeps(caller)
var output bytes.Buffer
deps.Out.w = &output
filePath := writeImportFixture(t, "md")
os.Args = []string{"dws", "doc", "import", "--file", filePath}
root := newDocCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs([]string{"import", "--file", filePath})
if err := root.Execute(); err != nil {
t.Fatalf("doc import dry-run changed behavior: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run made %d remote calls", len(caller.calls))
}
if !strings.Contains(output.String(), "导入本地文件为在线文档") || !strings.Contains(output.String(), "md") {
t.Fatalf("unexpected dry-run output:\n%s", output.String())
}
}
func TestExtractNodeIDFromDocURL(t *testing.T) {
tests := map[string]string{
"https://alidocs.dingtalk.com/i/nodes/node-1": "node-1",
"https://alidocs.dingtalk.com/i/nodes/node-2/?from=test": "node-2",
"https://alidocs.dingtalk.com/i/nodes/node-3#sheet=Sheet1": "node-3",
"": "",
}
for rawURL, want := range tests {
if got := extractNodeIDFromDocURL(rawURL); got != want {
t.Errorf("extractNodeIDFromDocURL(%q) = %q, want %q", rawURL, got, want)
}
}
}
@@ -98,6 +98,12 @@ func TestWukongSyncDocCommands(t *testing.T) {
func TestWukongSyncSheetCommands(t *testing.T) {
root := newSheetCommand()
importCmd := requireWukongSyncCommand(t, root, "import")
requireWukongSyncFlags(t, importCmd, "file", "folder-token", "workspace", "name", "folder")
importGetCmd := requireWukongSyncCommand(t, root, "import", "get")
requireWukongSyncFlags(t, importGetCmd, "task-id")
tableGetCmd := requireWukongSyncCommand(t, root, "table-get")
requireWukongSyncFlags(t, tableGetCmd, "node", "sheet-id", "range", "no-header")
+2
View File
@@ -6,6 +6,7 @@ These repo-local entrypoints are the supported shell entrypoints for building, t
- `make test`: run `go test ./...`
- `make lint`: run formatting checks and required `golangci-lint`
- `make fmt`: format Go source files under `cmd/`, `internal/`, and `test/`
- `make policy`: reuse the current `dws` binary and run the complete policy suite (`make build` first)
- `make package`: build all release artifacts locally via `goreleaser --snapshot`
- `make release`: build and publish a release via `goreleaser`
@@ -15,4 +16,5 @@ Script groups:
- Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh`
- Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh`
- Policy checks: `./scripts/policy/check-schema-command-registry.sh`, `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh`
- Policy runtime files: set `DWS_POLICY_TMPDIR` to override the default `.worktrees/policy-tmp` workspace
- Release helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh`
@@ -34,12 +34,15 @@ done
[ -n "$BASE_REF" ] || { usage; exit 2; }
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
printf 'error: interface authority is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-interface-authority.XXXXXX")"
TMP_ROOT="$(policy_runtime_mktemp_dir dws-interface-authority)"
BASE_WORKTREE="$TMP_ROOT/base-worktree"
BASELINE="$TMP_ROOT/merge-base.txt"
CANDIDATE_BIN="$TMP_ROOT/interface-baseline"
@@ -35,6 +35,9 @@ done
[ -n "$BASE_REF" ] || { usage; exit 2; }
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
printf 'error: Schema authority is not available locally: %s\n' "$BASE_REF" >&2
exit 2
@@ -44,7 +47,7 @@ if [ ! -x "$BIN" ]; then
exit 2
fi
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-schema-authority.XXXXXX")"
TMP_ROOT="$(policy_runtime_mktemp_dir dws-schema-authority)"
BASE_WORKTREE="$TMP_ROOT/base-worktree"
BASE_BIN="$TMP_ROOT/base-dws"
BASE_RAW="$TMP_ROOT/base-schema.json"
+20 -24
View File
@@ -5,6 +5,7 @@ set -eu
# Stub placeholder — extend with actual surface comparison logic as needed.
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BIN="${DWS_BIN:-$ROOT/dws}"
STRICT=0
if [ "${1:-}" = "--strict" ]; then
@@ -13,32 +14,27 @@ fi
cd "$ROOT"
# Verify that all expected top-level commands exist in the built binary
if command -v go >/dev/null 2>&1; then
BIN_PATH="/tmp/dws-surface-check"
trap 'rm -f "$BIN_PATH"' EXIT HUP INT TERM
go build -ldflags="-s -w" -o "$BIN_PATH" ./cmd 2>/dev/null || { echo "build failed"; exit 1; }
if [ ! -x "$BIN" ]; then
printf 'error: dws binary not found at %s (run make build first)\n' "$BIN" >&2
exit 2
fi
# These utility commands are stable across the current open-source CLI shape.
EXPECTED_COMMANDS="auth cache completion version"
missing=0
for cmd in $EXPECTED_COMMANDS; do
# Use `help <command>` so hidden commands are also validated.
if ! "$BIN_PATH" help "$cmd" >/dev/null 2>&1; then
printf 'missing command: %s\n' "$cmd" >&2
missing=$((missing + 1))
fi
done
# Full Schema canonical/primary/alias delivery is exercised once by
# check-schema-binary.sh. Keep this check focused on the basic command tree.
rm -f "$BIN_PATH"
trap - EXIT HUP INT TERM
if [ "$STRICT" -eq 1 ] && [ "$missing" -gt 0 ]; then
printf 'command surface check: %d missing commands\n' "$missing" >&2
exit 1
# These utility commands are stable across the current open-source CLI shape.
EXPECTED_COMMANDS="auth cache completion version"
missing=0
for cmd in $EXPECTED_COMMANDS; do
# Use `help <command>` so hidden commands are also validated.
if ! "$BIN" help "$cmd" >/dev/null 2>&1; then
printf 'missing command: %s\n' "$cmd" >&2
missing=$((missing + 1))
fi
done
# Full Schema canonical/primary/alias delivery is exercised once by
# check-schema-binary.sh. Keep this check focused on the basic command tree.
if [ "$STRICT" -eq 1 ] && [ "$missing" -gt 0 ]; then
printf 'command surface check: %d missing commands\n' "$missing" >&2
exit 1
fi
printf 'command surface check: ok\n'
+9 -1
View File
@@ -62,13 +62,21 @@ done
[ -n "$BASE_REF" ] || { usage; exit 2; }
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
printf 'error: coverage base ref is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
TMP_ROOT="$(policy_runtime_mktemp_dir dws-coverage-gate)"
CHECKER="$TMP_ROOT/coverage-gate"
trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM
go build -o "$CHECKER" ./scripts/policy/coverage-gate
module="$(go list -m -f '{{.Path}}')"
set -- go run ./scripts/policy/coverage-gate \
set -- "$CHECKER" \
--diff-profile "$DIFF_PROFILE" \
--base-ref "$BASE_REF" \
--module "$module" \
+12 -4
View File
@@ -7,9 +7,17 @@ set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
exec_tmp="$(policy_runtime_mktemp_dir dws-generated-drift)"
metadata_generator="$exec_tmp/schema-agent-metadata"
catalog_generator="$exec_tmp/schema-catalog"
trap 'rm -rf "$tmp" "$exec_tmp"' EXIT HUP INT TERM
go build -o "$metadata_generator" ./internal/generator/cmd_schema_agent_metadata
go build -a -o "$catalog_generator" ./internal/generator/cmd_schema_catalog
# CommandRegistry is a reviewed input, never a generated artifact. Keep an
# independent byte-for-byte guard around the ordinary downstream generators.
@@ -26,7 +34,7 @@ audit_tmp="$tmp/audit.json"
catalog_tmp="$tmp/catalog.json"
catalog_tmp_second="$tmp/catalog-second.json"
go run ./internal/generator/cmd_schema_agent_metadata \
"$metadata_generator" \
-root . \
-registry internal/cli/schema_command_registry.json \
-output-dir "$metadata_tmp" \
@@ -46,11 +54,11 @@ if ! cmp -s internal/cli/schema_agent_metadata_audit.json "$audit_tmp"; then
exit 1
fi
go run -a ./internal/generator/cmd_schema_catalog \
"$catalog_generator" \
-root . \
-output "$catalog_tmp"
go run -a ./internal/generator/cmd_schema_catalog \
"$catalog_generator" \
-root . \
-output "$catalog_tmp_second"
+10 -5
View File
@@ -7,12 +7,17 @@ set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BASELINE="${INTERFACE_BASELINE:-$ROOT/test/fixtures/cli-interface-baseline.txt}"
SNAPSHOT_HOME="$(mktemp -d)"
SNAPSHOT_BIN="$(mktemp)"
CURRENT="$(mktemp)"
trap 'rm -rf "$CURRENT" "$SNAPSHOT_HOME" "$SNAPSHOT_BIN"' EXIT
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
TMP_ROOT="$(policy_runtime_mktemp_dir dws-interface-baseline)"
SNAPSHOT_HOME="$TMP_ROOT/home"
SNAPSHOT_BIN="$TMP_ROOT/interface-baseline"
CURRENT="$TMP_ROOT/current.txt"
mkdir -p "$SNAPSHOT_HOME"
trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM
# Compile with the caller's normal Go cache, then isolate only the execution
# HOME so user-installed DWS plugins cannot alter the public command tree.
go build -o "$SNAPSHOT_BIN" ./scripts/policy/interface-baseline
+19 -9
View File
@@ -2,16 +2,26 @@
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
BIN="${DWS_BIN:-$ROOT/dws}"
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
if [ ! -x "$BIN" ]; then
printf 'error: dws binary not found at %s (run make build first)\n' "$BIN" >&2
exit 2
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
exec_tmp="$(policy_runtime_mktemp_dir dws-schema-binary)"
smoke_generator="$exec_tmp/schema-registry-smoke"
trap 'rm -rf "$tmp" "$exec_tmp"' EXIT HUP INT TERM
go build -o "$tmp/dws" ./cmd
go run ./internal/generator/cmd_schema_registry_smoke >"$tmp/smoke-vector.json"
go build -o "$smoke_generator" ./internal/generator/cmd_schema_registry_smoke
"$smoke_generator" >"$tmp/smoke-vector.json"
"$tmp/dws" schema list --format json >"$tmp/list.json"
"$tmp/dws" schema --all --format json >"$tmp/all.json"
"$BIN" schema list --format json >"$tmp/list.json"
"$BIN" schema --all --format json >"$tmp/all.json"
if ! jq -e '
.version == 1 and
@@ -111,8 +121,8 @@ while IFS= read -r command; do
canonical="$(printf '%s\n' "$command" | jq -r '.canonical_path')"
primary="$(printf '%s\n' "$command" | jq -r '.primary_cli_path')"
"$tmp/dws" schema "$canonical" --format json >"$tmp/canonical.json"
"$tmp/dws" schema --cli-path "$primary" --format json >"$tmp/primary.json"
"$BIN" schema "$canonical" --format json >"$tmp/canonical.json"
"$BIN" schema --cli-path "$primary" --format json >"$tmp/primary.json"
jq -S --arg canonical "$canonical" '
[.products[].tools[] | select(.canonical_path == $canonical)] |
if length == 1 then .[0] else null end
@@ -132,8 +142,8 @@ alias_command="$(jq -c 'first(.commands[] | select((.alias_cli_paths | length) >
if [ -n "$alias_command" ]; then
canonical="$(printf '%s\n' "$alias_command" | jq -r '.canonical_path')"
alias_path="$(printf '%s\n' "$alias_command" | jq -r '.alias_cli_paths[0]')"
"$tmp/dws" schema "$canonical" --format json >"$tmp/canonical.json"
"$tmp/dws" schema --cli-path "$alias_path" --format json >"$tmp/alias.json"
"$BIN" schema "$canonical" --format json >"$tmp/canonical.json"
"$BIN" schema --cli-path "$alias_path" --format json >"$tmp/alias.json"
jq -S 'del(.cli_path, .is_alias)' "$tmp/canonical.json" >"$tmp/canonical-content.json"
jq -S 'del(.cli_path, .is_alias)' "$tmp/alias.json" >"$tmp/alias-content.json"
if ! cmp -s "$tmp/canonical-content.json" "$tmp/alias-content.json"; then
+2
View File
@@ -4,6 +4,8 @@ set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
. "$ROOT/scripts/policy/search.sh"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
if [ -e internal/cli/schema_native_contracts.go ] ||
[ -e internal/cli/schema_native_contracts_generated.go ] ||
@@ -8,6 +8,8 @@ set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
. "$ROOT/scripts/policy/search.sh"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
if [ -e internal/cli/schema_native_contracts.go ] ||
[ -e internal/cli/schema_native_contracts_generated.go ] ||
+9 -4
View File
@@ -2,10 +2,15 @@
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
CHECKER="$(mktemp)"
CHECK_HOME="$(mktemp -d)"
trap 'rm -rf "$CHECKER" "$CHECK_HOME"' EXIT
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
TMP_ROOT="$(policy_runtime_mktemp_dir dws-skill-command-check)"
CHECKER="$TMP_ROOT/skill-command-check"
CHECK_HOME="$TMP_ROOT/home"
mkdir -p "$CHECK_HOME"
trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM
go build -o "$CHECKER" ./scripts/policy/skill-command-check
HOME="$CHECK_HOME" DWS_LANG=zh "$CHECKER"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Shared runtime workspace for policy helpers that compile and execute Go
# programs. Callers may override DWS_POLICY_TMPDIR and GOTMPDIR.
policy_prepare_runtime() {
policy_root="$1"
DWS_POLICY_TMPDIR="${DWS_POLICY_TMPDIR:-$policy_root/.worktrees/policy-tmp}"
mkdir -p "$DWS_POLICY_TMPDIR/go"
DWS_POLICY_TMPDIR="$(CDPATH= cd -- "$DWS_POLICY_TMPDIR" && pwd)"
export DWS_POLICY_TMPDIR
if [ -z "${GOTMPDIR:-}" ]; then
GOTMPDIR="$DWS_POLICY_TMPDIR/go"
fi
mkdir -p "$GOTMPDIR"
GOTMPDIR="$(CDPATH= cd -- "$GOTMPDIR" && pwd)"
export GOTMPDIR
}
policy_runtime_mktemp_dir() {
prefix="$1"
mktemp -d "$DWS_POLICY_TMPDIR/${prefix}.XXXXXX"
}
+31 -6
View File
@@ -39,19 +39,24 @@ done
[ -n "$BASE_REF" ] && [ -n "$PROFILE" ] || { usage; exit 2; }
cd "$ROOT"
. "$ROOT/scripts/policy/policy-runtime.sh"
policy_prepare_runtime "$ROOT"
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
printf 'error: coverage base ref is not available locally: %s\n' "$BASE_REF" >&2
exit 2
}
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-platform-coverage.XXXXXX")"
TMP_ROOT="$(policy_runtime_mktemp_dir dws-platform-coverage)"
PACKAGES="$TMP_ROOT/packages"
SORTED_PACKAGES="$TMP_ROOT/packages.sorted"
TEST_PACKAGES="$TMP_ROOT/test-packages"
cleanup() {
rm -rf "$TMP_ROOT"
}
trap cleanup EXIT HUP INT TERM
: >"$PACKAGES"
: >"$TEST_PACKAGES"
git diff --name-only --diff-filter=ACMR "$BASE_REF" -- '*.go' |
while IFS= read -r file; do
@@ -70,9 +75,11 @@ git diff --name-only --diff-filter=ACMR "$BASE_REF" -- '*.go' |
sort -u "$PACKAGES" >"$SORTED_PACKAGES"
set --
COVERPKG=""
FIRST_PACKAGE=""
while IFS= read -r package; do
if [ -n "$package" ]; then
set -- "$@" "$package"
[ -n "$FIRST_PACKAGE" ] || FIRST_PACKAGE="$package"
if [ -z "$COVERPKG" ]; then
COVERPKG="$package"
else
@@ -87,15 +94,33 @@ else
printf 'native changed coverage packages:'
printf ' %s' "$@"
printf '\n'
for package in "$@"; do
tests="$(go test -list '^(TestAllShortcuts|TestCrossPlatformCoverage)' "$package")"
if printf '%s\n' "$tests" | grep -Eq '^(TestAllShortcuts|TestCrossPlatformCoverage)'; then
printf '%s\n' "$package" >>"$TEST_PACKAGES"
fi
done
set --
while IFS= read -r package; do
[ -n "$package" ] && set -- "$@" "$package"
done <"$TEST_PACKAGES"
if [ "$#" -eq 0 ]; then
# Keep a valid zero-coverage profile so the fail-closed gate can report
# the missing coverage when no explicit platform test exists.
set -- "$FIRST_PACKAGE"
fi
printf 'native coverage test packages:'
printf ' %s' "$@"
printf '\n'
# The shortcut integration harness lives in internal/shortcut/builtin but
# executes code from every product package. Cross-package instrumentation is
# required for those real executions to count toward the owning source file.
#
# Platform runners intentionally execute only the exhaustive shortcut
# harness and explicitly cross-platform tests. All changed production
# packages remain instrumented, so unexercised statements still count as
# uncovered. This keeps the native Windows gate independent of unrelated
# tests that require Unix permissions, shell scripts, or process semantics.
# Platform runners intentionally execute only packages that actually declare
# an exhaustive shortcut harness or explicit cross-platform tests. All
# changed production packages remain instrumented via coverpkg, so unexercised
# statements still count as uncovered. Avoiding zero-test driver binaries also
# keeps profiles small enough for the fail-closed gate to process reliably.
go test -count=1 -timeout="$TIMEOUT" -run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
-coverpkg="$COVERPKG" -coverprofile="$PROFILE" -covermode=atomic "$@"
fi
+3 -1
View File
@@ -105,12 +105,14 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow create` | 创建并发布工作流 | `--base-id` `--dsl` | `--dsl` 为完整 workflow-dsl/v1;非幂等,不自动重试 |
| `workflow update` | 更新并发布工作流 | `--base-id` `--workflow-id` `--dsl` | 全量替换,先 get 留底;检查 `data.valid/issues` |
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
> **当前不支持通过 CLI 新建/修改/删除工作流**,请去 AI 表格 Web 端(数据表页面 → 自动化)配置。
> 当前支持创建、更新、查询和启停;删除、运行历史及手动触发仍未开放。
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
@@ -1,22 +1,100 @@
# workflow — 自动化工作流管理
启停 / 查看 / 列出 Base 下的自动化工作流(在 AI 表格 Web 端配置的 "当 X 时自动 Y" 流程)。
适用场景:用户问 "停掉这个流程"、"看下都有哪些自动化流程"、"流程 X 的配置是什么"。
创建 / 更新 / 启停 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。
适用场景:用户要求创建自动化、修改流程、停掉流程、查询已有流程或恢复运行。
## 命令一览
| 命令 | 用途 |
|------|------|
| `workflow create` | 创建并发布自动化工作流 |
| `workflow update` | 更新并发布已有自动化工作流 |
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
| `workflow get` | 获取单个工作流详情(含 flowSchema 完整节点定义) |
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
> 当前**不支持通过 CLI 新建工作流**,请在 AI 表格 Web 端配置好后用 `workflow list` 拿到 ID 再启停。
## DSL 入参格式与最小 Demo
`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。
复杂工作流应先用 `table get` / `field get` / `view list` 确认真实 `sheetId`、`fieldId`、`viewId`,并检查所有 `next`、`loopEntry`、branch `to` 和 ref。下面是一个不依赖数据表字段的最小定时消息工作流:
```json
{
"version": "workflow-dsl/v1",
"name": "每日提醒",
"description": "可选说明",
"trigger": "start",
"steps": {
"start": {
"type": "Scheduled",
"next": "send",
"data": {
"mode": "daily",
"time": "09:00",
"timezone": "GMT+08:00"
}
},
"send": {
"type": "SendMessage",
"data": {
"title": "定时任务已触发",
"to": {"users": [{"ref": "$.system_node.ownerUserId"}]}
}
}
}
}
```
create 和 update 都必须同时满足 `status=success`、`data.valid=true`、`data.issues=[]` 才表示发布成功。
## 命令详情
### workflow create — 创建并发布工作流
```bash
dws aitable workflow create \
--base-id BASE_ID \
--dsl @workflow.json \
--locale zh-CN \
--format json
cat workflow.json | dws aitable workflow create --base-id BASE_ID --dsl - --format json
```
| flag | 必填 | 说明 |
|------|------|------|
| `--base-id` | 是 | 所属 Base ID |
| `--dsl` | 是 | workflow-dsl/v1 JSON object;支持内联、`@filepath`、`-` stdin |
| `--locale` | 否 | 请求语言,如 `zh-CN` / `zh_CN` |
`create` 非幂等,CLI 不自动重试。若网络中断导致结果不确定,先 `workflow list` 按名称确认是否已经创建,再决定是否重试。`status=success` 只说明服务正常返回;`data.valid=false` 仍是发布失败,必须读取 `issues`。
### workflow update — 更新并发布工作流
```bash
# 先留底,再提交完整目标 DSL
dws aitable workflow get --base-id BASE_ID --workflow-id WORKFLOW_ID --format json > /tmp/workflow-backup.json
dws aitable workflow update \
--base-id BASE_ID \
--workflow-id WORKFLOW_ID \
--dsl @workflow.json \
--locale zh_CN \
--format json
```
| flag | 必填 | 说明 |
|------|------|------|
| `--base-id` | 是 | 所属 Base ID |
| `--workflow-id` | 是 | 目标工作流 ID,对应 list 的 `flowId` |
| `--dsl` | 是 | 完整目标 workflow-dsl/v1 JSON object;支持内联、`@filepath`、`-` stdin |
| `--locale` | 否 | 请求语言,如 `zh-CN` / `zh_CN` |
update 返回结构与 create 相同,并会对瞬态错误重试。成功时 `data.flowId` 应与传入的工作流 ID 一致;随后用 `workflow get/list` 验证发布结果和运行状态。
### workflow list — 列出工作流
```bash
@@ -119,12 +197,12 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes -
| 能力 | 状态 |
|------|------|
| 新建工作流 | ✅ 创建并发布 |
| 修改工作流配置 | ✅ 更新并发布 |
| 列出工作流 | ✅ |
| 看工作流详情(含 flowSchema) | ✅ |
| 启用/禁用 | ✅ |
| 新建工作流 | ❌ 当前不支持,请去 AI 表格 Web 端 → 数据表 → 自动化 创建 |
| 修改工作流配置 | ❌ 同上,需 Web UI 编辑 |
| 删除工作流 | ❌ 同上 |
| 删除工作流 | ❌ 暂未开放 |
| 查看运行历史/执行日志 | ❌ 暂未开放 |
| 手动触发/单次运行 | ❌ 暂未开放 |
@@ -132,6 +210,9 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes -
| 场景 | code | type | 备注 |
|------|------|------|------|
| create/update 返回 `valid=false` | — | success envelope | 读取 `data.issues` 修正 DSL,不能当作发布成功 |
| create 下游失败 | `CREATE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | create 不自动重试;先 list 排查是否已创建 |
| update 下游失败 | `UPDATE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | update 会重试瞬态错误,最终失败时保留 DSL 和 workflowId 排查 |
| `workflow-id` 不存在调 get | `GET_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 可能为 null,先 `workflow list` 核对 ID |
| `workflow-id` 不存在调 enable | `ENABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 含 "场域中不存在该 namespace" |
| `workflow-id` 不存在调 disable | `DISABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | 同上 |
@@ -142,6 +223,18 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes -
## 典型工作流
### 创建并确认一个工作流
```bash
dws aitable workflow create --base-id BASE_ID --dsl @/tmp/workflow.json --locale zh-CN --format json \
| tee /tmp/workflow-result.json
jq '{valid: .data.valid, flowId: .data.flowId, issues: .data.issues}' /tmp/workflow-result.json
FLOW_ID=$(jq -r '.data.flowId' /tmp/workflow-result.json)
dws aitable workflow list --base-id BASE_ID --format json \
| jq --arg id "$FLOW_ID" '.data.list[] | select(.flowId == $id) | {flowId, name, status}'
```
### 看看 Base 里有哪些自动化在跑
```bash
@@ -175,6 +268,9 @@ done
## 注意事项
- `--workflow-id` 接受的就是 `list` 返回里的 `flowId`(同值,CLI 屏蔽了服务端字段名差异)。
- create / update 的 `--dsl` 必须是 JSON object,不能传数组、二次字符串化 JSON 或 `flowSchema`。
- `status=success` 且 `data.valid=false` 仍是 DSL 校验失败;`issues` 才是下一步修复依据。
- create 不自动重试;update 仅对网络、5xx、`retryable:true` 等瞬态错误自动重试。
- enable / disable 出参里的 `enabled` / `disabled` 是 **动作确认 flag**,不是当前状态字段。要确认真生效请走 `workflow list` 查 `status`。
- `workflow get` 的 `flowSchema` 结构随触发器/动作类型变化,不要假设固定字段。
- 新建/修改/删除工作流目前必须在 AI 表格 Web 端(数据表页面 → 自动化)完成。
- 删除、运行历史和手动触发当前仍未开放。
+16 -4
View File
@@ -2,12 +2,13 @@
## 适用范围(重要)
`sheet` 产品线仅支持钉钉在线电子表格(`contentType=ALIDOC`、`extension=axls`),不支持上传的 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件。
`sheet` 的单元格与工作表操作仅支持钉钉在线电子表格(`contentType=ALIDOC`、`extension=axls`)。`sheet import create` 是 Agent 可发现的文件入口:它接收本地 `xlsx` / `xls`,转换并新建在线电子表格;不能把已上传的 xlsx 节点直接当作 axls 操作。
| 文件类型 | 处理方式 |
|---------|---------|
| 在线电子表格(`axls`) | 走 `sheet` 全部命令(读/写/筛选/合并/导出等服务端原子操作) |
| `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件 | 必须用 `dws doc download --node <ID> --output <路径>` 先下载到本地再解析处理,禁止调用任何 `sheet` 子命令(sheet 底层 MCP 工具仅认 `axls`,传入 xlsx 节点会直接报错) |
| 本地路径上的 `xlsx` / `xls` | 用 `dws sheet import create --file <路径> --folder-token <ID>` 或 `--workspace <ID>` 转换为新的在线电子表格 |
| 钉盘/文档中已有的 `xlsx` / `xls` / `xlsm` / `csv` 节点 | 不能直接调用工作表/单元格命令;先用 `dws doc download --node <ID> --output <路径>` 下载。若用户要转为在线表格,再对下载文件执行 `sheet import` |
| 想把在线表格导出为 xlsx | 用 `dws sheet export` ——输入是 `axls`,输出是 xlsx(axls → xlsx 的格式转换) |
> 用户直接粘贴原始 `alidocs` URL 时必须先 probe:先执行 `dws doc info --node <URL> --format json`,按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) 校验 `contentType` 和 `extension`:
@@ -125,6 +126,7 @@ dws sheet filter-view --help
| `sheet list-float-images` | 列出工作表所有浮动图片 |
| `sheet update-float-image` | 更新浮动图片属性 |
| `sheet delete-float-image` | 删除浮动图片 |
| `sheet import create` | 导入本地 xlsx/xls 为新的在线电子表格(`sheet import` 保留兼容) |
| `sheet export` | 导出表格为 xlsx |
| `sheet filter get` | 获取全局筛选信息 |
| `sheet filter create` | 创建全局筛选 |
@@ -176,6 +178,7 @@ dws sheet filter-view --help
| 查找替换 | [sheet/sheet-search-replace.md](./sheet/sheet-search-replace.md) | find / replace |
| 全局筛选 | [sheet/sheet-filter.md](./sheet/sheet-filter.md) | filter get / create / delete / update / clear-criteria / sort |
| 筛选视图 | [sheet/sheet-filter-view.md](./sheet/sheet-filter-view.md) | filter-view 全系列 |
| 导入 | [sheet/sheet-import.md](./sheet/sheet-import.md) | import / import get |
| 导出 | [sheet/sheet-export.md](./sheet/sheet-export.md) | export |
> `template list/search/apply` 无独立子文档,直接执行 `dws sheet template <子命令> --help` 查看用法。
@@ -369,6 +372,15 @@ dws sheet filter-view --help
- 清除筛选视图列条件 → `filter-view delete-criteria`
- 注意与 `filter-view delete`(删除整个筛选视图)区分:`delete-criteria` 仅清除指定列的条件,不删除筛选视图本身
### 导入
用户说"导入Excel/把xlsx转为在线表格/上传本地表格并在线编辑":
- 使用 `sheet import`,本地文件仅支持 `xlsx` / `xls`
- `--folder-token` 与 `--workspace` 至少提供一个;不指定 `--name` 时使用文件名
- CLI 内部完成上传、转换和轮询,Agent 不要自行拆分流程或重复提交
- 若返回 `timed_out:true`,执行返回的 `next_command` 续查,不要重新执行 import
- 详见 [sheet/sheet-import.md](./sheet/sheet-import.md)
### 导出
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
@@ -2139,7 +2151,7 @@ dws sheet export --node <NODE_ID> --output ./
- `copy` 当指定名称与已有工作表重复时,系统会自动重命名为合法值
- `copy` 的 `--index` 可选,不传时副本将放置在源工作表之后的默认位置
- ★ 关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读)
- sheet 产品线仅支持 `axls`(在线电子表格,`contentType=ALIDOC`),不支持 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件
- 工作表与单元格命令仅支持 `axls`;本地 `xlsx` / `xls` 可通过 `sheet import` 转换为新的在线电子表格
- 遇到未知 `alidocs` URL 时,必须先 probe(`dws doc info --node <URL> --format json`)确认 `contentType` 和 `extension`,才能决定是否走 sheet
- 当节点 `extension=xlsx` / `xls` / `xlsm` / `csv`(`contentType≠ALIDOC`)时,必须用 `dws doc download --node <ID> --output <路径>` 先下载到本地再处理,禁止调用任何 sheet 子命令(sheet 底层 MCP 工具只识别 axls,调用 xlsx 节点必失败)
- 要把在线表格导出为 xlsx 文件——走 `dws sheet export`(axls → xlsx 的格式转换);要读已有的 xlsx 文件——走 `dws doc download` 后在本地解析,两者方向相反
- 要把在线表格导出为 xlsx 文件,走 `dws sheet export`;要把本地 xlsx/xls 转为在线表格,走 `dws sheet import`;要读取已上传但未转换的 xlsx 节点,先走 `dws doc download`
@@ -0,0 +1,76 @@
# 导入本地表格(import)
## 使用场景
用户说“导入 Excel”“把 xlsx 变成在线表格”“上传本地表格并在线编辑”时,使用 Agent 可发现入口 `dws sheet import create`。它只新建在线电子表格,不覆盖已有表格。旧入口 `dws sheet import` 继续兼容人工调用。
- 支持本地 `xlsx` / `xls`,文件上限 20MB
- `--folder-token` 与 `--workspace` 至少提供一个
- CLI 内部完成创建会话、OSS 上传、确认转换和轮询,Agent 不要自行拆分或重试
- `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令
## 命令
```bash
# 导入到指定文件夹
dws sheet import create \
--file ./quote.xlsx \
--folder-token <FOLDER_TOKEN> \
--format json
# 导入到指定知识库,并自定义名称
dws sheet import create \
--file ./report.xls \
--workspace <WORKSPACE_ID> \
--name "月度报表" \
--format json
# 导入超时或中断后续查
dws sheet import get --task-id <TASK_ID> --format json
```
公开参数:
| 命令 | 参数 | 说明 |
|------|------|------|
| `sheet import create` | `--file` | 本地 xlsx/xls 文件(必填) |
| `sheet import create` | `--folder-token` | 目标文件夹 ID 或 URL,与 `--workspace` 至少传一个 |
| `sheet import create` | `--workspace` | 目标知识库 ID 或 URL,与 `--folder-token` 至少传一个 |
| `sheet import create` | `--name`, `-n` | 导入后的名称;默认取文件名并去掉扩展名 |
| `sheet import get` | `--task-id` | 导入任务 ID |
## 返回与续查
成功时返回:
```json
{
"success": true,
"taskId": "<TASK_ID>",
"documentUrl": "<DOCUMENT_URL>",
"documentName": "月度报表",
"documentType": "1",
"nodeId": "<NODE_ID>"
}
```
轮询达到上限时命令以退出码 0 返回业务态,避免 Agent 重复创建文档:
```json
{
"success": false,
"timed_out": true,
"taskId": "<TASK_ID>",
"status": "processing",
"next_command": "dws sheet import get --task-id <TASK_ID>"
}
```
检测到 `timed_out:true` 后,只执行 `next_command` 续查。不要重新执行 `sheet import create`。
## 边界
- 本命令接受的是本地文件路径;它把 xlsx/xls 转换成新的 axls 在线表格
- 已经存在于钉盘或文档中的 xlsx 节点不能直接传给工作表/单元格命令;先用 `doc download` 下载,需要在线编辑时再执行 `sheet import create`
- 向已有在线表格写数据应使用 `range update`、`append`、`csv-put` 或 `table-put`
- md/doc/docx 等文字文档导入使用 `dws doc import`
@@ -105,12 +105,14 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow create` | 创建并发布工作流 | `--base-id` `--dsl` | `--dsl` 为完整 workflow-dsl/v1;非幂等,不自动重试 |
| `workflow update` | 更新并发布工作流 | `--base-id` `--workflow-id` `--dsl` | 全量替换,先 get 留底;检查 `data.valid/issues` |
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
> **当前不支持通过 CLI 新建/修改/删除工作流**,请去 AI 表格 Web 端(数据表页面 → 自动化)配置。
> 当前支持创建、更新、查询和启停;删除、运行历史及手动触发仍未开放。
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
@@ -1,22 +1,100 @@
# workflow — 自动化工作流管理
启停 / 查看 / 列出 Base 下的自动化工作流(在 AI 表格 Web 端配置的 "当 X 时自动 Y" 流程)。
适用场景:用户问 "停掉这个流程"、"看下都有哪些自动化流程"、"流程 X 的配置是什么"。
创建 / 更新 / 启停 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。
适用场景:用户要求创建自动化、修改流程、停掉流程、查询已有流程或恢复运行。
## 命令一览
| 命令 | 用途 |
|------|------|
| `workflow create` | 创建并发布自动化工作流 |
| `workflow update` | 更新并发布已有自动化工作流 |
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
| `workflow get` | 获取单个工作流详情(含 flowSchema 完整节点定义) |
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
> 当前**不支持通过 CLI 新建工作流**,请在 AI 表格 Web 端配置好后用 `workflow list` 拿到 ID 再启停。
## DSL 入参格式与最小 Demo
`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。
复杂工作流应先用 `table get` / `field get` / `view list` 确认真实 `sheetId`、`fieldId`、`viewId`,并检查所有 `next`、`loopEntry`、branch `to` 和 ref。下面是一个不依赖数据表字段的最小定时消息工作流:
```json
{
"version": "workflow-dsl/v1",
"name": "每日提醒",
"description": "可选说明",
"trigger": "start",
"steps": {
"start": {
"type": "Scheduled",
"next": "send",
"data": {
"mode": "daily",
"time": "09:00",
"timezone": "GMT+08:00"
}
},
"send": {
"type": "SendMessage",
"data": {
"title": "定时任务已触发",
"to": {"users": [{"ref": "$.system_node.ownerUserId"}]}
}
}
}
}
```
create 和 update 都必须同时满足 `status=success`、`data.valid=true`、`data.issues=[]` 才表示发布成功。
## 命令详情
### workflow create — 创建并发布工作流
```bash
dws aitable workflow create \
--base-id BASE_ID \
--dsl @workflow.json \
--locale zh-CN \
--format json
cat workflow.json | dws aitable workflow create --base-id BASE_ID --dsl - --format json
```
| flag | 必填 | 说明 |
|------|------|------|
| `--base-id` | 是 | 所属 Base ID |
| `--dsl` | 是 | workflow-dsl/v1 JSON object;支持内联、`@filepath`、`-` stdin |
| `--locale` | 否 | 请求语言,如 `zh-CN` / `zh_CN` |
`create` 非幂等,CLI 不自动重试。若网络中断导致结果不确定,先 `workflow list` 按名称确认是否已经创建,再决定是否重试。`status=success` 只说明服务正常返回;`data.valid=false` 仍是发布失败,必须读取 `issues`。
### workflow update — 更新并发布工作流
```bash
# 先留底,再提交完整目标 DSL
dws aitable workflow get --base-id BASE_ID --workflow-id WORKFLOW_ID --format json > /tmp/workflow-backup.json
dws aitable workflow update \
--base-id BASE_ID \
--workflow-id WORKFLOW_ID \
--dsl @workflow.json \
--locale zh_CN \
--format json
```
| flag | 必填 | 说明 |
|------|------|------|
| `--base-id` | 是 | 所属 Base ID |
| `--workflow-id` | 是 | 目标工作流 ID,对应 list 的 `flowId` |
| `--dsl` | 是 | 完整目标 workflow-dsl/v1 JSON object;支持内联、`@filepath`、`-` stdin |
| `--locale` | 否 | 请求语言,如 `zh-CN` / `zh_CN` |
update 返回结构与 create 相同,并会对瞬态错误重试。成功时 `data.flowId` 应与传入的工作流 ID 一致;随后用 `workflow get/list` 验证发布结果和运行状态。
### workflow list — 列出工作流
```bash
@@ -119,12 +197,12 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes -
| 能力 | 状态 |
|------|------|
| 新建工作流 | ✅ 创建并发布 |
| 修改工作流配置 | ✅ 更新并发布 |
| 列出工作流 | ✅ |
| 看工作流详情(含 flowSchema) | ✅ |
| 启用/禁用 | ✅ |
| 新建工作流 | ❌ 当前不支持,请去 AI 表格 Web 端 → 数据表 → 自动化 创建 |
| 修改工作流配置 | ❌ 同上,需 Web UI 编辑 |
| 删除工作流 | ❌ 同上 |
| 删除工作流 | ❌ 暂未开放 |
| 查看运行历史/执行日志 | ❌ 暂未开放 |
| 手动触发/单次运行 | ❌ 暂未开放 |
@@ -132,6 +210,9 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes -
| 场景 | code | type | 备注 |
|------|------|------|------|
| create/update 返回 `valid=false` | — | success envelope | 读取 `data.issues` 修正 DSL,不能当作发布成功 |
| create 下游失败 | `CREATE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | create 不自动重试;先 list 排查是否已创建 |
| update 下游失败 | `UPDATE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | update 会重试瞬态错误,最终失败时保留 DSL 和 workflowId 排查 |
| `workflow-id` 不存在调 get | `GET_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 可能为 null,先 `workflow list` 核对 ID |
| `workflow-id` 不存在调 enable | `ENABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 含 "场域中不存在该 namespace" |
| `workflow-id` 不存在调 disable | `DISABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | 同上 |
@@ -142,6 +223,18 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes -
## 典型工作流
### 创建并确认一个工作流
```bash
dws aitable workflow create --base-id BASE_ID --dsl @/tmp/workflow.json --locale zh-CN --format json \
| tee /tmp/workflow-result.json
jq '{valid: .data.valid, flowId: .data.flowId, issues: .data.issues}' /tmp/workflow-result.json
FLOW_ID=$(jq -r '.data.flowId' /tmp/workflow-result.json)
dws aitable workflow list --base-id BASE_ID --format json \
| jq --arg id "$FLOW_ID" '.data.list[] | select(.flowId == $id) | {flowId, name, status}'
```
### 看看 Base 里有哪些自动化在跑
```bash
@@ -175,6 +268,9 @@ done
## 注意事项
- `--workflow-id` 接受的就是 `list` 返回里的 `flowId`(同值,CLI 屏蔽了服务端字段名差异)。
- create / update 的 `--dsl` 必须是 JSON object,不能传数组、二次字符串化 JSON 或 `flowSchema`。
- `status=success` 且 `data.valid=false` 仍是 DSL 校验失败;`issues` 才是下一步修复依据。
- create 不自动重试;update 仅对网络、5xx、`retryable:true` 等瞬态错误自动重试。
- enable / disable 出参里的 `enabled` / `disabled` 是 **动作确认 flag**,不是当前状态字段。要确认真生效请走 `workflow list` 查 `status`。
- `workflow get` 的 `flowSchema` 结构随触发器/动作类型变化,不要假设固定字段。
- 新建/修改/删除工作流目前必须在 AI 表格 Web 端(数据表页面 → 自动化)完成。
- 删除、运行历史和手动触发当前仍未开放。
+2 -1
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-sheet
description: 钉钉电子表格。Use when 用户说 电子表格/工作表/单元格读写/单元格追加/查找/公式/超链接/插入图片/浮动图片/sheet。Distinct from dingtalk-aitable(AI表格/多维表/字段类型)、dingtalk-doc(普通文档)。命令前缀:dws sheet。
description: 钉钉电子表格。Use when 用户说 电子表格/导入Excel为在线表格/工作表/单元格读写/单元格追加/查找/公式/超链接/插入图片/浮动图片/sheet。Distinct from dingtalk-aitable(AI表格/多维表/字段类型)、dingtalk-doc(普通文档)。命令前缀:dws sheet。
cli_version: ">=0.2.14"
metadata:
category: product
@@ -43,6 +43,7 @@ metadata:
| 用户说 | 命令 |
|--------|------|
| "创建电子表格" | `dws sheet create --name "<标题>"` |
| "导入本地 Excel 为在线表格" | `dws sheet import create --file <xlsx或xls> --folder-token <ID>` 或 `--workspace <ID>` |
| "新建工作表" | `dws sheet new --node <nodeId或URL> --name "<sheet名>"` |
| "读取单元格" | `dws sheet range read --node <nodeId或URL> --sheet-id <sheetId> --range A1:B2` |
| "写入单元格" | `dws sheet range update --node <nodeId或URL> --sheet-id <sheetId> --range A1:B2 --values '[[..]]'` |
@@ -2,12 +2,13 @@
## 适用范围(重要)
`sheet` 产品线仅支持钉钉在线电子表格(`contentType=ALIDOC`、`extension=axls`),不支持上传的 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件。
`sheet` 的单元格与工作表操作仅支持钉钉在线电子表格(`contentType=ALIDOC`、`extension=axls`)。`sheet import create` 是 Agent 可发现的文件入口:它接收本地 `xlsx` / `xls`,转换并新建在线电子表格;不能把已上传的 xlsx 节点直接当作 axls 操作。
| 文件类型 | 处理方式 |
|---------|---------|
| 在线电子表格(`axls`) | 走 `sheet` 全部命令(读/写/筛选/合并/导出等服务端原子操作) |
| `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件 | 必须用 `dws doc download --node <ID> --output <路径>` 先下载到本地再解析处理,禁止调用任何 `sheet` 子命令(sheet 底层 MCP 工具仅认 `axls`,传入 xlsx 节点会直接报错) |
| 本地路径上的 `xlsx` / `xls` | 用 `dws sheet import create --file <路径> --folder-token <ID>` 或 `--workspace <ID>` 转换为新的在线电子表格 |
| 钉盘/文档中已有的 `xlsx` / `xls` / `xlsm` / `csv` 节点 | 不能直接调用工作表/单元格命令;先用 `dws doc download --node <ID> --output <路径>` 下载。若用户要转为在线表格,再对下载文件执行 `sheet import` |
| 想把在线表格导出为 xlsx | 用 `dws sheet export` ——输入是 `axls`,输出是 xlsx(axls → xlsx 的格式转换) |
> 用户直接粘贴原始 `alidocs` URL 时必须先 probe:先执行 `dws doc info --node <URL> --format json`,按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) 校验 `contentType` 和 `extension`:
@@ -125,6 +126,7 @@ dws sheet filter-view --help
| `sheet list-float-images` | 列出工作表所有浮动图片 |
| `sheet update-float-image` | 更新浮动图片属性 |
| `sheet delete-float-image` | 删除浮动图片 |
| `sheet import create` | 导入本地 xlsx/xls 为新的在线电子表格(`sheet import` 保留兼容) |
| `sheet export` | 导出表格为 xlsx |
| `sheet filter get` | 获取全局筛选信息 |
| `sheet filter create` | 创建全局筛选 |
@@ -176,6 +178,7 @@ dws sheet filter-view --help
| 查找替换 | [sheet/sheet-search-replace.md](./sheet/sheet-search-replace.md) | find / replace |
| 全局筛选 | [sheet/sheet-filter.md](./sheet/sheet-filter.md) | filter get / create / delete / update / clear-criteria / sort |
| 筛选视图 | [sheet/sheet-filter-view.md](./sheet/sheet-filter-view.md) | filter-view 全系列 |
| 导入 | [sheet/sheet-import.md](./sheet/sheet-import.md) | import / import get |
| 导出 | [sheet/sheet-export.md](./sheet/sheet-export.md) | export |
> `template list/search/apply` 无独立子文档,直接执行 `dws sheet template <子命令> --help` 查看用法。
@@ -369,6 +372,15 @@ dws sheet filter-view --help
- 清除筛选视图列条件 → `filter-view delete-criteria`
- 注意与 `filter-view delete`(删除整个筛选视图)区分:`delete-criteria` 仅清除指定列的条件,不删除筛选视图本身
### 导入
用户说"导入Excel/把xlsx转为在线表格/上传本地表格并在线编辑":
- 使用 `sheet import`,本地文件仅支持 `xlsx` / `xls`
- `--folder-token` 与 `--workspace` 至少提供一个;不指定 `--name` 时使用文件名
- CLI 内部完成上传、转换和轮询,Agent 不要自行拆分流程或重复提交
- 若返回 `timed_out:true`,执行返回的 `next_command` 续查,不要重新执行 import
- 详见 [sheet/sheet-import.md](./sheet/sheet-import.md)
### 导出
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
@@ -2139,7 +2151,7 @@ dws sheet export --node <NODE_ID> --output ./
- `copy` 当指定名称与已有工作表重复时,系统会自动重命名为合法值
- `copy` 的 `--index` 可选,不传时副本将放置在源工作表之后的默认位置
- ★ 关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读)
- sheet 产品线仅支持 `axls`(在线电子表格,`contentType=ALIDOC`),不支持 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件
- 工作表与单元格命令仅支持 `axls`;本地 `xlsx` / `xls` 可通过 `sheet import` 转换为新的在线电子表格
- 遇到未知 `alidocs` URL 时,必须先 probe(`dws doc info --node <URL> --format json`)确认 `contentType` 和 `extension`,才能决定是否走 sheet
- 当节点 `extension=xlsx` / `xls` / `xlsm` / `csv`(`contentType≠ALIDOC`)时,必须用 `dws doc download --node <ID> --output <路径>` 先下载到本地再处理,禁止调用任何 sheet 子命令(sheet 底层 MCP 工具只识别 axls,调用 xlsx 节点必失败)
- 要把在线表格导出为 xlsx 文件——走 `dws sheet export`(axls → xlsx 的格式转换);要读已有的 xlsx 文件——走 `dws doc download` 后在本地解析,两者方向相反
- 要把在线表格导出为 xlsx 文件,走 `dws sheet export`;要把本地 xlsx/xls 转为在线表格,走 `dws sheet import`;要读取已上传但未转换的 xlsx 节点,先走 `dws doc download`
@@ -0,0 +1,76 @@
# 导入本地表格(import)
## 使用场景
用户说“导入 Excel”“把 xlsx 变成在线表格”“上传本地表格并在线编辑”时,使用 Agent 可发现入口 `dws sheet import create`。它只新建在线电子表格,不覆盖已有表格。旧入口 `dws sheet import` 继续兼容人工调用。
- 支持本地 `xlsx` / `xls`,文件上限 20MB
- `--folder-token` 与 `--workspace` 至少提供一个
- CLI 内部完成创建会话、OSS 上传、确认转换和轮询,Agent 不要自行拆分或重试
- `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令
## 命令
```bash
# 导入到指定文件夹
dws sheet import create \
--file ./quote.xlsx \
--folder-token <FOLDER_TOKEN> \
--format json
# 导入到指定知识库,并自定义名称
dws sheet import create \
--file ./report.xls \
--workspace <WORKSPACE_ID> \
--name "月度报表" \
--format json
# 导入超时或中断后续查
dws sheet import get --task-id <TASK_ID> --format json
```
公开参数:
| 命令 | 参数 | 说明 |
|------|------|------|
| `sheet import create` | `--file` | 本地 xlsx/xls 文件(必填) |
| `sheet import create` | `--folder-token` | 目标文件夹 ID 或 URL,与 `--workspace` 至少传一个 |
| `sheet import create` | `--workspace` | 目标知识库 ID 或 URL,与 `--folder-token` 至少传一个 |
| `sheet import create` | `--name`, `-n` | 导入后的名称;默认取文件名并去掉扩展名 |
| `sheet import get` | `--task-id` | 导入任务 ID |
## 返回与续查
成功时返回:
```json
{
"success": true,
"taskId": "<TASK_ID>",
"documentUrl": "<DOCUMENT_URL>",
"documentName": "月度报表",
"documentType": "1",
"nodeId": "<NODE_ID>"
}
```
轮询达到上限时命令以退出码 0 返回业务态,避免 Agent 重复创建文档:
```json
{
"success": false,
"timed_out": true,
"taskId": "<TASK_ID>",
"status": "processing",
"next_command": "dws sheet import get --task-id <TASK_ID>"
}
```
检测到 `timed_out:true` 后,只执行 `next_command` 续查。不要重新执行 `sheet import create`。
## 边界
- 本命令接受的是本地文件路径;它把 xlsx/xls 转换成新的 axls 在线表格
- 已经存在于钉盘或文档中的 xlsx 节点不能直接传给工作表/单元格命令;先用 `doc download` 下载,需要在线编辑时再执行 `sheet import create`
- 向已有在线表格写数据应使用 `range update`、`append`、`csv-put` 或 `table-put`
- md/doc/docx 等文字文档导入使用 `dws doc import`
+28 -2
View File
@@ -577,8 +577,8 @@ func TestBuildEntrypointsUseStripLdflags(t *testing.T) {
want: `go build -ldflags="-s -w" -o $tmpBin "$Root/cmd"`,
},
{
relPath: filepath.Join("..", "..", "scripts", "policy", "check-command-surface.sh"),
want: `go build -ldflags="-s -w" -o "$BIN_PATH" ./cmd`,
relPath: filepath.Join("..", "..", "scripts", "dev", "build.sh"),
want: `-ldflags="-s -w"`,
},
}
@@ -599,6 +599,32 @@ func TestBuildEntrypointsUseStripLdflags(t *testing.T) {
}
}
func TestPolicyBinaryChecksReuseBuiltDWS(t *testing.T) {
t.Parallel()
for _, relPath := range []string{
filepath.Join("..", "..", "scripts", "policy", "check-command-surface.sh"),
filepath.Join("..", "..", "scripts", "policy", "check-schema-binary.sh"),
} {
scriptPath, err := filepath.Abs(relPath)
if err != nil {
t.Fatalf("Abs(%s) error = %v", relPath, err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
}
text := string(data)
if !strings.Contains(text, `BIN="${DWS_BIN:-$ROOT/dws}"`) {
t.Fatalf("%s does not reuse DWS_BIN", scriptPath)
}
if strings.Contains(text, `go build -o "$tmp/dws" ./cmd`) ||
strings.Contains(text, `go build -ldflags="-s -w"`) {
t.Fatalf("%s unexpectedly rebuilds dws", scriptPath)
}
}
}
// TestInstallScriptsCacheMultiSkills verifies install.sh / install.ps1 /
// install-skills.sh / build/npm/install.js all carry the wiring that caches
// the multi/ tree to ~/.dws/skills/multi/ during install. This is what lets