From 809d026b7ecee564d36b923a61b70e0870f07547 Mon Sep 17 00:00:00 2001 From: SCzheng <144249901+sczheng189@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:53:34 +0800 Subject: [PATCH] ci: add CLI compatibility and PR quality gates (#602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(interface): 添加接口完整性和CLI烟雾测试检查 - 在Makefile中新增interface-integrity、update-interface-baseline、cli-smoke和mock-mcp-smoke目标 - 实现接口基线脚本以比较CLI公共命令树与基线文件 - 新增脚本确保二进制文件可渲染所有顶层命令的帮助信息 - 添加mock-mcp-smoke测试验证HTTP和stdio MCP请求/响应传输 - 在GitHub Actions CI工作流中加入interface-integrity、cli-smoke和mock-mcp-smoke检查 - 新增AI行为检查工作流,限制AI生成PR的改动范围和禁改保护文件 - 文档中补充PR质量门禁要求及接口变更流程说明 * feat(policy): 增加多项兼容性和完整性检查 - Makefile中新增reset-interface-baseline、schema-compatibility、update-schema-baseline、skill-command-integrity等目标 - CI流程新增schema-compatibility和skill-command-integrity步骤 - 文档中详细说明接口兼容性基线更新和重置流程及schema基线注意事项 - 实现interface-baseline工具支持兼容性重置和合并,多项接口兼容性检查逻辑完善 - 新增schema-compat工具用于标准化schema列表及兼容性检查与合并 - 新增skill-command-integrity检查确保技能中引用命令存在 - 为interface-baseline和schema-compat添加单元测试覆盖基本兼容性规则 * test(skill-command-check): 增加命令解析和解析结果测试用例 - 补充对 parseReference 函数的边界情况和跳过条件的测试 - 新增 resolveCommandReference 函数测试,覆盖有效、无效及跳过场景 - 增加 isPlaceholder 函数的测试,验证占位符识别准确性 refactor(skill-command-check): 优化命令路径解析和验证逻辑 - 使用 resolveCommandReference 统一处理命令解析结果,清晰区分有效、无效和跳过情况 - 新增 commandResolution 类型及常量,提升代码可读性和扩展性 - 调整 parseReference 增加对 shell 组合符 “ & ” 的跳过处理 docs(mono): 更新最佳实践和产品文档命令示例 - 优化《best_practices/07-minutes.md》中行动项与摘要拉取示例命令,提升准确性 - 修改产品文档中 ALIDOC 表格数据和多维表格记录相关命令,命令路径更规范统一 - 同步多端技能文档,确保命令示例一致且正确 * ci: check interface against PR merge-base * feat(policy): enforce CLI contract compatibility * ci: enforce PR coverage thresholds * ci: add fail-closed CI gate * ci: adapt schema compatibility gate to runtime catalog * fix(ci): close schema compatibility gate gaps * test(auth): skip POSIX mode assertion on Windows * test(auth): scope POSIX file-backend checks * fix(ci): enforce native platform coverage * test(ci): make skill paths portable --------- Co-authored-by: 修雨 --- .github/workflows/ai-behavior-check.yml | 88 + .github/workflows/ci.yml | 334 ++- .gitignore | 6 + Makefile | 44 +- docs/ci-pr-gates.md | 86 + internal/auth/filelock_test.go | 4 + internal/auth/portable_store_test.go | 10 + internal/auth/secure_store_test.go | 4 + internal/cli/schema_agent_metadata/index.json | 2 +- internal/cli/schema_agent_metadata_audit.json | 2 +- internal/cli/schema_catalog.json | 4 +- ...check-authoritative-interface-baselines.sh | 91 + ...heck-authoritative-schema-compatibility.sh | 79 + scripts/policy/check-cli-smoke.sh | 49 + scripts/policy/check-coverage-gate.sh | 89 + scripts/policy/check-interface-baseline.sh | 53 + scripts/policy/check-skill-commands.sh | 11 + scripts/policy/coverage-gate/main.go | 404 +++ scripts/policy/coverage-gate/main_test.go | 259 ++ scripts/policy/interface-baseline/contract.go | 598 ++++ .../interface-baseline/contract_test.go | 250 ++ scripts/policy/interface-baseline/main.go | 95 + scripts/policy/run-platform-coverage-gate.sh | 89 + scripts/policy/schema-compat/main.go | 606 ++++ scripts/policy/schema-compat/main_test.go | 394 +++ scripts/policy/skill-command-check/main.go | 302 ++ .../policy/skill-command-check/main_test.go | 171 ++ .../references/best_practices/07-minutes.md | 5 +- .../mono/references/products/doc/doc-info.md | 4 +- .../dingtalk-doc/references/doc/doc-info.md | 4 +- test/fixtures/cli-interface-baseline.txt | 2672 +++++++++++++++++ 31 files changed, 6759 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/ai-behavior-check.yml create mode 100644 docs/ci-pr-gates.md create mode 100755 scripts/policy/check-authoritative-interface-baselines.sh create mode 100755 scripts/policy/check-authoritative-schema-compatibility.sh create mode 100755 scripts/policy/check-cli-smoke.sh create mode 100755 scripts/policy/check-coverage-gate.sh create mode 100755 scripts/policy/check-interface-baseline.sh create mode 100755 scripts/policy/check-skill-commands.sh create mode 100644 scripts/policy/coverage-gate/main.go create mode 100644 scripts/policy/coverage-gate/main_test.go create mode 100644 scripts/policy/interface-baseline/contract.go create mode 100644 scripts/policy/interface-baseline/contract_test.go create mode 100644 scripts/policy/interface-baseline/main.go create mode 100755 scripts/policy/run-platform-coverage-gate.sh create mode 100644 scripts/policy/schema-compat/main.go create mode 100644 scripts/policy/schema-compat/main_test.go create mode 100644 scripts/policy/skill-command-check/main.go create mode 100644 scripts/policy/skill-command-check/main_test.go create mode 100644 test/fixtures/cli-interface-baseline.txt diff --git a/.github/workflows/ai-behavior-check.yml b/.github/workflows/ai-behavior-check.yml new file mode 100644 index 00000000..7905ee2a --- /dev/null +++ b/.github/workflows/ai-behavior-check.yml @@ -0,0 +1,88 @@ +name: AI Behavior Check + +on: + pull_request_target: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + pull-requests: read + statuses: write + +jobs: + ai-behavior-check: + name: AI Behavior Policy Evaluator + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Deliberately do not check out or execute pull-request code here. + # pull_request_target keeps this policy anchored to the base branch. + - name: Check AI-generated PR boundaries + uses: actions/github-script@v7 + with: + script: | + const sha = context.payload.pull_request.head.sha; + const setStatus = (state, description) => + github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha, + state, + context: 'AI Behavior Check', + description, + }); + + await setStatus('pending', 'Evaluating AI-generated PR boundaries'); + + const labels = context.payload.pull_request.labels.map(({ name }) => name); + if (!labels.includes('ai-generated')) { + await setStatus('success', 'Not labeled ai-generated'); + core.notice('Not an ai-generated PR; no AI-only policy applied.'); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + per_page: 100, + }); + + const maxChangedFiles = 30; + if (files.length > maxChangedFiles) { + await setStatus( + 'failure', + `Changes ${files.length} files; limit is ${maxChangedFiles}` + ); + core.setFailed( + `AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.` + ); + return; + } + + const protectedPaths = files + .map(({ filename }) => filename) + .filter((filename) => + filename.startsWith('.github/workflows/') || + filename.startsWith('scripts/release/') || + filename === '.goreleaser.yaml' || + filename === 'Makefile' + ); + + if (protectedPaths.length > 0) { + await setStatus('failure', 'Modifies protected release/CI infrastructure'); + core.setFailed( + 'AI-generated PR modifies protected release/CI infrastructure:\n' + + protectedPaths.map((filename) => ` - ${filename}`).join('\n') + + '\nSplit these changes into a human-owned PR with explicit review.' + ); + return; + } + + await setStatus( + 'success', + `Passed with ${files.length} changed files (limit ${maxChangedFiles})` + ); + core.notice( + `AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).` + ); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5f19973..ebbee297 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,7 @@ on: pull_request: permissions: - contents: write - pull-requests: write + contents: read jobs: lint: @@ -26,7 +25,7 @@ jobs: - name: Format Check run: | - unformatted="$(find cmd internal test -name '*.go' -print0 | xargs -0r gofmt -l)" + unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)" test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1) - name: Go Vet @@ -101,18 +100,108 @@ jobs: - name: Build Windows CLI run: go build -o dws.exe ./cmd - - name: Test Windows auth and DPAPI paths - run: | - go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth - go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)' + - name: Test Windows auth and DPAPI packages + run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth - coverage: - name: Coverage - runs-on: ubuntu-latest + - name: Test Windows auth migration diagnostics + run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)' + + coverage-darwin: + name: Coverage (macOS) + runs-on: macos-latest timeout-minutes: 20 steps: - name: Check out repository uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Resolve authoritative coverage base + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + set -eu + base_ref="$PUSH_BEFORE_SHA" + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")" + fi + if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then + base_ref="$(git rev-parse HEAD^)" + fi + git rev-parse --verify "${base_ref}^{commit}" >/dev/null + echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV" + + - name: Run and enforce macOS changed-code coverage + run: make coverage-gate-platform BASE_REF="$COVERAGE_BASE_REF" PROFILE=coverage-darwin.txt + + - name: Upload macOS coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-darwin + path: coverage-darwin.txt + + coverage-windows: + name: Coverage (Windows) + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Resolve authoritative coverage base + shell: bash + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + set -eu + base_ref="$PUSH_BEFORE_SHA" + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")" + fi + if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then + base_ref="$(git rev-parse HEAD^)" + fi + git rev-parse --verify "${base_ref}^{commit}" >/dev/null + echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV" + + - name: Run and enforce Windows changed-code coverage + shell: bash + run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt + + - name: Upload Windows coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-windows + path: coverage-windows.txt + + coverage: + name: Coverage + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - name: Set up Go uses: actions/setup-go@v5 @@ -125,11 +214,51 @@ jobs: - name: Build run: make build - - name: Run tests with coverage + - name: Resolve authoritative coverage base + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} run: | - go test -coverprofile=coverage.txt -covermode=atomic ./cmd/... ./internal/... + set -eu + base_ref="$PUSH_BEFORE_SHA" + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")" + fi + if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then + base_ref="$(git rev-parse HEAD^)" + fi + git rev-parse --verify "${base_ref}^{commit}" >/dev/null + echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV" + + - name: Run current and baseline unit tests with coverage + run: | + set -eu + go test -count=1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/... + go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/... + + base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")" + rmdir "$base_worktree" + cleanup() { + git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true + } + trap cleanup EXIT + git worktree add --detach "$base_worktree" "$COVERAGE_BASE_REF" + ( + cd "$base_worktree" + go test -count=1 \ + -coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \ + -covermode=atomic \ + ./ ./cmd/... ./internal/... ./skills/... + ) go tool cover -func=coverage.txt + - name: Enforce coverage gate + env: + COVERAGE_TARGET: "80" + COVERAGE_ENFORCE_OVERALL: "false" + run: make coverage-gate BASE_REF="$COVERAGE_BASE_REF" + - name: Generate coverage report run: go tool cover -html=coverage.txt -o coverage.html @@ -139,32 +268,10 @@ jobs: name: coverage-report path: | coverage.txt + coverage-base.txt + coverage-policy.txt coverage.html - - name: Update coverage badge - if: github.ref == 'refs/heads/main' - run: | - COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//') - echo "Coverage: ${COVERAGE}%" - if (( $(echo "$COVERAGE >= 80" | bc -l) )); then - COLOR="brightgreen" - elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then - COLOR="yellow" - else - COLOR="red" - fi - mkdir -p .github/badges - curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg - - - name: Commit badge - if: github.ref == 'refs/heads/main' - run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - git add .github/badges/coverage.svg || true - git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]" - git push || true - policy: name: Policy Check runs-on: ubuntu-latest @@ -187,6 +294,99 @@ jobs: - name: Generated Drift run: ./scripts/policy/check-generated-drift.sh + interface-integrity: + name: Interface Integrity + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + run: make build + + - name: Resolve authoritative compatibility merge-base + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + set -eu + base_ref="$PUSH_BEFORE_SHA" + if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")" + fi + if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then + base_ref="$(git rev-parse HEAD^)" + fi + git rev-parse --verify "${base_ref}^{commit}" >/dev/null + stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')" + if [ -z "$stable_ref" ]; then + echo "No stable release tag is reachable from compatibility base $base_ref" >&2 + exit 1 + fi + git rev-parse --verify "${stable_ref}^{commit}" >/dev/null + echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV" + echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV" + + - name: Check historical commands and help compatibility + run: | + make authoritative-interface-integrity \ + BASE_REF="$COMPATIBILITY_BASE_REF" + if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then + make authoritative-interface-integrity \ + BASE_REF="$COMPATIBILITY_STABLE_REF" + fi + + - name: Check complete Schema compatibility + run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF" + + - name: Check skill command references + run: make skill-command-integrity + + cli-smoke: + name: CLI Smoke + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build + run: make build + + - name: Check public top-level commands + run: make cli-smoke + + mock-mcp-smoke: + name: Mock MCP Smoke + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Check HTTP and stdio MCP transport + run: make mock-mcp-smoke + edition-tests: name: Edition Contract Tests runs-on: ubuntu-latest @@ -203,11 +403,71 @@ jobs: - name: Run edition contract tests run: go test -v -count=1 ./pkg/editiontest/... + ci-gate: + name: CI Gate + needs: + - lint + - test + - test-darwin + - test-windows + - coverage + - coverage-darwin + - coverage-windows + - policy + - interface-integrity + - cli-smoke + - mock-mcp-smoke + - edition-tests + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Verify required checks + env: + LINT_RESULT: ${{ needs.lint.result }} + TEST_RESULT: ${{ needs.test.result }} + TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }} + TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }} + COVERAGE_RESULT: ${{ needs.coverage.result }} + COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }} + COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }} + POLICY_RESULT: ${{ needs.policy.result }} + INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }} + CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }} + MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }} + EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }} + run: | + failed=0 + for check in \ + "Lint:$LINT_RESULT" \ + "Test:$TEST_RESULT" \ + "Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \ + "Test (Windows):$TEST_WINDOWS_RESULT" \ + "Coverage:$COVERAGE_RESULT" \ + "Coverage (macOS):$COVERAGE_DARWIN_RESULT" \ + "Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \ + "Policy Check:$POLICY_RESULT" \ + "Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \ + "CLI Smoke:$CLI_SMOKE_RESULT" \ + "Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \ + "Edition Contract Tests:$EDITION_TESTS_RESULT" + do + name="${check%%:*}" + result="${check#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$result" != "success" ]; then + failed=1 + fi + done + test "$failed" -eq 0 + notify-downstream: name: Notify Wukong Overlay - needs: [test, policy, edition-tests] + needs: [ci-gate] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.event_name == 'push' + permissions: {} steps: - name: Trigger downstream CI run: | diff --git a/.gitignore b/.gitignore index 0e2bad78..954c0625 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ test/cli_compat/testdata/ /internal/compat/testdata/* .gitignore .worktrees/ +.qoder/ # Secrets & credentials .env @@ -47,4 +48,9 @@ test/dev_functional/results.jsonl /.qoder/ .vercel .env* +# Local Go coverage output +/coverage.txt +/coverage-base.txt +/coverage-policy.txt +/coverage.html dwsbin diff --git a/Makefile b/Makefile index 9ef595e2..4658b361 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ GO ?= go -.PHONY: all help build rebuild test lint fmt policy edition-test test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release publish-homebrew-formula setup-hooks +.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 all: setup-hooks fmt lint build test rebuild @@ -11,6 +11,16 @@ help: @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 interface-integrity - Check historical commands and help contracts still work\n" + @printf " make authoritative-interface-integrity BASE_REF= - Check the Git-owned PR merge-base\n" + @printf " make coverage-gate BASE_REF= - Enforce overall non-regression and changed-code coverage\n" + @printf " make coverage-gate-platform BASE_REF= PROFILE= - Enforce native-platform changed-code coverage\n" + @printf " make update-interface-baseline - Add new CLI contracts without removing history\n" + @printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n" + @printf " make schema-compatibility BASE_REF= - Check the complete Schema contract against the PR merge-base\n" + @printf " make skill-command-integrity - Check dws commands referenced by skills exist\n" + @printf " make cli-smoke - Verify help for every public top-level command\n" + @printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n" @printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n" @printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n" @printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n" @@ -32,7 +42,7 @@ lint: @./scripts/dev/lint.sh fmt: - @find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w + @find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w policy: @./scripts/policy/check-open-source-assets.sh @@ -46,6 +56,36 @@ policy: edition-test: $(GO) test -v -count=1 ./pkg/editiontest/... +interface-integrity: + @./scripts/policy/check-interface-baseline.sh + +authoritative-interface-integrity: + @./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)" + +coverage-gate: + @./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable + +coverage-gate-platform: + @./scripts/policy/run-platform-coverage-gate.sh --base-ref "$(BASE_REF)" --profile "$(PROFILE)" + +update-interface-baseline: + @./scripts/policy/check-interface-baseline.sh --update + +reset-interface-baseline: + @./scripts/policy/check-interface-baseline.sh --reset + +schema-compatibility: + @./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)" + +skill-command-integrity: + @./scripts/policy/check-skill-commands.sh + +cli-smoke: + @./scripts/policy/check-cli-smoke.sh + +mock-mcp-smoke: + $(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport + test-schema-agent-examples: DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestManualAgentExamplesDryRun$$' diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md new file mode 100644 index 00000000..16da5cd3 --- /dev/null +++ b/docs/ci-pr-gates.md @@ -0,0 +1,86 @@ +# Pull request quality gates + +The repository defines five focused checks in addition to its existing CI: + +- **Interface Integrity** enforces backwards compatibility. Every historical + command path and alias must still resolve, every historical command must + still render `-h`, and historical flags must keep their type and shorthand. + New commands, aliases, and flags are allowed. The same job compares the full + complete `dws schema --all` contract with the PR merge-base, blocking removed + products/tools/parameters, incompatible parameter or interface mappings, + constraint drift, and safety-semantic drift. It also checks that executable + `dws ...` references in `skills/**/*.md` resolve to real commands. + Help compatibility covers command/alias/flag spelling, flag type and + shorthand; descriptive prose may evolve without breaking the gate. +- **Coverage** runs unit tests on every pull request and prints both overall and + changed-code statement coverage. During the migration to the 80% repository + target, overall coverage may not regress from a profile generated from the + merge-base with the same test command, while changed production Go + statements must meet 80%. Linux, Windows, and macOS each generate a native + coverage profile for changed packages and enforce the threshold against + changed files buildable on that platform, so build-tagged source cannot be + hidden by an Ubuntu-only profile. Overall non-regression allows 0.1 percentage point of measurement + variance to avoid failing unchanged code on test-path noise. Set + `COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make + the overall target fail closed as well. +- **CLI Smoke** builds the release binary and renders offline help for the root + and every public top-level command. +- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests + (`Initialize -> ListTools -> CallTool`). +- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It + limits the change to 30 files and blocks release/CI infrastructure changes. + It uses `pull_request_target` without checking out PR code, so the policy + cannot be bypassed by changing the workflow in the same pull request. The + evaluator writes an `AI Behavior Check` commit status to the PR head SHA so + GitHub rulesets can require it. + +## Running the compatibility gates + +Run: + +```sh +make build +make interface-integrity +make authoritative-interface-integrity BASE_REF= +make schema-compatibility BASE_REF= +make skill-command-integrity +make cli-smoke +make coverage-gate BASE_REF= +# Run on the corresponding native runner with its generated profile: +make coverage-gate-platform BASE_REF= PROFILE= +``` + +CI derives the authoritative Interface snapshots from both the PR merge-base +and the latest reachable stable release tag. The complete Schema snapshot comes +from the PR merge-base, which contains the registry-first Schema introduced on +`main`. The candidate branch cannot bless a breaking change by editing a +fixture. Schema additions are allowed; historical products, tools, parameters, +parameter mappings, positional execution fields, constraints, and safety +semantics remain protected. Positional descriptions are documentation and may +change without breaking compatibility. + +`make update-interface-baseline` still extends the local checked-in Interface +fixture used by `make interface-integrity`. Updates are monotonic: they add new +commands and flags without removing history. + +For an intentional compatibility reset at a major-version boundary, run +`make reset-interface-baseline`. This replaces all CLI compatibility history +with the current command tree and must receive explicit human review. + +## Required GitHub repository settings + +Create a ruleset for `main` that requires pull requests and code-owner review, +then mark these aggregate status checks as required: + +- `CI Gate` +- `Multi Profile E2E` +- `AI Behavior Check` + +`CI Gate` fails closed unless every first-layer CI job succeeds, including +lint, tests, native Linux/Windows/macOS coverage, policy, +Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate +check keeps repository rules stable when an internal job is renamed or split. + +The `ai-generated` label must be applied by the PR-creation automation or by a +maintainer; GitHub cannot infer reliably whether a human-authored PR contains +AI-generated code. diff --git a/internal/auth/filelock_test.go b/internal/auth/filelock_test.go index d7d3d2d6..452c70dd 100644 --- a/internal/auth/filelock_test.go +++ b/internal/auth/filelock_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "sync" "sync/atomic" "testing" @@ -127,6 +128,9 @@ func TestAcquireTokenLock_Contention(t *testing.T) { } func TestAcquireTokenLock_LockFilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows enforces file access through ACLs, not POSIX mode bits") + } t.Parallel() configDir := t.TempDir() diff --git a/internal/auth/portable_store_test.go b/internal/auth/portable_store_test.go index 95ae727b..f3c7764c 100644 --- a/internal/auth/portable_store_test.go +++ b/internal/auth/portable_store_test.go @@ -61,6 +61,7 @@ func TestExportPortableAuthBundleRequiresAuthToken(t *testing.T) { } func TestPortableAuthTargetPopulated(t *testing.T) { + requirePortableFileBackend(t) t.Setenv(keychain.DisableKeychainEnv, "1") root := t.TempDir() configDir := filepath.Join(root, ".dws") @@ -82,6 +83,7 @@ func TestPortableAuthTargetPopulated(t *testing.T) { } func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) { + requirePortableFileBackend(t) t.Setenv(keychain.DisableKeychainEnv, "1") sourceKeychain := filepath.Join(t.TempDir(), "source-keychain") t.Setenv(keychain.StorageDirEnv, sourceKeychain) @@ -140,6 +142,7 @@ func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) { } func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) { + requirePortableFileBackend(t) t.Setenv(keychain.DisableKeychainEnv, "1") SetRuntimeProfile("") t.Cleanup(func() { SetRuntimeProfile("") }) @@ -211,3 +214,10 @@ func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) { t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken) } } + +func requirePortableFileBackend(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("portable bundle round trips require a file-DEK backend; Windows uses DPAPI registry storage") + } +} diff --git a/internal/auth/secure_store_test.go b/internal/auth/secure_store_test.go index 9f4d3f85..4d20df2b 100644 --- a/internal/auth/secure_store_test.go +++ b/internal/auth/secure_store_test.go @@ -3,12 +3,16 @@ package auth import ( "os" "path/filepath" + "runtime" "sync" "testing" "time" ) func TestSaveSecureTokenData_FixesUnsafePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows enforces directory access through ACLs, not POSIX mode bits") + } configDir := filepath.Join(t.TempDir(), "unsafe") // Create directory with overly permissive mode. if err := os.MkdirAll(configDir, 0o755); err != nil { diff --git a/internal/cli/schema_agent_metadata/index.json b/internal/cli/schema_agent_metadata/index.json index 8b23ba37..c76d4802 100644 --- a/internal/cli/schema_agent_metadata/index.json +++ b/internal/cli/schema_agent_metadata/index.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:b42825642a99ec968d421660de7bab9de4b756d81906f1df2e1301865760a24f", + "source_hash": "sha256:024b37942df489fb1b6e5a7768a5da50316c402dfe2fa8d0b40dc5a03d0aeddc", "surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3", "coverage": { "surface_products": 22, diff --git a/internal/cli/schema_agent_metadata_audit.json b/internal/cli/schema_agent_metadata_audit.json index cf2a1f7a..ba63e722 100644 --- a/internal/cli/schema_agent_metadata_audit.json +++ b/internal/cli/schema_agent_metadata_audit.json @@ -1,6 +1,6 @@ { "version": 1, - "source_hash": "sha256:b42825642a99ec968d421660de7bab9de4b756d81906f1df2e1301865760a24f", + "source_hash": "sha256:024b37942df489fb1b6e5a7768a5da50316c402dfe2fa8d0b40dc5a03d0aeddc", "surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3", "source_files": 149, "hint_files": 46, diff --git a/internal/cli/schema_catalog.json b/internal/cli/schema_catalog.json index 9df6cc20..9511710e 100644 --- a/internal/cli/schema_catalog.json +++ b/internal/cli/schema_catalog.json @@ -1,12 +1,12 @@ { "version": 1, - "source_hash": "sha256:897ab12f22bd7c658b52ae4c18336d65ad8f4f22c6def41ba9dc221d88ed17f3", + "source_hash": "sha256:0ed2874f8e5000b0d9156d12d45b535d98eb65aaf2f8cdaa330390846b6b8e84", "surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3", "catalog": { "agent_metadata": { "products_with_metadata": 22, "source": "embedded-skill-metadata", - "source_hash": "sha256:b42825642a99ec968d421660de7bab9de4b756d81906f1df2e1301865760a24f", + "source_hash": "sha256:024b37942df489fb1b6e5a7768a5da50316c402dfe2fa8d0b40dc5a03d0aeddc", "surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3", "surface_products": 22, "surface_tools": 564, diff --git a/scripts/policy/check-authoritative-interface-baselines.sh b/scripts/policy/check-authoritative-interface-baselines.sh new file mode 100755 index 00000000..57bc563b --- /dev/null +++ b/scripts/policy/check-authoritative-interface-baselines.sh @@ -0,0 +1,91 @@ +#!/bin/sh +set -eu + +# Compare the candidate command tree against a Git-owned historical source +# revision. CI invokes this for both the PR merge-base and latest stable tag; a +# fixture from the candidate branch is never used as authority. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +BASE_REF="" + +usage() { + printf '%s\n' "usage: $0 --base-ref " >&2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --base-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + BASE_REF="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'error: unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +[ -n "$BASE_REF" ] || { usage; exit 2; } + +cd "$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")" +BASE_WORKTREE="$TMP_ROOT/base-worktree" +BASELINE="$TMP_ROOT/merge-base.txt" +CANDIDATE_BIN="$TMP_ROOT/interface-baseline" +CANDIDATE_HOME="$TMP_ROOT/candidate-home" + +cleanup() { + git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +install_snapshot_helper() { + worktree="$1" + helper="$worktree/scripts/policy/interface-baseline" + + # Use one contract format on both sides while constructing the command tree + # from the authoritative historical source revision. + rm -rf "$helper" + mkdir -p "$helper" + cp -R "$ROOT/scripts/policy/interface-baseline/." "$helper/" +} + +generate_reference() { + ref="$1" + worktree="$2" + output="$3" + home="$4" + bin="$TMP_ROOT/reference-$(basename "$worktree")" + + git -C "$ROOT" worktree add --detach "$worktree" "$ref" >/dev/null + install_snapshot_helper "$worktree" + mkdir -p "$home" + ( + cd "$worktree" + go build -o "$bin" ./scripts/policy/interface-baseline + HOME="$home" DWS_LANG=zh "$bin" >"$output" + ) +} + +generate_reference "$BASE_REF" "$BASE_WORKTREE" "$BASELINE" "$TMP_ROOT/base-home" + +mkdir -p "$CANDIDATE_HOME" +go build -o "$CANDIDATE_BIN" ./scripts/policy/interface-baseline + +printf 'checking candidate against authoritative interface ref %s\n' "$BASE_REF" +HOME="$CANDIDATE_HOME" DWS_LANG=zh "$CANDIDATE_BIN" --check "$BASELINE" diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh new file mode 100755 index 00000000..32b63e01 --- /dev/null +++ b/scripts/policy/check-authoritative-schema-compatibility.sh @@ -0,0 +1,79 @@ +#!/bin/sh +set -eu + +# Compare the candidate's complete Schema contract with the Git-owned PR +# merge-base (or previous main SHA on push). The candidate branch cannot +# weaken this authority by editing a checked-in fixture. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +BIN="${DWS_BIN:-$ROOT/dws}" +BASE_REF="" + +usage() { + printf '%s\n' "usage: $0 --base-ref " >&2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --base-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + BASE_REF="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'error: unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +[ -n "$BASE_REF" ] || { usage; exit 2; } + +cd "$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 +} +if [ ! -x "$BIN" ]; then + printf 'error: dws binary not found at %s (run make build first)\n' "$BIN" >&2 + exit 2 +fi + +TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-schema-authority.XXXXXX")" +BASE_WORKTREE="$TMP_ROOT/base-worktree" +BASE_BIN="$TMP_ROOT/base-dws" +BASE_RAW="$TMP_ROOT/base-schema.json" +BASELINE="$TMP_ROOT/base-contract.json" +CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json" +CHECKER="$TMP_ROOT/schema-compat" + +cleanup() { + git -C "$ROOT" worktree remove --force "$BASE_WORKTREE" >/dev/null 2>&1 || true + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +git -C "$ROOT" worktree add --detach "$BASE_WORKTREE" "$BASE_REF" >/dev/null +( + cd "$BASE_WORKTREE" + go build -o "$BASE_BIN" ./cmd +) +go build -o "$CHECKER" ./scripts/policy/schema-compat + +mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/candidate-home" +HOME="$TMP_ROOT/base-home" DWS_LANG=zh \ + "$BASE_BIN" schema --all --format json >"$BASE_RAW" +HOME="$TMP_ROOT/candidate-home" DWS_LANG=zh \ + "$BIN" schema --all --format json >"$CANDIDATE_RAW" + +"$CHECKER" --normalize "$BASE_RAW" >"$BASELINE" +printf 'checking complete Schema contract against PR merge-base %s\n' "$BASE_REF" +"$CHECKER" --check "$BASELINE" --current "$CANDIDATE_RAW" diff --git a/scripts/policy/check-cli-smoke.sh b/scripts/policy/check-cli-smoke.sh new file mode 100755 index 00000000..3f6b3c46 --- /dev/null +++ b/scripts/policy/check-cli-smoke.sh @@ -0,0 +1,49 @@ +#!/bin/sh +set -eu + +# Verify that the shipped binary can render help for every public top-level +# command without login state or network access. Interface Integrity checks +# the complete tree; this smoke test checks the actual compiled artifact. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +BIN="${DWS_BIN:-$ROOT/dws}" +BASELINE="$ROOT/test/fixtures/cli-interface-baseline.txt" + +if [ ! -x "$BIN" ]; then + printf 'error: dws binary not found at %s (run make build first)\n' "$BIN" >&2 + exit 1 +fi +if [ ! -f "$BASELINE" ]; then + printf 'error: interface baseline missing at %s\n' "$BASELINE" >&2 + exit 1 +fi + +SNAPSHOT_HOME="$(mktemp -d)" +trap 'rm -rf "$SNAPSHOT_HOME"' EXIT + +# DWS_DISABLE_KEYCHAIN=1 routes the DEK to a file (the Linux scheme) instead of +# the macOS system Keychain. Without it, running the binary under this fresh +# HOME triggers a GUI Keychain authorization prompt that blocks the +# non-interactive smoke test indefinitely. Linux CI already uses the file DEK. +run_help() { + output="$(HOME="$SNAPSHOT_HOME" DWS_DISABLE_KEYCHAIN=1 DWS_LANG=zh "$BIN" "$@" --help 2>&1)" || { + printf 'error: dws %s --help exited non-zero\n%s\n' "$*" "$output" >&2 + return 1 + } +} + +run_help + +command_line="$(sed -n '/^\[root\]$/{n;s/^ commands: //;p;q;}' "$BASELINE")" +if [ -z "$command_line" ]; then + printf 'error: no root commands found in interface baseline\n' >&2 + exit 1 +fi + +count=0 +for command in $(printf '%s\n' "$command_line" | tr -d ','); do + run_help "$command" + count=$((count + 1)) +done + +printf 'CLI smoke check: ok (root + %s public top-level commands)\n' "$count" diff --git a/scripts/policy/check-coverage-gate.sh b/scripts/policy/check-coverage-gate.sh new file mode 100755 index 00000000..7ffb37f9 --- /dev/null +++ b/scripts/policy/check-coverage-gate.sh @@ -0,0 +1,89 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +BASE_REF="" +OVERALL_PROFILE="coverage.txt" +BASELINE_PROFILE="coverage-base.txt" +DIFF_PROFILE="coverage-policy.txt" +TARGET="${COVERAGE_TARGET:-80}" +OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0.1}" +ENFORCE_OVERALL="${COVERAGE_ENFORCE_OVERALL:-false}" +CHANGED_ONLY="false" +SCOPE_BUILDABLE="false" + +usage() { + printf '%s\n' "usage: $0 --base-ref [--changed-only] [--scope-buildable] [--overall-profile ] [--baseline-profile ] [--diff-profile ]" >&2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --base-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + BASE_REF="$2" + shift 2 + ;; + --overall-profile) + [ "$#" -ge 2 ] || { usage; exit 2; } + OVERALL_PROFILE="$2" + shift 2 + ;; + --baseline-profile) + [ "$#" -ge 2 ] || { usage; exit 2; } + BASELINE_PROFILE="$2" + shift 2 + ;; + --diff-profile) + [ "$#" -ge 2 ] || { usage; exit 2; } + DIFF_PROFILE="$2" + shift 2 + ;; + --changed-only) + CHANGED_ONLY="true" + shift + ;; + --scope-buildable) + SCOPE_BUILDABLE="true" + shift + ;; + *) + printf 'error: unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +[ -n "$BASE_REF" ] || { usage; exit 2; } +cd "$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 +} + +module="$(go list -m -f '{{.Path}}')" +set -- go run ./scripts/policy/coverage-gate \ + --diff-profile "$DIFF_PROFILE" \ + --base-ref "$BASE_REF" \ + --module "$module" \ + --overall-tolerance "$OVERALL_TOLERANCE" \ + --target "$TARGET" \ + --enforce-overall-target="$ENFORCE_OVERALL" + +if [ "$CHANGED_ONLY" = "true" ]; then + set -- "$@" --changed-only +else + baseline="$(go tool cover -func="$BASELINE_PROFILE" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')" + [ -n "$baseline" ] || { + printf 'error: cannot parse authoritative coverage from %s\n' "$BASELINE_PROFILE" >&2 + exit 2 + } + set -- "$@" \ + --overall-profile "$OVERALL_PROFILE" \ + --diff-profile "$OVERALL_PROFILE" \ + --baseline-overall "$baseline" +fi +if [ "$SCOPE_BUILDABLE" = "true" ]; then + set -- "$@" --scope-buildable +fi +"$@" diff --git a/scripts/policy/check-interface-baseline.sh b/scripts/policy/check-interface-baseline.sh new file mode 100755 index 00000000..a0f6dd39 --- /dev/null +++ b/scripts/policy/check-interface-baseline.sh @@ -0,0 +1,53 @@ +#!/bin/sh +set -eu + +# Compare the public Cobra command tree with the reviewed interface baseline. +# The Go snapshot helper builds the tree once in-process, so this remains fast +# even when the CLI has hundreds of command nodes. + +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" +# 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 +if [ "${1:-}" = "--reset" ]; then + mkdir -p "$(dirname "$BASELINE")" + HOME="$SNAPSHOT_HOME" DWS_LANG=zh "$SNAPSHOT_BIN" >"$CURRENT" + cp "$CURRENT" "$BASELINE" + printf 'WARNING: interface compatibility history replaced: %s (%s command nodes)\n' \ + "${BASELINE#"$ROOT"/}" "$(grep -c '^\[' "$BASELINE")" + exit 0 +fi + +if [ "${1:-}" = "--update" ]; then + mkdir -p "$(dirname "$BASELINE")" + + if [ -f "$BASELINE" ]; then + HOME="$SNAPSHOT_HOME" DWS_LANG=zh "$SNAPSHOT_BIN" --merge "$BASELINE" >"$CURRENT" + else + HOME="$SNAPSHOT_HOME" DWS_LANG=zh "$SNAPSHOT_BIN" >"$CURRENT" + fi + cp "$CURRENT" "$BASELINE" + printf 'interface baseline extended: %s (%s historical command nodes)\n' \ + "${BASELINE#"$ROOT"/}" "$(grep -c '^\[' "$BASELINE")" + exit 0 +fi + +if [ "$#" -gt 0 ]; then + printf 'error: unknown option: %s\n' "$1" >&2 + exit 2 +fi + +if [ ! -f "$BASELINE" ]; then + printf 'error: baseline missing at %s — run make update-interface-baseline\n' \ + "${BASELINE#"$ROOT"/}" >&2 + exit 1 +fi + +HOME="$SNAPSHOT_HOME" DWS_LANG=zh "$SNAPSHOT_BIN" --check "$BASELINE" diff --git a/scripts/policy/check-skill-commands.sh b/scripts/policy/check-skill-commands.sh new file mode 100755 index 00000000..d5ef3cfa --- /dev/null +++ b/scripts/policy/check-skill-commands.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +CHECKER="$(mktemp)" +CHECK_HOME="$(mktemp -d)" +trap 'rm -rf "$CHECKER" "$CHECK_HOME"' EXIT + +cd "$ROOT" +go build -o "$CHECKER" ./scripts/policy/skill-command-check +HOME="$CHECK_HOME" DWS_LANG=zh "$CHECKER" diff --git a/scripts/policy/coverage-gate/main.go b/scripts/policy/coverage-gate/main.go new file mode 100644 index 00000000..137b4113 --- /dev/null +++ b/scripts/policy/coverage-gate/main.go @@ -0,0 +1,404 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +// Command coverage-gate enforces non-regressing overall coverage and a strict +// threshold for executable statements changed by the current PR. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "math" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +var ( + profileLine = regexp.MustCompile(`^(.+):(\d+)\.(\d+),(\d+)\.(\d+)\s+(\d+)\s+(\d+)$`) + hunkHeader = regexp.MustCompile(`^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@`) +) + +type stringList []string + +func (values *stringList) String() string { return strings.Join(*values, ",") } +func (values *stringList) Set(value string) error { + *values = append(*values, value) + return nil +} + +type coverageBlock struct { + File string + StartLine int + EndLine int + Statements int + Count int +} + +type lineRange struct { + Start int + End int +} + +type gateInput struct { + Overall []coverageBlock + Diff []coverageBlock + Changed map[string][]lineRange + BaselineOverall float64 + OverallTolerance float64 + Target float64 + EnforceOverall bool + ChangedOnly bool +} + +type gateResult struct { + Overall float64 + ChangedCoverage float64 + ChangedStatements int + Failures []string +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, gitChangedLines, goListBuildableFiles)) +} + +func run( + args []string, + stdout, stderr io.Writer, + changedLoader func(string) (map[string][]lineRange, error), + buildableLoader func() (map[string]bool, error), +) int { + var overallPath string + var diffPaths stringList + var baseRef string + var modulePath string + var baselineOverall float64 + var overallTolerance float64 + var target float64 + var enforceOverall bool + var changedOnly bool + var scopeBuildable bool + flags := flag.NewFlagSet("coverage-gate", flag.ContinueOnError) + flags.SetOutput(stderr) + flags.StringVar(&overallPath, "overall-profile", "", "coverage profile used for overall coverage") + flags.Var(&diffPaths, "diff-profile", "coverage profile used for changed-code coverage (repeatable)") + flags.StringVar(&baseRef, "base-ref", "", "Git merge-base or previous main SHA") + flags.StringVar(&modulePath, "module", "", "Go module path used to normalize profile filenames") + flags.Float64Var(&baselineOverall, "baseline-overall", -1, "authoritative overall coverage percentage") + flags.Float64Var(&overallTolerance, "overall-tolerance", 0.1, "allowed overall coverage measurement variance in percentage points") + flags.Float64Var(&target, "target", 80, "required changed-code and eventual overall coverage percentage") + flags.BoolVar(&enforceOverall, "enforce-overall-target", false, "require overall coverage to reach target") + flags.BoolVar(&changedOnly, "changed-only", false, "enforce changed-code coverage without an overall baseline") + flags.BoolVar(&scopeBuildable, "scope-buildable", false, "only evaluate changed files buildable on the current platform") + if err := flags.Parse(args); err != nil { + return 2 + } + + if len(diffPaths) == 0 || baseRef == "" || modulePath == "" || (!changedOnly && (overallPath == "" || baselineOverall < 0)) { + fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and --baseline-overall") + return 2 + } + var overall []coverageBlock + if !changedOnly { + var err error + overall, err = readProfiles([]string{overallPath}, modulePath) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + } + diff, err := readProfiles(diffPaths, modulePath) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + changed, err := changedLoader(baseRef) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + if scopeBuildable { + buildable, err := buildableLoader() + if err != nil { + fmt.Fprintf(stderr, "resolve buildable Go files: %v\n", err) + return 2 + } + changed = filterChangedFiles(changed, buildable) + } + result := evaluate(gateInput{ + Overall: overall, + Diff: diff, + Changed: changed, + BaselineOverall: baselineOverall, + OverallTolerance: overallTolerance, + Target: target, + EnforceOverall: enforceOverall, + ChangedOnly: changedOnly, + }) + + if !changedOnly { + mode := "transition: non-regression" + if enforceOverall { + mode = "required" + } + fmt.Fprintf(stdout, "overall coverage: %.1f%% (merge-base %.1f%%; tolerance %.1fpp; target %.1f%%; %s)\n", result.Overall, baselineOverall, overallTolerance, target, mode) + } + if result.ChangedStatements == 0 { + fmt.Fprintf(stdout, "changed code coverage: n/a (no changed executable statements; target %.1f%%)\n", target) + } else { + fmt.Fprintf(stdout, "changed code coverage: %.1f%% (%d executable statements; target %.1f%%)\n", result.ChangedCoverage, result.ChangedStatements, target) + } + if len(result.Failures) > 0 { + fmt.Fprintln(stderr, "coverage gate failed:") + for _, failure := range result.Failures { + fmt.Fprintf(stderr, " - %s\n", failure) + } + return 1 + } + return 0 +} + +func evaluate(input gateInput) gateResult { + result := gateResult{Failures: []string{}} + if !input.ChangedOnly { + result.Overall = coveragePercent(input.Overall) + baselineRounded := roundOne(input.BaselineOverall) + overallRounded := roundOne(result.Overall) + if overallRounded+input.OverallTolerance+1e-9 < baselineRounded { + result.Failures = append(result.Failures, fmt.Sprintf("overall coverage regressed from %.1f%% to %.1f%%", baselineRounded, overallRounded)) + } + if input.EnforceOverall && overallRounded < input.Target { + result.Failures = append(result.Failures, fmt.Sprintf("overall coverage %.1f%% is below target %.1f%%", overallRounded, input.Target)) + } + } + + profiledFiles := map[string]bool{} + covered, total := 0, 0 + seen := map[string]bool{} + for _, block := range input.Diff { + profiledFiles[block.File] = true + if !intersectsAny(block, input.Changed[block.File]) { + continue + } + key := fmt.Sprintf("%s:%d:%d:%d", block.File, block.StartLine, block.EndLine, block.Statements) + if seen[key] { + continue + } + seen[key] = true + total += block.Statements + if block.Count > 0 { + covered += block.Statements + } + } + var missing []string + for path := range input.Changed { + if !profiledFiles[path] { + missing = append(missing, path) + } + } + sort.Strings(missing) + if len(missing) > 0 { + result.Failures = append(result.Failures, "changed production Go files missing from coverage profiles: "+strings.Join(missing, ", ")) + } + result.ChangedStatements = total + if total > 0 { + result.ChangedCoverage = float64(covered) * 100 / float64(total) + if result.ChangedCoverage+1e-9 < input.Target { + result.Failures = append(result.Failures, fmt.Sprintf("changed code coverage %.1f%% is below target %.1f%%", result.ChangedCoverage, input.Target)) + } + } + return result +} + +func filterChangedFiles(changed map[string][]lineRange, allowed map[string]bool) map[string][]lineRange { + filtered := map[string][]lineRange{} + for path, ranges := range changed { + if allowed[path] { + filtered[path] = ranges + } + } + return filtered +} + +func readProfiles(paths []string, modulePath string) ([]coverageBlock, error) { + var blocks []coverageBlock + for _, path := range paths { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open coverage profile %s: %w", path, err) + } + scanner := bufio.NewScanner(file) + line := 0 + for scanner.Scan() { + line++ + text := strings.TrimSpace(scanner.Text()) + if line == 1 && strings.HasPrefix(text, "mode:") { + continue + } + match := profileLine.FindStringSubmatch(text) + if len(match) != 8 { + file.Close() + return nil, fmt.Errorf("%s:%d: invalid coverage profile line", path, line) + } + values := make([]int, 0, 6) + for _, raw := range match[2:] { + value, err := strconv.Atoi(raw) + if err != nil { + file.Close() + return nil, fmt.Errorf("%s:%d: parse coverage number: %w", path, line, err) + } + values = append(values, value) + } + blocks = append(blocks, coverageBlock{ + File: normalizeProfilePath(match[1], modulePath), + StartLine: values[0], + EndLine: values[2], + Statements: values[4], + Count: values[5], + }) + } + err = scanner.Err() + file.Close() + if err != nil { + return nil, fmt.Errorf("read coverage profile %s: %w", path, err) + } + } + return blocks, nil +} + +func gitChangedLines(baseRef string) (map[string][]lineRange, error) { + command := exec.Command("git", "diff", "--unified=0", "--no-color", baseRef+"...HEAD", "--", "*.go") + output, err := command.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil, fmt.Errorf("git diff failed: %s", strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, fmt.Errorf("run git diff: %w", err) + } + return parseChangedLines(output) +} + +func goListBuildableFiles() (map[string]bool, error) { + rootCommand := exec.Command("git", "rev-parse", "--show-toplevel") + rootOutput, err := rootCommand.Output() + if err != nil { + return nil, fmt.Errorf("resolve repository root: %w", err) + } + root := strings.TrimSpace(string(rootOutput)) + + command := exec.Command("go", "list", "-json", "./...") + output, err := command.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil, fmt.Errorf("go list failed: %s", strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, fmt.Errorf("run go list: %w", err) + } + + buildable := map[string]bool{} + decoder := json.NewDecoder(bytes.NewReader(output)) + for { + var packageInfo struct { + Dir string + GoFiles []string + CgoFiles []string + } + if err := decoder.Decode(&packageInfo); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("decode go list output: %w", err) + } + for _, name := range append(packageInfo.GoFiles, packageInfo.CgoFiles...) { + relative, err := filepath.Rel(root, filepath.Join(packageInfo.Dir, name)) + if err != nil { + return nil, fmt.Errorf("normalize buildable file %s: %w", name, err) + } + relative = filepath.ToSlash(relative) + if relative != ".." && !strings.HasPrefix(relative, "../") && isProductionGo(relative) { + buildable[relative] = true + } + } + } + return buildable, nil +} + +func parseChangedLines(diff []byte) (map[string][]lineRange, error) { + changed := map[string][]lineRange{} + var path string + scanner := bufio.NewScanner(bytes.NewReader(diff)) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "+++ ") { + path = strings.TrimPrefix(line, "+++ ") + path = strings.TrimPrefix(path, "b/") + if path == "/dev/null" || !isProductionGo(path) { + path = "" + } + continue + } + if path == "" || !strings.HasPrefix(line, "@@") { + continue + } + match := hunkHeader.FindStringSubmatch(line) + if len(match) == 0 { + return nil, fmt.Errorf("invalid diff hunk header %q", line) + } + start, _ := strconv.Atoi(match[1]) + count := 1 + if match[2] != "" { + count, _ = strconv.Atoi(match[2]) + } + if count > 0 { + changed[path] = append(changed[path], lineRange{Start: start, End: start + count - 1}) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return changed, nil +} + +func isProductionGo(path string) bool { + return strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") && !strings.HasPrefix(path, "test/") +} + +func normalizeProfilePath(path, modulePath string) string { + return strings.TrimPrefix(path, strings.TrimSuffix(modulePath, "/")+"/") +} + +func intersectsAny(block coverageBlock, ranges []lineRange) bool { + for _, changed := range ranges { + if block.StartLine <= changed.End && block.EndLine >= changed.Start { + return true + } + } + return false +} + +func coveragePercent(blocks []coverageBlock) float64 { + covered, total := 0, 0 + for _, block := range blocks { + total += block.Statements + if block.Count > 0 { + covered += block.Statements + } + } + if total == 0 { + return 0 + } + return float64(covered) * 100 / float64(total) +} + +func roundOne(value float64) float64 { return math.Round(value*10) / 10 } diff --git a/scripts/policy/coverage-gate/main_test.go b/scripts/policy/coverage-gate/main_test.go new file mode 100644 index 00000000..b7fdf20d --- /dev/null +++ b/scripts/policy/coverage-gate/main_test.go @@ -0,0 +1,259 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestRun(t *testing.T) { + profile := filepath.Join(t.TempDir(), "coverage.out") + body := "mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 1\n" + if err := os.WriteFile(profile, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + args := []string{ + "--overall-profile", profile, + "--diff-profile", profile, + "--base-ref", "base", + "--module", "example.com/project", + "--baseline-overall", "100", + "--target", "80", + "--enforce-overall-target=true", + } + loader := func(ref string) (map[string][]lineRange, error) { + if ref != "base" { + t.Fatalf("base ref=%q", ref) + } + return map[string][]lineRange{"internal/a.go": {{Start: 10, End: 12}}}, nil + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, loader, nil); code != 0 { + t.Fatalf("run code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "overall coverage: 100.0%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") { + t.Fatalf("unexpected output %q", stdout.String()) + } + + stderr.Reset() + if code := run(nil, &stdout, &stderr, loader, nil); code != 2 { + t.Fatalf("missing arguments code=%d, want 2", code) + } + stderr.Reset() + badLoader := func(string) (map[string][]lineRange, error) { return nil, errors.New("diff failed") } + if code := run(args, &stdout, &stderr, badLoader, nil); code != 2 || !strings.Contains(stderr.String(), "diff failed") { + t.Fatalf("loader failure code=%d stderr=%q", code, stderr.String()) + } +} + +func TestRunChangedOnlyWithBuildableScope(t *testing.T) { + profile := filepath.Join(t.TempDir(), "coverage.out") + body := "mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 1\n" + if err := os.WriteFile(profile, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + args := []string{ + "--changed-only", + "--scope-buildable", + "--diff-profile", profile, + "--base-ref", "base", + "--module", "example.com/project", + "--target", "80", + } + loader := func(string) (map[string][]lineRange, error) { + return map[string][]lineRange{ + "internal/a.go": {{Start: 10, End: 12}}, + "internal/a_windows.go": {{Start: 1, End: 3}}, + }, nil + } + buildableLoader := func() (map[string]bool, error) { + return map[string]bool{"internal/a.go": true}, nil + } + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr, loader, buildableLoader); code != 0 { + t.Fatalf("run code=%d stderr=%s", code, stderr.String()) + } + if strings.Contains(stdout.String(), "overall coverage") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") { + t.Fatalf("unexpected changed-only output %q", stdout.String()) + } + + missingProfileScope := func() (map[string]bool, error) { + return map[string]bool{"internal/a.go": true, "internal/a_windows.go": true}, nil + } + stdout.Reset() + stderr.Reset() + if code := run(args, &stdout, &stderr, loader, missingProfileScope); code != 1 || !strings.Contains(stderr.String(), "internal/a_windows.go") { + t.Fatalf("missing native profile code=%d stderr=%q", code, stderr.String()) + } + + brokenScope := func() (map[string]bool, error) { return nil, errors.New("go list failed") } + stdout.Reset() + stderr.Reset() + if code := run(args, &stdout, &stderr, loader, brokenScope); code != 2 || !strings.Contains(stderr.String(), "go list failed") { + t.Fatalf("scope failure code=%d stderr=%q", code, stderr.String()) + } +} + +func TestReadProfiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "coverage.out") + if err := os.WriteFile(path, []byte("mode: set\nexample.com/project/internal/a.go:1.1,2.2 3 1\n"), 0o600); err != nil { + t.Fatal(err) + } + blocks, err := readProfiles([]string{path}, "example.com/project") + if err != nil { + t.Fatal(err) + } + if len(blocks) != 1 || blocks[0].File != "internal/a.go" || blocks[0].Statements != 3 { + t.Fatalf("blocks=%v", blocks) + } + if _, err := readProfiles([]string{filepath.Join(t.TempDir(), "missing")}, "example.com/project"); err == nil { + t.Fatal("missing profile should fail") + } +} + +func TestParseChangedLinesRejectsInvalidHunk(t *testing.T) { + _, err := parseChangedLines([]byte("+++ b/internal/a.go\n@@ invalid @@\n")) + if err == nil { + t.Fatal("invalid hunk should fail") + } +} + +func TestGitChangedLines(t *testing.T) { + repository := t.TempDir() + runGit := func(args ...string) string { + t.Helper() + command := exec.Command("git", args...) + command.Dir = repository + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } + return strings.TrimSpace(string(output)) + } + runGit("init") + runGit("config", "user.email", "coverage@example.com") + runGit("config", "user.name", "Coverage Test") + path := filepath.Join(repository, "a.go") + if err := os.WriteFile(path, []byte("package sample\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit("add", "a.go") + runGit("commit", "-m", "base") + base := runGit("rev-parse", "HEAD") + if err := os.WriteFile(path, []byte("package sample\n\nfunc added() int { return 1 }\n"), 0o600); err != nil { + t.Fatal(err) + } + runGit("add", "a.go") + runGit("commit", "-m", "change") + + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(repository); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(previous) }) + changed, err := gitChangedLines(base) + if err != nil { + t.Fatal(err) + } + if len(changed["a.go"]) == 0 { + t.Fatalf("changed lines=%v", changed) + } +} + +func TestParseChangedLines(t *testing.T) { + diff := []byte("diff --git a/internal/a.go b/internal/a.go\n+++ b/internal/a.go\n@@ -1,0 +2,3 @@\n+one\n+two\n+three\n" + + "diff --git a/internal/a_test.go b/internal/a_test.go\n+++ b/internal/a_test.go\n@@ -0,0 +1,2 @@\n+test\n") + got, err := parseChangedLines(diff) + if err != nil { + t.Fatal(err) + } + want := map[string][]lineRange{"internal/a.go": {{Start: 2, End: 4}}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseChangedLines() = %#v, want %#v", got, want) + } +} + +func TestFilterChangedFiles(t *testing.T) { + changed := map[string][]lineRange{ + "internal/a.go": {{Start: 1, End: 2}}, + "internal/a_windows.go": {{Start: 3, End: 4}}, + } + got := filterChangedFiles(changed, map[string]bool{"internal/a_windows.go": true}) + want := map[string][]lineRange{"internal/a_windows.go": {{Start: 3, End: 4}}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("filterChangedFiles() = %#v, want %#v", got, want) + } +} + +func TestEvaluateAllowsNonRegressionAndCoveredChanges(t *testing.T) { + result := evaluate(gateInput{ + Overall: []coverageBlock{ + {File: "internal/a.go", Statements: 8, Count: 1}, + {File: "internal/a.go", Statements: 2, Count: 0}, + }, + Diff: []coverageBlock{ + {File: "internal/a.go", StartLine: 10, EndLine: 12, Statements: 4, Count: 1}, + {File: "internal/a.go", StartLine: 20, EndLine: 22, Statements: 1, Count: 0}, + }, + Changed: map[string][]lineRange{"internal/a.go": {{Start: 10, End: 22}}}, + BaselineOverall: 80, + Target: 80, + }) + if len(result.Failures) != 0 { + t.Fatalf("unexpected failures: %v", result.Failures) + } + if result.ChangedCoverage != 80 { + t.Fatalf("changed coverage = %.1f, want 80", result.ChangedCoverage) + } +} + +func TestEvaluateAllowsMeasurementTolerance(t *testing.T) { + result := evaluate(gateInput{ + Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}}, + Changed: map[string][]lineRange{}, + BaselineOverall: 40.4, + OverallTolerance: 0.1, + Target: 80, + }) + if len(result.Failures) != 0 { + t.Fatalf("measurement tolerance should allow 40.3%% vs 40.4%%: %v", result.Failures) + } +} + +func TestEvaluateFailsClosed(t *testing.T) { + result := evaluate(gateInput{ + Overall: []coverageBlock{{Statements: 10, Count: 1}, {Statements: 10, Count: 0}}, + Diff: []coverageBlock{{File: "internal/a.go", StartLine: 1, EndLine: 2, Statements: 2, Count: 0}}, + Changed: map[string][]lineRange{"internal/a.go": {{Start: 1, End: 2}}, "internal/missing.go": {{Start: 1, End: 1}}}, + BaselineOverall: 54.2, + Target: 80, + }) + if len(result.Failures) != 3 { + t.Fatalf("got failures %v, want overall, changed-code, and missing-profile failures", result.Failures) + } +} + +func TestEvaluateOverallTargetCanBeEnabled(t *testing.T) { + result := evaluate(gateInput{ + Overall: []coverageBlock{{Statements: 60, Count: 1}, {Statements: 40, Count: 0}}, + Diff: []coverageBlock{}, + Changed: map[string][]lineRange{}, + BaselineOverall: 54.2, + Target: 80, + EnforceOverall: true, + }) + if len(result.Failures) != 1 { + t.Fatalf("failures = %v, want overall target failure", result.Failures) + } +} diff --git a/scripts/policy/interface-baseline/contract.go b/scripts/policy/interface-baseline/contract.go new file mode 100644 index 00000000..bee33435 --- /dev/null +++ b/scripts/policy/interface-baseline/contract.go @@ -0,0 +1,598 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type flagContract struct { + Name string + Shorthand string + Type string + Required bool + RequiredSet bool + Hidden bool + HiddenSet bool + NoOpt string + NoOptSet bool + Scope string + ScopeSet bool +} + +type commandContract struct { + Path string + Runnable bool + RunnableSet bool + Hidden bool + HiddenSet bool + Children map[string]struct{} + Aliases map[string]struct{} + Flags map[string]flagContract +} + +type interfaceContract struct { + Commands map[string]*commandContract +} + +func newInterfaceContract() interfaceContract { + return interfaceContract{Commands: map[string]*commandContract{}} +} + +func (c interfaceContract) command(path string) *commandContract { + if existing := c.Commands[path]; existing != nil { + return existing + } + created := &commandContract{ + Path: path, + Children: map[string]struct{}{}, + Aliases: map[string]struct{}{}, + Flags: map[string]flagContract{}, + } + c.Commands[path] = created + return created +} + +func snapshot(root *cobra.Command) interfaceContract { + contract := newInterfaceContract() + var walk func(*cobra.Command, []string) + walk = func(cmd *cobra.Command, parent []string) { + pathParts := parent + if cmd.HasParent() { + pathParts = append(append([]string(nil), parent...), cmd.Name()) + } + path := "root" + if len(pathParts) > 0 { + path = strings.Join(pathParts, ".") + } + entry := contract.command(path) + entry.Runnable = cmd.Runnable() + entry.RunnableSet = true + entry.Hidden = cmd.Hidden + entry.HiddenSet = true + for _, alias := range cmd.Aliases { + entry.Aliases[alias] = struct{}{} + } + cmd.InitDefaultHelpFlag() + captureFlagSet(entry.Flags, cmd.InheritedFlags(), "inherited") + captureFlagSet(entry.Flags, cmd.PersistentFlags(), "persistent") + captureFlagSet(entry.Flags, cmd.LocalNonPersistentFlags(), "local") + for _, child := range publicChildren(cmd) { + entry.Children[child.Name()] = struct{}{} + walk(child, pathParts) + } + } + walk(root, nil) + return contract +} + +func captureFlagSet(target map[string]flagContract, set *pflag.FlagSet, scope string) { + if set == nil { + return + } + set.VisitAll(func(f *pflag.Flag) { + target[f.Name] = flagContract{ + Name: f.Name, + Shorthand: f.Shorthand, + Type: f.Value.Type(), + Required: isRequiredFlag(f), + RequiredSet: true, + Hidden: f.Hidden, + HiddenSet: true, + NoOpt: f.NoOptDefVal, + NoOptSet: true, + Scope: scope, + ScopeSet: true, + } + }) +} + +func isRequiredFlag(flag *pflag.Flag) bool { + for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] { + if value == "true" { + return true + } + } + return false +} + +func publicChildren(cmd *cobra.Command) []*cobra.Command { + var children []*cobra.Command + for _, child := range cmd.Commands() { + if !child.Hidden { + children = append(children, child) + } + } + sort.Slice(children, func(i, j int) bool { return children[i].Name() < children[j].Name() }) + return children +} + +func readContract(path string) (interfaceContract, error) { + data, err := os.ReadFile(path) + if err != nil { + return interfaceContract{}, err + } + return parseContract(data) +} + +func parseContract(data []byte) (interfaceContract, error) { + contract := newInterfaceContract() + var current *commandContract + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + path := strings.TrimSuffix(strings.TrimPrefix(line, "["), "]") + if path == "" { + return interfaceContract{}, fmt.Errorf("empty command path") + } + current = contract.command(path) + continue + } + if current == nil { + return interfaceContract{}, fmt.Errorf("property before command section: %q", line) + } + key, value, ok := strings.Cut(line, ":") + if !ok { + return interfaceContract{}, fmt.Errorf("invalid contract line %q", line) + } + items := splitList(value) + switch key { + case "runnable": + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + if err != nil { + return interfaceContract{}, fmt.Errorf("%s: invalid runnable value %q", current.Path, value) + } + current.Runnable = parsed + current.RunnableSet = true + case "hidden": + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + if err != nil { + return interfaceContract{}, fmt.Errorf("%s: invalid hidden value %q", current.Path, value) + } + current.Hidden = parsed + current.HiddenSet = true + case "commands": + for _, item := range items { + current.Children[item] = struct{}{} + } + case "aliases": + for _, item := range items { + current.Aliases[item] = struct{}{} + } + case "flags": + for _, item := range items { + parsed, err := parseFlag(item) + if err != nil { + return interfaceContract{}, fmt.Errorf("%s: %w", current.Path, err) + } + if old, exists := current.Flags[parsed.Name]; exists && old != parsed { + return interfaceContract{}, fmt.Errorf("%s: conflicting duplicate flag --%s", current.Path, parsed.Name) + } + current.Flags[parsed.Name] = parsed + } + default: + return interfaceContract{}, fmt.Errorf("unknown contract property %q", key) + } + } + if err := scanner.Err(); err != nil { + return interfaceContract{}, err + } + return contract, nil +} + +func splitList(value string) []string { + var result []string + for _, item := range strings.Split(strings.TrimSpace(value), ",") { + if item = strings.TrimSpace(item); item != "" { + result = append(result, item) + } + } + return result +} + +func parseFlag(raw string) (flagContract, error) { + parts := strings.Split(raw, "|") + spelling, flagType, ok := strings.Cut(parts[0], ":") + if !ok || flagType == "" { + return flagContract{}, fmt.Errorf("invalid flag contract %q", raw) + } + var shorthand string + longName := spelling + if before, after, found := strings.Cut(spelling, "/"); found { + shorthand = strings.TrimPrefix(before, "-") + longName = after + } + longName = strings.TrimPrefix(longName, "--") + if longName == "" { + return flagContract{}, fmt.Errorf("invalid flag contract %q", raw) + } + contract := flagContract{Name: longName, Shorthand: shorthand, Type: flagType, Scope: "local"} + for _, attribute := range parts[1:] { + key, value, found := strings.Cut(attribute, "=") + if !found { + return flagContract{}, fmt.Errorf("invalid flag attribute %q", attribute) + } + switch key { + case "required": + contract.Required, found = parseBoolAttribute(value) + contract.RequiredSet = found + case "hidden": + contract.Hidden, found = parseBoolAttribute(value) + contract.HiddenSet = found + case "no-opt": + contract.NoOpt, _ = strconv.Unquote(value) + found = value == strconv.Quote(contract.NoOpt) + contract.NoOptSet = found + case "scope": + contract.Scope = value + found = value == "local" || value == "persistent" || value == "inherited" + contract.ScopeSet = found + default: + return flagContract{}, fmt.Errorf("unknown flag attribute %q", key) + } + if !found { + return flagContract{}, fmt.Errorf("invalid flag attribute %q", attribute) + } + } + return contract, nil +} + +func parseBoolAttribute(value string) (bool, bool) { + parsed, err := strconv.ParseBool(value) + return parsed, err == nil +} + +func checkCompatibility(root *cobra.Command, baseline interfaceContract) []string { + var failures []string + paths := sortedCommandPaths(baseline) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + for _, path := range paths { + expected := baseline.Commands[path] + cmd, ok := resolveCommand(root, path) + if !ok { + failures = append(failures, fmt.Sprintf("historical command %q is missing", displayPath(path))) + continue + } + if err := cmd.Help(); err != nil { + failures = append(failures, fmt.Sprintf("%q -h cannot render: %v", displayPath(path), err)) + } + if expected.RunnableSet && expected.Runnable && !cmd.Runnable() { + failures = append(failures, fmt.Sprintf("historical command %q became non-runnable", displayPath(path))) + } + if expected.HiddenSet && !expected.Hidden && cmd.Hidden { + failures = append(failures, fmt.Sprintf("historical command %q became hidden", displayPath(path))) + } + for _, alias := range sortedSet(expected.Aliases) { + aliasPath := aliasCommandPath(path, alias) + resolved, found := resolveCommand(root, aliasPath) + if !found || resolved != cmd { + failures = append(failures, fmt.Sprintf("historical alias %q is missing", displayPath(aliasPath))) + } + } + for _, expectedFlag := range sortedFlags(expected.Flags) { + actual, actualScope := lookupFlag(cmd, expectedFlag.Name) + if actual == nil { + failures = append(failures, fmt.Sprintf("%q lost flag --%s", displayPath(path), expectedFlag.Name)) + continue + } + if actual.Value.Type() != expectedFlag.Type { + failures = append(failures, fmt.Sprintf( + "%q flag --%s changed type from %s to %s", + displayPath(path), expectedFlag.Name, expectedFlag.Type, actual.Value.Type(), + )) + } + if expectedFlag.Shorthand != "" && actual.Shorthand != expectedFlag.Shorthand { + failures = append(failures, fmt.Sprintf( + "%q flag --%s lost shorthand -%s", + displayPath(path), expectedFlag.Name, expectedFlag.Shorthand, + )) + } + if expectedFlag.RequiredSet && !expectedFlag.Required && isRequiredFlag(actual) { + failures = append(failures, fmt.Sprintf( + "%q flag --%s became required", displayPath(path), expectedFlag.Name, + )) + } + if expectedFlag.HiddenSet && !expectedFlag.Hidden && actual.Hidden { + failures = append(failures, fmt.Sprintf( + "%q flag --%s became hidden", displayPath(path), expectedFlag.Name, + )) + } + if expectedFlag.NoOptSet && actual.NoOptDefVal != expectedFlag.NoOpt { + failures = append(failures, fmt.Sprintf( + "%q flag --%s changed no-opt value from %q to %q", + displayPath(path), expectedFlag.Name, expectedFlag.NoOpt, actual.NoOptDefVal, + )) + } + if expectedFlag.ScopeSet && expectedFlag.Scope == "persistent" && actualScope == "local" { + failures = append(failures, fmt.Sprintf( + "%q flag --%s narrowed persistent scope to local", displayPath(path), expectedFlag.Name, + )) + } + } + for _, actualFlag := range effectiveFlags(cmd) { + if _, existed := expected.Flags[actualFlag.Name]; existed || !actualFlag.Required { + continue + } + failures = append(failures, fmt.Sprintf( + "%q added required flag --%s", displayPath(path), actualFlag.Name, + )) + } + } + return failures +} + +func resolveCommand(root *cobra.Command, path string) (*cobra.Command, bool) { + if path == "root" { + return root, true + } + cmd, remaining, err := root.Find(strings.Split(path, ".")) + return cmd, err == nil && len(remaining) == 0 +} + +func lookupFlag(cmd *cobra.Command, name string) (*pflag.Flag, string) { + cmd.InitDefaultHelpFlag() + if flag := cmd.LocalNonPersistentFlags().Lookup(name); flag != nil { + return flag, "local" + } + if flag := cmd.PersistentFlags().Lookup(name); flag != nil { + return flag, "persistent" + } + if flag := cmd.InheritedFlags().Lookup(name); flag != nil { + return flag, "inherited" + } + return nil, "" +} + +func effectiveFlags(cmd *cobra.Command) []flagContract { + flags := map[string]flagContract{} + cmd.InitDefaultHelpFlag() + captureFlagSet(flags, cmd.InheritedFlags(), "inherited") + captureFlagSet(flags, cmd.PersistentFlags(), "persistent") + captureFlagSet(flags, cmd.LocalNonPersistentFlags(), "local") + return sortedFlags(flags) +} + +func aliasCommandPath(path, alias string) string { + if path == "root" || !strings.Contains(path, ".") { + return alias + } + parent, _, _ := strings.Cut(path, ".") + index := strings.LastIndex(path, ".") + if index >= 0 { + parent = path[:index] + } + return parent + "." + alias +} + +func mergeContracts(historical, current interfaceContract) (interfaceContract, []string) { + merged := cloneContract(historical) + var failures []string + for path, addition := range current.Commands { + target, existed := merged.Commands[path] + if !existed { + target = merged.command(path) + target.Runnable = addition.Runnable + target.RunnableSet = addition.RunnableSet + target.Hidden = addition.Hidden + target.HiddenSet = addition.HiddenSet + } + if target.RunnableSet && target.Runnable && !addition.Runnable { + failures = append(failures, fmt.Sprintf("historical command %q became non-runnable", displayPath(path))) + } + if target.HiddenSet && !target.Hidden && addition.Hidden { + failures = append(failures, fmt.Sprintf("historical command %q became hidden", displayPath(path))) + } + if !target.RunnableSet { + target.Runnable = addition.Runnable + target.RunnableSet = addition.RunnableSet + } + if !target.HiddenSet { + target.Hidden = addition.Hidden + target.HiddenSet = addition.HiddenSet + } + for child := range addition.Children { + target.Children[child] = struct{}{} + } + for alias := range addition.Aliases { + target.Aliases[alias] = struct{}{} + } + for name, newFlag := range addition.Flags { + oldFlag, exists := target.Flags[name] + if !exists { + if existed && newFlag.Required { + failures = append(failures, fmt.Sprintf("%q added required flag --%s", displayPath(path), name)) + continue + } + target.Flags[name] = newFlag + continue + } + if oldFlag.Type != newFlag.Type { + failures = append(failures, fmt.Sprintf( + "%q flag --%s changed type from %s to %s", + displayPath(path), name, oldFlag.Type, newFlag.Type, + )) + continue + } + if oldFlag.Shorthand != "" && oldFlag.Shorthand != newFlag.Shorthand { + failures = append(failures, fmt.Sprintf( + "%q flag --%s lost shorthand -%s", + displayPath(path), name, oldFlag.Shorthand, + )) + continue + } + if oldFlag.Shorthand == "" && newFlag.Shorthand != "" { + oldFlag.Shorthand = newFlag.Shorthand + } + if oldFlag.RequiredSet && !oldFlag.Required && newFlag.Required { + failures = append(failures, fmt.Sprintf("%q flag --%s became required", displayPath(path), name)) + continue + } + if oldFlag.HiddenSet && !oldFlag.Hidden && newFlag.Hidden { + failures = append(failures, fmt.Sprintf("%q flag --%s became hidden", displayPath(path), name)) + continue + } + if oldFlag.NoOptSet && newFlag.NoOptSet && oldFlag.NoOpt != newFlag.NoOpt { + failures = append(failures, fmt.Sprintf("%q flag --%s changed no-opt value", displayPath(path), name)) + continue + } + if oldFlag.ScopeSet && oldFlag.Scope == "persistent" && newFlag.Scope == "local" { + failures = append(failures, fmt.Sprintf("%q flag --%s narrowed persistent scope to local", displayPath(path), name)) + continue + } + if !oldFlag.RequiredSet { + oldFlag.Required = newFlag.Required + oldFlag.RequiredSet = newFlag.RequiredSet + } + if !oldFlag.HiddenSet { + oldFlag.Hidden = newFlag.Hidden + oldFlag.HiddenSet = newFlag.HiddenSet + } + if !oldFlag.NoOptSet { + oldFlag.NoOpt = newFlag.NoOpt + oldFlag.NoOptSet = newFlag.NoOptSet + } + if !oldFlag.ScopeSet { + oldFlag.Scope = newFlag.Scope + oldFlag.ScopeSet = newFlag.ScopeSet + } + target.Flags[name] = oldFlag + } + } + return merged, failures +} + +func cloneContract(source interfaceContract) interfaceContract { + cloned := newInterfaceContract() + for path, command := range source.Commands { + target := cloned.command(path) + target.Runnable = command.Runnable + target.RunnableSet = command.RunnableSet + target.Hidden = command.Hidden + target.HiddenSet = command.HiddenSet + for child := range command.Children { + target.Children[child] = struct{}{} + } + for alias := range command.Aliases { + target.Aliases[alias] = struct{}{} + } + for name, flag := range command.Flags { + target.Flags[name] = flag + } + } + return cloned +} + +func renderContract(w io.Writer, contract interfaceContract) error { + for index, path := range sortedCommandPaths(contract) { + if index > 0 { + if _, err := fmt.Fprintln(w); err != nil { + return err + } + } + command := contract.Commands[path] + if _, err := fmt.Fprintf(w, "[%s]\n", path); err != nil { + return err + } + fmt.Fprintf(w, " runnable: %t\n", command.Runnable) + fmt.Fprintf(w, " hidden: %t\n", command.Hidden) + if values := sortedSet(command.Children); len(values) > 0 { + fmt.Fprintf(w, " commands: %s\n", strings.Join(values, ", ")) + } + if values := sortedSet(command.Aliases); len(values) > 0 { + fmt.Fprintf(w, " aliases: %s\n", strings.Join(values, ", ")) + } + if values := sortedFlags(command.Flags); len(values) > 0 { + rendered := make([]string, 0, len(values)) + for _, flag := range values { + name := "--" + flag.Name + if flag.Shorthand != "" { + name = "-" + flag.Shorthand + "/" + name + } + rendered = append(rendered, fmt.Sprintf( + "%s:%s|required=%t|hidden=%t|no-opt=%s|scope=%s", + name, flag.Type, flag.Required, flag.Hidden, strconv.Quote(flag.NoOpt), flag.Scope, + )) + } + fmt.Fprintf(w, " flags: %s\n", strings.Join(rendered, ", ")) + } + } + return nil +} + +func sortedCommandPaths(contract interfaceContract) []string { + paths := make([]string, 0, len(contract.Commands)) + for path := range contract.Commands { + paths = append(paths, path) + } + sort.Slice(paths, func(i, j int) bool { + if paths[i] == "root" { + return true + } + if paths[j] == "root" { + return false + } + return paths[i] < paths[j] + }) + return paths +} + +func sortedSet(values map[string]struct{}) []string { + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func sortedFlags(values map[string]flagContract) []flagContract { + result := make([]flagContract, 0, len(values)) + for _, value := range values { + result = append(result, value) + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} + +func displayPath(path string) string { + if path == "root" { + return "dws" + } + return "dws " + strings.ReplaceAll(path, ".", " ") +} diff --git a/scripts/policy/interface-baseline/contract_test.go b/scripts/policy/interface-baseline/contract_test.go new file mode 100644 index 00000000..9911bd1a --- /dev/null +++ b/scripts/policy/interface-baseline/contract_test.go @@ -0,0 +1,250 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestRunModes(t *testing.T) { + var generated, stderr bytes.Buffer + if code := run(nil, testRoot(), &generated, &stderr); code != 0 { + t.Fatalf("generate code=%d stderr=%s", code, stderr.String()) + } + baseline := filepath.Join(t.TempDir(), "baseline.txt") + if err := os.WriteFile(baseline, generated.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + stderr.Reset() + if code := run([]string{"--check", baseline}, testRoot(), &stdout, &stderr); code != 0 { + t.Fatalf("check code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "compatibility check: ok") { + t.Fatalf("unexpected check output %q", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + if code := run([]string{"--merge", baseline}, testRoot(), &stdout, &stderr); code != 0 { + t.Fatalf("merge code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "[old]") { + t.Fatalf("unexpected merge output %q", stdout.String()) + } + + stderr.Reset() + if code := run([]string{"--check", baseline, "--merge", baseline}, testRoot(), &stdout, &stderr); code != 2 { + t.Fatalf("conflicting modes code=%d, want 2", code) + } + + stderr.Reset() + missingRoot := &cobra.Command{Use: "dws"} + missingRoot.InitDefaultHelpCmd() + if code := run([]string{"--check", baseline}, missingRoot, &stdout, &stderr); code != 1 { + t.Fatalf("incompatible check code=%d, want 1", code) + } + if !strings.Contains(stderr.String(), "historical command") { + t.Fatalf("unexpected incompatible output %q", stderr.String()) + } + + stderr.Reset() + if code := run([]string{"--check", filepath.Join(t.TempDir(), "missing")}, testRoot(), &stdout, &stderr); code != 2 { + t.Fatalf("missing baseline code=%d, want 2", code) + } + stderr.Reset() + if code := run([]string{"--unknown"}, testRoot(), &stdout, &stderr); code != 2 { + t.Fatalf("unknown flag code=%d, want 2", code) + } +} + +func TestCompatibilityAllowsAdditions(t *testing.T) { + root := testRoot() + baseline, err := parseContract([]byte("[root]\n commands: old\n\n[old]\n flags: -n/--name:string, -h/--help:bool\n")) + if err != nil { + t.Fatal(err) + } + if failures := checkCompatibility(root, baseline); len(failures) != 0 { + t.Fatalf("additions should be compatible: %v", failures) + } +} + +func TestCompatibilityTreatsLegacyMetadataAsUnknown(t *testing.T) { + root := &cobra.Command{Use: "dws"} + old := &cobra.Command{Use: "old", Run: func(*cobra.Command, []string) {}} + old.Flags().String("required", "", "required") + if err := old.MarkFlagRequired("required"); err != nil { + t.Fatal(err) + } + root.AddCommand(old) + root.InitDefaultHelpCmd() + baseline, err := parseContract([]byte("[root]\n commands: old\n\n[old]\n flags: --required:string\n")) + if err != nil { + t.Fatal(err) + } + if failures := checkCompatibility(root, baseline); len(failures) != 0 { + t.Fatalf("legacy metadata should be unknown: %v", failures) + } +} + +func TestCompatibilityRejectsMissingCommandAndFlag(t *testing.T) { + root := testRoot() + baseline, err := parseContract([]byte("[root]\n\n[removed]\n flags: --gone:string\n\n[old]\n flags: --gone:string\n")) + if err != nil { + t.Fatal(err) + } + failures := checkCompatibility(root, baseline) + if len(failures) != 2 { + t.Fatalf("got %d failures, want 2: %v", len(failures), failures) + } +} + +func TestCompatibilityAllowsNewShorthandButRejectsRemovedShorthand(t *testing.T) { + root := testRoot() + baseline, _ := parseContract([]byte("[root]\n\n[old]\n flags: --name:string\n")) + if failures := checkCompatibility(root, baseline); len(failures) != 0 { + t.Fatalf("new shorthand should be compatible: %v", failures) + } + + baseline, _ = parseContract([]byte("[root]\n\n[old]\n flags: -x/--name:string\n")) + if failures := checkCompatibility(root, baseline); len(failures) != 1 { + t.Fatalf("removed shorthand should fail: %v", failures) + } +} + +func TestCompatibilityRejectsCommandContractRegressions(t *testing.T) { + baselineRoot := &cobra.Command{Use: "dws"} + baselineRoot.AddCommand(&cobra.Command{Use: "old", Run: func(*cobra.Command, []string) {}}) + baselineRoot.InitDefaultHelpCmd() + baseline := snapshot(baselineRoot) + + tests := []struct { + name string + mutate func(*cobra.Command) + want string + }{ + { + name: "runnable to non-runnable", + mutate: func(command *cobra.Command) { + command.Run = nil + }, + want: "became non-runnable", + }, + { + name: "visible to hidden", + mutate: func(command *cobra.Command) { + command.Hidden = true + }, + want: "became hidden", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + currentRoot := &cobra.Command{Use: "dws"} + current := &cobra.Command{Use: "old", Run: func(*cobra.Command, []string) {}} + test.mutate(current) + currentRoot.AddCommand(current) + currentRoot.InitDefaultHelpCmd() + assertFailureContains(t, checkCompatibility(currentRoot, baseline), test.want) + }) + } +} + +func TestCompatibilityRejectsFlagContractRegressions(t *testing.T) { + newRoot := func(persistent bool) (*cobra.Command, *cobra.Command) { + root := &cobra.Command{Use: "dws"} + old := &cobra.Command{Use: "old"} + if persistent { + old.PersistentFlags().Bool("toggle", false, "toggle") + } else { + old.Flags().Bool("toggle", false, "toggle") + } + root.AddCommand(old) + root.InitDefaultHelpCmd() + return root, old + } + + baselineRoot, _ := newRoot(true) + baseline := snapshot(baselineRoot) + tests := []struct { + name string + mutate func(*cobra.Command) + want string + }{ + { + name: "optional to required", + mutate: func(command *cobra.Command) { + _ = command.MarkPersistentFlagRequired("toggle") + }, + want: "became required", + }, + { + name: "visible to hidden", + mutate: func(command *cobra.Command) { + _ = command.PersistentFlags().MarkHidden("toggle") + }, + want: "became hidden", + }, + { + name: "no-opt changed", + mutate: func(command *cobra.Command) { + command.PersistentFlags().Lookup("toggle").NoOptDefVal = "false" + }, + want: "changed no-opt value", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + currentRoot, current := newRoot(true) + test.mutate(current) + assertFailureContains(t, checkCompatibility(currentRoot, baseline), test.want) + }) + } + + currentRoot, _ := newRoot(false) + assertFailureContains(t, checkCompatibility(currentRoot, baseline), "narrowed persistent scope") +} + +func TestCompatibilityRejectsNewRequiredFlag(t *testing.T) { + baselineRoot := testRoot() + baseline := snapshot(baselineRoot) + currentRoot := testRoot() + old, _, err := currentRoot.Find([]string{"old"}) + if err != nil { + t.Fatal(err) + } + old.Flags().String("required-new", "", "required") + if err := old.MarkFlagRequired("required-new"); err != nil { + t.Fatal(err) + } + assertFailureContains(t, checkCompatibility(currentRoot, baseline), "added required flag") +} + +func assertFailureContains(t *testing.T, failures []string, want string) { + t.Helper() + for _, failure := range failures { + if strings.Contains(failure, want) { + return + } + } + t.Fatalf("failures %v do not contain %q", failures, want) +} + +func testRoot() *cobra.Command { + root := &cobra.Command{Use: "dws"} + old := &cobra.Command{Use: "old"} + old.Flags().StringP("name", "n", "", "name") + old.Flags().String("extra", "", "addition") + root.AddCommand(old, &cobra.Command{Use: "new"}) + root.InitDefaultHelpCmd() + return root +} diff --git a/scripts/policy/interface-baseline/main.go b/scripts/policy/interface-baseline/main.go new file mode 100644 index 00000000..aa9f6ff7 --- /dev/null +++ b/scripts/policy/interface-baseline/main.go @@ -0,0 +1,95 @@ +// 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. + +// Command interface-baseline snapshots and checks the backwards-compatible +// public Cobra surface of DWS CLI. +package main + +import ( + "flag" + "fmt" + "io" + "os" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/spf13/cobra" +) + +func main() { + os.Exit(run(os.Args[1:], app.NewRootCommand(), os.Stdout, os.Stderr)) +} + +func run(args []string, root *cobra.Command, stdout, stderr io.Writer) int { + var checkPath string + var mergePath string + flags := flag.NewFlagSet("interface-baseline", flag.ContinueOnError) + flags.SetOutput(stderr) + flags.StringVar(&checkPath, "check", "", "check current CLI against a historical baseline") + flags.StringVar(&mergePath, "merge", "", "merge current additions into a historical baseline") + if err := flags.Parse(args); err != nil { + return 2 + } + + if checkPath != "" && mergePath != "" { + fmt.Fprintln(stderr, "--check and --merge are mutually exclusive") + return 2 + } + + root.InitDefaultHelpCmd() + current := snapshot(root) + + switch { + case checkPath != "": + baseline, err := readContract(checkPath) + if err != nil { + fmt.Fprintf(stderr, "read interface baseline: %v\n", err) + return 2 + } + failures := checkCompatibility(root, baseline) + if len(failures) > 0 { + fmt.Fprintln(stderr, "CLI backwards-compatibility check failed:") + for _, failure := range failures { + fmt.Fprintf(stderr, " - %s\n", failure) + } + return 1 + } + fmt.Fprintf(stdout, + "interface compatibility check: ok (%d historical command nodes; additions allowed)\n", + len(baseline.Commands), + ) + case mergePath != "": + baseline, err := readContract(mergePath) + if err != nil { + fmt.Fprintf(stderr, "read interface baseline: %v\n", err) + return 2 + } + merged, failures := mergeContracts(baseline, current) + if len(failures) > 0 { + fmt.Fprintln(stderr, "cannot merge incompatible interface changes:") + for _, failure := range failures { + fmt.Fprintf(stderr, " - %s\n", failure) + } + return 1 + } + if err := renderContract(stdout, merged); err != nil { + fmt.Fprintf(stderr, "render merged interface baseline: %v\n", err) + return 2 + } + default: + if err := renderContract(stdout, current); err != nil { + fmt.Fprintf(stderr, "render interface baseline: %v\n", err) + return 2 + } + } + return 0 +} diff --git a/scripts/policy/run-platform-coverage-gate.sh b/scripts/policy/run-platform-coverage-gate.sh new file mode 100755 index 00000000..ac2a1900 --- /dev/null +++ b/scripts/policy/run-platform-coverage-gate.sh @@ -0,0 +1,89 @@ +#!/bin/sh +set -eu + +# Discover production Go packages changed by the candidate and buildable on +# the current native runner, then generate and enforce their coverage profile. + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)" +BASE_REF="" +PROFILE="" +TIMEOUT="${COVERAGE_PLATFORM_TIMEOUT:-10m}" + +usage() { + printf '%s\n' "usage: $0 --base-ref --profile " >&2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --base-ref) + [ "$#" -ge 2 ] || { usage; exit 2; } + BASE_REF="$2" + shift 2 + ;; + --profile) + [ "$#" -ge 2 ] || { usage; exit 2; } + PROFILE="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + printf 'error: unknown argument: %s\n' "$1" >&2 + usage + exit 2 + ;; + esac +done + +[ -n "$BASE_REF" ] && [ -n "$PROFILE" ] || { usage; exit 2; } +cd "$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")" +PACKAGES="$TMP_ROOT/packages" +SORTED_PACKAGES="$TMP_ROOT/packages.sorted" +cleanup() { + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT HUP INT TERM +: >"$PACKAGES" + +git diff --name-only --diff-filter=ACMR "$BASE_REF...HEAD" -- '*.go' | + while IFS= read -r file; do + case "$file" in + *_test.go|test/*) continue ;; + esac + directory="$(dirname -- "$file")" + package="./$directory" + [ "$directory" = "." ] && package="." + active_files="$(go list -e -f '{{range .GoFiles}}{{.}}{{"\n"}}{{end}}{{range .CgoFiles}}{{.}}{{"\n"}}{{end}}' "$package")" + if printf '%s\n' "$active_files" | grep -Fqx -- "$(basename -- "$file")"; then + printf '%s\n' "$package" + fi + done >"$PACKAGES" + +sort -u "$PACKAGES" >"$SORTED_PACKAGES" +set -- +while IFS= read -r package; do + [ -n "$package" ] && set -- "$@" "$package" +done <"$SORTED_PACKAGES" + +if [ "$#" -eq 0 ]; then + printf '%s\n' 'mode: atomic' >"$PROFILE" +else + printf 'native changed coverage packages:' + printf ' %s' "$@" + printf '\n' + go test -count=1 -timeout="$TIMEOUT" -coverprofile="$PROFILE" -covermode=atomic "$@" +fi + +./scripts/policy/check-coverage-gate.sh \ + --base-ref "$BASE_REF" \ + --changed-only \ + --scope-buildable \ + --diff-profile "$PROFILE" diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go new file mode 100644 index 00000000..5a0f619d --- /dev/null +++ b/scripts/policy/schema-compat/main.go @@ -0,0 +1,606 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +// Command schema-compat normalizes and checks the backwards-compatible +// execution contract returned by `dws schema --all --format json`. +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "sort" + "strings" +) + +const schemaContractVersion = 3 + +type schemaContract struct { + Version int `json:"version"` + Products map[string]productSchema `json:"products"` +} + +type productSchema struct { + Tools map[string]toolSchema `json:"tools"` +} + +type toolSchema struct { + PrimaryCLIPath string `json:"primary_cli_path"` + InterfaceMode string `json:"interface_mode"` + InterfaceRef string `json:"interface_ref,omitempty"` + Availability string `json:"availability"` + Parameters map[string]parameterSchema `json:"parameters"` + Constraints string `json:"constraints,omitempty"` + Positionals []positionalSchema `json:"positionals,omitempty"` + DryRun string `json:"dry_run,omitempty"` + Effect string `json:"effect"` + Risk string `json:"risk"` + Confirmation string `json:"confirmation"` + Idempotency string `json:"idempotency"` +} + +type positionalSchema struct { + Name string `json:"name"` + Index int `json:"index"` + Type string `json:"type"` + Required bool `json:"required"` + Variadic bool `json:"variadic,omitempty"` +} + +type parameterSchema struct { + Type string `json:"type"` + Property string `json:"property,omitempty"` + InterfaceType string `json:"interface_type,omitempty"` + Required bool `json:"required,omitempty"` + CLIRequired bool `json:"cli_required,omitempty"` + RequiredWhen string `json:"required_when,omitempty"` + Default string `json:"default,omitempty"` + InterfaceDefault string `json:"interface_default,omitempty"` + Format string `json:"format,omitempty"` + Enum []string `json:"enum,omitempty"` +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + var normalizePath, checkPath, mergePath, currentPath string + flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError) + flags.SetOutput(stderr) + flags.StringVar(&normalizePath, "normalize", "", "normalize a raw complete Schema response") + flags.StringVar(&checkPath, "check", "", "check against a normalized historical baseline") + flags.StringVar(&mergePath, "merge", "", "merge additions into a normalized historical baseline") + flags.StringVar(¤tPath, "current", "", "raw current complete Schema response") + if err := flags.Parse(args); err != nil { + return 2 + } + + modes := 0 + for _, value := range []string{normalizePath, checkPath, mergePath} { + if value != "" { + modes++ + } + } + if modes != 1 { + fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required") + return 2 + } + + if normalizePath != "" { + currentPath = normalizePath + } + if currentPath == "" { + fmt.Fprintln(stderr, "--current is required with --check or --merge") + return 2 + } + current, err := normalizeRawFile(currentPath) + if err != nil { + fmt.Fprintf(stderr, "normalize current Schema contract: %v\n", err) + return 2 + } + + switch { + case normalizePath != "": + if err := writeContract(stdout, current); err != nil { + fmt.Fprintf(stderr, "write schema contract: %v\n", err) + return 2 + } + case checkPath != "": + baseline, err := readContract(checkPath) + if err != nil { + fmt.Fprintf(stderr, "read schema baseline: %v\n", err) + return 2 + } + failures := checkCompatibility(baseline, current) + if len(failures) > 0 { + fmt.Fprintln(stderr, "Schema backwards-compatibility check failed:") + for _, failure := range failures { + fmt.Fprintf(stderr, " - %s\n", failure) + } + return 1 + } + fmt.Fprintf(stdout, "Schema compatibility check: ok (%d historical products; additions allowed)\n", len(baseline.Products)) + case mergePath != "": + baseline, err := readContract(mergePath) + if err != nil { + fmt.Fprintf(stderr, "read schema baseline: %v\n", err) + return 2 + } + merged, failures := mergeContracts(baseline, current) + if len(failures) > 0 { + fmt.Fprintln(stderr, "cannot merge incompatible schema changes:") + for _, failure := range failures { + fmt.Fprintf(stderr, " - %s\n", failure) + } + return 1 + } + if err := writeContract(stdout, merged); err != nil { + fmt.Fprintf(stderr, "write schema contract: %v\n", err) + return 2 + } + } + return 0 +} + +func normalizeRawFile(path string) (schemaContract, error) { + data, err := os.ReadFile(path) + if err != nil { + return schemaContract{}, err + } + var payload struct { + Kind string `json:"kind"` + Products []json.RawMessage `json:"products"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return schemaContract{}, err + } + if payload.Kind != "schema" { + return schemaContract{}, fmt.Errorf("unexpected kind %q", payload.Kind) + } + if payload.Products == nil { + return schemaContract{}, fmt.Errorf("products array is missing") + } + contract := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{}} + for _, rawProduct := range payload.Products { + var product struct { + ID string `json:"id"` + Tools []json.RawMessage `json:"tools"` + } + if err := json.Unmarshal(rawProduct, &product); err != nil { + return schemaContract{}, err + } + if product.ID == "" { + return schemaContract{}, fmt.Errorf("product without id") + } + if _, exists := contract.Products[product.ID]; exists { + return schemaContract{}, fmt.Errorf("duplicate product id %q", product.ID) + } + normalized := productSchema{Tools: map[string]toolSchema{}} + for _, rawTool := range product.Tools { + id, tool, err := normalizeTool(rawTool) + if err != nil { + return schemaContract{}, fmt.Errorf("product %s: %w", product.ID, err) + } + if _, exists := normalized.Tools[id]; exists { + return schemaContract{}, fmt.Errorf("product %s: duplicate tool id %q", product.ID, id) + } + normalized.Tools[id] = tool + } + contract.Products[product.ID] = normalized + } + if len(contract.Products) == 0 { + return schemaContract{}, fmt.Errorf("complete Schema contract contains no products") + } + totalTools := 0 + for _, product := range contract.Products { + totalTools += len(product.Tools) + } + if totalTools == 0 { + return schemaContract{}, fmt.Errorf("complete Schema contract contains no tools") + } + return contract, nil +} + +func normalizeTool(raw json.RawMessage) (string, toolSchema, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return "", toolSchema{}, err + } + for _, field := range []string{ + "canonical_path", + "primary_cli_path", + "parameters", + "effect", + "risk", + "confirmation", + "idempotency", + "interface_mode", + "availability", + "field_provenance", + } { + if _, ok := fields[field]; !ok { + return "", toolSchema{}, fmt.Errorf("tool is not a complete schema --all leaf: missing %s", field) + } + } + + var tool struct { + CanonicalPath string `json:"canonical_path"` + PrimaryCLIPath string `json:"primary_cli_path"` + InterfaceMode string `json:"interface_mode"` + InterfaceRef json.RawMessage `json:"interface_ref"` + Availability string `json:"availability"` + Parameters map[string]json.RawMessage `json:"parameters"` + Required []string `json:"required"` + Constraints json.RawMessage `json:"constraints"` + Positionals json.RawMessage `json:"positionals"` + DryRun json.RawMessage `json:"dry_run"` + Effect string `json:"effect"` + Risk string `json:"risk"` + Confirmation string `json:"confirmation"` + Idempotency string `json:"idempotency"` + } + if err := json.Unmarshal(raw, &tool); err != nil { + return "", toolSchema{}, err + } + id := strings.TrimSpace(tool.CanonicalPath) + if id == "" { + return "", toolSchema{}, fmt.Errorf("tool without canonical_path") + } + if strings.TrimSpace(tool.PrimaryCLIPath) == "" { + return "", toolSchema{}, fmt.Errorf("tool %s without primary_cli_path", id) + } + if tool.Parameters == nil { + return "", toolSchema{}, fmt.Errorf("tool %s parameters must be an object", id) + } + requiredParameters := stringSet(tool.Required) + parameters := map[string]parameterSchema{} + for name, rawSchema := range tool.Parameters { + parameter, err := normalizeParameter(rawSchema) + if err != nil { + return "", toolSchema{}, fmt.Errorf("parameter %s: %w", name, err) + } + if requiredParameters[name] { + parameter.Required = true + } + parameters[name] = parameter + } + for required := range requiredParameters { + if _, ok := parameters[required]; !ok { + return "", toolSchema{}, fmt.Errorf("required parameter %q is missing", required) + } + } + + interfaceRef, err := canonicalRawJSON(tool.InterfaceRef) + if err != nil { + return "", toolSchema{}, fmt.Errorf("interface_ref: %w", err) + } + constraints, err := canonicalRawJSON(tool.Constraints) + if err != nil { + return "", toolSchema{}, fmt.Errorf("constraints: %w", err) + } + positionals, err := normalizePositionals(tool.Positionals) + if err != nil { + return "", toolSchema{}, fmt.Errorf("positionals: %w", err) + } + dryRun, err := canonicalRawJSON(tool.DryRun) + if err != nil { + return "", toolSchema{}, fmt.Errorf("dry_run: %w", err) + } + + return id, toolSchema{ + PrimaryCLIPath: strings.TrimSpace(tool.PrimaryCLIPath), + InterfaceMode: strings.TrimSpace(tool.InterfaceMode), + InterfaceRef: interfaceRef, + Availability: strings.TrimSpace(tool.Availability), + Parameters: parameters, + Constraints: constraints, + Positionals: positionals, + DryRun: dryRun, + Effect: strings.TrimSpace(tool.Effect), + Risk: strings.TrimSpace(tool.Risk), + Confirmation: strings.TrimSpace(tool.Confirmation), + Idempotency: strings.TrimSpace(tool.Idempotency), + }, nil +} + +func normalizePositionals(raw json.RawMessage) ([]positionalSchema, error) { + if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil, nil + } + var positionals []positionalSchema + if err := json.Unmarshal(raw, &positionals); err != nil { + return nil, err + } + seenIndexes := map[int]bool{} + for index := range positionals { + positional := &positionals[index] + positional.Name = strings.TrimSpace(positional.Name) + positional.Type = strings.TrimSpace(positional.Type) + if positional.Name == "" { + return nil, fmt.Errorf("positional at index %d has no name", positional.Index) + } + if positional.Index < 0 { + return nil, fmt.Errorf("positional %q has negative index", positional.Name) + } + if positional.Type == "" { + return nil, fmt.Errorf("positional %q has no type", positional.Name) + } + if seenIndexes[positional.Index] { + return nil, fmt.Errorf("duplicate positional index %d", positional.Index) + } + seenIndexes[positional.Index] = true + } + sort.Slice(positionals, func(i, j int) bool { + return positionals[i].Index < positionals[j].Index + }) + return positionals, nil +} + +func normalizeParameter(raw json.RawMessage) (parameterSchema, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return parameterSchema{}, err + } + for _, field := range []string{"type", "required", "field_provenance"} { + if _, ok := fields[field]; !ok { + return parameterSchema{}, fmt.Errorf("not a complete schema --all parameter: missing %s", field) + } + } + + var parameter struct { + Required bool `json:"required"` + CLIRequired bool `json:"cli_required"` + RequiredWhen string `json:"required_when"` + Property string `json:"property"` + InterfaceType string `json:"interface_type"` + Default json.RawMessage `json:"default"` + InterfaceDefault json.RawMessage `json:"interface_default"` + Format string `json:"format"` + Enum []string `json:"enum"` + } + if err := json.Unmarshal(raw, ¶meter); err != nil { + return parameterSchema{}, err + } + + var schema map[string]any + if err := json.Unmarshal(raw, &schema); err != nil { + return parameterSchema{}, err + } + parameterType := schemaType(schema) + if parameterType == "unspecified" { + return parameterSchema{}, fmt.Errorf("type is missing") + } + defaultValue, err := canonicalRawJSON(parameter.Default) + if err != nil { + return parameterSchema{}, fmt.Errorf("default: %w", err) + } + interfaceDefault, err := canonicalRawJSON(parameter.InterfaceDefault) + if err != nil { + return parameterSchema{}, fmt.Errorf("interface_default: %w", err) + } + enum := append([]string(nil), parameter.Enum...) + sort.Strings(enum) + + return parameterSchema{ + Type: parameterType, + Property: strings.TrimSpace(parameter.Property), + InterfaceType: strings.TrimSpace(parameter.InterfaceType), + Required: parameter.Required, + CLIRequired: parameter.CLIRequired, + RequiredWhen: strings.TrimSpace(parameter.RequiredWhen), + Default: defaultValue, + InterfaceDefault: interfaceDefault, + Format: strings.TrimSpace(parameter.Format), + Enum: enum, + }, nil +} + +func canonicalRawJSON(raw json.RawMessage) (string, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return "", nil + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return "", err + } + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + return string(encoded), nil +} + +func schemaType(schema map[string]any) string { + if value, ok := schema["type"]; ok { + encoded, _ := json.Marshal(value) + return string(encoded) + } + for _, keyword := range []string{"oneOf", "anyOf", "allOf"} { + if value, ok := schema[keyword]; ok { + encoded, _ := json.Marshal(value) + return keyword + ":" + string(encoded) + } + } + return "unspecified" +} + +func readContract(path string) (schemaContract, error) { + data, err := os.ReadFile(path) + if err != nil { + return schemaContract{}, err + } + var contract schemaContract + if err := json.Unmarshal(data, &contract); err != nil { + return schemaContract{}, err + } + if contract.Version != schemaContractVersion { + return schemaContract{}, fmt.Errorf("unsupported schema contract version %d", contract.Version) + } + if len(contract.Products) == 0 { + return schemaContract{}, fmt.Errorf("historical schema contract contains no products") + } + return contract, nil +} + +func checkCompatibility(baseline, current schemaContract) []string { + var failures []string + for productID, oldProduct := range baseline.Products { + newProduct, ok := current.Products[productID] + if !ok { + failures = append(failures, fmt.Sprintf("historical schema product %q is missing", productID)) + continue + } + for toolID, oldTool := range oldProduct.Tools { + newTool, ok := newProduct.Tools[toolID] + if !ok { + failures = append(failures, fmt.Sprintf("historical schema tool %q is missing", productID+"/"+toolID)) + continue + } + toolPath := productID + "/" + toolID + failures = append(failures, checkToolCompatibility(toolPath, oldTool, newTool)...) + } + } + sort.Strings(failures) + return failures +} + +func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []string { + var failures []string + for _, field := range []struct { + name string + old string + new string + }{ + {name: "primary_cli_path", old: oldTool.PrimaryCLIPath, new: newTool.PrimaryCLIPath}, + {name: "interface_mode", old: oldTool.InterfaceMode, new: newTool.InterfaceMode}, + {name: "interface_ref", old: oldTool.InterfaceRef, new: newTool.InterfaceRef}, + {name: "availability", old: oldTool.Availability, new: newTool.Availability}, + {name: "constraints", old: oldTool.Constraints, new: newTool.Constraints}, + {name: "effect", old: oldTool.Effect, new: newTool.Effect}, + {name: "risk", old: oldTool.Risk, new: newTool.Risk}, + {name: "confirmation", old: oldTool.Confirmation, new: newTool.Confirmation}, + {name: "idempotency", old: oldTool.Idempotency, new: newTool.Idempotency}, + } { + if field.old != field.new { + failures = append(failures, fmt.Sprintf("schema tool %q changed %s", toolPath, field.name)) + } + } + if !equalPositionals(oldTool.Positionals, newTool.Positionals) { + failures = append(failures, fmt.Sprintf("schema tool %q changed positionals", toolPath)) + } + if oldTool.DryRun != "" && oldTool.DryRun != newTool.DryRun { + failures = append(failures, fmt.Sprintf("schema tool %q changed or removed dry_run", toolPath)) + } + + for parameter, oldParameter := range oldTool.Parameters { + newParameter, ok := newTool.Parameters[parameter] + if !ok { + failures = append(failures, fmt.Sprintf("schema tool %q lost parameter %q", toolPath, parameter)) + continue + } + failures = append(failures, checkParameterCompatibility(toolPath, parameter, oldParameter, newParameter)...) + } + sort.Strings(failures) + return failures +} + +func equalPositionals(oldPositionals, newPositionals []positionalSchema) bool { + if len(oldPositionals) != len(newPositionals) { + return false + } + for index := range oldPositionals { + if oldPositionals[index] != newPositionals[index] { + return false + } + } + return true +} + +func checkParameterCompatibility(toolPath, name string, oldParameter, newParameter parameterSchema) []string { + var failures []string + for _, field := range []struct { + name string + old string + new string + }{ + {name: "type", old: oldParameter.Type, new: newParameter.Type}, + {name: "property", old: oldParameter.Property, new: newParameter.Property}, + {name: "interface_type", old: oldParameter.InterfaceType, new: newParameter.InterfaceType}, + {name: "default", old: oldParameter.Default, new: newParameter.Default}, + {name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault}, + {name: "format", old: oldParameter.Format, new: newParameter.Format}, + } { + if field.old != field.new { + failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed %s", toolPath, name, field.name)) + } + } + if !oldParameter.Required && newParameter.Required { + failures = append(failures, fmt.Sprintf("schema tool %q made parameter %q newly required", toolPath, name)) + } + if !oldParameter.CLIRequired && newParameter.CLIRequired { + failures = append(failures, fmt.Sprintf("schema tool %q made parameter %q newly cli_required", toolPath, name)) + } + if oldParameter.RequiredWhen != newParameter.RequiredWhen && newParameter.RequiredWhen != "" { + failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed required_when", toolPath, name)) + } + if enumNarrowed(oldParameter.Enum, newParameter.Enum) { + failures = append(failures, fmt.Sprintf("schema tool %q parameter %q narrowed enum", toolPath, name)) + } + sort.Strings(failures) + return failures +} + +func enumNarrowed(oldValues, newValues []string) bool { + if len(oldValues) == 0 { + return len(newValues) > 0 + } + if len(newValues) == 0 { + return false + } + current := stringSet(newValues) + for _, value := range oldValues { + if !current[value] { + return true + } + } + return false +} + +func mergeContracts(historical, current schemaContract) (schemaContract, []string) { + failures := checkCompatibility(historical, current) + if len(failures) > 0 { + return cloneContract(historical), failures + } + return cloneContract(current), nil +} + +func cloneContract(source schemaContract) schemaContract { + data, _ := json.Marshal(source) + var cloned schemaContract + _ = json.Unmarshal(data, &cloned) + return cloned +} + +func writeContract(w io.Writer, contract schemaContract) error { + contract.Version = schemaContractVersion + if contract.Products == nil { + contract.Products = map[string]productSchema{} + } + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(contract) +} + +func stringSet(values []string) map[string]bool { + set := make(map[string]bool, len(values)) + for _, value := range values { + set[value] = true + } + return set +} diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go new file mode 100644 index 00000000..89b4fd1a --- /dev/null +++ b/scripts/policy/schema-compat/main_test.go @@ -0,0 +1,394 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +const completeSchemaJSON = `{ + "kind":"schema", + "level":"catalog", + "products":[{ + "id":"doc", + "tools":[{ + "canonical_path":"doc.create", + "primary_cli_path":"doc create", + "interface_mode":"local", + "interface_ref":{"transport":"local"}, + "availability":"available", + "parameters":{ + "title":{ + "type":"string", + "property":"title", + "required":true, + "cli_required":true, + "interface_type":"string", + "default":null, + "field_provenance":{} + }, + "format":{ + "type":["string","null"], + "property":"format", + "required":false, + "interface_type":"string", + "default":"markdown", + "enum":["markdown","text"], + "field_provenance":{} + } + }, + "constraints":{"require_one_of":[["title","format"]]}, + "positionals":[{ + "name":"content", + "index":0, + "type":"string", + "required":false, + "description":"original prose" + }], + "effect":"write", + "risk":"medium", + "confirmation":"not_required", + "idempotency":"unknown", + "field_provenance":{} + }] + }] +}` + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } + +func TestRunSchemaModes(t *testing.T) { + directory := t.TempDir() + raw := filepath.Join(directory, "raw.json") + writeTestFile(t, raw, completeSchemaJSON) + + var normalized, stderr bytes.Buffer + if code := run([]string{"--normalize", raw}, &normalized, &stderr); code != 0 { + t.Fatalf("normalize code=%d stderr=%s", code, stderr.String()) + } + baseline := filepath.Join(directory, "baseline.json") + writeTestFile(t, baseline, normalized.String()) + + var stdout bytes.Buffer + stderr.Reset() + if code := run([]string{"--check", baseline, "--current", raw}, &stdout, &stderr); code != 0 { + t.Fatalf("check code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "compatibility check: ok") { + t.Fatalf("unexpected check output %q", stdout.String()) + } + + stdout.Reset() + stderr.Reset() + if code := run([]string{"--merge", baseline, "--current", raw}, &stdout, &stderr); code != 0 { + t.Fatalf("merge code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), `"doc.create"`) { + t.Fatalf("unexpected merge output %q", stdout.String()) + } + + empty := filepath.Join(directory, "empty.json") + writeTestFile(t, empty, `{"kind":"schema","products":[]}`) + stderr.Reset() + if code := run([]string{"--check", baseline, "--current", empty}, &stdout, &stderr); code != 2 { + t.Fatalf("empty current contract code=%d, want 2", code) + } + + for _, args := range [][]string{ + nil, + {"--normalize", raw, "--check", baseline}, + {"--check", baseline}, + {"--normalize", filepath.Join(directory, "missing")}, + {"--unknown"}, + } { + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 2 { + t.Errorf("run(%v) code=%d, want 2", args, code) + } + } + + stderr.Reset() + if code := run([]string{"--normalize", raw}, failingWriter{}, &stderr); code != 2 { + t.Fatalf("write failure code=%d, want 2", code) + } +} + +func TestNormalizeRawFileValidation(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "invalid json", body: `{`, want: "unexpected end"}, + {name: "wrong kind", body: `{"kind":"other","products":[]}`, want: "unexpected kind"}, + {name: "missing products", body: `{"kind":"schema"}`, want: "products array is missing"}, + {name: "empty products", body: `{"kind":"schema","products":[]}`, want: "contains no products"}, + {name: "empty tools", body: `{"kind":"schema","products":[{"id":"doc","tools":[]}]}`, want: "contains no tools"}, + {name: "missing product id", body: `{"kind":"schema","products":[{"tools":[]}]}`, want: "product without id"}, + {name: "duplicate product", body: `{"kind":"schema","products":[{"id":"doc"},{"id":"doc"}]}`, want: "duplicate product"}, + {name: "compact tool rejected", body: `{"kind":"schema","products":[{"id":"doc","tools":[{"canonical_path":"doc.create","parameters":{},"effect":"write","risk":"medium","confirmation":"not_required","idempotency":"unknown","interface_mode":"local","availability":"available"}]}]}`, want: "not a complete schema --all leaf"}, + {name: "invalid required", body: strings.Replace(completeSchemaJSON, `"required":true`, `"required":"yes"`, 1), want: "cannot unmarshal string"}, + {name: "incomplete parameter", body: strings.Replace(completeSchemaJSON, `"field_provenance":{}`, `"incomplete":true`, 1), want: "not a complete schema --all parameter"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "raw.json") + writeTestFile(t, path, test.body) + _, err := normalizeRawFile(path) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("normalizeRawFile() error=%v, want %q", err, test.want) + } + }) + } +} + +func TestNormalizeCompleteSchemaPayload(t *testing.T) { + path := filepath.Join(t.TempDir(), "schema.json") + writeTestFile(t, path, completeSchemaJSON) + + contract, err := normalizeRawFile(path) + if err != nil { + t.Fatal(err) + } + tool := contract.Products["doc"].Tools["doc.create"] + if tool.PrimaryCLIPath != "doc create" || tool.Constraints == "" || tool.Effect != "write" { + t.Fatalf("normalized tool contract is incomplete: %#v", tool) + } + if len(tool.Positionals) != 1 || tool.Positionals[0].Name != "content" { + t.Fatalf("normalized positionals = %#v", tool.Positionals) + } + if got := tool.Parameters["title"]; got.Type != `"string"` || !got.Required || got.Property != "title" || got.InterfaceType != "string" { + t.Fatalf("title parameter = %#v", got) + } + if got := tool.Parameters["format"]; got.Type != `["string","null"]` || got.Default != `"markdown"` { + t.Fatalf("format parameter = %#v", got) + } +} + +func TestSchemaCompatibilityIgnoresPositionalDescription(t *testing.T) { + directory := t.TempDir() + baselinePath := filepath.Join(directory, "baseline.json") + currentPath := filepath.Join(directory, "current.json") + writeTestFile(t, baselinePath, completeSchemaJSON) + writeTestFile(t, currentPath, strings.Replace(completeSchemaJSON, "original prose", "edited prose only", 1)) + + baseline, err := normalizeRawFile(baselinePath) + if err != nil { + t.Fatal(err) + } + current, err := normalizeRawFile(currentPath) + if err != nil { + t.Fatal(err) + } + if failures := checkCompatibility(baseline, current); len(failures) != 0 { + t.Fatalf("positional description edit should be compatible: %v", failures) + } +} + +func TestSchemaTypeAndHelpers(t *testing.T) { + if got := schemaType(map[string]any{"type": []any{"string", "null"}}); got != `["string","null"]` { + t.Fatalf("schemaType(type)=%q", got) + } + if got := schemaType(map[string]any{"oneOf": []any{"a"}}); got != `oneOf:["a"]` { + t.Fatalf("schemaType(oneOf)=%q", got) + } + if got := schemaType(map[string]any{}); got != "unspecified" { + t.Fatalf("schemaType(empty)=%q", got) + } + if !enumNarrowed([]string{"a", "b"}, []string{"a"}) || enumNarrowed([]string{"a"}, []string{"a", "b"}) { + t.Fatal("enum narrowing classification is incorrect") + } +} + +func TestSchemaCompatibilityAllowsAdditionsAndLooserInputs(t *testing.T) { + baseline := baselineContract() + mutateTool(&baseline, func(tool *toolSchema) { + tool.DryRun = "" + }) + current := cloneContract(baseline) + mutateParameter(¤t, func(parameter *parameterSchema) { + parameter.Required = false + parameter.CLIRequired = false + parameter.Enum = append(parameter.Enum, "html") + }) + mutateTool(¤t, func(tool *toolSchema) { + tool.Parameters["folder"] = parameterSchema{Type: `"string"`} + tool.DryRun = `{"mode":"native"}` + }) + current.Products["doc"].Tools["doc.read"] = toolSchema{Parameters: map[string]parameterSchema{}} + current.Products["sheet"] = productSchema{Tools: map[string]toolSchema{}} + if failures := checkCompatibility(baseline, current); len(failures) != 0 { + t.Fatalf("compatible additions should pass: %v", failures) + } +} + +func TestSchemaCompatibilityRejectsContractDrift(t *testing.T) { + tests := []struct { + name string + want string + mutate func(*schemaContract) + }{ + {name: "removed product", want: "historical schema product", mutate: func(contract *schemaContract) { delete(contract.Products, "doc") }}, + {name: "removed tool", want: "historical schema tool", mutate: func(contract *schemaContract) { delete(contract.Products["doc"].Tools, "doc.create") }}, + {name: "removed parameter", want: "lost parameter", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { delete(tool.Parameters, "title") }) + }}, + {name: "changed type", want: "changed type", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.Type = `"number"` }) + }}, + {name: "new required", want: "newly required", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.Required = true }) + }}, + {name: "new cli required", want: "newly cli_required", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.CLIRequired = true }) + }}, + {name: "changed required when", want: "changed required_when", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.RequiredWhen = "scope=team" }) + }}, + {name: "changed property", want: "changed property", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.Property = "subject" }) + }}, + {name: "changed interface type", want: "changed interface_type", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.InterfaceType = "integer" }) + }}, + {name: "changed default", want: "changed default", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.Default = `"html"` }) + }}, + {name: "changed interface default", want: "changed interface_default", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.InterfaceDefault = `"html"` }) + }}, + {name: "changed format", want: "changed format", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.Format = "uri" }) + }}, + {name: "narrowed enum", want: "narrowed enum", mutate: func(contract *schemaContract) { + mutateParameter(contract, func(parameter *parameterSchema) { parameter.Enum = []string{"markdown"} }) + }}, + {name: "changed primary cli path", want: "changed primary_cli_path", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.PrimaryCLIPath = "doc make" }) + }}, + {name: "changed interface mode", want: "changed interface_mode", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.InterfaceMode = "mcp" }) + }}, + {name: "changed constraints", want: "changed constraints", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.Constraints = `{}` }) + }}, + {name: "changed positionals", want: "changed positionals", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.Positionals[0].Name = "id" }) + }}, + {name: "changed interface mapping", want: "changed interface_ref", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.InterfaceRef = `{"transport":"mcp"}` }) + }}, + {name: "changed availability", want: "changed availability", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.Availability = "unavailable" }) + }}, + {name: "changed confirmation", want: "changed confirmation", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.Confirmation = "user_required" }) + }}, + {name: "changed risk", want: "changed risk", mutate: func(contract *schemaContract) { mutateTool(contract, func(tool *toolSchema) { tool.Risk = "high" }) }}, + {name: "changed effect", want: "changed effect", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.Effect = "destructive" }) + }}, + {name: "changed idempotency", want: "changed idempotency", mutate: func(contract *schemaContract) { + mutateTool(contract, func(tool *toolSchema) { tool.Idempotency = "idempotent" }) + }}, + {name: "removed dry run", want: "changed or removed dry_run", mutate: func(contract *schemaContract) { mutateTool(contract, func(tool *toolSchema) { tool.DryRun = "" }) }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + current := baselineContract() + test.mutate(¤t) + failures := strings.Join(checkCompatibility(baselineContract(), current), "\n") + if !strings.Contains(failures, test.want) { + t.Fatalf("failures=%q, want %q", failures, test.want) + } + }) + } +} + +func TestMergeContracts(t *testing.T) { + historical := baselineContract() + current := cloneContract(historical) + mutateTool(¤t, func(tool *toolSchema) { + tool.Parameters["folder"] = parameterSchema{Type: `"string"`} + }) + merged, failures := mergeContracts(historical, current) + if len(failures) != 0 || merged.Products["doc"].Tools["doc.create"].Parameters["folder"].Type == "" { + t.Fatalf("merge=%v failures=%v", merged, failures) + } + + mutateParameter(¤t, func(parameter *parameterSchema) { + parameter.Type = `"number"` + }) + if _, failures := mergeContracts(historical, current); len(failures) == 0 { + t.Fatal("incompatible merge unexpectedly passed") + } +} + +func baselineContract() schemaContract { + return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{ + "doc": {Tools: map[string]toolSchema{ + "doc.create": { + PrimaryCLIPath: "doc create", + InterfaceMode: "local", + InterfaceRef: `{"transport":"local"}`, + Availability: "available", + Parameters: map[string]parameterSchema{ + "title": { + Type: `"string"`, + Property: "title", + InterfaceType: "string", + }, + "format": { + Type: `"string"`, + Property: "format", + InterfaceType: "string", + Default: `"markdown"`, + Enum: []string{"markdown", "text"}, + }, + }, + Constraints: `{"require_one_of":[["title","format"]]}`, + Positionals: []positionalSchema{{ + Name: "content", + Index: 0, + Type: "string", + }}, + DryRun: `{"mode":"native"}`, + Effect: "write", + Risk: "medium", + Confirmation: "not_required", + Idempotency: "unknown", + }, + }}, + }} +} + +func mutateTool(contract *schemaContract, mutate func(*toolSchema)) { + product := contract.Products["doc"] + tool := product.Tools["doc.create"] + mutate(&tool) + product.Tools["doc.create"] = tool + contract.Products["doc"] = product +} + +func mutateParameter(contract *schemaContract, mutate func(*parameterSchema)) { + mutateTool(contract, func(tool *toolSchema) { + parameter := tool.Parameters["format"] + mutate(¶meter) + tool.Parameters["format"] = parameter + }) +} + +func writeTestFile(t *testing.T, path, body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/scripts/policy/skill-command-check/main.go b/scripts/policy/skill-command-check/main.go new file mode 100644 index 00000000..4642fb91 --- /dev/null +++ b/scripts/policy/skill-command-check/main.go @@ -0,0 +1,302 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +// Command skill-command-check verifies that executable `dws ...` references +// in published skill Markdown resolve against the current Cobra command tree. +package main + +import ( + "bufio" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" + "github.com/spf13/cobra" +) + +var ( + inlineCommand = regexp.MustCompile("`(dws\\s+[^`]+)`") + lineCommand = regexp.MustCompile(`^\s*(?:[>$]\s*)?(dws\s+.+?)\s*$`) + antiMarkers = []string{ + "禁止", "不存在", "不要使用", "不要用", "错误写法", "错误命令", + "反模式", "反例", "错例", "臆造", "虚构", "不支持", "unknown ", + "❌", "×", "已下线", "废弃", + } + antiCommands = map[string]bool{ + "dws calendar list": true, + "dws minutes detail": true, + "dws minutes info": true, + "dws minutes summary": true, + "dws minutes transcribe": true, + "dws minutes transcription": true, + "dws report inbox": true, + } +) + +type commandRef struct { + File string + Line int + Text string +} + +type commandResolution uint8 + +const ( + resolutionValid commandResolution = iota + resolutionInvalid + resolutionSkip +) + +func main() { + rootPath, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + os.Exit(run(rootPath, app.NewRootCommand(), os.Stdout, os.Stderr)) +} + +func run(rootPath string, root *cobra.Command, stdout, stderr io.Writer) int { + refs, err := extractReferences(filepath.Join(rootPath, "skills")) + if err != nil { + fmt.Fprintln(stderr, err) + return 2 + } + + root.InitDefaultHelpCmd() + var failures []string + checked := map[string]bool{} + for _, ref := range refs { + path, _, skip := parseReference(ref.Text) + if skip || path == "" || antiCommands[path] { + continue + } + if checked[path] { + continue + } + + switch resolveCommandReference(root, path) { + case resolutionSkip: + continue + case resolutionInvalid: + failures = append(failures, formatFailure(rootPath, ref, "command path does not exist")) + continue + case resolutionValid: + checked[path] = true + } + } + + sort.Strings(failures) + if len(failures) > 0 { + fmt.Fprintf(stderr, "skill command integrity check failed (%d references):\n", len(failures)) + for _, failure := range failures { + fmt.Fprintf(stderr, " - %s\n", failure) + } + return 1 + } + fmt.Fprintf(stdout, "skill command integrity check: ok (%d executable command paths)\n", len(checked)) + return 0 +} + +func extractReferences(root string) ([]commandRef, error) { + var refs []commandRef + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".md" { + return nil + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + lineNumber := 0 + inFence := false + for scanner.Scan() { + lineNumber++ + line := scanner.Text() + if strings.HasPrefix(strings.TrimSpace(line), "```") { + inFence = !inFence + continue + } + if isAntiPatternLine(line) { + continue + } + seen := map[string]bool{} + for _, match := range inlineCommand.FindAllStringSubmatch(line, -1) { + command := strings.TrimSpace(match[1]) + seen[command] = true + refs = append(refs, commandRef{File: path, Line: lineNumber, Text: command}) + } + if inFence { + match := lineCommand.FindStringSubmatch(line) + if len(match) != 2 { + continue + } + command := strings.TrimSpace(match[1]) + if !seen[command] { + refs = append(refs, commandRef{File: path, Line: lineNumber, Text: command}) + } + } + } + return scanner.Err() + }) + return refs, err +} + +func isAntiPatternLine(line string) bool { + for _, marker := range antiMarkers { + if strings.Contains(line, marker) { + return true + } + } + return false +} + +func parseReference(raw string) (string, []string, bool) { + if strings.Contains(raw, "|") || strings.Contains(raw, "$(") || strings.Contains(raw, "&&") || strings.Contains(raw, " & ") { + return "", nil, true + } + if comment := strings.Index(raw, " #"); comment >= 0 { + raw = raw[:comment] + } + raw = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), "\\")) + if strings.Contains(raw, "[flags]") || strings.Contains(raw, "[command]") || strings.Contains(raw, "...") { + return "", nil, true + } + tokens := shellFields(raw) + if len(tokens) < 2 || tokens[0] != "dws" { + return "", nil, true + } + var pathTokens []string + var flags []string + for i := 1; i < len(tokens); i++ { + token := tokens[i] + if strings.HasPrefix(token, "--") { + name := strings.TrimPrefix(strings.SplitN(token, "=", 2)[0], "--") + if name != "" { + flags = append(flags, name) + } + if !strings.Contains(token, "=") && i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") { + i++ + } + continue + } + if strings.HasPrefix(token, "-") && len(token) == 2 { + // Shorthand validity is already covered by the help compatibility + // baseline; command references are keyed by long flags where present. + if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "-") { + i++ + } + continue + } + pathTokens = append(pathTokens, token) + } + for _, token := range pathTokens { + if strings.Contains(token, "/") || strings.Contains(token, "*") { + return "", nil, true + } + } + return "dws " + strings.Join(pathTokens, " "), uniqueSorted(flags), false +} + +func resolveCommandReference(root *cobra.Command, path string) commandResolution { + cmd, remaining, err := root.Find(strings.Fields(strings.TrimPrefix(path, "dws "))) + if err != nil || cmd == nil { + return resolutionInvalid + } + if len(remaining) == 0 { + return resolutionValid + } + if !cmd.HasSubCommands() { + // Once a leaf command has been found, remaining tokens are positional + // arguments. This check intentionally validates command paths only. + return resolutionValid + } + if isPlaceholder(remaining[0]) { + // Documentation such as `dws --help` or + // `dws sheet --help` describes a command shape rather than an + // executable command reference. + return resolutionSkip + } + return resolutionInvalid +} + +func isPlaceholder(token string) bool { + if len(token) < 2 { + return false + } + first, last := token[0], token[len(token)-1] + return (first == '<' && last == '>') || (first == '[' && last == ']') +} + +func shellFields(input string) []string { + var fields []string + var current strings.Builder + var quote rune + escaped := false + flush := func() { + if current.Len() > 0 { + fields = append(fields, current.String()) + current.Reset() + } + } + for _, char := range input { + if escaped { + current.WriteRune(char) + escaped = false + continue + } + if char == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if char == quote { + quote = 0 + } else { + current.WriteRune(char) + } + continue + } + if char == '\'' || char == '"' { + quote = char + continue + } + if char == ' ' || char == '\t' { + flush() + continue + } + current.WriteRune(char) + } + flush() + return fields +} + +func uniqueSorted(values []string) []string { + set := map[string]bool{} + for _, value := range values { + set[value] = true + } + result := make([]string, 0, len(set)) + for value := range set { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func formatFailure(root string, ref commandRef, reason string) string { + relative, _ := filepath.Rel(root, ref.File) + return fmt.Sprintf("%s:%d: `%s`: %s", relative, ref.Line, ref.Text, reason) +} diff --git a/scripts/policy/skill-command-check/main_test.go b/scripts/policy/skill-command-check/main_test.go new file mode 100644 index 00000000..a3864239 --- /dev/null +++ b/scripts/policy/skill-command-check/main_test.go @@ -0,0 +1,171 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestRunSkillCommandCheck(t *testing.T) { + root := testCommandRoot() + directory := t.TempDir() + skills := filepath.Join(directory, "skills") + if err := os.MkdirAll(skills, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(skills, "SKILL.md") + body := "Use `dws auth login`.\nUse `dws auth login` again.\nUse `dws auth bogus-command`.\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if code := run(directory, root, &stdout, &stderr); code != 1 { + t.Fatalf("invalid command code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "SKILL.md:3") || !strings.Contains(stderr.String(), "command path does not exist") { + t.Fatalf("unexpected failure %q", stderr.String()) + } + + body = "Use `dws auth login`.\n占位写法 `dws --help`.\n禁止使用 `dws auth bogus-command`.\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + stdout.Reset() + stderr.Reset() + if code := run(directory, testCommandRoot(), &stdout, &stderr); code != 0 { + t.Fatalf("valid commands code=%d stderr=%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "ok (1 executable command paths)") { + t.Fatalf("unexpected success output %q", stdout.String()) + } + + stderr.Reset() + if code := run(filepath.Join(directory, "missing"), testCommandRoot(), &stdout, &stderr); code != 2 { + t.Fatalf("missing skills code=%d, want 2", code) + } +} + +func TestExtractReferences(t *testing.T) { + directory := t.TempDir() + markdown := filepath.Join(directory, "sample.md") + body := "Inline `dws auth login`.\n```sh\n$ dws schema \"dev app create\"\nnot a command\n```\n❌ `dws bogus`\n" + if err := os.WriteFile(markdown, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "ignored.txt"), []byte("dws bogus"), 0o600); err != nil { + t.Fatal(err) + } + refs, err := extractReferences(directory) + if err != nil { + t.Fatal(err) + } + if len(refs) != 2 || refs[0].Line != 1 || refs[1].Line != 3 { + t.Fatalf("references=%v", refs) + } +} + +func TestShellFieldsAndFailureFormatting(t *testing.T) { + got := shellFields(`dws schema "dev app create" 'single value' escaped\ value`) + want := []string{"dws", "schema", "dev app create", "single value", "escaped value"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("shellFields()=%v, want %v", got, want) + } + root := t.TempDir() + ref := commandRef{File: filepath.Join(root, "skills", "SKILL.md"), Line: 7, Text: "dws bogus"} + wantFailure := filepath.Join("skills", "SKILL.md") + ":7: `dws bogus`: missing" + if got := formatFailure(root, ref, "missing"); got != wantFailure { + t.Fatalf("formatFailure()=%q, want %q", got, wantFailure) + } + if !isAntiPatternLine("这是错误命令 dws bogus") || isAntiPatternLine("dws auth login") { + t.Fatal("anti-pattern marker detection is incorrect") + } +} + +func testCommandRoot() *cobra.Command { + root := &cobra.Command{Use: "dws", Args: cobra.NoArgs} + auth := &cobra.Command{Use: "auth", Args: cobra.NoArgs} + auth.AddCommand(&cobra.Command{Use: "login", Aliases: []string{"signin"}}) + root.AddCommand(auth, &cobra.Command{Use: "schema [path]", Args: cobra.MaximumNArgs(1)}) + return root +} + +func TestParseReferenceExtractsCommandPath(t *testing.T) { + path, flags, skip := parseReference(`dws doc read --node --format json`) + if skip || path != "dws doc read" { + t.Fatalf("parseReference() = %q, skip=%v", path, skip) + } + if len(flags) != 2 || flags[0] != "format" || flags[1] != "node" { + t.Fatalf("flags = %v", flags) + } +} + +func TestParseReferenceSkipsCombinedDocumentationNotation(t *testing.T) { + if _, _, skip := parseReference(`dws doc create/update --content-format jsonml`); !skip { + t.Fatal("combined create/update notation should not be treated as an executable command") + } +} + +func TestParseReferenceSkipsShellComposition(t *testing.T) { + if _, _, skip := parseReference(`dws A & dws B & wait`); !skip { + t.Fatal("shell composition should not be treated as an executable command") + } +} + +func TestResolveCommandReference(t *testing.T) { + root := &cobra.Command{Use: "dws", Args: cobra.NoArgs} + auth := &cobra.Command{Use: "auth", Args: cobra.NoArgs} + auth.AddCommand(&cobra.Command{Use: "login", Aliases: []string{"signin"}}) + sheet := &cobra.Command{Use: "sheet", Args: cobra.NoArgs} + sheet.AddCommand(&cobra.Command{Use: "read"}) + root.AddCommand( + auth, + sheet, + &cobra.Command{Use: "schema [path]", Args: cobra.MaximumNArgs(1)}, + &cobra.Command{Use: "plugin-info ", Args: cobra.ExactArgs(1)}, + ) + + tests := []struct { + name string + path string + want commandResolution + }{ + {name: "existing leaf", path: "dws auth login", want: resolutionValid}, + {name: "existing group", path: "dws auth", want: resolutionValid}, + {name: "top-level alias", path: "dws auth signin", want: resolutionValid}, + {name: "missing top-level command", path: "dws bogus-command", want: resolutionInvalid}, + {name: "missing nested command", path: "dws auth bogus-command", want: resolutionInvalid}, + {name: "top-level placeholder", path: "dws ", want: resolutionSkip}, + {name: "nested placeholder", path: "dws sheet ", want: resolutionSkip}, + {name: "quoted positional argument", path: "dws schema dev app create", want: resolutionValid}, + {name: "leaf positional argument", path: "dws plugin-info example", want: resolutionValid}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := resolveCommandReference(root, test.path); got != test.want { + t.Fatalf("resolveCommandReference(%q) = %d, want %d", test.path, got, test.want) + } + }) + } +} + +func TestIsPlaceholder(t *testing.T) { + for _, token := range []string{"", "<子命令>", "[optional]"} { + if !isPlaceholder(token) { + t.Errorf("isPlaceholder(%q) = false, want true", token) + } + } + for _, token := range []string{"cmd", "", "[optional"} { + if isPlaceholder(token) { + t.Errorf("isPlaceholder(%q) = true, want false", token) + } + } +} diff --git a/skills/mono/references/best_practices/07-minutes.md b/skills/mono/references/best_practices/07-minutes.md index a2686105..655ee8f1 100644 --- a/skills/mono/references/best_practices/07-minutes.md +++ b/skills/mono/references/best_practices/07-minutes.md @@ -27,11 +27,10 @@ dws minutes list mine --limit 5 --format json | Recipe | 行动指南(固定路线) | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| meeting-followup | 1. **提取待办优先**:`minutes_extract_todos.py`(开源版未引入;可手动用 `dws minutes todo list` 拼装) → 获取行动项列表 无脚本时按[「多源并行采集」](./_common/conventions.md#多源并行采集公共模式)执行 2. 提取行动项 3. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` → `todo task create --title "<行动项>" --executors `(每条行动项;批量时可写 JSON 调 `python scripts/todo_batch_create.py todos.json`) 4. `chat message send --group --text "<通知内容>"` 通知已创建 | -| share-minutes | **拉摘要优先**:`minutes_recent_summary.py`(开源版未引入;可手动用 `dws minutes list` + `dws minutes summary get` 组合) → 获取近期听记摘要 备选手动:1. `minutes list mine` → 取 `taskUuid` 2. 用户选定 3. `minutes get summary --id ` → 取摘要 4. 单聊:`contact user search --query "<姓名>"` → 取 `openDingTalkId` → `chat message send --open-dingtalk-id --text "<摘要内容>"`(推荐);群聊:`--group --text "<摘要内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | +| meeting-followup | 1. **提取待办优先**:`minutes_extract_todos.py`(开源版未引入;可手动用 `dws minutes get todos --id ` 拼装) → 获取行动项列表 无脚本时按[「多源并行采集」](./_common/conventions.md#多源并行采集公共模式)执行 2. 提取行动项 3. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` → `todo task create --title "<行动项>" --executors `(每条行动项;批量时可写 JSON 调 `python scripts/todo_batch_create.py todos.json`) 4. `chat message send --group --text "<通知内容>"` 通知已创建 | +| share-minutes | **拉摘要优先**:`minutes_recent_summary.py`(开源版未引入;可手动用 `dws minutes list` + `dws minutes get summary --id ` 组合) → 获取近期听记摘要 备选手动:1. `minutes list mine` → 取 `taskUuid` 2. 用户选定 3. `minutes get summary --id ` → 取摘要 4. 单聊:`contact user search --query "<姓名>"` → 取 `openDingTalkId` → `chat message send --open-dingtalk-id --text "<摘要内容>"`(推荐);群聊:`--group --text "<摘要内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | | browse-minutes | 1. `minutes list all --query "<关键词>" --limit ` → 取全量 `taskUuid`(翻页直至无更多;无主题筛选用 `minutes list mine`;**有时间范围时加 `--start`/`--end` 服务端过滤**,如 `--start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00"`) 2. **详情/元数据优先** `minutes get batch --ids `(仅 API 上限时拆多批);**摘要专项**且无 batch 字段时再用并行 `get summary` 或 `**python scripts/minutes_recent_summary.py --limit `** 3. 汇总展示;**同一用户诉求下避免**「半截 list → 再 list 剩余」「拆 4 条一批 summary 连跑多轮」等无必要拆分 | | minutes-detail | 1. 确定 `taskUuid`:用户已提供 → 直接用;未提供 → `minutes list mine --limit 5` 让用户选(**有时间线索时加 `--start`/`--end`**,如「上周五的会」→ `--start "2026-05-23T00:00:00+08:00" --end "2026-05-23T23:59:59+08:00"`) 2. 并行拉取四维信息:`minutes get info --id ` `&` `minutes get summary --id ` `&` `minutes get keywords --id ` `&` `minutes get todos --id ` `& wait` 3. 整合输出:基础信息(标题、时间、参与人)→ AI 摘要 → 关键字 → 行动项/待办 4. **展示发言人列表**:从转写/info 提取所有发言人(含已标注姓名和匿名编号),列出每位发言人的发言次数和时长占比,引导用户:「是否需要查看某位发言人的详细内容总结?请输入姓名或编号」→ 用户选定 → 进入 `minutes-speaker-summarize` recipe 5.(可选)用户要求看原文 → `minutes get transcription --id ` | | minutes-speaker-summarize | 1. 读取转写 → `minutes get transcription --id ` 2. 声纹标注检查:已标注 → 跳 Step 6;匿名编号 → 继续 3. 转写原文推断(称呼/自我介绍/上下文指代)→ 高置信度跳 Step 6 4. 并发身份推断:`calendar event list` + `participant list` 取日程参与人(最高优先) & `aisearch person` & `chat message list` & `doc search` `& wait`;未找到同时段日程 → 引导用户提供日程链接或参会人名单;两路以上一致才下结论 5. 置信度判断:>70% 直接输出;≤70% 展示 TOP3 候选让用户选(最多一次) 6. 结构化总结输出(核心观点 + 问题 + Action Item + 立场)→ 追问是否替换发言人标注。详见 [10-minutes-speaker-match.md](./10-minutes-speaker-match.md) | | speaker-correct | 1. **查同时段日程**:从听记元数据获取录音开始时间 → `calendar event list`(前后 30 分钟内)→ 若匹配到日程则提取参会人名单并展示 2. **展示发言人状态表格**:列出所有发言人及标注状态(已识别 / 未标注 / 未标注,发言较少) 3. **让用户选择识别方式**:听音识人(裁剪代表性音频片段让用户辨认)/ 手动设置(用户直接告知对应关系)/ 智能匹配(结合参会人名单和发言内容推断) 4. **执行替换**:确认对应关系后,通讯录查询获取 dingUid → `dws contact user search --query "<姓名>" --format json` → 取 userId(长整型)→ `dws minutes speaker replace --id --from "发言人X" --to "<姓名>" --target-uid --format json`;多个匹配时列出候选让用户选;无匹配时执行不带 `--target-uid` 的替换 5. 替换成功后追问「还有其他发言人需要帮你识别和替换吗?」详见 [11-minutes-speaker-correct.md](./11-minutes-speaker-correct.md) | - diff --git a/skills/mono/references/products/doc/doc-info.md b/skills/mono/references/products/doc/doc-info.md index 49b7a682..0b98b804 100644 --- a/skills/mono/references/products/doc/doc-info.md +++ b/skills/mono/references/products/doc/doc-info.md @@ -27,8 +27,8 @@ Flags: | contentType | extension | 操作 | 命令 | |-------------|-----------|------|------| | ALIDOC | adoc | 在线获取 Markdown 内容 | `dws doc read --node ` | -| ALIDOC | axls | 在线读取表格数据 | `dws sheet get-all-sheets` → `dws sheet get-range` | -| ALIDOC | able | 在线查询多维表格记录 | `dws aitable get-tables` → `dws aitable query-records` | +| ALIDOC | axls | 在线读取表格数据 | `dws sheet list` → `dws sheet range read` | +| ALIDOC | able | 在线查询多维表格记录 | `dws aitable table list` → `dws aitable record query` | | 非 ALIDOC | — | **不支持在线分析** | 告知用户需下载到本地后查看 | **关键规则**:非 ALIDOC 类型文件(PDF/Word/图片/视频等)不支持在线分析,用户可以选择下载后本地查看。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-info.md b/skills/multi/dingtalk-doc/references/doc/doc-info.md index 49b7a682..0b98b804 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-info.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-info.md @@ -27,8 +27,8 @@ Flags: | contentType | extension | 操作 | 命令 | |-------------|-----------|------|------| | ALIDOC | adoc | 在线获取 Markdown 内容 | `dws doc read --node ` | -| ALIDOC | axls | 在线读取表格数据 | `dws sheet get-all-sheets` → `dws sheet get-range` | -| ALIDOC | able | 在线查询多维表格记录 | `dws aitable get-tables` → `dws aitable query-records` | +| ALIDOC | axls | 在线读取表格数据 | `dws sheet list` → `dws sheet range read` | +| ALIDOC | able | 在线查询多维表格记录 | `dws aitable table list` → `dws aitable record query` | | 非 ALIDOC | — | **不支持在线分析** | 告知用户需下载到本地后查看 | **关键规则**:非 ALIDOC 类型文件(PDF/Word/图片/视频等)不支持在线分析,用户可以选择下载后本地查看。 diff --git a/test/fixtures/cli-interface-baseline.txt b/test/fixtures/cli-interface-baseline.txt new file mode 100644 index 00000000..713dd844 --- /dev/null +++ b/test/fixtures/cli-interface-baseline.txt @@ -0,0 +1,2672 @@ +[root] + commands: agoal, aisearch, aitable, api, attendance, auth, calendar, chat, completion, config, contact, dev, devdoc, ding, doc, doctor, drive, help, live, mail, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, wiki + flags: --client-id:string, --client-secret:string, --debug:bool, --dry-run:bool, --fields:string, -f/--format:string, -h/--help:bool, --jq:string, --mock:bool, --profile:string, --timeout:int, -v/--verbose:bool, -y/--yes:bool + +[agoal] + commands: contract, obj-template, report, scorecard, strategy, user + flags: -h/--help:bool + +[agoal.contract] + commands: detail, fields, list, update + flags: -h/--help:bool + +[agoal.contract.detail] + flags: --contract-id:string, -h/--help:bool, --request-id:string + +[agoal.contract.fields] + flags: -h/--help:bool, --request-id:string + +[agoal.contract.list] + flags: -h/--help:bool, --request-id:string, --scope-id:string, --scope-type:string + +[agoal.contract.update] + flags: --audit-config:string, --contract-id:string, --dimensions:string, -h/--help:bool, --objective-template:string, --request-id:string + +[agoal.obj-template] + commands: create-or-update, list + flags: -h/--help:bool + +[agoal.obj-template.create-or-update] + flags: --compute-by-weight:bool, --dimension-weight:bool, --dimensions:string, -h/--help:bool, --objective-weight:bool, --request-id:string, --template-id:string, --title:string + +[agoal.obj-template.list] + flags: -h/--help:bool, --keyword:string, --page:int, --page-size:int, --request-id:string + +[agoal.report] + commands: list-statistics, submit-detail + flags: -h/--help:bool + +[agoal.report.list-statistics] + flags: -h/--help:bool, --keyword:string, --request-id:string + +[agoal.report.submit-detail] + flags: -h/--help:bool, --keyword:string, --page:int, --page-size:int, --query-date:string, --request-id:string, --submit-state:string, --template-id:string + +[agoal.scorecard] + commands: detail, entity-detail, update + flags: -h/--help:bool + +[agoal.scorecard.detail] + flags: --dept-id:string, -h/--help:bool, --request-id:string, --selected-time:string + +[agoal.scorecard.entity-detail] + flags: --entity-id:string, -h/--help:bool, --request-id:string, --sc-id:string + +[agoal.scorecard.update] + flags: --content:string, --dept-id:string, -h/--help:bool, --id:string, --request-id:string, --selected-time:string, --tracking-period-type:string + +[agoal.strategy] + commands: detail, list, update + flags: -h/--help:bool + +[agoal.strategy.detail] + flags: -h/--help:bool, --profile-id:string, --request-id:string + +[agoal.strategy.list] + flags: -h/--help:bool, --request-id:string, --scope-id:string, --scope-type:string + +[agoal.strategy.update] + flags: --content:string, -h/--help:bool, --profile-id:string, --request-id:string + +[agoal.user] + commands: objectives, rules + flags: -h/--help:bool + +[agoal.user.objectives] + flags: -h/--help:bool, --period-ids:string, --request-id:string, --rule-id:string, --user-id:string + +[agoal.user.rules] + flags: -h/--help:bool, --request-id:string, --user-id:string + +[aisearch] + commands: behavior, enterprise, person + flags: -d/--dimension:string, -h/--help:bool, -w/--keyword:string + +[aisearch.behavior] + flags: --behavior-type:string, --chat-scope:string, --direction:string, -h/--help:bool, --queries:string, --time-range:string, --types:string + +[aisearch.enterprise] + aliases: content, knowledge, search-enterprise, search_enterprise + flags: -h/--help:bool, --queries:string, --time-range:string, --types:string + +[aisearch.person] + aliases: ask, contact, find, lookup, people, query, search, search-person, search-user, user, user-search + flags: -h/--help:bool + +[aitable] + commands: advperm, attachment, base, chart, create, dashboard, export, field, form, import, info, list, record, search, section, table, template, view, workflow + flags: -h/--help:bool + +[aitable.advperm] + commands: disable, enable, role-create, role-delete, role-get, role-list, role-update + flags: -h/--help:bool + +[aitable.advperm.disable] + flags: --base-id:string, -h/--help:bool + +[aitable.advperm.enable] + flags: --base-id:string, -h/--help:bool + +[aitable.advperm.role-create] + flags: --base-id:string, --flow-type:string, -h/--help:bool, --name:string, --role-type:string, --sub-roles:string + +[aitable.advperm.role-delete] + flags: --base-id:string, -h/--help:bool, --role-id:string + +[aitable.advperm.role-get] + flags: --base-id:string, -h/--help:bool, --role-id:string + +[aitable.advperm.role-list] + flags: --base-id:string, -h/--help:bool + +[aitable.advperm.role-update] + flags: --base-id:string, --flow-type:string, -h/--help:bool, --name:string, --role-id:string, --role-type:string, --sub-roles:string + +[aitable.attachment] + commands: upload + flags: -h/--help:bool + +[aitable.attachment.upload] + flags: --base-id:string, --file-name:string, -h/--help:bool, --mime-type:string, --size:int64 + +[aitable.base] + commands: copy, create, delete, get, get-primary-doc-id, list, search, update + flags: -h/--help:bool + +[aitable.base.copy] + flags: --base-id:string, -h/--help:bool, --only-struct:bool, --target-folder-id:string + +[aitable.base.create] + flags: --folder-id:string, -h/--help:bool, --name:string, --template-id:string + +[aitable.base.delete] + flags: --base-id:string, -h/--help:bool, --reason:string + +[aitable.base.get] + flags: --base-id:string, -h/--help:bool + +[aitable.base.get-primary-doc-id] + flags: --base-id:string, -h/--help:bool, --record-id:string, --table-id:string + +[aitable.base.list] + flags: --cursor:string, -h/--help:bool, --limit:int + +[aitable.base.search] + flags: --cursor:string, -h/--help:bool, --query:string + +[aitable.base.update] + flags: --base-id:string, --desc:string, -h/--help:bool, --name:string + +[aitable.chart] + commands: create, delete, get, share, update, widgets-example + flags: -h/--help:bool + +[aitable.chart.create] + flags: --base-id:string, --config:string, --dashboard-id:string, -h/--help:bool, --layout:string + +[aitable.chart.delete] + flags: --base-id:string, --chart-id:string, --dashboard-id:string, -h/--help:bool, --reason:string + +[aitable.chart.get] + flags: --base-id:string, --chart-id:string, --dashboard-id:string, -h/--help:bool + +[aitable.chart.share] + commands: get, update + flags: -h/--help:bool + +[aitable.chart.share.get] + flags: --base-id:string, --chart-id:string, --dashboard-id:string, -h/--help:bool + +[aitable.chart.share.update] + flags: --allow-back-to-doc:bool, --base-id:string, --chart-id:string, --dashboard-id:string, --enabled:string, -h/--help:bool, --share-type:string + +[aitable.chart.update] + flags: --base-id:string, --chart-id:string, --config:string, --dashboard-id:string, -h/--help:bool, --layout:string + +[aitable.chart.widgets-example] + flags: -h/--help:bool + +[aitable.create] + flags: --folder-id:string, -h/--help:bool, --name:string, --template-id:string + +[aitable.dashboard] + commands: arrange, config-example, create, delete, get, share, update + flags: -h/--help:bool + +[aitable.dashboard.arrange] + flags: --base-id:string, --dashboard-id:string, -h/--help:bool + +[aitable.dashboard.config-example] + flags: -h/--help:bool + +[aitable.dashboard.create] + flags: --base-id:string, --config:string, -h/--help:bool, --name:string + +[aitable.dashboard.delete] + flags: --base-id:string, --dashboard-id:string, -h/--help:bool, --reason:string + +[aitable.dashboard.get] + flags: --base-id:string, --dashboard-id:string, -h/--help:bool + +[aitable.dashboard.share] + commands: get, update + flags: -h/--help:bool + +[aitable.dashboard.share.get] + flags: --base-id:string, --dashboard-id:string, -h/--help:bool + +[aitable.dashboard.share.update] + flags: --allow-back-to-doc:bool, --base-id:string, --dashboard-id:string, --enabled:string, -h/--help:bool, --share-type:string + +[aitable.dashboard.update] + flags: --base-id:string, --config:string, --dashboard-id:string, -h/--help:bool, --name:string + +[aitable.export] + commands: data + flags: -h/--help:bool + +[aitable.export.data] + flags: --base-id:string, --format:string, -h/--help:bool, --scope:string, --table-id:string, --task-id:string, --timeout-ms:int, --view-id:string + +[aitable.field] + commands: create, delete, get, list, search-options, update + flags: -h/--help:bool + +[aitable.field.create] + flags: --ai-config:string, --base-id:string, --config:string, --fields:string, -h/--help:bool, --name:string, --table-id:string, --type:string + +[aitable.field.delete] + flags: --base-id:string, --field-id:string, -h/--help:bool, --table-id:string + +[aitable.field.get] + flags: --base-id:string, --field-ids:string, -h/--help:bool, --table-id:string + +[aitable.field.list] + flags: --base-id:string, --field-ids:string, -h/--help:bool, --table-id:string + +[aitable.field.search-options] + flags: --base-id:string, --field-id:string, -h/--help:bool, --keyword:string, --limit:int, --table-id:string + +[aitable.field.update] + flags: --ai-config:string, --base-id:string, --config:string, --field-id:string, -h/--help:bool, --name:string, --table-id:string + +[aitable.form] + commands: create, delete, field, get, list, questions, share, update + flags: -h/--help:bool + +[aitable.form.create] + flags: --base-id:string, --description:string, -h/--help:bool, --name:string, --table-id:string + +[aitable.form.delete] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.form.field] + commands: hide, list, update + flags: -h/--help:bool + +[aitable.form.field.hide] + flags: --base-id:string, --field-id:string, -h/--help:bool, --hidden:string, --table-id:string, --view-id:string + +[aitable.form.field.list] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.form.field.update] + flags: --base-id:string, --field-description:string, --field-id:string, -h/--help:bool, --required:string, --table-id:string, --view-id:string + +[aitable.form.get] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.form.list] + flags: --base-id:string, -h/--help:bool, --table-id:string + +[aitable.form.questions] + commands: create, delete + flags: -h/--help:bool + +[aitable.form.questions.create] + flags: --ai-config:string, --base-id:string, --config:string, --fields:string, -h/--help:bool, --name:string, --table-id:string, --type:string + +[aitable.form.questions.delete] + flags: --base-id:string, --field-id:string, -h/--help:bool, --table-id:string + +[aitable.form.share] + commands: get, update + flags: -h/--help:bool + +[aitable.form.share.get] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.form.share.update] + flags: --base-id:string, --enabled:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.form.update] + flags: --base-id:string, --description:string, -h/--help:bool, --name:string, --table-id:string, --title:string, --view-id:string + +[aitable.import] + commands: data, upload + flags: -h/--help:bool + +[aitable.import.data] + flags: --field-mapping:string, --header-row:int, -h/--help:bool, --import-id:string, --src-sheet-name:string, --table-id:string, --timeout:int + +[aitable.import.upload] + flags: --base-id:string, --file-name:string, --file-size:int64, -h/--help:bool + +[aitable.info] + flags: --base-id:string, -h/--help:bool + +[aitable.list] + flags: --cursor:string, -h/--help:bool, --limit:int + +[aitable.record] + commands: batch-update, create, delete, get, history-list, list, primary-doc-create, primary-doc-get, query, query-empty, share-url, update, upsert + flags: -h/--help:bool + +[aitable.record.batch-update] + flags: --base-id:string, --cells:string, -h/--help:bool, --record-ids:string, --table-id:string + +[aitable.record.create] + flags: --base-id:string, -h/--help:bool, --records:string, --records-file:string, --table-id:string + +[aitable.record.delete] + flags: --base-id:string, -h/--help:bool, --record-ids:string, --table-id:string + +[aitable.record.get] + flags: --base-id:string, --field-ids:string, -h/--help:bool, --record-ids:string, --table-id:string + +[aitable.record.history-list] + flags: --base-id:string, -h/--help:bool, --limit:int, --offset:int, --record-id:string, --table-id:string + +[aitable.record.list] + flags: --all:bool, --base-id:string, --cursor:string, --field-ids:string, --filters:string, -h/--help:bool, --limit:int, --page-limit:int, --query:string, --record-ids:string, --sort:string, --table-id:string + +[aitable.record.primary-doc-create] + flags: --base-id:string, --field-id:string, -h/--help:bool, --record-id:string, --table-id:string + +[aitable.record.primary-doc-get] + flags: --base-id:string, -h/--help:bool, --record-id:string, --table-id:string + +[aitable.record.query] + flags: --all:bool, --base-id:string, --cursor:string, --field-ids:string, --filters:string, -h/--help:bool, --limit:int, --page-limit:int, --query:string, --record-ids:string, --sort:string, --table-id:string + +[aitable.record.query-empty] + flags: --base-id:string, --cursor:string, -h/--help:bool, --limit:int, --table-id:string + +[aitable.record.share-url] + flags: --base-id:string, -h/--help:bool, --record-ids:string, --table-id:string, --view-id:string + +[aitable.record.update] + flags: --base-id:string, -h/--help:bool, --records:string, --records-file:string, --table-id:string + +[aitable.record.upsert] + flags: --base-id:string, -h/--help:bool, --records:string, --records-file:string, --table-id:string + +[aitable.search] + flags: --cursor:string, -h/--help:bool, --query:string + +[aitable.section] + commands: create, delete, list-empty, list-nodes, move-node, rename, reorder + flags: -h/--help:bool + +[aitable.section.create] + flags: --base-id:string, -h/--help:bool, --index:int, --name:string, --parent-section-id:string + +[aitable.section.delete] + flags: --base-id:string, -h/--help:bool, --section-id:string + +[aitable.section.list-empty] + flags: --base-id:string, -h/--help:bool + +[aitable.section.list-nodes] + flags: --base-id:string, -h/--help:bool + +[aitable.section.move-node] + flags: --base-id:string, -h/--help:bool, --new-parent-section-id:string, --node-id:string, --target-index:int + +[aitable.section.rename] + flags: --base-id:string, -h/--help:bool, --new-name:string, --section-id:string + +[aitable.section.reorder] + flags: --base-id:string, -h/--help:bool, --section-id:string, --target-index:int + +[aitable.table] + commands: create, delete, get, list, update + flags: -h/--help:bool + +[aitable.table.create] + flags: --base-id:string, --fields:string, -h/--help:bool, --name:string + +[aitable.table.delete] + flags: --base-id:string, -h/--help:bool, --reason:string, --table-id:string + +[aitable.table.get] + flags: --base-id:string, -h/--help:bool, --table-ids:string + +[aitable.table.list] + flags: --base-id:string, -h/--help:bool, --table-ids:string + +[aitable.table.update] + flags: --base-id:string, --description:string, -h/--help:bool, --name:string, --record-name-key:string, --table-id:string + +[aitable.template] + commands: search + flags: -h/--help:bool + +[aitable.template.search] + flags: --cursor:string, -h/--help:bool, --limit:int, --query:string + +[aitable.view] + commands: create, delete, duplicate, get, list, lock, update + flags: -h/--help:bool + +[aitable.view.create] + flags: --base-id:string, --config:string, --desc:string, -h/--help:bool, --name:string, --table-id:string, --view-sub-type:string, --view-type:string + +[aitable.view.delete] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.duplicate] + flags: --base-id:string, -h/--help:bool, --new-name:string, --table-id:string, --view-id:string + +[aitable.view.get] + commands: aggregate, card, field-widths, fill-color-rule, filter, frozen-cols, group, lock, row-height, sort, timebar, visible-fields + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-ids:string + +[aitable.view.get.aggregate] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.card] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.field-widths] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.fill-color-rule] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.filter] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.frozen-cols] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.group] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.lock] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.row-height] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.sort] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.timebar] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.get.visible-fields] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.list] + flags: --base-id:string, -h/--help:bool, --table-id:string, --view-ids:string + +[aitable.view.lock] + flags: --base-id:string, -h/--help:bool, --off:bool, --table-id:string, --view-id:string + +[aitable.view.update] + commands: aggregate, card, field-widths, fill-color-rule, filter, frozen-cols, group, name, row-height, sort, timebar, visible-fields + flags: --base-id:string, --config:string, --desc:string, -h/--help:bool, --name:string, --table-id:string, --view-id:string + +[aitable.view.update.aggregate] + flags: --action:string, --base-id:string, --clear-field-id:string, --field-id:string, -h/--help:bool, --json:string, --table-id:string, --view-id:string + +[aitable.view.update.card] + flags: --base-id:string, --cover-field-id:string, --cover-mode:string, --cover-resize-mode:string, --display-field-name:bool, -h/--help:bool, --hidden-field-title:bool, --json:string, --no-cover:bool, --table-id:string, --view-id:string + +[aitable.view.update.field-widths] + flags: --base-id:string, --field-id:string, -h/--help:bool, --json:string, --table-id:string, --view-id:string, --width:int + +[aitable.view.update.fill-color-rule] + flags: --base-id:string, -h/--help:bool, --json:string, --table-id:string, --view-id:string + +[aitable.view.update.filter] + flags: --base-id:string, -h/--help:bool, --json:string, --table-id:string, --view-id:string + +[aitable.view.update.frozen-cols] + flags: --base-id:string, --count:int, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.update.group] + flags: --base-id:string, -h/--help:bool, --json:string, --table-id:string, --view-id:string + +[aitable.view.update.name] + flags: --base-id:string, -h/--help:bool, --name:string, --table-id:string, --view-id:string + +[aitable.view.update.row-height] + flags: --base-id:string, --cell-height:int, -h/--help:bool, --table-id:string, --view-id:string + +[aitable.view.update.sort] + flags: --base-id:string, -h/--help:bool, --json:string, --table-id:string, --view-id:string + +[aitable.view.update.timebar] + flags: --base-id:string, --color-configs:string, --display-field-id:string, --end-field:string, -h/--help:bool, --json:string, --official-holiday:bool, --start-field:string, --table-id:string, --timeline-scale:string, --view-id:string + +[aitable.view.update.visible-fields] + flags: --base-id:string, --field-ids:string, -h/--help:bool, --json:string, --table-id:string, --view-id:string + +[aitable.workflow] + commands: disable, enable, get, list + flags: -h/--help:bool + +[aitable.workflow.disable] + flags: --base-id:string, -h/--help:bool, --workflow-id:string + +[aitable.workflow.enable] + flags: --base-id:string, -h/--help:bool, --workflow-id:string + +[aitable.workflow.get] + flags: --base-id:string, -h/--help:bool, --workflow-id:string + +[aitable.workflow.list] + flags: --base-id:string, -h/--help:bool, --limit:int, --offset:int + +[api] + flags: --base-url:string, --data:string, -h/--help:bool, --page-all:bool, --page-delay:int, --page-limit:int, --params:string + +[attendance] + commands: adjustment, approve, boss-check, check, checkin, class, globalsetting, group, overtime, record, report, rules, schedule, selfsetting, shift, summary, vacation + flags: -h/--help:bool + +[attendance.adjustment] + commands: get, search + flags: -h/--help:bool + +[attendance.adjustment.get] + flags: --adjustment-id:int64, -h/--help:bool + +[attendance.adjustment.search] + flags: -h/--help:bool, --limit:int, --page:int, --query:string + +[attendance.approve] + commands: list, templates + flags: -h/--help:bool + +[attendance.approve.list] + flags: --end:string, -h/--help:bool, --start:string, --types:string, --users:string + +[attendance.approve.templates] + flags: -h/--help:bool, --type:string + +[attendance.boss-check] + flags: --absent-min:int, -h/--help:bool, --plan-id:string, --remark:string, --result:string, --result-id:string, --time:string, --user-say-yes:bool + +[attendance.check] + commands: record, result + flags: -h/--help:bool + +[attendance.check.record] + flags: --end:string, -h/--help:bool, --start:string, --users:string + +[attendance.check.result] + flags: --end:string, -h/--help:bool, --limit:int, --offset:int, --start:string, --users:string + +[attendance.checkin] + commands: records + flags: -h/--help:bool + +[attendance.checkin.records] + flags: --end:string, -h/--help:bool, --operator-corp-id:string, --operator-staff-id:string, --staff-ids:string, --start:string + +[attendance.class] + commands: create, get, search, update + flags: -h/--help:bool + +[attendance.class.create] + flags: --class-vo:string, -h/--help:bool, --name:string, --owner:string, --yes:bool + +[attendance.class.get] + flags: --class-id:int64, -h/--help:bool + +[attendance.class.search] + flags: --filter-type:string, -h/--help:bool, --limit:int, --page:int, --query:string + +[attendance.class.update] + flags: --class-id:int64, --class-vo:string, -h/--help:bool, --name:string, --owner:string, --yes:bool + +[attendance.globalsetting] + commands: get, save + flags: -h/--help:bool + +[attendance.globalsetting.get] + flags: -h/--help:bool, --scope:string, --setting-scene:string + +[attendance.globalsetting.save] + flags: --boss-daily-report-type:int, --boss-monthly-report-type:int, --boss-weekly-report-type:int, --check-remind-corp:bool, --check-remind-pc-corp:bool, --enable-check-cert-push:bool, --enable-personal-daily-report:bool, --enable-personal-monthly-report:bool, --enable-personal-weekly-report:bool, --enable-personal-weekly-report-card:bool, --fast-check-corp:bool, -h/--help:bool, --lack-remind-corp:bool, --scope:string, --setting-scene:string, --yes:bool + +[attendance.group] + commands: create, filtered-get, get, search, update, update-members + flags: -h/--help:bool + +[attendance.group.create] + flags: --corp-id:string, --group-vo:string, -h/--help:bool, --name:string, --owner:string, --type:string, --yes:bool + +[attendance.group.filtered-get] + flags: --bles:bool, --group-id:int64, -h/--help:bool, --member:bool, --position:bool, --wifi:bool + +[attendance.group.get] + flags: --group-id:int64, -h/--help:bool + +[attendance.group.search] + flags: -h/--help:bool, --limit:int, --page:int, --query:string, --query-ble:bool, --query-position:bool, --type:string + +[attendance.group.update] + flags: --classIds:string, --enable-outside-check:string, --group-id:int64, --group-vo:string, -h/--help:bool, --name:string, --owner:string, --type:string, --yes:bool + +[attendance.group.update-members] + flags: --add-depts:string, --add-extra-users:string, --add-users:string, --group-id:int64, -h/--help:bool, --remove-depts:string, --remove-extra-users:string, --remove-users:string, --yes:bool + +[attendance.overtime] + commands: get, search + flags: -h/--help:bool + +[attendance.overtime.get] + flags: -h/--help:bool, --overtime-id:int64 + +[attendance.overtime.search] + flags: -h/--help:bool, --limit:int, --page:int, --query:string + +[attendance.record] + commands: get + flags: -h/--help:bool + +[attendance.record.get] + flags: --date:string, -h/--help:bool, --user:string + +[attendance.report] + commands: columns, query-data, query-leave + flags: -h/--help:bool + +[attendance.report.columns] + flags: -h/--help:bool + +[attendance.report.query-data] + flags: --columns:string, --end:string, -h/--help:bool, --start:string, --users:string + +[attendance.report.query-leave] + flags: --end:string, -h/--help:bool, --leave-names:string, --start:string, --users:string + +[attendance.rules] + flags: --date:string, -h/--help:bool + +[attendance.schedule] + commands: get, import + flags: -h/--help:bool + +[attendance.schedule.get] + flags: --end:string, -h/--help:bool, --start:string, --users:string + +[attendance.schedule.import] + flags: --groupId:string, -h/--help:bool, --scheduleVOS:string, --user-say-yes:bool + +[attendance.selfsetting] + commands: get, save + flags: -h/--help:bool + +[attendance.selfsetting.get] + flags: -h/--help:bool, --setting-scene:string, --user:string + +[attendance.selfsetting.save] + flags: --boss-month-report-type:int, --boss-push-start-min:int, --boss-week-report-type:int, --can-update-off-duty:bool, --check-remind-setting:string, --check-remind-user-off-duty:bool, --check-remind-user-on-duty:bool, --check-result-msg:int, --enable-offduty-check-remind-of-pc:bool, --enable-onduty-check-remind-of-pc:bool, --fast-check-late-need-confirm:bool, -h/--help:bool, --lack-remind-user:int, --lack-send-todo-msg:int, --offduty-check-type:int, --offduty-remind-end-min:int, --offduty-remind-start-min:int, --onduty-check-type:int, --onduty-remind-end-min:int, --onduty-remind-start-min:int, --person-daily-report-switch:int, --person-month-report-type:int, --person-week-report-type:int, --setting-scene:string, --user:string, --vibration-remind-switch:bool, --voice-remind-switch:bool, --yes:bool + +[attendance.shift] + commands: list + flags: -h/--help:bool + +[attendance.shift.list] + flags: --end:string, -h/--help:bool, --start:string, --users:string + +[attendance.summary] + flags: --date:string, -h/--help:bool, --stats-type:string, --tag-name:string, --user:string + +[attendance.vacation] + commands: balance, records, save-balance, types, update-type + flags: -h/--help:bool + +[attendance.vacation.balance] + flags: -h/--help:bool, --leave-code:string, --users:string + +[attendance.vacation.records] + flags: --end:string, -h/--help:bool, --leave-code:string, --start:string, --user:string + +[attendance.vacation.save-balance] + flags: --end:string, -h/--help:bool, --leave-code:string, --num:string, --reason:string, --start:string, --target:string, --user-say-yes:bool + +[attendance.vacation.types] + flags: -h/--help:bool + +[attendance.vacation.update-type] + flags: -h/--help:bool, --leave-code:string, --name:string, --paid:bool, --per-hours:int, --unit:string, --user-say-yes:bool, --visibility-rules:string, --when-can-leave:string + +[auth] + commands: export, import, login, logout, reset, status + flags: -h/--help:bool + +[auth.export] + flags: --base64:bool, -h/--help:bool, -o/--output:string + +[auth.import] + flags: --base64:bool, --force:bool, -h/--help:bool, -i/--input:string + +[auth.login] + flags: --device:bool, --force:bool, -h/--help:bool, --no-browser:bool, --recommend:bool, --token:string + +[auth.logout] + flags: -h/--help:bool, --profile:string + +[auth.reset] + flags: -h/--help:bool + +[auth.status] + flags: -h/--help:bool, --profile:string + +[calendar] + commands: acl, attachment, attendee, book, busy, event, room + flags: -h/--help:bool + +[calendar.acl] + commands: add, delete, list + flags: -h/--help:bool + +[calendar.acl.add] + flags: -h/--help:bool, --no-notification:bool, --privilege:string, --user:string + +[calendar.acl.delete] + flags: --acl-id:string, -h/--help:bool + +[calendar.acl.list] + flags: -h/--help:bool + +[calendar.attachment] + commands: add + flags: -h/--help:bool + +[calendar.attachment.add] + flags: --calendar-id:string, --event:string, --files:string, -h/--help:bool + +[calendar.attendee] + commands: add, delete, list + aliases: participant + flags: --calendar-id:string, --event:string, -h/--help:bool + +[calendar.attendee.add] + flags: --attendees:string, -h/--help:bool, --optional:bool + +[calendar.attendee.delete] + flags: --attendees:string, -h/--help:bool + +[calendar.attendee.list] + flags: -h/--help:bool + +[calendar.book] + commands: get, list, search, update + flags: -h/--help:bool + +[calendar.book.get] + flags: -h/--help:bool, --id:string + +[calendar.book.list] + flags: -h/--help:bool + +[calendar.book.search] + flags: -h/--help:bool, --query:string + +[calendar.book.update] + flags: --desc:string, -h/--help:bool, --id:string, --summary:string + +[calendar.busy] + commands: search + flags: -h/--help:bool + +[calendar.busy.search] + flags: --end:string, -h/--help:bool, --rooms:string, --start:string, --users:string + +[calendar.event] + commands: create, delete, get, list, respond, suggest, update + flags: -h/--help:bool + +[calendar.event.create] + flags: --attendees:string, --calendar-id:string, --desc:string, --end:string, --free-busy:string, -h/--help:bool, --location:string, --open-dingtalk-ids:string, --recurrence-count:int, --recurrence-day-of-month:int, --recurrence-days-of-week:string, --recurrence-end-date:string, --recurrence-first-day-of-week:string, --recurrence-index:string, --recurrence-interval:int, --recurrence-range-type:string, --recurrence-type:string, --remind-minutes:string, --rich-text-desc:string, --rooms:string, --start:string, --timezone:string, --title:string + +[calendar.event.delete] + flags: --calendar-id:string, -h/--help:bool, --id:string + +[calendar.event.get] + flags: --calendar-id:string, -h/--help:bool, --id:string + +[calendar.event.list] + flags: --calendar-id:string, --cursor:string, --end:string, -h/--help:bool, --limit:int, --start:string + +[calendar.event.respond] + flags: --calendar-id:string, -h/--help:bool, --id:string, --status:string + +[calendar.event.suggest] + flags: --duration:string, --end:string, -h/--help:bool, --start:string, --timezone:string, --users:string + +[calendar.event.update] + flags: --calendar-id:string, --desc:string, --end:string, --free-busy:string, -h/--help:bool, --id:string, --location:string, --recurrence-count:int, --recurrence-day-of-month:int, --recurrence-days-of-week:string, --recurrence-end-date:string, --recurrence-first-day-of-week:string, --recurrence-index:string, --recurrence-interval:int, --recurrence-range-type:string, --recurrence-type:string, --rich-text-desc:string, --start:string, --timezone:string, --title:string + +[calendar.room] + commands: add, delete, list-groups, search + flags: -h/--help:bool + +[calendar.room.add] + flags: --calendar-id:string, --event:string, -h/--help:bool, --rooms:string + +[calendar.room.delete] + flags: --calendar-id:string, --event:string, -h/--help:bool, --rooms:string + +[calendar.room.list-groups] + flags: -h/--help:bool, --limit:string, --page:string + +[calendar.room.search] + flags: --end:string, --group-id:string, -h/--help:bool, --limit:string, --page:string, --room-name:string, --start:string + +[chat] + commands: bot, category, chmod, clear-all-red-point, clear-messages, clear-red-point, conversation-info, data-auth, group, group-mute, group-mute-member, group-role, hide, list-all-conversations, list-top-conversations, mark-read, mark-unread, media, message, mute, mute-at-all, mute-red-envelope, search, search-common, set-top, text + aliases: im + flags: -h/--help:bool + +[chat.bot] + commands: find, search + flags: -h/--help:bool + +[chat.bot.find] + flags: --cursor:string, -h/--help:bool, --limit:int, --query:string + +[chat.bot.search] + flags: -h/--help:bool, --name:string, --page:int, --size:int + +[chat.category] + commands: add-conv, create, create-smart, delete, list, list-conversations, remove-conv, rename + flags: -h/--help:bool + +[chat.category.add-conv] + flags: --category-ids:string, --group:string, -h/--help:bool + +[chat.category.create] + flags: -h/--help:bool, --title:string + +[chat.category.create-smart] + flags: -h/--help:bool, --keywords:string, --members:string, --name:string + +[chat.category.delete] + flags: --category-id:int64, -h/--help:bool + +[chat.category.list] + flags: -h/--help:bool + +[chat.category.list-conversations] + flags: --category-id:int, --exclude-muted:bool, -h/--help:bool + +[chat.category.remove-conv] + flags: --category-ids:string, --group:string, -h/--help:bool + +[chat.category.rename] + flags: --category-id:int64, -h/--help:bool, --title:string + +[chat.chmod] + flags: --agentCode:string, --conversation-id:string, --grant-type:string, -h/--help:bool, --open-dingtalk-id:string, --permParam:stringArray, --session-id:string, --ttl:string, --user:string + +[chat.clear-all-red-point] + flags: -h/--help:bool + +[chat.clear-messages] + flags: --conversation-id:string, -h/--help:bool + +[chat.clear-red-point] + flags: --conversation-id:string, -h/--help:bool + +[chat.conversation-info] + flags: --group:string, -h/--help:bool, --open-dingtalk-id:string, --user:string + +[chat.data-auth] + commands: cross-org + flags: -h/--help:bool + +[chat.data-auth.cross-org] + flags: --agentCode:string, --all:bool, --grant-type:string, -h/--help:bool, --session-id:string, --target-org-id:string, --ttl:string + +[chat.group] + commands: audit-join-validation, bots, create, dismiss, get-by-group-id, invite-url, list-all, list-join-validations, list-my-groups, members, notice, quit, rename, set-admin, set-history, share-invite, transfer-owner, update-alias, update-icon, update-nick, update-settings + flags: -h/--help:bool + +[chat.group-mute] + flags: --group:string, -h/--help:bool, --off:bool + +[chat.group-mute-member] + flags: --group:string, -h/--help:bool, --mute-time:int64, --off:bool, --user:string, --users:string + +[chat.group-role] + commands: add, list, query-user, remove, remove-user, set-user, update + flags: -h/--help:bool + +[chat.group-role.add] + flags: --group:string, -h/--help:bool, --name:string + +[chat.group-role.list] + flags: --group:string, -h/--help:bool + +[chat.group-role.query-user] + flags: --group:string, -h/--help:bool, --user:string + +[chat.group-role.remove] + flags: --group:string, -h/--help:bool, --role-id:string + +[chat.group-role.remove-user] + flags: --group:string, -h/--help:bool, --role-ids:string, --user:string + +[chat.group-role.set-user] + flags: --group:string, -h/--help:bool, --role-ids:string, --user:string + +[chat.group-role.update] + flags: --group:string, -h/--help:bool, --name:string, --role-id:string + +[chat.group.audit-join-validation] + flags: --applicant:string, --description:string, --group:string, -h/--help:bool, --inviter:string, --record-id:string, --status:string + +[chat.group.bots] + flags: --group:string, -h/--help:bool + +[chat.group.create] + flags: -h/--help:bool, --name:string, --thread:bool, --type:string, --users:string + +[chat.group.dismiss] + flags: --group:string, -h/--help:bool + +[chat.group.get-by-group-id] + flags: --group-id:int64, -h/--help:bool + +[chat.group.invite-url] + flags: --expires-seconds:int64, --group:string, -h/--help:bool + +[chat.group.list-all] + flags: --cursor:string, -h/--help:bool, --limit:int + +[chat.group.list-join-validations] + flags: --cursor:string, -h/--help:bool, --limit:int + +[chat.group.list-my-groups] + flags: --exclude-muted:bool, -h/--help:bool, --limit:int, --role:string + +[chat.group.members] + commands: add, add-bot, list-by-ids, remove, remove-bot + flags: --cursor:string, -h/--help:bool, --id:string + +[chat.group.members.add] + flags: -h/--help:bool, --id:string, --users:string + +[chat.group.members.add-bot] + flags: -h/--help:bool, --id:string, --robot-code:string + +[chat.group.members.list-by-ids] + flags: -h/--help:bool, --id:string, --users:string + +[chat.group.members.remove] + flags: -h/--help:bool, --id:string, --users:string + +[chat.group.members.remove-bot] + flags: --bot-id:string, -h/--help:bool, --id:string + +[chat.group.notice] + commands: create, edit, get, list + flags: -h/--help:bool + +[chat.group.notice.create] + flags: --content:string, --group:string, -h/--help:bool, --run-at:string, --send-ding:bool, --sticky:bool + +[chat.group.notice.edit] + flags: --content:string, --group:string, -h/--help:bool, --notice-id:string, --send-ding:bool, --sticky:bool + +[chat.group.notice.get] + flags: --group:string, -h/--help:bool, --notice-id:string + +[chat.group.notice.list] + flags: --cursor:string, --group:string, -h/--help:bool, --limit:int, --scheduled:bool + +[chat.group.quit] + flags: --group:string, -h/--help:bool + +[chat.group.rename] + flags: -h/--help:bool, --id:string, --name:string + +[chat.group.set-admin] + flags: --group:string, -h/--help:bool, --off:bool, --user:string, --users:string + +[chat.group.set-history] + flags: --group:string, -h/--help:bool, --option:string + +[chat.group.share-invite] + flags: --expires-seconds:int64, -h/--help:bool, --receiver:string, --source:string, --target:string, --uuid:string + +[chat.group.transfer-owner] + flags: --group:string, -h/--help:bool, --new-owner:string, --user:string + +[chat.group.update-alias] + flags: --alias-title:string, --group:string, -h/--help:bool + +[chat.group.update-icon] + flags: --group:string, -h/--help:bool, --icon-media-id:string + +[chat.group.update-nick] + flags: --group:string, -h/--help:bool, --nick:string + +[chat.group.update-settings] + flags: --group:string, -h/--help:bool, --setting-key:string, --status:int + +[chat.hide] + flags: --conversation-id:string, -h/--help:bool + +[chat.list-all-conversations] + flags: --cursor:int64, --exclude-muted:bool, -h/--help:bool, --limit:int + +[chat.list-top-conversations] + flags: --cursor:int64, --exclude-muted:bool, -h/--help:bool, --limit:int + +[chat.mark-read] + flags: --conversation-id:string, -h/--help:bool, --message-id:string + +[chat.mark-unread] + flags: --conversation-id:string, -h/--help:bool + +[chat.media] + commands: upload + flags: -h/--help:bool + +[chat.media.upload] + flags: --file:string, -h/--help:bool, --type:string + +[chat.message] + commands: add-emoji, add-text-emotion, combine-forward, create-text-emotion, download-media, forward, forward-topic, list, list-all, list-by-ids, list-by-sender, list-emotion-replies, list-focused, list-mentions, list-pin-msg, list-topic-replies, list-unread-conversations, query-send-status, read-status, recall, recall-by-bot, remove-emoji, remove-text-emotion, reply, search, search-advanced, send, send-by-bot, send-by-webhook, send-card, set-pin-msg, set-top-msg, unset-pin-msg, unset-top-msg, update-card + flags: -h/--help:bool + +[chat.message.add-emoji] + flags: --chat:string, --conversation-id:string, --emoji:string, --group:string, -h/--help:bool, --id:string, --msg-id:string + +[chat.message.add-text-emotion] + flags: --background-id:string, --chat:string, --conversation-id:string, --emotion-id:string, --emotion-name:string, --group:string, -h/--help:bool, --id:string, --msg-id:string, --text:string + +[chat.message.combine-forward] + flags: --dest-conversation-id:string, -h/--help:bool, --msg-ids:string, --src-conversation-id:string, --uuid:string + +[chat.message.create-text-emotion] + flags: --background-id:string, --emotion-name:string, -h/--help:bool, --text:string + +[chat.message.download-media] + flags: -h/--help:bool, --message-id:string, --open-conversation-id:string, --output:string, --resource-id:string, --type:string + +[chat.message.forward] + flags: --dest-conversation-id:string, -h/--help:bool, --msg-id:string, --src-conversation-id:string, --uuid:string + +[chat.message.forward-topic] + flags: --dest-conversation-id:string, -h/--help:bool, --src-conversation-id:string, --src-msg-id:string, --src-thread-id:string + +[chat.message.list] + flags: --direction:string, --group:string, -h/--help:bool, --limit:int, --open-dingtalk-id:string, --time:string, --user:string + +[chat.message.list-all] + flags: --cursor:string, --end:string, -h/--help:bool, --limit:int, --start:string + +[chat.message.list-by-ids] + flags: -h/--help:bool, --msg-ids:string + +[chat.message.list-by-sender] + flags: --cursor:string, --end:string, -h/--help:bool, --limit:int, --sender-open-dingtalk-id:string, --sender-user-id:string, --start:string + +[chat.message.list-emotion-replies] + flags: -h/--help:bool, --msg-ids:string + +[chat.message.list-focused] + flags: --cursor:int64, -h/--help:bool, --limit:int + +[chat.message.list-mentions] + flags: --cursor:string, --end:string, --group:string, -h/--help:bool, --limit:int, --start:string + +[chat.message.list-pin-msg] + flags: --cursor:string, -h/--help:bool, --open-conversation-id:string, --size:int + +[chat.message.list-topic-replies] + flags: --direction:string, --group:string, -h/--help:bool, --limit:int, --time:string, --topic-id:string + +[chat.message.list-unread-conversations] + flags: --count:int, --exclude-muted:bool, -h/--help:bool + +[chat.message.query-send-status] + flags: -h/--help:bool, --open-task-id:string + +[chat.message.read-status] + flags: --conversation-id:string, -h/--help:bool, --message-id:string, --target-open-dingtalk-ids:string, --user:string, --users:string + +[chat.message.recall] + flags: --conversation-id:string, -h/--help:bool, --msg-id:string + +[chat.message.recall-by-bot] + flags: --group:string, -h/--help:bool, --keys:string, --robot-code:string + +[chat.message.remove-emoji] + flags: --chat:string, --conversation-id:string, --emoji:string, --group:string, -h/--help:bool, --id:string, --msg-id:string + +[chat.message.remove-text-emotion] + flags: --background-id:string, --chat:string, --conversation-id:string, --emotion-id:string, --emotion-name:string, --group:string, -h/--help:bool, --id:string, --msg-id:string, --text:string + +[chat.message.reply] + flags: --ai-tag:bool, --conversation-id:string, -h/--help:bool, --ref-msg-id:string, --ref-sender:string, --text:string, --uuid:string + +[chat.message.search] + flags: --cursor:string, --end:string, --group:string, -h/--help:bool, --limit:int, --query:string, --start:string + +[chat.message.search-advanced] + flags: --at-ids:string, --at-me:bool, --conversation-ids:string, --cursor:string, --end:string, -h/--help:bool, --limit:int, --query:string, --sender-ids:string, --start:string, --user:string, --users:string + +[chat.message.send] + flags: --ai-tag:bool, --at-all:bool, --at-open-dingtalk-ids:string, --file-path:string, --group:string, -h/--help:bool, --media-id:string, --msg-type:string, --open-dingtalk-id:string, --text:string, --title:string, --user:string, --uuid:string + +[chat.message.send-by-bot] + flags: --at-all:bool, --at-open-dingtalk-ids:string, --at-user-ids:string, --group:string, -h/--help:bool, --open-dingtalk-ids:string, --robot-code:string, --text:string, --title:string, --users:string + +[chat.message.send-by-webhook] + flags: --at-all:bool, --at-mobiles:string, --at-users:string, -h/--help:bool, --text:string, --title:string, --token:string + +[chat.message.send-card] + flags: --group:string, -h/--help:bool, --receiver:string + +[chat.message.set-pin-msg] + flags: -h/--help:bool, --msg-id:string, --open-conversation-id:string + +[chat.message.set-top-msg] + flags: -h/--help:bool, --msg-id:string, --open-conversation-id:string + +[chat.message.unset-pin-msg] + flags: -h/--help:bool, --msg-id:string, --open-conversation-id:string + +[chat.message.unset-top-msg] + flags: -h/--help:bool, --msg-id:string, --open-conversation-id:string + +[chat.message.update-card] + flags: --biz-id:string, --content:string, --flow-status:int, -h/--help:bool + +[chat.mute] + flags: --chat:string, --conversation-id:string, -h/--help:bool, --id:string, --off:bool + +[chat.mute-at-all] + flags: --conversation-id:string, -h/--help:bool, --off:bool + +[chat.mute-red-envelope] + flags: --conversation-id:string, -h/--help:bool, --off:bool + +[chat.search] + flags: --cursor:string, --exclude-muted:bool, -h/--help:bool, --limit:int, --query:string + +[chat.search-common] + flags: --cursor:string, --exclude-muted:bool, -h/--help:bool, --limit:int, --match-mode:string, --nicks:string + +[chat.set-top] + flags: --conversation-id:string, -h/--help:bool, --off:bool + +[chat.text] + commands: translate + flags: -h/--help:bool + +[chat.text.translate] + flags: -h/--help:bool, --query:string, --to:string + +[completion] + flags: -h/--help:bool + +[config] + commands: list + flags: -h/--help:bool + +[config.list] + flags: --category:string, -h/--help:bool, --json:bool, --show-hidden:bool, --show-values:bool + +[contact] + commands: dept, label, relation, user + flags: -h/--help:bool + +[contact.dept] + commands: get-info, list-children, list-members, search + flags: -h/--help:bool + +[contact.dept.get-info] + flags: --dept:string, -h/--help:bool + +[contact.dept.list-children] + flags: --dept:string, -h/--help:bool + +[contact.dept.list-members] + flags: --depts:string, -h/--help:bool + +[contact.dept.search] + flags: -h/--help:bool, --query:string + +[contact.label] + commands: get, list, list-members + aliases: role + flags: -h/--help:bool + +[contact.label.get] + flags: -h/--help:bool, --names:string + +[contact.label.list] + flags: -h/--help:bool + +[contact.label.list-members] + flags: -h/--help:bool, --id:string + +[contact.relation] + commands: list-my-followings + flags: -h/--help:bool + +[contact.relation.list-my-followings] + flags: -h/--help:bool + +[contact.user] + commands: dismission, get, get-self, profile, search, search-mobile + flags: -h/--help:bool + +[contact.user.dismission] + commands: search + flags: -h/--help:bool + +[contact.user.dismission.search] + flags: --depts:string, --end:string, -h/--help:bool, --hide-partner:bool, --hide-retirement:bool, --limit:int, --name:string, --page:int, --start:string + +[contact.user.get] + flags: -h/--help:bool, --ids:string + +[contact.user.get-self] + aliases: current, me, self, whoami + flags: -h/--help:bool + +[contact.user.profile] + commands: fields, get + flags: -h/--help:bool + +[contact.user.profile.fields] + flags: -h/--help:bool + +[contact.user.profile.get] + flags: --fields:string, -h/--help:bool, --staff-id:string + +[contact.user.search] + flags: -h/--help:bool, --query:string + +[contact.user.search-mobile] + flags: -h/--help:bool, --mobile:string + +[dev] + commands: app, connect, doc + flags: -h/--help:bool + +[dev.app] + commands: create, credentials, delete, disable, enable, event, get, list, member, permission, robot, security, update, version, webapp + flags: -h/--help:bool + +[dev.app.create] + flags: --desc:string, -h/--help:bool, --icon-media-id:string, --name:string + +[dev.app.credentials] + commands: get + flags: -h/--help:bool + +[dev.app.credentials.get] + flags: -h/--help:bool, --unified-app-id:string + +[dev.app.delete] + flags: --confirm-name:string, -h/--help:bool, --unified-app-id:string + +[dev.app.disable] + flags: -h/--help:bool, --unified-app-id:string + +[dev.app.enable] + flags: -h/--help:bool, --unified-app-id:string + +[dev.app.event] + commands: list, subscribe, unsubscribe + flags: -h/--help:bool + +[dev.app.event.list] + flags: --cursor:string, -h/--help:bool, --keyword:string, --page-size:int, --unified-app-id:string + +[dev.app.event.subscribe] + flags: --event-codes:string, -h/--help:bool, --unified-app-id:string + +[dev.app.event.unsubscribe] + flags: --event-codes:string, -h/--help:bool, --unified-app-id:string + +[dev.app.get] + flags: -h/--help:bool, --unified-app-id:string + +[dev.app.list] + flags: --app-group-id:int, --app-key:string, --creator:string, --cursor:string, --develop-type:int, --filter-cool-app:int, -h/--help:bool, --name:string, --page-size:int, --robot-name:string, --sort-order:string, --sort-type:string + +[dev.app.member] + commands: add, list, remove + flags: -h/--help:bool + +[dev.app.member.add] + flags: -h/--help:bool, --member-type:string, --unified-app-id:string, --user-ids:string + +[dev.app.member.list] + flags: -h/--help:bool, --unified-app-id:string + +[dev.app.member.remove] + flags: -h/--help:bool, --member-type:string, --unified-app-id:string, --user-ids:string + +[dev.app.permission] + commands: add, list, remove + flags: -h/--help:bool + +[dev.app.permission.add] + flags: -h/--help:bool, --scope-values:string, --unified-app-id:string + +[dev.app.permission.list] + aliases: search + flags: --api-status:string, --auth-status:string, --cursor:string, -h/--help:bool, --keyword:string, --page-size:int, --scope-type:string, --scope-value:string, --unified-app-id:string + +[dev.app.permission.remove] + flags: -h/--help:bool, --scope-values:string, --unified-app-id:string + +[dev.app.robot] + commands: config, disable, enable, get, result, submit + flags: -h/--help:bool + +[dev.app.robot.config] + flags: --add-scope:bool, --brief:string, --desc:string, --disable-ssl-verify:bool, --event-callback-url:string, -h/--help:bool, --i18n-brief:string, --i18n-description:string, --i18n-name:string, --icon-media-id:string, --mode:string, --name:string, --outgoing-url:string, --skills:string, --unified-app-id:string + +[dev.app.robot.disable] + flags: -h/--help:bool, --unified-app-id:string + +[dev.app.robot.enable] + flags: -h/--help:bool, --unified-app-id:string + +[dev.app.robot.get] + flags: -h/--help:bool, --unified-app-id:string + +[dev.app.robot.result] + flags: -h/--help:bool, --task-id:string + +[dev.app.robot.submit] + flags: --desc:string, -h/--help:bool, --icon-media-id:string, --name:string, --preview-media-id:string, --robot-name:string, --task-id:string + +[dev.app.security] + commands: config + flags: -h/--help:bool + +[dev.app.security.config] + flags: -h/--help:bool, --ip-whitelist:string, --redirect-urls:string, --sso-urls:string, --unified-app-id:string + +[dev.app.update] + flags: --desc:string, -h/--help:bool, --icon-media-id:string, --name:string, --unified-app-id:string + +[dev.app.version] + commands: check-approval, create, get, list, publish, status + flags: -h/--help:bool + +[dev.app.version.check-approval] + flags: -h/--help:bool, --unified-app-id:string, --version-id:string + +[dev.app.version.create] + flags: --desc:string, -h/--help:bool, --unified-app-id:string, --version:string + +[dev.app.version.get] + flags: -h/--help:bool, --unified-app-id:string, --version-id:string + +[dev.app.version.list] + flags: --cursor:string, -h/--help:bool, --page-size:int, --unified-app-id:string + +[dev.app.version.publish] + flags: --approver-user-id:string, --confirmed-sensitive:bool, -h/--help:bool, --unified-app-id:string, --version-id:string + +[dev.app.version.status] + flags: -h/--help:bool, --unified-app-id:string, --version-id:string + +[dev.app.webapp] + commands: config, get + flags: -h/--help:bool + +[dev.app.webapp.config] + flags: --h5-page-type:string, -h/--help:bool, --homepage-url:string, --omp-url:string, --pc-homepage-url:string, --unified-app-id:string + +[dev.app.webapp.get] + flags: -h/--help:bool, --unified-app-id:string + +[dev.connect] + commands: list, restart, status, stop + flags: --agent-approval-mode:string, --agent-cmd:string, --agent-memory:bool, --agent-model:string, --agent-permission-mode:string, --agent-timeout:int, --agent-workdir:string, --allowed-groups:string, --allowed-users:string, --alwayson:bool, --approval-card-template:string, --audit-sheet:string, --audit-sheet-tab:string, --card-template:string, --channel:string, --daemon:bool, -h/--help:bool, --knowledge-dir:string, --knowledge-source:string, --notify-staff-id:string, --owner-user-id:string, --reply-card:bool, --robot-client-id:string, --robot-client-secret:string, --role-config:string, --unified-app-id:string, --user-rate-limit:int, --yolo:bool + +[dev.connect.list] + flags: -h/--help:bool, --json:bool + +[dev.connect.restart] + flags: -h/--help:bool, --robot-client-id:string, --unified-app-id:string + +[dev.connect.status] + flags: -h/--help:bool, --json:bool, --robot-client-id:string, --unified-app-id:string + +[dev.connect.stop] + flags: -h/--help:bool, --robot-client-id:string, --unified-app-id:string + +[dev.doc] + commands: search + flags: -h/--help:bool + +[dev.doc.search] + flags: -h/--help:bool, --page:string, --query:string, --size:string + +[devdoc] + commands: article + flags: -h/--help:bool + +[devdoc.article] + commands: search + flags: -h/--help:bool + +[devdoc.article.search] + flags: -h/--help:bool, --page:string, --query:string, --size:string + +[ding] + commands: message + flags: -h/--help:bool + +[ding.message] + commands: list, recall, recall-personal, receiver-status, send, send-by-message, send-personal + flags: -h/--help:bool + +[ding.message.list] + flags: --cursor:int64, -h/--help:bool, --type:string + +[ding.message.recall] + flags: -h/--help:bool, --id:string, --robot-code:string + +[ding.message.recall-personal] + flags: -h/--help:bool, --id:string + +[ding.message.receiver-status] + flags: --ding-id:string, -h/--help:bool + +[ding.message.send] + flags: --content:string, -h/--help:bool, --robot-code:string, --type:string, --users:string + +[ding.message.send-by-message] + flags: --group:string, -h/--help:bool, --message-id:string, --type:string, --users:string, --uuid:string + +[ding.message.send-personal] + flags: --content:string, -h/--help:bool, --type:string, --users:string, --uuid:string + +[doc] + commands: block, comment, create, export, file, import, info, media, read, template, update, version + flags: -h/--help:bool + +[doc.block] + commands: delete, insert, list, update + flags: -h/--help:bool + +[doc.block.delete] + flags: --block-id:string, -h/--help:bool, --node:string + +[doc.block.insert] + flags: --content-format:string, --element:string, --fix-jsonml:bool, --heading:string, -h/--help:bool, --index:int, --level:int, --node:string, --parent-block:string, --ref-block:string, --text:string, --where:string + +[doc.block.list] + flags: --block-id:string, --block-type:string, --content-format:string, --end-index:int, -h/--help:bool, --node:string, --start-index:int + +[doc.block.update] + flags: --block-id:string, --content-format:string, --element:string, --fix-jsonml:bool, --heading:string, -h/--help:bool, --level:int, --node:string, --text:string + +[doc.comment] + commands: create, create-inline, list, reply + flags: -h/--help:bool + +[doc.comment.create] + flags: --content:string, -h/--help:bool, --mention:string, --node:string + +[doc.comment.create-inline] + flags: --block-id:string, --content:string, --end:int, -h/--help:bool, --mention:string, --node:string, --selected-text:string, --start:int + +[doc.comment.list] + flags: --cursor:string, -h/--help:bool, --limit:int, --node:string, --resolve-status:string, --type:string + +[doc.comment.reply] + flags: --comment-key:string, --content:string, --emoji:bool, -h/--help:bool, --mention:string, --node:string + +[doc.create] + flags: --content:string, --content-file:string, --content-format:string, --fix-jsonml:bool, --folder:string, -h/--help:bool, --name:string, --workspace:string + +[doc.export] + commands: get + flags: --export-format:string, -h/--help:bool, --node:string, --output:string + +[doc.export.get] + flags: -h/--help:bool, --job-id:string + +[doc.file] + commands: create + flags: -h/--help:bool + +[doc.file.create] + flags: --folder:string, -h/--help:bool, --name:string, --type:string, --workspace:string + +[doc.import] + commands: get + flags: --file:string, --folder:string, -h/--help:bool, -n/--name:string, --workspace:string + +[doc.import.get] + flags: -h/--help:bool, --task-id:string + +[doc.info] + flags: -h/--help:bool, --node:string + +[doc.media] + commands: download, insert + flags: -h/--help:bool + +[doc.media.download] + flags: -h/--help:bool, --node:string, --resource-id:string + +[doc.media.insert] + flags: --file:string, -h/--help:bool, --index:int, --mime-type:string, --name:string, --node:string, --ref-block:string, --where:string + +[doc.read] + flags: --content-format:string, -h/--help:bool, --node:string, --output:string + +[doc.template] + commands: apply, list, search + flags: -h/--help:bool + +[doc.template.apply] + flags: --folder:string, -h/--help:bool, --name:string, --template-id:string, --workspace:string + +[doc.template.list] + flags: --cursor:string, -h/--help:bool, --limit:int, --source:string + +[doc.template.search] + flags: --cursor:string, -h/--help:bool, --limit:int, --query:string, --source:string + +[doc.update] + flags: --content:string, --content-file:string, --content-format:string, --dry-run:bool, --fix-jsonml:bool, -h/--help:bool, --index:int, --mode:string, --node:string, --revision:int, --yes:bool + +[doc.version] + commands: list, revert, save + flags: -h/--help:bool + +[doc.version.list] + aliases: ls + flags: --cursor:string, -h/--help:bool, --limit:int, --node:string + +[doc.version.revert] + flags: -h/--help:bool, --node:string, --version:int + +[doc.version.save] + flags: -h/--help:bool, --node:string + +[doctor] + flags: -h/--help:bool, --json:bool, --perf:bool, --timeout:int + +[drive] + commands: commit, copy, delete, download, info, list, list-spaces, mkdir, move, permission, publish, recent, recycle, rename, search, upload, upload-info + flags: -h/--help:bool + +[drive.commit] + flags: --file-name:string, --file-size:int64, --folder:string, -h/--help:bool, --space-id:string, --upload-id:string + +[drive.copy] + flags: --folder:string, -h/--help:bool, --node:string, --workspace:string + +[drive.delete] + flags: -h/--help:bool, --node:string + +[drive.download] + flags: -h/--help:bool, --node:string, --output:string, --space-id:string + +[drive.info] + flags: -h/--help:bool, --node:string, --space-id:string + +[drive.list] + flags: --cursor:string, --folder:string, -h/--help:bool, --limit:int, --order:string, --order-by:string, --space-id:string, --thumbnail:bool, --workspace:string + +[drive.list-spaces] + flags: --cursor:string, -h/--help:bool, --limit:int, --space-type:string + +[drive.mkdir] + flags: --folder:string, -h/--help:bool, --name:string, --space-id:string + +[drive.move] + flags: --folder:string, -h/--help:bool, --node:string, --workspace:string + +[drive.permission] + commands: add, list, remove, update + aliases: perm + flags: -h/--help:bool + +[drive.permission.add] + flags: -h/--help:bool, --node:string, --role:string, --users:string, --workspace:string + +[drive.permission.list] + aliases: ls + flags: --filter-role:string, -h/--help:bool, --limit:int, --node:string, --workspace:string + +[drive.permission.remove] + aliases: rm + flags: -h/--help:bool, --node:string, --users:string, --workspace:string + +[drive.permission.update] + flags: -h/--help:bool, --node:string, --role:string, --users:string, --workspace:string + +[drive.publish] + commands: get, set, unset + flags: -h/--help:bool + +[drive.publish.get] + aliases: status + flags: -h/--help:bool, --node:string + +[drive.publish.set] + flags: -h/--help:bool, --node:string, --permission:string + +[drive.publish.unset] + aliases: close, off + flags: -h/--help:bool, --node:string + +[drive.recent] + flags: --creator-type:int, --cursor:string, --file-types:intSlice, -h/--help:bool, --limit:int, --operate-type:intSlice, --org-ids:intSlice + +[drive.recycle] + commands: list, restore + flags: -h/--help:bool + +[drive.recycle.list] + aliases: ls + flags: --cursor:string, -h/--help:bool, --limit:int, --space-id:string + +[drive.recycle.restore] + flags: -h/--help:bool, --id:string + +[drive.rename] + flags: -h/--help:bool, --name:string, --node:string + +[drive.search] + flags: --created-from:int64, --created-to:int64, --creator-uids:stringSlice, --cursor:string, --extensions:stringSlice, --file-types:stringSlice, -h/--help:bool, --limit:int, --modified-from:int64, --modified-to:int64, --query:string, --target:string + +[drive.upload] + flags: --convert:bool, --file:string, --file-name:string, --folder:string, -h/--help:bool, --mime-type:string, --space-id:string, --workspace:string + +[drive.upload-info] + flags: --file-name:string, --file-size:int64, --folder:string, -h/--help:bool, --mime-type:string, --space-id:string + +[help] + flags: -h/--help:bool + +[live] + commands: stream + flags: -h/--help:bool + +[live.stream] + commands: list + flags: -h/--help:bool + +[live.stream.list] + flags: -h/--help:bool + +[mail] + commands: allow-list, attachment, auto-reply, block-list, contact, draft, folder, mailbox, message, rule, sent-message, tag, template, thread, user + flags: -h/--help:bool + +[mail.allow-list] + commands: add, list, remove + flags: -h/--help:bool + +[mail.allow-list.add] + flags: --email:string, --entries:string, -h/--help:bool + +[mail.allow-list.list] + flags: --email:string, -h/--help:bool + +[mail.allow-list.remove] + flags: --email:string, --entries:string, -h/--help:bool + +[mail.attachment] + commands: download, list + flags: -h/--help:bool + +[mail.attachment.download] + flags: --attachment-id:string, --email:string, -h/--help:bool, --message-id:string, --name:string, --output:string + +[mail.attachment.list] + flags: --email:string, -h/--help:bool, --id:string + +[mail.auto-reply] + commands: get, update + flags: -h/--help:bool + +[mail.auto-reply.get] + flags: --email:string, -h/--help:bool + +[mail.auto-reply.update] + flags: --content:string, --email:string, --enabled:string, --end:string, -h/--help:bool, --scope:string, --start:string + +[mail.block-list] + commands: add, list, remove + flags: -h/--help:bool + +[mail.block-list.add] + flags: --email:string, --entries:string, -h/--help:bool + +[mail.block-list.list] + flags: --email:string, -h/--help:bool + +[mail.block-list.remove] + flags: --email:string, --entries:string, -h/--help:bool + +[mail.contact] + commands: batch-delete, create, list, update + flags: -h/--help:bool + +[mail.contact.batch-delete] + flags: --contact-ids:string, --email:string, -h/--help:bool + +[mail.contact.create] + flags: --contact-email:string, --display-name:string, --email:string, --first-name:string, -h/--help:bool, --last-name:string, --middle-name:string + +[mail.contact.list] + flags: --cursor:string, --email:string, -h/--help:bool, --limit:string + +[mail.contact.update] + flags: --contact-email:string, --contact-id:string, --display-name:string, --email:string, --first-name:string, -h/--help:bool, --last-name:string, --middle-name:string + +[mail.draft] + commands: create, send, update + flags: -h/--help:bool + +[mail.draft.create] + flags: --attachment:stringArray, --cc:string, --content:string, --from:string, -h/--help:bool, --inline-attachment:stringArray, --subject:string, --to:string + +[mail.draft.send] + flags: --from:string, -h/--help:bool, --id:string + +[mail.draft.update] + flags: --attachment:stringArray, --cc:string, --content:string, --from:string, -h/--help:bool, --id:string, --inline-attachment:stringArray, --subject:string, --to:string + +[mail.folder] + commands: create, delete, list, update + flags: -h/--help:bool + +[mail.folder.create] + flags: --email:string, --folder:string, -h/--help:bool, --name:string + +[mail.folder.delete] + flags: --email:string, -h/--help:bool, --id:string + +[mail.folder.list] + flags: --email:string, --folder:string, -h/--help:bool + +[mail.folder.update] + flags: --email:string, -h/--help:bool, --id:string, --name:string + +[mail.mailbox] + commands: list, profile + flags: -h/--help:bool + +[mail.mailbox.list] + flags: -h/--help:bool + +[mail.mailbox.profile] + flags: --email:string, -h/--help:bool + +[mail.message] + commands: batch-delete, batch-get, batch-move, batch-update, forward, get, list, reply, reply-all, search, send, verify + flags: -h/--help:bool + +[mail.message.batch-delete] + flags: --email:string, -h/--help:bool, --ids:string + +[mail.message.batch-get] + flags: --email:string, -h/--help:bool, --ids:string + +[mail.message.batch-move] + flags: --email:string, --folder:string, -h/--help:bool, --ids:string + +[mail.message.batch-update] + flags: --action:string, --email:string, -h/--help:bool, --ids:string, --tags:string + +[mail.message.forward] + flags: --attachment:stringArray, --content:string, --from:string, -h/--help:bool, --id:string, --inline-attachment:stringArray, --subject:string, --to:string + +[mail.message.get] + flags: --email:string, -h/--help:bool, --id:string + +[mail.message.list] + flags: --cursor:string, --email:string, --folder-id:string, -h/--help:bool, --limit:string + +[mail.message.reply] + flags: --attachment:stringArray, --content:string, --from:string, -h/--help:bool, --id:string, --inline-attachment:stringArray, --subject:string, --to:string + +[mail.message.reply-all] + flags: --attachment:stringArray, --content:string, --from:string, -h/--help:bool, --id:string, --inline-attachment:stringArray, --subject:string, --to:string + +[mail.message.search] + flags: --cursor:string, --email:string, -h/--help:bool, --limit:string, --query:string + +[mail.message.send] + flags: --attachment:stringArray, --cc:string, --content:string, --from:string, -h/--help:bool, --inline-attachment:stringArray, --subject:string, --to:string + +[mail.message.verify] + flags: --email:string, -h/--help:bool, --internet-message-id:string + +[mail.rule] + commands: adjust, create, delete, list, update + flags: -h/--help:bool + +[mail.rule.adjust] + flags: --direction:string, --email:string, -h/--help:bool, --id:string + +[mail.rule.create] + flags: --actions:string, --conditions:string, --email:string, --enabled:string, -h/--help:bool, --name:string + +[mail.rule.delete] + flags: --email:string, -h/--help:bool, --id:string + +[mail.rule.list] + flags: --email:string, -h/--help:bool + +[mail.rule.update] + flags: --actions:string, --conditions:string, --email:string, --enabled:string, -h/--help:bool, --id:string, --name:string + +[mail.sent-message] + commands: recall, recall-detail + flags: -h/--help:bool + +[mail.sent-message.recall] + flags: --email:string, -h/--help:bool, --id:string, --subject:string, --yes:bool + +[mail.sent-message.recall-detail] + flags: --email:string, -h/--help:bool, --id:string + +[mail.tag] + commands: create, delete, list, update + flags: -h/--help:bool + +[mail.tag.create] + flags: --email:string, -h/--help:bool, --name:string, --parent-id:string + +[mail.tag.delete] + flags: --email:string, -h/--help:bool, --id:string + +[mail.tag.list] + flags: --email:string, -h/--help:bool + +[mail.tag.update] + flags: --email:string, -h/--help:bool, --id:string, --name:string + +[mail.template] + commands: create, delete, get, list, update + flags: -h/--help:bool + +[mail.template.create] + flags: --cc:string, --content:string, --email:string, --from:string, -h/--help:bool, --is-draft:bool, --name:string, --subject:string, --to:string + +[mail.template.delete] + flags: --email:string, -h/--help:bool, --id:string + +[mail.template.get] + flags: --email:string, -h/--help:bool, --id:string + +[mail.template.list] + flags: --cursor:string, --email:string, -h/--help:bool, --limit:string + +[mail.template.update] + flags: --cc:string, --content:string, --email:string, --from:string, -h/--help:bool, --id:string, --name:string, --subject:string, --to:string + +[mail.thread] + commands: batch-trash, batch-update, get, list, trash, update + flags: -h/--help:bool + +[mail.thread.batch-trash] + flags: --email:string, -h/--help:bool, --ids:string, --yes:bool + +[mail.thread.batch-update] + flags: --action:string, --email:string, -h/--help:bool, --ids:string, --tag-ids:string + +[mail.thread.get] + flags: --email:string, -h/--help:bool, --id:string + +[mail.thread.list] + flags: --ascending:bool, --cursor:string, --email:string, --end:string, --folder:string, -h/--help:bool, --limit:int, --start:string + +[mail.thread.trash] + flags: --email:string, -h/--help:bool, --id:string, --yes:bool + +[mail.thread.update] + flags: --action:string, --email:string, -h/--help:bool, --id:string, --tag-ids:string + +[mail.user] + commands: search + flags: -h/--help:bool + +[mail.user.search] + flags: --cursor:string, --email:string, --employee-no:string, -h/--help:bool, --keyword:string, --limit:string + +[minutes] + commands: get, hot-word, list, mind-graph, permission, record, replace-text, speaker, tag, update, upload + flags: -h/--help:bool + +[minutes.get] + commands: audio, batch, info, keywords, summary, todos, transcription + flags: -h/--help:bool + +[minutes.get.audio] + flags: -h/--help:bool, --id:string + +[minutes.get.batch] + flags: -h/--help:bool, --ids:string + +[minutes.get.info] + flags: -h/--help:bool, --id:string + +[minutes.get.keywords] + flags: -h/--help:bool, --id:string + +[minutes.get.summary] + flags: -h/--help:bool, --id:string + +[minutes.get.todos] + flags: -h/--help:bool, --id:string + +[minutes.get.transcription] + flags: --cursor:string, --direction:string, -h/--help:bool, --id:string + +[minutes.hot-word] + commands: add, list + flags: -h/--help:bool + +[minutes.hot-word.add] + flags: -h/--help:bool, --words:string + +[minutes.hot-word.list] + flags: -h/--help:bool + +[minutes.list] + commands: all, mine, shared + flags: -h/--help:bool + +[minutes.list.all] + flags: --cursor:string, --end:string, -h/--help:bool, --limit:float64, --query:string, --start:string + +[minutes.list.mine] + flags: --cursor:string, --end:string, -h/--help:bool, --limit:float64, --query:string, --start:string + +[minutes.list.shared] + flags: --cursor:string, --end:string, -h/--help:bool, --limit:float64, --query:string, --start:string + +[minutes.mind-graph] + commands: create, status + flags: -h/--help:bool + +[minutes.mind-graph.create] + flags: -h/--help:bool, --id:string + +[minutes.mind-graph.status] + flags: -h/--help:bool, --id:string + +[minutes.permission] + commands: add, remove + flags: -h/--help:bool + +[minutes.permission.add] + flags: --cover:bool, -h/--help:bool, --ids:string, --member-uids:string, --policy:string, --sub-resources:string + +[minutes.permission.remove] + flags: -h/--help:bool, --ids:string, --member-uids:string + +[minutes.record] + commands: pause, resume, start, stop + flags: -h/--help:bool + +[minutes.record.pause] + flags: -h/--help:bool, --id:string, --session-id:string + +[minutes.record.resume] + flags: -h/--help:bool, --id:string, --session-id:string + +[minutes.record.start] + flags: -h/--help:bool, --session-id:string + +[minutes.record.stop] + flags: -h/--help:bool, --id:string, --session-id:string + +[minutes.replace-text] + flags: -h/--help:bool, --id:string, --replace:string, --search:string + +[minutes.speaker] + commands: replace, summary + flags: -h/--help:bool + +[minutes.speaker.replace] + flags: --from:string, -h/--help:bool, --id:string, --target-uid:string, --to:string + +[minutes.speaker.summary] + commands: create, get + flags: -h/--help:bool + +[minutes.speaker.summary.create] + flags: -h/--help:bool, --ids:string + +[minutes.speaker.summary.get] + flags: -h/--help:bool, --ids:string + +[minutes.tag] + commands: list, query + flags: -h/--help:bool + +[minutes.tag.list] + flags: -h/--help:bool + +[minutes.tag.query] + flags: --cursor:string, -h/--help:bool, --limit:float64, --tag-id:string + +[minutes.update] + commands: summary, title + flags: -h/--help:bool + +[minutes.update.summary] + flags: --content:string, -h/--help:bool, --id:string + +[minutes.update.title] + flags: -h/--help:bool, --id:string, --title:string + +[minutes.upload] + commands: cancel, complete, create + flags: -h/--help:bool + +[minutes.upload.cancel] + flags: -h/--help:bool, --session-id:string + +[minutes.upload.complete] + flags: -h/--help:bool, --session-id:string + +[minutes.upload.create] + flags: --enable-message-card:bool, --file-name:string, --file-size:int64, -h/--help:bool, --input-language:string, --template-id:string, --title:string + +[oa] + commands: approval + flags: -h/--help:bool + +[oa.approval] + commands: append-task, approve, detail, ding-info, list-cc, list-executed, list-forms, list-initiated, list-pending, list-submitted, oa-cc-noticer, oa-comments, records, redirect-task, reject, revert-activities, revert-task, revoke, search-forms, tasks + flags: -h/--help:bool + +[oa.approval.append-task] + flags: --activate-type:string, --agree-all:string, --appender-user-ids:string, -h/--help:bool, --instance-id:string, --task-id:string, --type:string + +[oa.approval.approve] + flags: -h/--help:bool, --instance-id:string, --remark:string, --task-id:string + +[oa.approval.detail] + flags: -h/--help:bool, --instance-id:string + +[oa.approval.ding-info] + flags: -h/--help:bool, --task-id:string + +[oa.approval.list-cc] + flags: -h/--help:bool, --limit:string, --page:string, --query:string + +[oa.approval.list-executed] + flags: -h/--help:bool, --limit:string, --page:string, --query:string + +[oa.approval.list-forms] + flags: --cursor:string, -h/--help:bool, --limit:string + +[oa.approval.list-initiated] + flags: --cursor:string, --end:string, -h/--help:bool, --limit:string, --process-code:string, --start:string + +[oa.approval.list-pending] + flags: --end:string, -h/--help:bool, --limit:string, --page:string, --query:string, --start:string + +[oa.approval.list-submitted] + flags: -h/--help:bool, --limit:string, --page:string, --query:string + +[oa.approval.oa-cc-noticer] + flags: -h/--help:bool, --instance-id:string, --operator-id:string, --users:string + +[oa.approval.oa-comments] + flags: --content:string, -h/--help:bool, --instance-id:string + +[oa.approval.records] + flags: -h/--help:bool, --instance-id:string + +[oa.approval.redirect-task] + flags: -h/--help:bool, --remark:string, --task-id:string, --to-actioner-id:string + +[oa.approval.reject] + flags: -h/--help:bool, --instance-id:string, --remark:string, --task-id:string + +[oa.approval.revert-activities] + flags: -h/--help:bool, --task-id:string + +[oa.approval.revert-task] + flags: --action:string, -h/--help:bool, --instance-id:string, --remark:string, --target-activity-id:string, --task-id:string + +[oa.approval.revoke] + flags: -h/--help:bool, --instance-id:string, --remark:string + +[oa.approval.search-forms] + flags: -h/--help:bool, --query:string + +[oa.approval.tasks] + flags: -h/--help:bool, --instance-id:string + +[pat] + commands: browser-policy, chmod + flags: -h/--help:bool + +[pat.browser-policy] + flags: --agentCode:string, --enabled:bool, -h/--help:bool + +[pat.chmod] + flags: --agentCode:string, --domain:stringArray, --domains:stringSlice, --grant-type:string, -h/--help:bool, --product:stringArray, --products:stringSlice, --recommend:bool, --session-id:string + +[plugin] + commands: build, config, create, dev, disable, enable, info, install, list, remove, validate + flags: -h/--help:bool + +[plugin.build] + flags: -h/--help:bool + +[plugin.config] + commands: get, list, set, unset + flags: -h/--help:bool + +[plugin.config.get] + flags: -h/--help:bool + +[plugin.config.list] + flags: -h/--help:bool, --json:bool + +[plugin.config.set] + flags: -h/--help:bool + +[plugin.config.unset] + flags: -h/--help:bool + +[plugin.create] + flags: --description:string, -h/--help:bool + +[plugin.dev] + flags: -h/--help:bool, --off:bool + +[plugin.disable] + flags: -h/--help:bool + +[plugin.enable] + flags: -h/--help:bool + +[plugin.info] + flags: -h/--help:bool + +[plugin.install] + flags: --dir:string, --git:string, -h/--help:bool + +[plugin.list] + flags: -h/--help:bool, --json:bool + +[plugin.remove] + flags: -h/--help:bool, --keep-data:bool + +[plugin.validate] + flags: -h/--help:bool + +[profile] + commands: list, switch, use + flags: -h/--help:bool + +[profile.list] + aliases: ls + flags: -h/--help:bool + +[profile.switch] + flags: --corpId:string, -h/--help:bool, --name:string + +[profile.use] + flags: --corpId:string, -h/--help:bool, --name:string + +[recovery] + commands: execute, finalize, plan + flags: -h/--help:bool + +[recovery.execute] + flags: --event-id:string, -h/--help:bool, --last:bool + +[recovery.finalize] + flags: --event-id:string, --execution-file:string, -h/--help:bool, --outcome:string + +[recovery.plan] + flags: --event-id:string, -h/--help:bool, --last:bool + +[report] + commands: create, created, detail, entry, inbox, list, outbox, sent, stats, template + aliases: log + flags: -h/--help:bool + +[report.create] + flags: --contents:string, --contents-file:string, --dd-from:string, -h/--help:bool, --template-id:string, --to-chat:bool, --to-user-ids:string + +[report.created] + flags: --cursor:int, --end:string, -h/--help:bool, --modified-end:string, --modified-start:string, --size:int, --start:string, --template-name:string + +[report.detail] + flags: -h/--help:bool, --report-id:string + +[report.entry] + commands: get, stats, submit + flags: -h/--help:bool + +[report.entry.get] + flags: -h/--help:bool, --report-id:string + +[report.entry.stats] + flags: -h/--help:bool, --report-id:string + +[report.entry.submit] + flags: --contents:string, --contents-file:string, --dd-from:string, -h/--help:bool, --template-id:string, --to-chat:bool, --to-user-ids:string + +[report.inbox] + commands: list + flags: --cursor:int, --end:string, -h/--help:bool, --sender-user-ids:stringSlice, --size:int, --start:string + +[report.inbox.list] + flags: --cursor:int, --end:string, -h/--help:bool, --sender-user-ids:stringSlice, --size:int, --start:string + +[report.list] + flags: --cursor:int, --end:string, -h/--help:bool, --sender-user-ids:stringSlice, --size:int, --start:string + +[report.outbox] + commands: list + flags: -h/--help:bool + +[report.outbox.list] + flags: --cursor:int, --end:string, -h/--help:bool, --modified-end:string, --modified-start:string, --size:int, --start:string, --template-name:string + +[report.sent] + flags: --cursor:int, --end:string, -h/--help:bool, --modified-end:string, --modified-start:string, --size:int, --start:string, --template-name:string + +[report.stats] + flags: -h/--help:bool, --report-id:string + +[report.template] + commands: detail, get, list + flags: -h/--help:bool + +[report.template.detail] + flags: -h/--help:bool, --name:string + +[report.template.get] + flags: -h/--help:bool, --name:string + +[report.template.list] + flags: -h/--help:bool + +[schema] + flags: --cli-path:string, -h/--help:bool + +[sheet] + commands: add-dimension, append, batch-update, chart, cond-format, copy, create, create-float-image, csv-get, csv-put, delete-dimension, delete-dropdown, delete-float-image, delete-sheet, export, filter, filter-view, find, get-dropdown, get-float-image, group-dimension, info, insert-dimension, list, list-float-images, media-upload, merge-cells, move-dimension, new, range, replace, set-dropdown, template, ungroup-dimension, unmerge-cells, update, update-dimension, update-float-image, write-image + flags: -h/--help:bool + +[sheet.add-dimension] + flags: --dimension:string, -h/--help:bool, --length:int, --node:string, --sheet-id:string + +[sheet.append] + flags: -h/--help:bool, --node:string, --sheet-id:string, --values:string + +[sheet.batch-update] + flags: --continue-on-error:bool, -h/--help:bool, --node:string, --operations:string + +[sheet.chart] + commands: create, delete, list, update + flags: -h/--help:bool + +[sheet.chart.create] + flags: -h/--help:bool, --node:string, --properties:string, --sheet-id:string + +[sheet.chart.delete] + flags: --chart-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.chart.list] + flags: --chart-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.chart.update] + flags: --chart-id:string, -h/--help:bool, --node:string, --properties:string, --sheet-id:string + +[sheet.cond-format] + commands: create, delete, list, update + flags: -h/--help:bool + +[sheet.cond-format.create] + flags: --cell-style:string, --condition:string, --data-bar-style:string, -h/--help:bool, --node:string, --ranges:string, --sheet-id:string + +[sheet.cond-format.delete] + flags: -h/--help:bool, --node:string, --rule-id:string, --sheet-id:string, --yes:bool + +[sheet.cond-format.list] + flags: -h/--help:bool, --node:string, --rule-id:string, --sheet-id:string + +[sheet.cond-format.update] + flags: --cell-style:string, --condition:string, --data-bar-style:string, -h/--help:bool, --node:string, --ranges:string, --rule-id:string, --sheet-id:string + +[sheet.copy] + flags: -h/--help:bool, --index:int, --name:string, --node:string, --sheet-id:string, --title:string + +[sheet.create] + flags: --folder:string, -h/--help:bool, --name:string, --workspace:string + +[sheet.create-float-image] + flags: --height:int, -h/--help:bool, --node:string, --offset-x:int, --offset-y:int, --range:string, --sheet-id:string, --src:string, --width:int + +[sheet.csv-get] + flags: -h/--help:bool, --max-chars:int, --node:string, --range:string, --sheet-id:string, --value-render-option:string + +[sheet.csv-put] + flags: --allow-overwrite:bool, --csv:string, -h/--help:bool, --node:string, --sheet-id:string, --start-cell:string + +[sheet.delete-dimension] + flags: --dimension:string, -h/--help:bool, --length:string, --node:string, --position:string, --sheet-id:string + +[sheet.delete-dropdown] + flags: -h/--help:bool, --node:string, --range:string, --sheet-id:string + +[sheet.delete-float-image] + flags: --float-image-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.delete-sheet] + flags: -h/--help:bool, --node:string, --sheet-id:string + +[sheet.export] + flags: -h/--help:bool, --node:string, --output:string + +[sheet.filter] + commands: clear-criteria, create, delete, get, sort, update + flags: -h/--help:bool + +[sheet.filter-view] + commands: create, delete, delete-criteria, get-criteria, info, list, list-criteria, update, update-criteria + flags: -h/--help:bool + +[sheet.filter-view.create] + flags: --criteria:string, -h/--help:bool, --name:string, --node:string, --range:string, --sheet-id:string + +[sheet.filter-view.delete] + flags: --filter-view-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter-view.delete-criteria] + flags: --column:int, --filter-view-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter-view.get-criteria] + flags: --column:int, --filter-view-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter-view.info] + flags: --filter-view-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter-view.list] + flags: -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter-view.list-criteria] + flags: --filter-view-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter-view.update] + flags: --criteria:string, --filter-view-id:string, -h/--help:bool, --name:string, --node:string, --range:string, --sheet-id:string + +[sheet.filter-view.update-criteria] + flags: --column:int, --filter-criteria:string, --filter-view-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter.clear-criteria] + flags: --column:int, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter.create] + flags: --criteria:string, -h/--help:bool, --node:string, --range:string, --sheet-id:string + +[sheet.filter.delete] + flags: -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter.get] + flags: -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter.sort] + flags: --ascending:bool, --column:int, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.filter.update] + flags: --criteria:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.find] + flags: -h/--help:bool, --include-hidden:bool, --match-case:bool, --match-entire-cell:bool, --match-formula:bool, --node:string, --query:string, --range:string, --sheet-id:string, --use-regexp:bool + +[sheet.get-dropdown] + flags: -h/--help:bool, --node:string, --range:string, --sheet-id:string + +[sheet.get-float-image] + flags: --float-image-id:string, -h/--help:bool, --node:string, --sheet-id:string + +[sheet.group-dimension] + flags: --group-state:string, -h/--help:bool, --node:string, --range:string, --sheet-id:string + +[sheet.info] + flags: -h/--help:bool, --node:string, --sheet-id:string + +[sheet.insert-dimension] + flags: --dimension:string, -h/--help:bool, --length:string, --node:string, --position:string, --sheet-id:string + +[sheet.list] + flags: -h/--help:bool, --node:string + +[sheet.list-float-images] + flags: -h/--help:bool, --node:string, --sheet-id:string + +[sheet.media-upload] + flags: --file:string, -h/--help:bool, --mime-type:string, --name:string, --node:string + +[sheet.merge-cells] + flags: -h/--help:bool, --merge-type:string, --node:string, --range:string, --sheet-id:string + +[sheet.move-dimension] + flags: --destination-index:string, --dimension:string, --end-index:string, -h/--help:bool, --node:string, --sheet-id:string, --start-index:string + +[sheet.new] + flags: -h/--help:bool, --name:string, --node:string + +[sheet.range] + commands: batch-clear, batch-set-style, clear, copy-to, fill, move-to, read, set-style, sort, update + flags: -h/--help:bool + +[sheet.range.batch-clear] + flags: -h/--help:bool, --node:string, --ranges:string, --type:string + +[sheet.range.batch-set-style] + flags: --batch:string, --continue-on-error:bool, -h/--help:bool, --node:string + +[sheet.range.clear] + flags: -h/--help:bool, --node:string, --range:string, --sheet-id:string, --type:string + +[sheet.range.copy-to] + flags: -h/--help:bool, --node:string, --paste-type:string, --sheet-id:string, --source-range:string, --target-range:string, --target-sheet-id:string + +[sheet.range.fill] + flags: --fill-type:string, -h/--help:bool, --node:string, --sheet-id:string, --source-range:string, --target-range:string + +[sheet.range.move-to] + flags: -h/--help:bool, --node:string, --sheet-id:string, --source-range:string, --target-range:string, --target-sheet-id:string + +[sheet.range.read] + aliases: get + flags: -h/--help:bool, --node:string, --range:string, --sheet-id:string, --value-render-option:string + +[sheet.range.set-style] + flags: --bg-color:string, --bg-colors-json:string, --font-color:string, --font-colors-json:string, --font-size:int, --font-sizes-json:string, --font-weight:string, --font-weights-json:string, --h-align:string, --h-aligns-json:string, -h/--help:bool, --node:string, --number-format:string, --range:string, --sheet-id:string, --v-align:string, --v-aligns-json:string, --word-wrap:string + +[sheet.range.sort] + flags: --has-header:bool, -h/--help:bool, --node:string, --range:string, --sheet-id:string, --sort-keys:string + +[sheet.range.update] + flags: -h/--help:bool, --node:string, --range:string, --sheet-id:string, --values:string + +[sheet.replace] + flags: --find:string, -h/--help:bool, --include-hidden:bool, --match-case:bool, --match-entire-cell:bool, --node:string, --range:string, --replacement:string, --sheet-id:string, --use-regexp:bool + +[sheet.set-dropdown] + flags: -h/--help:bool, --multi-select:bool, --node:string, --options:string, --range:string, --sheet-id:string + +[sheet.template] + commands: apply, list, search + flags: -h/--help:bool + +[sheet.template.apply] + flags: --folder:string, -h/--help:bool, --name:string, --template-id:string, --workspace:string + +[sheet.template.list] + flags: --cursor:string, -h/--help:bool, --limit:int, --source:string + +[sheet.template.search] + flags: --cursor:string, -h/--help:bool, --limit:int, --query:string, --source:string + +[sheet.ungroup-dimension] + flags: -h/--help:bool, --node:string, --range:string, --sheet-id:string + +[sheet.unmerge-cells] + flags: -h/--help:bool, --node:string, --range:string, --sheet-id:string + +[sheet.update] + flags: --frozen-column-count:int, --frozen-row-count:int, -h/--help:bool, --hidden:bool, --index:int, --name:string, --node:string, --sheet-id:string, --tab-color:string, --title:string + +[sheet.update-dimension] + flags: --dimension:string, -h/--help:bool, --hidden:bool, --length:string, --node:string, --pixel-size:int, --sheet-id:string, --start-index:string + +[sheet.update-float-image] + flags: --float-image-id:string, --height:int, -h/--help:bool, --node:string, --offset-x:int, --offset-y:int, --range:string, --sheet-id:string, --src:string, --width:int + +[sheet.write-image] + flags: --file:string, --height:int, -h/--help:bool, --mime-type:string, --name:string, --node:string, --range:string, --sheet-id:string, --width:int + +[skill] + commands: get, install, search, setup + flags: -h/--help:bool + +[skill.get] + flags: -h/--help:bool, --skill-id:string + +[skill.install] + flags: -h/--help:bool + +[skill.search] + flags: -h/--help:bool, --query:string, --source:string + +[skill.setup] + flags: -x/--exclude:stringSlice, -h/--help:bool, --mode:string, -s/--skill:stringSlice, --source:string, --target:string, --yes:bool + +[todo] + commands: comment, task + flags: -h/--help:bool + +[todo.comment] + commands: add, delete, list + flags: -h/--help:bool + +[todo.comment.add] + flags: --content:string, -h/--help:bool, --task-id:string + +[todo.comment.delete] + flags: --comment-id:string, -h/--help:bool, --task-id:string + +[todo.comment.list] + flags: -h/--help:bool, --page:string, --size:string, --task-id:string + +[todo.task] + commands: add-attachment, add-executor, add-participant, add-reminder, create, create-sub, delete, done, get, list, list-attachment, list-sub, remove-attachment, remove-executor, remove-participant, reset-reminder, update + flags: -h/--help:bool + +[todo.task.add-attachment] + flags: --file-path:string, -h/--help:bool, --task-id:string + +[todo.task.add-executor] + flags: --executors:string, -h/--help:bool, --task-id:string + +[todo.task.add-participant] + flags: -h/--help:bool, --participants:string, --task-id:string + +[todo.task.add-reminder] + flags: --base-time:string, --due-date-offset:string, -h/--help:bool, --reminder-time-stamp:string, --task-id:string + +[todo.task.create] + flags: --due:string, --executors:string, -h/--help:bool, --priority:string, --recurrence:string, --title:string + +[todo.task.create-sub] + flags: --due:string, --executors:string, -h/--help:bool, --parent-id:string, --priority:string, --recurrence:string, --title:string + +[todo.task.delete] + flags: -h/--help:bool, --task-id:string + +[todo.task.done] + flags: -h/--help:bool, --status:string, --task-id:string + +[todo.task.get] + flags: -h/--help:bool, --task-id:string + +[todo.task.list] + flags: -h/--help:bool, --page:string, --plan-finish-date-end:string, --plan-finish-date-start:string, --priority:string, --role-types:string, --size:string, --status:string + +[todo.task.list-attachment] + flags: -h/--help:bool, --task-id:string + +[todo.task.list-sub] + flags: -h/--help:bool, --task-id:string + +[todo.task.remove-attachment] + flags: --attachment-id:string, -h/--help:bool, --task-id:string + +[todo.task.remove-executor] + flags: --executors:string, -h/--help:bool, --task-id:string + +[todo.task.remove-participant] + flags: -h/--help:bool, --participants:string, --task-id:string + +[todo.task.reset-reminder] + flags: -h/--help:bool, --reminder-rules:string, --task-id:string + +[todo.task.update] + flags: --done:string, --due:string, -h/--help:bool, --priority:string, --task-id:string, --title:string + +[upgrade] + flags: --all:bool, --beta:bool, --check:bool, --force:bool, -h/--help:bool, --list:bool, --rollback:bool, --skip-skills:bool, --version:string + +[version] + flags: -h/--help:bool + +[wiki] + commands: member, node, space + flags: -h/--help:bool + +[wiki.member] + commands: add, list, remove, update + flags: -h/--help:bool + +[wiki.member.add] + flags: -h/--help:bool, --role:string, --users:string, --workspace:string + +[wiki.member.list] + aliases: ls + flags: --filter-role:string, -h/--help:bool, --limit:int, --workspace:string + +[wiki.member.remove] + flags: -h/--help:bool, --users:string, --workspace:string + +[wiki.member.update] + flags: -h/--help:bool, --role:string, --users:string, --workspace:string + +[wiki.node] + commands: copy, create, delete, list, move, search + flags: -h/--help:bool + +[wiki.node.copy] + flags: --folder:string, -h/--help:bool, --node:string, --workspace:string + +[wiki.node.create] + flags: --folder:string, -h/--help:bool, --name:string, --type:string, --workspace:string + +[wiki.node.delete] + flags: -h/--help:bool, --node:string, --workspace:string + +[wiki.node.list] + aliases: ls + flags: --cursor:string, --folder:string, -h/--help:bool, --limit:int, --workspace:string + +[wiki.node.move] + flags: --folder:string, -h/--help:bool, --node:string, --workspace:string + +[wiki.node.search] + flags: --cursor:string, --extensions:stringSlice, -h/--help:bool, --limit:int, --query:string, --workspace:string + +[wiki.space] + commands: create, delete, get, list, search + flags: -h/--help:bool + +[wiki.space.create] + flags: --desc:string, -h/--help:bool, --icon:string, --name:string + +[wiki.space.delete] + flags: -h/--help:bool, --workspace:string + +[wiki.space.get] + flags: -h/--help:bool, --workspace:string + +[wiki.space.list] + flags: --cursor:string, -h/--help:bool, --limit:string, --type:string + +[wiki.space.search] + flags: -h/--help:bool, --limit:string, --query:string, --type:string